diff --git a/apps/desktop/src/electron-docx-media-capture.ts b/apps/desktop/src/electron-docx-media-capture.ts
new file mode 100644
index 0000000..7362055
--- /dev/null
+++ b/apps/desktop/src/electron-docx-media-capture.ts
@@ -0,0 +1,79 @@
+import type { WebContents } from "electron";
+import type {
+ DocxMediaCaptureAdapter,
+ DocxMediaCaptureRequest
+} from "@md-to-pdf/application";
+import type { DocxMediaCapturePlan } from "@md-to-pdf/core";
+
+interface CaptureScreenshotResult {
+ data: string;
+}
+
+function createRenderScript(request: DocxMediaCaptureRequest) {
+ const serialized = JSON.stringify(request);
+ return `(async () => {
+ const request = ${serialized};
+ const renderer = window.__mdToPdfRenderDocxMedia;
+ if (!renderer) {
+ throw new Error("DOCX 媒体渲染运行时不可用");
+ }
+ return renderer(request.payload, request.dimensions);
+ })()`;
+}
+
+export async function captureDocxMediaWithElectronWebContents(
+ webContents: WebContents,
+ request: DocxMediaCaptureRequest
+) {
+ const plan = (await webContents.executeJavaScript(
+ createRenderScript(request),
+ true
+ )) as DocxMediaCapturePlan;
+ const ownsDebugger = !webContents.debugger.isAttached();
+ if (ownsDebugger) {
+ webContents.debugger.attach("1.3");
+ }
+
+ try {
+ const captures = [];
+ for (const target of plan.targets) {
+ const result = (await webContents.debugger.sendCommand(
+ "Page.captureScreenshot",
+ {
+ format: "png",
+ fromSurface: true,
+ captureBeyondViewport: true,
+ clip: {
+ x: target.captureX,
+ y: target.captureY,
+ width: target.captureWidthPx,
+ height: target.captureHeightPx,
+ scale: target.rasterScale
+ }
+ }
+ )) as CaptureScreenshotResult;
+ captures.push({
+ id: target.id,
+ png: Buffer.from(result.data, "base64")
+ });
+ }
+ return { plan, captures };
+ } finally {
+ if (ownsDebugger && webContents.debugger.isAttached()) {
+ webContents.debugger.detach();
+ }
+ }
+}
+
+export class ElectronDocxMediaCaptureAdapter
+ implements DocxMediaCaptureAdapter
+{
+ constructor(private readonly webContents: WebContents) {}
+
+ capture(request: DocxMediaCaptureRequest) {
+ return captureDocxMediaWithElectronWebContents(
+ this.webContents,
+ request
+ );
+ }
+}
diff --git a/apps/desktop/tests/electron-docx-media-capture.test.ts b/apps/desktop/tests/electron-docx-media-capture.test.ts
new file mode 100644
index 0000000..dabf9b3
--- /dev/null
+++ b/apps/desktop/tests/electron-docx-media-capture.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it, vi } from "vitest";
+import type { WebContents } from "electron";
+import {
+ createPagedDocumentPayload,
+ defaultExportConfig,
+ type DocxMediaCapturePlan
+} from "@md-to-pdf/core";
+import { captureDocxMediaWithElectronWebContents } from "../src/electron-docx-media-capture.js";
+
+const plan: DocxMediaCapturePlan = {
+ targets: [
+ {
+ id: "docx-media-1",
+ kind: "image",
+ ordinal: 1,
+ kindOrdinal: 1,
+ altText: "图片",
+ displayWidthPx: 200,
+ displayHeightPx: 100,
+ captureX: 12,
+ captureY: 24,
+ captureWidthPx: 200,
+ captureHeightPx: 100,
+ rasterScale: 3.125
+ }
+ ],
+ echartsErrors: [],
+ mermaidErrors: []
+};
+
+const request = {
+ payload: createPagedDocumentPayload({
+ document: {
+ rendererVersion: 1,
+ articleHtml: '',
+ bodyHtml: "",
+ metadata: {
+ title: "",
+ author: "",
+ subject: "",
+ keywords: [],
+ language: "zh-CN"
+ },
+ features: [],
+ warnings: []
+ },
+ fileName: "test.md",
+ themeCss: "",
+ exportConfig: defaultExportConfig
+ }),
+ dimensions: {
+ contentWidthPx: 600,
+ contentHeightPx: 900
+ }
+};
+
+describe("Electron DOCX 媒体捕获", () => {
+ it("复用隐藏 Chromium 并在捕获后释放调试会话", async () => {
+ let attached = false;
+ const attach = vi.fn(() => {
+ attached = true;
+ });
+ const detach = vi.fn(() => {
+ attached = false;
+ });
+ const sendCommand = vi.fn(async () => ({
+ data: Buffer.from("png").toString("base64")
+ }));
+ const webContents = {
+ executeJavaScript: vi.fn(async () => plan),
+ debugger: {
+ isAttached: vi.fn(() => attached),
+ attach,
+ detach,
+ sendCommand
+ }
+ } as unknown as WebContents;
+
+ const result = await captureDocxMediaWithElectronWebContents(
+ webContents,
+ request
+ );
+
+ expect(result.captures[0]).toEqual({
+ id: "docx-media-1",
+ png: Buffer.from("png")
+ });
+ expect(attach).toHaveBeenCalledWith("1.3");
+ expect(sendCommand).toHaveBeenCalledWith(
+ "Page.captureScreenshot",
+ expect.objectContaining({
+ format: "png",
+ clip: expect.objectContaining({ scale: 3.125 })
+ })
+ );
+ expect(detach).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/server/src/playwright-docx-media-capture.ts b/apps/server/src/playwright-docx-media-capture.ts
new file mode 100644
index 0000000..aa3a65e
--- /dev/null
+++ b/apps/server/src/playwright-docx-media-capture.ts
@@ -0,0 +1,75 @@
+import type { Page } from "playwright";
+import type {
+ DocxMediaCaptureAdapter,
+ DocxMediaCaptureRequest
+} from "@md-to-pdf/application";
+import type { DocxMediaCapturePlan } from "@md-to-pdf/core";
+
+interface CaptureScreenshotResult {
+ data: string;
+}
+
+async function renderCapturePlan(
+ page: Page,
+ request: DocxMediaCaptureRequest
+) {
+ return page.evaluate(
+ async ({ payload, dimensions }): Promise => {
+ const renderer = (
+ window as typeof window & {
+ __mdToPdfRenderDocxMedia?: (
+ renderPayload: typeof payload,
+ renderDimensions: typeof dimensions
+ ) => Promise;
+ }
+ ).__mdToPdfRenderDocxMedia;
+ if (!renderer) {
+ throw new Error("DOCX 媒体渲染运行时不可用");
+ }
+ return renderer(payload, dimensions);
+ },
+ request
+ );
+}
+
+export async function captureDocxMediaWithPlaywrightPage(
+ page: Page,
+ request: DocxMediaCaptureRequest
+) {
+ const plan = await renderCapturePlan(page, request);
+ const session = await page.context().newCDPSession(page);
+ try {
+ const captures = [];
+ for (const target of plan.targets) {
+ const result = (await session.send("Page.captureScreenshot", {
+ format: "png",
+ fromSurface: true,
+ captureBeyondViewport: true,
+ clip: {
+ x: target.captureX,
+ y: target.captureY,
+ width: target.captureWidthPx,
+ height: target.captureHeightPx,
+ scale: target.rasterScale
+ }
+ })) as CaptureScreenshotResult;
+ captures.push({
+ id: target.id,
+ png: Buffer.from(result.data, "base64")
+ });
+ }
+ return { plan, captures };
+ } finally {
+ await session.detach();
+ }
+}
+
+export class PlaywrightDocxMediaCaptureAdapter
+ implements DocxMediaCaptureAdapter
+{
+ constructor(private readonly page: Page) {}
+
+ capture(request: DocxMediaCaptureRequest) {
+ return captureDocxMediaWithPlaywrightPage(this.page, request);
+ }
+}
diff --git a/apps/server/tests/playwright-docx-media-capture.test.ts b/apps/server/tests/playwright-docx-media-capture.test.ts
new file mode 100644
index 0000000..fa2b86c
--- /dev/null
+++ b/apps/server/tests/playwright-docx-media-capture.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it, vi } from "vitest";
+import type { Page } from "playwright";
+import {
+ createPagedDocumentPayload,
+ defaultExportConfig,
+ type DocxMediaCapturePlan
+} from "@md-to-pdf/core";
+import { captureDocxMediaWithPlaywrightPage } from "../src/playwright-docx-media-capture.js";
+
+const plan: DocxMediaCapturePlan = {
+ targets: [
+ {
+ id: "docx-media-1",
+ kind: "echarts",
+ ordinal: 1,
+ kindOrdinal: 1,
+ altText: "图表",
+ displayWidthPx: 320,
+ displayHeightPx: 180,
+ captureX: 10,
+ captureY: 20,
+ captureWidthPx: 320,
+ captureHeightPx: 180,
+ rasterScale: 3.125
+ }
+ ],
+ echartsErrors: [],
+ mermaidErrors: []
+};
+
+const request = {
+ payload: createPagedDocumentPayload({
+ document: {
+ rendererVersion: 1,
+ articleHtml: '',
+ bodyHtml: "",
+ metadata: {
+ title: "",
+ author: "",
+ subject: "",
+ keywords: [],
+ language: "zh-CN"
+ },
+ features: [],
+ warnings: []
+ },
+ fileName: "test.md",
+ themeCss: "",
+ exportConfig: defaultExportConfig
+ }),
+ dimensions: {
+ contentWidthPx: 600,
+ contentHeightPx: 900
+ }
+};
+
+describe("Playwright DOCX 媒体捕获", () => {
+ it("按运行时计划以目标倍率捕获 PNG", async () => {
+ const detach = vi.fn(async () => undefined);
+ const send = vi.fn(async () => ({
+ data: Buffer.from("png").toString("base64")
+ }));
+ const page = {
+ evaluate: vi.fn(async () => plan),
+ context: () => ({
+ newCDPSession: vi.fn(async () => ({ send, detach }))
+ })
+ } as unknown as Page;
+
+ const result = await captureDocxMediaWithPlaywrightPage(
+ page,
+ request
+ );
+
+ expect(result.plan).toBe(plan);
+ expect(result.captures[0]).toEqual({
+ id: "docx-media-1",
+ png: Buffer.from("png")
+ });
+ expect(send).toHaveBeenCalledWith("Page.captureScreenshot", {
+ format: "png",
+ fromSurface: true,
+ captureBeyondViewport: true,
+ clip: {
+ x: 10,
+ y: 20,
+ width: 320,
+ height: 180,
+ scale: 3.125
+ }
+ });
+ expect(detach).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/web/src/paged-preview-frame.ts b/apps/web/src/paged-preview-frame.ts
index 5f0299e..b73c331 100644
--- a/apps/web/src/paged-preview-frame.ts
+++ b/apps/web/src/paged-preview-frame.ts
@@ -3,6 +3,7 @@ import {
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
PagedDocumentRuntime,
+ renderDocxMediaCapturePlan,
resolveMermaidOutputMode,
resolvePagedRenderTarget,
type PagedPreviewFrameMessage,
@@ -137,6 +138,13 @@ window.__mdToPdfRender = async (
}
return result;
};
+window.__mdToPdfRenderDocxMedia = (payload, dimensions) =>
+ renderDocxMediaCapturePlan(
+ runtime,
+ previewRoot,
+ payload,
+ dimensions
+ );
document.documentElement.dataset.runtimeReady = "true";
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
index fb313a3..902ead6 100644
--- a/apps/web/src/vite-env.d.ts
+++ b/apps/web/src/vite-env.d.ts
@@ -1,6 +1,7 @@
///
import type {
+ DocxMediaCapturePlan,
PagedDocumentPayload,
PagedDocumentRenderResult,
RenderedMarkdownDocument,
@@ -96,5 +97,12 @@ declare global {
interface Window {
mdToPdfDesktop?: DesktopApplicationBridge;
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
+ __mdToPdfRenderDocxMedia?: (
+ payload: PagedDocumentPayload,
+ dimensions: {
+ contentWidthPx: number;
+ contentHeightPx: number;
+ }
+ ) => Promise;
}
}
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index 8d9e65b..eb86ae8 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -91,6 +91,13 @@ H1~H6、加粗、斜体、删除线、行内代码、代码块、引用、三
同步。外部文档替换会重置历史,单次工具栏操作形成一个 CodeMirror
事务。
+`v0.6.0` 阶段 5 已完成 DOCX 资源预处理管线:复用连续预览运行时等待
+图片、Mermaid、ECharts 和字体,按纸张内容区生成稳定媒体捕获计划;
+Server 使用 Playwright Chromium、Desktop 使用 Electron Chromium,
+统一将所有媒体捕获为 PNG。目标为 300 DPI,并限制 4096px 单边、1600
+万像素、8 MiB 单图和 30 MiB 总量。本阶段只提供共享服务和平台适配器,
+尚未新增 DOCX HTTP 路由、应用 IPC、Pandoc 调用或导出按钮。
+
## 2. 已完成
### 2.1 项目骨架
@@ -568,8 +575,36 @@ H1~H6、加粗、斜体、删除线、行内代码、代码块、引用、三
关闭时对未保存内容进行确认;另存为成功后当前窗口追踪新文件路径,
Web 端不显示或拦截“另存为”。
+### 3.21 v0.6.0 DOCX 资源预处理
+
+- `packages/core` 新增图片、Mermaid、ECharts 的媒体类型、捕获计划、
+ PNG 资源清单和跨端限制常量;
+- `packages/preview-engine` 复用连续文档运行时建立 DOCX 媒体舞台,
+ 等待图片、字体和图表完成后按 DOM 顺序生成捕获目标;
+- 媒体按纸张方向、纸张尺寸、主题默认页边距和用户页边距限制显示宽高;
+- 普通图片、SVG、Mermaid 和 ECharts 统一输出 PNG,默认 300 DPI,
+ 超大媒体会按 4096px 单边和 1600 万像素上限降低倍率;
+- 捕获框使用覆盖元素边界的整数坐标,避免 Chromium 对小数坐标缩放时
+ 裁切边缘或产生不可预测的位图尺寸;
+- `packages/application` 校验捕获计划、媒体 ID、PNG 签名、IHDR 尺寸、
+ 单图大小、总大小和计划匹配关系,并生成稳定文件名;
+- Server 的 Playwright 与 Desktop 的 Electron 适配器均使用 Chromium
+ DevTools 截图协议,不引入 Sharp、Python 或其他原生图片依赖;
+- 真实 Chromium 冒烟已将输入 SVG 图片、Mermaid 和 ECharts 依次输出为
+ 753×378、809×222、2103×591 PNG,图表错误均为 0。
+
## 4. 已执行验证
+2026-07-30 对 v0.6.0 阶段 5 执行:
+
+- 完整工作区 61 个测试文件、308 项测试全部通过;
+- Markdown ECharts、Core、Renderer、Application、Preview Engine、
+ Web、Server 和 Desktop 类型检查全部通过;
+- Web Runtime、Server 和 Desktop 生产构建通过;
+- 真实 Playwright Chromium 使用 SVG 图片、Mermaid 和 ECharts 完成
+ 300 DPI PNG 捕获,媒体顺序、尺寸、格式和错误清单验证通过;
+- `git diff --check` 通过,仅有 Git 对 Windows 工作区换行转换的提示。
+
2026-07-29 对 v0.5.1 完整工作区及最终代码块修复执行:
```text
@@ -974,8 +1009,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
分发方式;
- 阶段 3:已实现 DOCX 共享模型和跨端导出协议;
- 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏;
-- 阶段 5~8:依次实现资源预处理、动态 reference.docx、Pandoc 转换服务
- 以及 Web/Desktop 导出交互;
+- 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器;
+- 阶段 6~8:依次实现动态 reference.docx、Pandoc 转换服务以及
+ Web/Desktop 导出交互;
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
diff --git a/docs/V0.6.0_DESIGN.md b/docs/V0.6.0_DESIGN.md
index f917065..042143e 100644
--- a/docs/V0.6.0_DESIGN.md
+++ b/docs/V0.6.0_DESIGN.md
@@ -198,10 +198,13 @@ Desktop 只提供平台运行时、传输与保存能力。
### 4.5 媒体策略
-- 普通 PNG、JPEG 等兼容图片保持原格式或安全规范化;
-- SVG 默认转换为 PNG,不直接嵌入 DOCX;
-- Mermaid 和 ECharts 使用 PNG;
-- PNG 按足够高的像素尺寸生成,DOCX 中使用纸张内容区对应的物理尺寸;
+- 普通图片、SVG、Mermaid 和 ECharts 在 DOCX 媒体准备阶段统一输出 PNG;
+- SVG 不直接嵌入 DOCX,避免 Word 与 WPS 的兼容差异;
+- 默认按 300 DPI,即 96 CSS DPI 的 3.125 倍捕获;
+- 单边最长 4096px、单图最多 1600 万像素、单图最多 8 MiB、单次导出
+ 媒体总量最多 30 MiB;
+- DOCX 中使用纸张内容区对应的物理尺寸,不使用 PNG 像素尺寸直接决定
+ 文档显示尺寸;
- 保持纵横比,不得超出可用内容宽高;
- 图表渲染必须等待字体、图片和图表资源完成;
- 图表错误产生局部、可理解的导出错误,不输出损坏 DOCX。
diff --git a/packages/application/README.md b/packages/application/README.md
index 3ae02e8..74581a5 100644
--- a/packages/application/README.md
+++ b/packages/application/README.md
@@ -10,12 +10,14 @@ Web Server 与 Electron Desktop 共用的应用服务层,组合
```text
src/
application-service.ts 渲染、主题和资源用例入口
+ docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装
image-resources.ts 本地、Base64 与受限远程图片处理
theme-registry.ts 内置及自定义主题扫描、校验与缓存
index.ts 公共导出入口
tests/
application-service.test.ts
bundled-themes.test.ts
+ docx-media-service.test.ts
image-resources.test.ts
```
@@ -32,6 +34,10 @@ const service = createApplicationService({
const themes = await service.listThemes();
const document = await service.render(request);
const preparedDocx = await service.prepareDocxExport(docxRequest);
+const preparedMedia = await prepareDocxMedia(
+ preparedDocx,
+ platformCaptureAdapter
+);
```
Web 端通过 `apps/server` 的 HTTP API 调用;桌面端在主进程中创建同一
@@ -42,6 +48,11 @@ Desktop 才会以 Markdown 所在目录为边界解析相对资源。
源请求、安全渲染文档、主题清单和主题 CSS。它不调用 Pandoc;后续 DOCX
引擎只消费该准备结果,避免 Server 与 Desktop 重复实现文档准备逻辑。
+`prepareDocxMedia()` 根据纸张方向、尺寸、主题默认页边距和用户配置计算
+内容区,调用平台捕获适配器,并校验捕获计划、PNG 签名、像素尺寸、单图
+大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png`
+清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。
+
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
diff --git a/packages/application/src/docx-media-service.ts b/packages/application/src/docx-media-service.ts
new file mode 100644
index 0000000..53f9c4c
--- /dev/null
+++ b/packages/application/src/docx-media-service.ts
@@ -0,0 +1,195 @@
+import {
+ MAXIMUM_DOCX_MEDIA_BYTES,
+ MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
+ MAXIMUM_DOCX_MEDIA_PIXELS,
+ MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
+ createPagedDocumentPayload,
+ docxMediaCapturePlanSchema,
+ getPaperDimensionsMm,
+ lengthToMillimeters,
+ millimetersToCssPixels,
+ resolvePageMargins,
+ type DocxMediaCapturePlan,
+ type DocxPngMediaResource,
+ type PagedDocumentPayload,
+ type PreparedDocxMedia
+} from "@md-to-pdf/core";
+import type { PreparedDocxExport } from "./application-service.js";
+
+export interface DocxMediaRenderDimensions {
+ contentWidthPx: number;
+ contentHeightPx: number;
+}
+
+export interface DocxMediaCaptureRequest {
+ payload: PagedDocumentPayload;
+ dimensions: DocxMediaRenderDimensions;
+}
+
+export interface DocxMediaCapture {
+ id: string;
+ png: Uint8Array;
+}
+
+export interface DocxMediaCaptureOutput {
+ plan: unknown;
+ captures: DocxMediaCapture[];
+}
+
+export interface DocxMediaCaptureAdapter {
+ capture(
+ request: DocxMediaCaptureRequest
+ ): Promise;
+}
+
+const pngSignature = [
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
+] as const;
+
+function readPngDimensions(content: Uint8Array) {
+ if (
+ content.byteLength < 24 ||
+ pngSignature.some((value, index) => content[index] !== value) ||
+ String.fromCharCode(...content.slice(12, 16)) !== "IHDR"
+ ) {
+ throw new Error("DOCX 媒体捕获结果不是有效 PNG");
+ }
+ const view = new DataView(
+ content.buffer,
+ content.byteOffset,
+ content.byteLength
+ );
+ const width = view.getUint32(16);
+ const height = view.getUint32(20);
+ if (
+ width < 1 ||
+ height < 1 ||
+ width > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
+ height > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
+ width * height > MAXIMUM_DOCX_MEDIA_PIXELS
+ ) {
+ throw new Error("DOCX PNG 媒体像素尺寸超过限制");
+ }
+ return { width, height };
+}
+
+export function getDocxMediaRenderDimensions(
+ prepared: PreparedDocxExport
+) {
+ const paper = prepared.request.exportConfig.paper;
+ const dimensions = getPaperDimensionsMm(
+ paper.format,
+ paper.orientation
+ );
+ const margins = resolvePageMargins(
+ paper,
+ prepared.theme.manifest.pageDefaults?.margins
+ );
+ const contentWidthMm =
+ dimensions.width -
+ lengthToMillimeters(margins.left) -
+ lengthToMillimeters(margins.right);
+ const contentHeightMm =
+ dimensions.height -
+ lengthToMillimeters(margins.top) -
+ lengthToMillimeters(margins.bottom);
+ return {
+ contentWidthPx: millimetersToCssPixels(contentWidthMm),
+ contentHeightPx: millimetersToCssPixels(contentHeightMm)
+ };
+}
+
+function validateCaptures(
+ plan: DocxMediaCapturePlan,
+ captures: DocxMediaCapture[]
+) {
+ const byId = new Map();
+ let totalBytes = 0;
+ for (const capture of captures) {
+ if (byId.has(capture.id)) {
+ throw new Error(`DOCX 媒体 ${capture.id} 重复`);
+ }
+ if (capture.png.byteLength > MAXIMUM_DOCX_MEDIA_BYTES) {
+ throw new Error(`DOCX 媒体 ${capture.id} 超过单文件大小限制`);
+ }
+ totalBytes += capture.png.byteLength;
+ if (totalBytes > MAXIMUM_DOCX_TOTAL_MEDIA_BYTES) {
+ throw new Error("DOCX PNG 媒体总大小超过限制");
+ }
+ byId.set(capture.id, capture.png);
+ }
+ if (
+ byId.size !== plan.targets.length ||
+ plan.targets.some((target) => !byId.has(target.id))
+ ) {
+ throw new Error("DOCX 媒体捕获结果与渲染计划不匹配");
+ }
+
+ const resources = plan.targets.map(
+ (target): DocxPngMediaResource => {
+ const content = byId.get(target.id)!;
+ const { width, height } = readPngDimensions(content);
+ const expectedWidth = Math.round(
+ target.captureWidthPx * target.rasterScale
+ );
+ const expectedHeight = Math.round(
+ target.captureHeightPx * target.rasterScale
+ );
+ if (
+ Math.abs(width - expectedWidth) > 2 ||
+ Math.abs(height - expectedHeight) > 2
+ ) {
+ throw new Error(
+ `DOCX 媒体 ${target.id} 的 PNG 尺寸与捕获计划不一致`
+ );
+ }
+ return {
+ ...target,
+ fileName: `media-${String(target.ordinal).padStart(
+ 3,
+ "0"
+ )}.png`,
+ contentType: "image/png",
+ content,
+ pixelWidth: width,
+ pixelHeight: height
+ };
+ }
+ );
+ return { resources, totalBytes };
+}
+
+export async function prepareDocxMedia(
+ prepared: PreparedDocxExport,
+ adapter: DocxMediaCaptureAdapter
+): Promise {
+ const dimensions = getDocxMediaRenderDimensions(prepared);
+ const output = await adapter.capture({
+ payload: createPagedDocumentPayload({
+ document: prepared.document,
+ fileName: prepared.request.fileName,
+ themeCss: prepared.theme.css,
+ exportConfig: prepared.request.exportConfig
+ }),
+ dimensions
+ });
+ const parsedPlan = docxMediaCapturePlanSchema.safeParse(output.plan);
+ if (!parsedPlan.success) {
+ throw new Error("DOCX 媒体捕获计划无效");
+ }
+ const { resources, totalBytes } = validateCaptures(
+ parsedPlan.data,
+ output.captures
+ );
+ return {
+ resources,
+ echartsErrors: parsedPlan.data.echartsErrors,
+ mermaidErrors: parsedPlan.data.mermaidErrors,
+ warnings: [
+ ...prepared.document.warnings,
+ ...parsedPlan.data.echartsErrors,
+ ...parsedPlan.data.mermaidErrors
+ ],
+ totalBytes
+ };
+}
diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts
index 1243fbc..df98652 100644
--- a/packages/application/src/index.ts
+++ b/packages/application/src/index.ts
@@ -21,6 +21,15 @@ export {
type MarkdownImageResource,
type ResolvedMarkdownImages
} from "./image-resources.js";
+export {
+ getDocxMediaRenderDimensions,
+ prepareDocxMedia,
+ type DocxMediaCapture,
+ type DocxMediaCaptureAdapter,
+ type DocxMediaCaptureOutput,
+ type DocxMediaCaptureRequest,
+ type DocxMediaRenderDimensions
+} from "./docx-media-service.js";
export {
createThemeRegistry,
type ThemeRecord,
diff --git a/packages/application/tests/docx-media-service.test.ts b/packages/application/tests/docx-media-service.test.ts
new file mode 100644
index 0000000..4dbaa27
--- /dev/null
+++ b/packages/application/tests/docx-media-service.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from "vitest";
+import {
+ defaultExportConfig,
+ type DocxMediaCapturePlan,
+ type ThemeManifest
+} from "@md-to-pdf/core";
+import {
+ getDocxMediaRenderDimensions,
+ prepareDocxMedia,
+ type DocxMediaCaptureAdapter,
+ type PreparedDocxExport
+} from "../src/index.js";
+
+function createPng(width: number, height: number) {
+ const content = new Uint8Array(24);
+ content.set([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
+ ]);
+ content.set([0x49, 0x48, 0x44, 0x52], 12);
+ const view = new DataView(content.buffer);
+ view.setUint32(16, width);
+ view.setUint32(20, height);
+ return content;
+}
+
+const manifest: ThemeManifest = {
+ manifestVersion: 1,
+ id: "test-theme",
+ name: "测试主题",
+ version: "1.0.0",
+ description: "测试",
+ author: "测试",
+ license: "内部许可",
+ entry: "theme.css",
+ domPreset: "typora",
+ defaultFontSize: "16px",
+ supportedFeatures: ["mermaid", "echarts"],
+ category: "general",
+ compatibleProfiles: [],
+ pageDefaults: {
+ margins: {
+ top: "20mm",
+ right: "20mm",
+ bottom: "20mm",
+ left: "20mm"
+ }
+ },
+ bundled: true
+};
+
+const prepared: PreparedDocxExport = {
+ request: {
+ markdown: "# 文档",
+ fileName: "报告.md",
+ language: "zh-CN",
+ resources: [],
+ exportConfig: {
+ ...defaultExportConfig,
+ themeId: manifest.id
+ }
+ },
+ document: {
+ rendererVersion: 1,
+ articleHtml: '',
+ bodyHtml: "",
+ metadata: {
+ title: "文档",
+ author: "",
+ subject: "",
+ keywords: [],
+ language: "zh-CN"
+ },
+ features: [],
+ warnings: ["原始图片已降级"]
+ },
+ theme: {
+ manifest,
+ source: "bundled",
+ css: "#write { color: black; }"
+ }
+};
+
+function createPlan(): DocxMediaCapturePlan {
+ return {
+ targets: [
+ {
+ id: "docx-media-1",
+ kind: "echarts",
+ ordinal: 1,
+ kindOrdinal: 1,
+ altText: "收入趋势",
+ caption: "年度收入",
+ displayWidthPx: 320,
+ displayHeightPx: 160,
+ captureX: 0,
+ captureY: 0,
+ captureWidthPx: 320,
+ captureHeightPx: 160,
+ rasterScale: 3.125
+ }
+ ],
+ echartsErrors: [],
+ mermaidErrors: []
+ };
+}
+
+describe("DOCX 媒体预处理服务", () => {
+ it("按主题页边距计算媒体内容区域", () => {
+ const dimensions = getDocxMediaRenderDimensions(prepared);
+ expect(dimensions.contentWidthPx).toBeCloseTo(
+ ((210 - 40) * 96) / 25.4
+ );
+ expect(dimensions.contentHeightPx).toBeCloseTo(
+ ((297 - 40) * 96) / 25.4
+ );
+ });
+
+ it("校验并输出稳定 PNG 媒体清单", async () => {
+ const adapter: DocxMediaCaptureAdapter = {
+ capture: async ({ dimensions, payload }) => {
+ expect(dimensions.contentWidthPx).toBeGreaterThan(600);
+ expect(payload.articleHtml).toContain('id="write"');
+ return {
+ plan: createPlan(),
+ captures: [
+ {
+ id: "docx-media-1",
+ png: createPng(1000, 500)
+ }
+ ]
+ };
+ }
+ };
+
+ const result = await prepareDocxMedia(prepared, adapter);
+
+ expect(result.resources[0]).toMatchObject({
+ id: "docx-media-1",
+ fileName: "media-001.png",
+ contentType: "image/png",
+ pixelWidth: 1000,
+ pixelHeight: 500,
+ caption: "年度收入"
+ });
+ expect(result.warnings).toEqual(["原始图片已降级"]);
+ expect(result.totalBytes).toBe(24);
+ });
+
+ it("拒绝伪 PNG、缺失捕获和尺寸不一致", async () => {
+ const capture = async (
+ png: Uint8Array,
+ includeCapture = true
+ ) =>
+ prepareDocxMedia(prepared, {
+ capture: async () => ({
+ plan: createPlan(),
+ captures: includeCapture
+ ? [{ id: "docx-media-1", png }]
+ : []
+ })
+ });
+
+ await expect(capture(new Uint8Array(24))).rejects.toThrow(
+ "不是有效 PNG"
+ );
+ await expect(
+ capture(createPng(1000, 500), false)
+ ).rejects.toThrow("不匹配");
+ await expect(capture(createPng(990, 500))).rejects.toThrow(
+ "尺寸与捕获计划不一致"
+ );
+ });
+});
diff --git a/packages/core/README.md b/packages/core/README.md
index 46fc055..0bb8bcb 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -48,8 +48,9 @@ const payload = createPagedDocumentPayload({
```
DOCX 协议固定 Pandoc `3.9.0.2`,并统一定义跨 HTTP/IPC 使用的请求、
-capability、错误码、结果、诊断和耗时类型。该包只描述数据协议,不启动
-Pandoc 或读写临时文件。
+capability、错误码、结果、诊断、耗时、媒体捕获计划和 PNG 资源清单。
+媒体协议固定 300 DPI 目标倍率,并限制边长、总像素、单文件大小和总量。
+该包只描述数据协议,不启动 Pandoc、浏览器或读写临时文件。
`classifyDocumentLink()` 只负责稳定分类,不执行平台动作。Web 根据分类
处理锚点和网络链接;Desktop 决定是否调用浏览器、系统程序或打开新的
diff --git a/packages/core/src/docx.ts b/packages/core/src/docx.ts
index 5f9564f..01e15ba 100644
--- a/packages/core/src/docx.ts
+++ b/packages/core/src/docx.ts
@@ -8,6 +8,14 @@ export const DOCX_FILE_EXTENSION = ".docx";
export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000;
export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500;
export const MAXIMUM_DOCX_RESOURCE_COUNT = 50;
+export const DOCX_MEDIA_RASTER_DPI = 300;
+export const DOCX_MEDIA_CSS_DPI = 96;
+export const DOCX_MEDIA_RASTER_SCALE =
+ DOCX_MEDIA_RASTER_DPI / DOCX_MEDIA_CSS_DPI;
+export const MAXIMUM_DOCX_MEDIA_EDGE_PIXELS = 4_096;
+export const MAXIMUM_DOCX_MEDIA_PIXELS = 16_000_000;
+export const MAXIMUM_DOCX_MEDIA_BYTES = 8 * 1024 * 1024;
+export const MAXIMUM_DOCX_TOTAL_MEDIA_BYTES = 30 * 1024 * 1024;
export const documentExportFormatSchema = z.enum(["pdf", "docx"]);
export type DocumentExportFormat = z.infer<
@@ -79,6 +87,67 @@ export const docxCapabilitySchema = z.discriminatedUnion("status", [
export type DocxCapability = z.infer;
+export const docxMediaKindSchema = z.enum([
+ "image",
+ "mermaid",
+ "echarts"
+]);
+
+export type DocxMediaKind = z.infer;
+
+export const docxMediaCaptureTargetSchema = z.object({
+ id: z.string().regex(/^docx-media-\d+$/u),
+ kind: docxMediaKindSchema,
+ ordinal: z.number().int().min(1).max(MAXIMUM_DOCX_RESOURCE_COUNT),
+ kindOrdinal: z
+ .number()
+ .int()
+ .min(1)
+ .max(MAXIMUM_DOCX_RESOURCE_COUNT),
+ altText: z.string().max(1_000),
+ caption: z.string().max(1_000).optional(),
+ displayWidthPx: z.number().positive().max(10_000),
+ displayHeightPx: z.number().positive().max(10_000),
+ captureX: z.number().int().nonnegative().max(100_000),
+ captureY: z.number().int().nonnegative().max(100_000),
+ captureWidthPx: z.number().int().positive().max(10_000),
+ captureHeightPx: z.number().int().positive().max(10_000),
+ rasterScale: z.number().positive().max(DOCX_MEDIA_RASTER_SCALE)
+});
+
+export type DocxMediaCaptureTarget = z.infer<
+ typeof docxMediaCaptureTargetSchema
+>;
+
+export const docxMediaCapturePlanSchema = z.object({
+ targets: z
+ .array(docxMediaCaptureTargetSchema)
+ .max(MAXIMUM_DOCX_RESOURCE_COUNT),
+ echartsErrors: z.array(z.string().max(1_000)),
+ mermaidErrors: z.array(z.string().max(1_000))
+});
+
+export type DocxMediaCapturePlan = z.infer<
+ typeof docxMediaCapturePlanSchema
+>;
+
+export interface DocxPngMediaResource
+ extends DocxMediaCaptureTarget {
+ fileName: string;
+ contentType: "image/png";
+ content: Uint8Array;
+ pixelWidth: number;
+ pixelHeight: number;
+}
+
+export interface PreparedDocxMedia {
+ resources: DocxPngMediaResource[];
+ echartsErrors: string[];
+ mermaidErrors: string[];
+ warnings: string[];
+ totalBytes: number;
+}
+
export const docxExportErrorCodeSchema = z.enum([
"INVALID_DOCX_REQUEST",
"INVALID_EXPORT_CONFIG",
diff --git a/packages/core/tests/docx.test.ts b/packages/core/tests/docx.test.ts
index dfd5fc5..93658b5 100644
--- a/packages/core/tests/docx.test.ts
+++ b/packages/core/tests/docx.test.ts
@@ -1,11 +1,14 @@
import { describe, expect, it } from "vitest";
import {
DOCX_MIME_TYPE,
+ DOCX_MEDIA_RASTER_SCALE,
DOCX_PANDOC_VERSION,
+ MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MARKDOWN_LENGTH,
createDocxFileName,
defaultExportConfig,
docxCapabilitySchema,
+ docxMediaCapturePlanSchema,
docxExportErrorResponseSchema,
docxExportRequestSchema
} from "../src/index.js";
@@ -111,4 +114,54 @@ describe("DOCX 共享协议", () => {
retryable: false
});
});
+
+ it("校验 DOCX PNG 媒体捕获计划", () => {
+ expect(DOCX_MEDIA_RASTER_SCALE).toBe(3.125);
+ expect(MAXIMUM_DOCX_MEDIA_EDGE_PIXELS).toBe(4096);
+ expect(
+ docxMediaCapturePlanSchema.parse({
+ targets: [
+ {
+ id: "docx-media-1",
+ kind: "mermaid",
+ ordinal: 1,
+ kindOrdinal: 1,
+ altText: "流程图",
+ caption: "处理流程",
+ displayWidthPx: 640,
+ displayHeightPx: 320,
+ captureX: 20,
+ captureY: 120,
+ captureWidthPx: 640,
+ captureHeightPx: 320,
+ rasterScale: 3.125
+ }
+ ],
+ echartsErrors: [],
+ mermaidErrors: []
+ }).targets
+ ).toHaveLength(1);
+ expect(
+ docxMediaCapturePlanSchema.safeParse({
+ targets: [
+ {
+ id: "../越界",
+ kind: "image",
+ ordinal: 1,
+ kindOrdinal: 1,
+ altText: "",
+ displayWidthPx: 100,
+ displayHeightPx: 100,
+ captureX: 0,
+ captureY: 0,
+ captureWidthPx: 100,
+ captureHeightPx: 100,
+ rasterScale: 3.125
+ }
+ ],
+ echartsErrors: [],
+ mermaidErrors: []
+ }).success
+ ).toBe(false);
+ });
});
diff --git a/packages/preview-engine/README.md b/packages/preview-engine/README.md
index 69ac4ce..6854723 100644
--- a/packages/preview-engine/README.md
+++ b/packages/preview-engine/README.md
@@ -12,6 +12,7 @@ src/
├── paged-document-runtime.ts 连续与分页渲染主流程
├── paged-preview.ts 分页载荷、消息协议与页面 CSS
├── continuous-preview.ts 无分页 DOM 更新与稳定节点复用
+├── docx-media-runtime.ts DOCX 连续媒体舞台与 PNG 捕获计划
├── incremental-pagination.ts 修改边界、稳定前缀与后缀分页缓存
├── paged-table-handler.ts 跨页表格表头处理
├── media-page-backfill.ts 图片与图表按文档顺序回填
@@ -45,6 +46,16 @@ const result = await runtime.render(payload, {
});
const continuous = await runtime.renderContinuous(payload);
+
+const mediaPlan = await renderDocxMediaCapturePlan(
+ runtime,
+ root,
+ payload,
+ {
+ contentWidthPx,
+ contentHeightPx
+ }
+);
```
`payload` 使用 `@md-to-pdf/core` 中的 `PagedDocumentPayload`。主题 CSS、
@@ -69,6 +80,17 @@ const continuous = await runtime.renderContinuous(payload);
得到基准高度;上一页空白与媒体需求接近时,才尝试只缩放媒体主体回填,
标题始终保持自然尺寸。
+## DOCX 媒体舞台
+
+`renderDocxMediaCapturePlan()` 复用连续渲染流程,等待字体、图片、
+Mermaid 和 ECharts 完成后,按 DOM 顺序标记普通图片、Mermaid SVG 与
+ECharts SVG。运行时将媒体限制在纸张内容区内,并返回整数外包围捕获框、
+显示尺寸、替代文本、图注和目标位图倍率。
+
+目标倍率默认为 3.125(300 DPI),并受 4096px 单边和 1600 万像素上限
+约束。运行时只生成平台无关的捕获计划;Server 使用 Playwright Chromium,
+Desktop 使用 Electron Chromium 输出 PNG。
+
## PDF 链接
`preparePdfDocumentLinks()` 仅在 PDF 目标中将本地链接编码为
diff --git a/packages/preview-engine/src/docx-media-runtime.ts b/packages/preview-engine/src/docx-media-runtime.ts
new file mode 100644
index 0000000..62ae4e1
--- /dev/null
+++ b/packages/preview-engine/src/docx-media-runtime.ts
@@ -0,0 +1,210 @@
+import {
+ DOCX_MEDIA_RASTER_SCALE,
+ MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
+ MAXIMUM_DOCX_MEDIA_PIXELS,
+ MAXIMUM_DOCX_RESOURCE_COUNT,
+ type DocxMediaCapturePlan,
+ type DocxMediaCaptureTarget,
+ type DocxMediaKind,
+ type PagedDocumentPayload
+} from "@md-to-pdf/core";
+import { PagedDocumentRuntime } from "./paged-document-runtime.js";
+
+export interface DocxMediaRenderDimensions {
+ contentWidthPx: number;
+ contentHeightPx: number;
+}
+
+declare global {
+ interface Window {
+ __mdToPdfRenderDocxMedia?: (
+ payload: PagedDocumentPayload,
+ dimensions: DocxMediaRenderDimensions
+ ) => Promise;
+ }
+}
+
+function finitePositive(value: number, fallback: number) {
+ return Number.isFinite(value) && value > 0 ? value : fallback;
+}
+
+function getMediaKind(element: Element): DocxMediaKind {
+ if (element.matches("img.md-document-image")) {
+ return "image";
+ }
+ if (element.closest(".md-echarts")) {
+ return "echarts";
+ }
+ return "mermaid";
+}
+
+function getCaption(element: Element) {
+ return (
+ element
+ .closest("figure")
+ ?.querySelector("figcaption")
+ ?.textContent?.trim() || undefined
+ );
+}
+
+function getAltText(
+ element: Element,
+ kind: DocxMediaKind,
+ kindOrdinal: number
+) {
+ if (element instanceof HTMLImageElement) {
+ return element.alt.trim() || `图片 ${kindOrdinal}`;
+ }
+ const labelled =
+ element.getAttribute("aria-label")?.trim() ||
+ element
+ .closest("[aria-label]")
+ ?.getAttribute("aria-label")
+ ?.trim();
+ if (labelled) {
+ return labelled;
+ }
+ return kind === "echarts"
+ ? `ECharts 图表 ${kindOrdinal}`
+ : `Mermaid 图表 ${kindOrdinal}`;
+}
+
+function fitElementToContent(
+ element: HTMLElement | SVGSVGElement,
+ dimensions: DocxMediaRenderDimensions
+) {
+ const initial = element.getBoundingClientRect();
+ const width = finitePositive(initial.width, dimensions.contentWidthPx);
+ const height = finitePositive(
+ initial.height,
+ Math.min(dimensions.contentHeightPx, width * 0.75)
+ );
+ const fitScale = Math.min(
+ 1,
+ dimensions.contentWidthPx / width,
+ dimensions.contentHeightPx / height
+ );
+ if (fitScale < 1) {
+ element.style.width = `${width * fitScale}px`;
+ element.style.height = `${height * fitScale}px`;
+ element.style.maxWidth = "none";
+ element.style.maxHeight = "none";
+ }
+ return element.getBoundingClientRect();
+}
+
+function getRasterScale(width: number, height: number) {
+ return Math.min(
+ DOCX_MEDIA_RASTER_SCALE,
+ MAXIMUM_DOCX_MEDIA_EDGE_PIXELS / width,
+ MAXIMUM_DOCX_MEDIA_EDGE_PIXELS / height,
+ Math.sqrt(MAXIMUM_DOCX_MEDIA_PIXELS / (width * height))
+ );
+}
+
+function createGeometryCss(dimensions: DocxMediaRenderDimensions) {
+ return `
+#preview-root {
+ width: ${dimensions.contentWidthPx}px !important;
+ padding: 0 !important;
+}
+
+#write {
+ width: ${dimensions.contentWidthPx}px !important;
+ padding: 0 !important;
+ box-shadow: none !important;
+}
+`;
+}
+
+export function collectDocxMediaCaptureTargets(
+ root: ParentNode,
+ dimensions: DocxMediaRenderDimensions
+) {
+ const article = root.querySelector("#write");
+ if (!article) {
+ throw new Error("DOCX 媒体舞台缺少 #write 文档容器");
+ }
+ const candidates = Array.from(
+ article.querySelectorAll(
+ [
+ "img.md-document-image",
+ ".mermaid:not(.mermaid-error) svg",
+ ".md-echarts:not(.md-echarts-error) .md-echarts-host svg"
+ ].join(",")
+ )
+ );
+ if (candidates.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
+ throw new Error(
+ `DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_RESOURCE_COUNT}`
+ );
+ }
+
+ const kindOrdinals: Record = {
+ image: 0,
+ mermaid: 0,
+ echarts: 0
+ };
+ return candidates.map((element, index): DocxMediaCaptureTarget => {
+ const kind = getMediaKind(element);
+ kindOrdinals[kind] += 1;
+ const kindOrdinal = kindOrdinals[kind];
+ const rect = fitElementToContent(element, dimensions);
+ const width = finitePositive(rect.width, 1);
+ const height = finitePositive(rect.height, 1);
+ const captureX = Math.max(
+ 0,
+ Math.floor(rect.left + window.scrollX)
+ );
+ const captureY = Math.max(
+ 0,
+ Math.floor(rect.top + window.scrollY)
+ );
+ const captureWidth = Math.max(
+ 1,
+ Math.ceil(rect.right + window.scrollX) - captureX
+ );
+ const captureHeight = Math.max(
+ 1,
+ Math.ceil(rect.bottom + window.scrollY) - captureY
+ );
+ const id = `docx-media-${index + 1}`;
+ element.dataset.docxMediaId = id;
+ return {
+ id,
+ kind,
+ ordinal: index + 1,
+ kindOrdinal,
+ altText: getAltText(element, kind, kindOrdinal),
+ ...(getCaption(element)
+ ? { caption: getCaption(element) }
+ : {}),
+ displayWidthPx: width,
+ displayHeightPx: height,
+ captureX,
+ captureY,
+ captureWidthPx: captureWidth,
+ captureHeightPx: captureHeight,
+ rasterScale: getRasterScale(captureWidth, captureHeight)
+ };
+ });
+}
+
+export async function renderDocxMediaCapturePlan(
+ runtime: PagedDocumentRuntime,
+ root: HTMLElement,
+ payload: PagedDocumentPayload,
+ dimensions: DocxMediaRenderDimensions
+): Promise {
+ const renderResult = await runtime.renderContinuous(payload, {
+ geometryCss: createGeometryCss(dimensions)
+ });
+ if (!renderResult) {
+ throw new Error("DOCX 媒体渲染已取消");
+ }
+ return {
+ targets: collectDocxMediaCaptureTargets(root, dimensions),
+ echartsErrors: renderResult.echartsErrors,
+ mermaidErrors: renderResult.mermaidErrors
+ };
+}
diff --git a/packages/preview-engine/src/index.ts b/packages/preview-engine/src/index.ts
index 474db3a..55224d2 100644
--- a/packages/preview-engine/src/index.ts
+++ b/packages/preview-engine/src/index.ts
@@ -1,6 +1,7 @@
export * from "./diagram-page-fit.js";
export * from "./continuous-preview.js";
export * from "./document-image-fit.js";
+export * from "./docx-media-runtime.js";
export * from "./echarts-page-fit.js";
export * from "./incremental-pagination.js";
export * from "./media-page-backfill.js";
diff --git a/packages/preview-engine/src/paged-document-runtime.ts b/packages/preview-engine/src/paged-document-runtime.ts
index d0b13a5..f8c98bb 100644
--- a/packages/preview-engine/src/paged-document-runtime.ts
+++ b/packages/preview-engine/src/paged-document-runtime.ts
@@ -63,6 +63,11 @@ export interface PagedDocumentRenderOptions {
mermaidOutput?: MermaidOutputMode;
}
+export interface ContinuousDocumentRenderOptions {
+ shouldContinue?: () => boolean;
+ geometryCss?: string;
+}
+
export interface PreviewEngineStyles {
highlightCss: string;
katexCss: string;
@@ -415,7 +420,8 @@ export class PagedDocumentRuntime {
private applyContinuousStyles(
documentRef: Document,
- payload: PagedPreviewPayload
+ payload: PagedPreviewPayload,
+ geometryCss = ""
) {
const style =
this.continuousStyle ?? documentRef.createElement("style");
@@ -427,7 +433,8 @@ export class PagedDocumentRuntime {
this.styles.echartsCss,
enablePrintMediaForPreview(payload.themeCss),
documentInteractionCss,
- continuousDocumentGeometryCss
+ continuousDocumentGeometryCss,
+ geometryCss
].join("\n");
if (!style.isConnected) {
documentRef.head.append(style);
@@ -930,10 +937,7 @@ export class PagedDocumentRuntime {
async renderContinuous(
payload: PagedPreviewPayload,
- options: Pick<
- PagedDocumentRenderOptions,
- "shouldContinue"
- > = {}
+ options: ContinuousDocumentRenderOptions = {}
): Promise {
const totalStartedAt = performance.now();
const shouldContinue = options.shouldContinue ?? (() => true);
@@ -950,7 +954,11 @@ export class PagedDocumentRuntime {
payload.metadata.language || "zh-CN";
documentRef.title =
payload.metadata.title || "Markdown 连续预览";
- this.applyContinuousStyles(documentRef, payload);
+ this.applyContinuousStyles(
+ documentRef,
+ payload,
+ options.geometryCss
+ );
const template = documentRef.createElement("template");
template.innerHTML = payload.articleHtml;
@@ -963,7 +971,8 @@ export class PagedDocumentRuntime {
const nextSignatures = nextNodes.map(createNodeSignature);
const identity = JSON.stringify({
themeCss: payload.themeCss,
- mermaid: payload.exportConfig.mermaid
+ mermaid: payload.exportConfig.mermaid,
+ geometryCss: options.geometryCss
});
const currentArticle =
this.root.querySelector(":scope > #write");
diff --git a/packages/preview-engine/tests/docx-media-runtime.test.ts b/packages/preview-engine/tests/docx-media-runtime.test.ts
new file mode 100644
index 0000000..992d1a7
--- /dev/null
+++ b/packages/preview-engine/tests/docx-media-runtime.test.ts
@@ -0,0 +1,124 @@
+// @vitest-environment happy-dom
+
+import { beforeEach, describe, expect, it } from "vitest";
+import { collectDocxMediaCaptureTargets } from "../src/index.js";
+
+describe("DOCX 媒体捕获计划", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ window.scrollTo(0, 0);
+ });
+
+ it("按文档顺序标记图片、Mermaid 和 ECharts", () => {
+ document.body.innerHTML = `
+
+
+
+ 系统架构
+
+
+
+
+
+
+ 收入趋势
+
+
+ `;
+ const media = Array.from(
+ document.querySelectorAll(
+ "img, svg"
+ )
+ );
+ media.forEach((element, index) => {
+ element.getBoundingClientRect = () =>
+ ({
+ x: 10,
+ y: 20 + index * 100,
+ left: 10,
+ top: 20 + index * 100,
+ right: 650,
+ bottom: 380 + index * 100,
+ width: 640,
+ height: 360,
+ toJSON: () => ({})
+ }) as DOMRect;
+ });
+
+ const targets = collectDocxMediaCaptureTargets(document, {
+ contentWidthPx: 700,
+ contentHeightPx: 900
+ });
+
+ expect(
+ targets.map(
+ ({ id, kind, kindOrdinal, altText, caption }) => ({
+ id,
+ kind,
+ kindOrdinal,
+ altText,
+ caption
+ })
+ )
+ ).toEqual([
+ {
+ id: "docx-media-1",
+ kind: "image",
+ kindOrdinal: 1,
+ altText: "架构截图",
+ caption: "系统架构"
+ },
+ {
+ id: "docx-media-2",
+ kind: "mermaid",
+ kindOrdinal: 1,
+ altText: "处理流程",
+ caption: undefined
+ },
+ {
+ id: "docx-media-3",
+ kind: "echarts",
+ kindOrdinal: 1,
+ altText: "年度收入",
+ caption: "收入趋势"
+ }
+ ]);
+ expect(
+ media.map((element) => element.dataset.docxMediaId)
+ ).toEqual(["docx-media-1", "docx-media-2", "docx-media-3"]);
+ expect(targets[0]?.rasterScale).toBe(3.125);
+ });
+
+ it("限制超大媒体的显示尺寸和 PNG 像素规模", () => {
+ document.body.innerHTML = `
+
+
+
+ `;
+ const image = document.querySelector("img")!;
+ image.getBoundingClientRect = () =>
+ ({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: image.style.width ? 800 : 4000,
+ bottom: image.style.width ? 600 : 3000,
+ width: image.style.width ? 800 : 4000,
+ height: image.style.width ? 600 : 3000,
+ toJSON: () => ({})
+ }) as DOMRect;
+
+ const [target] = collectDocxMediaCaptureTargets(document, {
+ contentWidthPx: 800,
+ contentHeightPx: 900
+ });
+
+ expect(target?.displayWidthPx).toBeLessThanOrEqual(800);
+ expect(
+ (target?.captureWidthPx ?? 0) *
+ (target?.rasterScale ?? 0)
+ ).toBeLessThanOrEqual(4096);
+ expect(target?.altText).toBe("图片 1");
+ });
+});