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"); + }); +});