From bf43433d38c8970278b0a6c5846299473ef05e37 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Thu, 30 Jul 2026 15:05:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Pandoc=20DOCX=20?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/electron-docx-media-capture.ts | 12 +- .../src/playwright-docx-media-capture.ts | 15 +- docs/PROGRESS.md | 18 +- package-lock.json | 1 + package.json | 6 +- packages/application/README.md | 15 +- packages/application/package.json | 1 + .../application/src/docx-export-service.ts | 461 ++++++++++++++++++ .../application/src/docx-media-service.ts | 29 +- packages/application/src/index.ts | 10 + .../tests/docx-export-service.test.ts | 279 +++++++++++ packages/core/src/docx.ts | 2 + packages/docx-engine/README.md | 13 +- .../docx-engine/assets/docx-media-filter.lua | 110 +++++ .../docx-engine/fixtures/pandoc-conversion.md | 37 ++ packages/docx-engine/package.json | 4 +- .../scripts/verify-pandoc-conversion.mjs | 197 ++++++++ .../scripts/verify-pandoc-reference.mjs | 33 +- packages/docx-engine/src/index.ts | 5 + packages/docx-engine/src/pandoc-converter.ts | 406 +++++++++++++++ packages/docx-engine/src/pandoc-media.ts | 151 ++++++ packages/docx-engine/src/pandoc-process.ts | 124 +++++ .../src/pandoc-runtime-manifest.ts | 54 ++ packages/docx-engine/src/pandoc-runtime.ts | 322 ++++++++++++ packages/docx-engine/src/reference-package.ts | 60 ++- packages/docx-engine/src/validator.ts | 34 +- .../tests/pandoc-converter.test.ts | 184 +++++++ .../docx-engine/tests/pandoc-media.test.ts | 106 ++++ .../docx-engine/tests/pandoc-runtime.test.ts | 148 ++++++ .../tests/reference-builder.test.ts | 25 + .../tests/reference-package.test.ts | 18 +- .../tests/reference-test-fixture.ts | 89 ++++ 32 files changed, 2908 insertions(+), 61 deletions(-) create mode 100644 packages/application/src/docx-export-service.ts create mode 100644 packages/application/tests/docx-export-service.test.ts create mode 100644 packages/docx-engine/assets/docx-media-filter.lua create mode 100644 packages/docx-engine/fixtures/pandoc-conversion.md create mode 100644 packages/docx-engine/scripts/verify-pandoc-conversion.mjs create mode 100644 packages/docx-engine/src/pandoc-converter.ts create mode 100644 packages/docx-engine/src/pandoc-media.ts create mode 100644 packages/docx-engine/src/pandoc-process.ts create mode 100644 packages/docx-engine/src/pandoc-runtime-manifest.ts create mode 100644 packages/docx-engine/src/pandoc-runtime.ts create mode 100644 packages/docx-engine/tests/pandoc-converter.test.ts create mode 100644 packages/docx-engine/tests/pandoc-media.test.ts create mode 100644 packages/docx-engine/tests/pandoc-runtime.test.ts create mode 100644 packages/docx-engine/tests/reference-test-fixture.ts diff --git a/apps/desktop/src/electron-docx-media-capture.ts b/apps/desktop/src/electron-docx-media-capture.ts index 7362055..bb2ffb1 100644 --- a/apps/desktop/src/electron-docx-media-capture.ts +++ b/apps/desktop/src/electron-docx-media-capture.ts @@ -23,12 +23,15 @@ function createRenderScript(request: DocxMediaCaptureRequest) { export async function captureDocxMediaWithElectronWebContents( webContents: WebContents, - request: DocxMediaCaptureRequest + request: DocxMediaCaptureRequest, + signal?: AbortSignal ) { + signal?.throwIfAborted(); const plan = (await webContents.executeJavaScript( createRenderScript(request), true )) as DocxMediaCapturePlan; + signal?.throwIfAborted(); const ownsDebugger = !webContents.debugger.isAttached(); if (ownsDebugger) { webContents.debugger.attach("1.3"); @@ -37,6 +40,7 @@ export async function captureDocxMediaWithElectronWebContents( try { const captures = []; for (const target of plan.targets) { + signal?.throwIfAborted(); const result = (await webContents.debugger.sendCommand( "Page.captureScreenshot", { @@ -57,6 +61,7 @@ export async function captureDocxMediaWithElectronWebContents( png: Buffer.from(result.data, "base64") }); } + signal?.throwIfAborted(); return { plan, captures }; } finally { if (ownsDebugger && webContents.debugger.isAttached()) { @@ -70,10 +75,11 @@ export class ElectronDocxMediaCaptureAdapter { constructor(private readonly webContents: WebContents) {} - capture(request: DocxMediaCaptureRequest) { + capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) { return captureDocxMediaWithElectronWebContents( this.webContents, - request + request, + signal ); } } diff --git a/apps/server/src/playwright-docx-media-capture.ts b/apps/server/src/playwright-docx-media-capture.ts index aa3a65e..57c6f90 100644 --- a/apps/server/src/playwright-docx-media-capture.ts +++ b/apps/server/src/playwright-docx-media-capture.ts @@ -34,13 +34,17 @@ async function renderCapturePlan( export async function captureDocxMediaWithPlaywrightPage( page: Page, - request: DocxMediaCaptureRequest + request: DocxMediaCaptureRequest, + signal?: AbortSignal ) { + signal?.throwIfAborted(); const plan = await renderCapturePlan(page, request); + signal?.throwIfAborted(); const session = await page.context().newCDPSession(page); try { const captures = []; for (const target of plan.targets) { + signal?.throwIfAborted(); const result = (await session.send("Page.captureScreenshot", { format: "png", fromSurface: true, @@ -58,6 +62,7 @@ export async function captureDocxMediaWithPlaywrightPage( png: Buffer.from(result.data, "base64") }); } + signal?.throwIfAborted(); return { plan, captures }; } finally { await session.detach(); @@ -69,7 +74,11 @@ export class PlaywrightDocxMediaCaptureAdapter { constructor(private readonly page: Page) {} - capture(request: DocxMediaCaptureRequest) { - return captureDocxMediaWithPlaywrightPage(this.page, request); + capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) { + return captureDocxMediaWithPlaywrightPage( + this.page, + request, + signal + ); } } diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index c8ecffa..df2b70f 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -106,6 +106,15 @@ Server 使用 Playwright Chromium、Desktop 使用 Electron Chromium, 配置和元数据。本机真实 Pandoc 验收覆盖公文 A4 和自定义横向两组模板, 输出仍由标准 Word 段落、文本 Run、表格和代码文本组成,可继续编辑。 +`v0.6.0` 阶段 7 已完成 Pandoc 转换服务:运行时按受信任配置、Desktop +资源、Docker 固定目录和 PATH 顺序发现并精确校验 3.9.0.2;默认模板和 +动态模板使用稳定指纹与有界缓存。静态 Lua Filter 在 Pandoc AST 层将 +普通图片、Mermaid 和 ECharts 替换为高分辨率 PNG,正文、表格、代码和 +公式继续由 Pandoc 生成可编辑结构。每次转换使用独立临时目录和隔离的 +data 目录,完成后校验 OOXML 并清理。共享应用服务提供有界并发、排队、 +总超时、取消、关闭、capability、错误码和完整分阶段耗时;尚未新增 +HTTP 路由、Desktop IPC 或导出按钮。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -985,8 +994,10 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - `packages/application` 负责共享 Markdown 渲染和主题应用服务; `apps/server` 与 `apps/desktop` 分别提供 HTTP 和 IPC 适配。 - `packages/docx-engine` 负责安全读取 Pandoc 默认模板、解析主题 DOCX - 样式、生成动态 OOXML 部件和执行结构校验;Pandoc 进程定位、临时目录 - 与转换超时仍属于后续转换服务。 + 样式、发现并校验固定 Pandoc、生成动态 OOXML、执行静态 Lua 媒体替换、 + 请求级临时转换和最终结构校验。 +- `packages/application/src/docx-export-service.ts` 负责 DOCX capability、 + 并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。 - `apps/desktop/src/desktop-application-controller.ts` 负责 Electron 多窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动 组装、协议和生命周期。 @@ -1022,7 +1033,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏; - 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器; - 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证; -- 阶段 7~8:依次实现 Pandoc 转换服务以及 Web/Desktop 导出交互; +- 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务; +- 阶段 8:实现 Web/Desktop DOCX 导出交互; - 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/package-lock.json b/package-lock.json index 8798221..e5dceab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9903,6 +9903,7 @@ "version": "0.4.1", "dependencies": { "@md-to-pdf/core": "0.1.0", + "@md-to-pdf/docx-engine": "0.1.0", "@md-to-pdf/renderer": "0.1.0" }, "devDependencies": { diff --git a/package.json b/package.json index 21dea44..6d6f7bc 100644 --- a/package.json +++ b/package.json @@ -11,19 +11,21 @@ "scripts": { "build": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop", "build:web-runtime": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/web", - "dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n markdown-echarts,core,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"", + "dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n markdown-echarts,core,docx-engine,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:docx-engine\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"", "dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts", "dev:core": "npm run dev -w @md-to-pdf/core", + "dev:docx-engine": "npm run dev -w @md-to-pdf/docx-engine", "dev:renderer": "npm run dev -w @md-to-pdf/renderer", "dev:application": "npm run dev -w @md-to-pdf/application", "dev:preview-engine": "npm run dev -w @md-to-pdf/preview-engine", "dev:server": "npm run dev -w @md-to-pdf/server", "dev:web": "npm run dev -w @md-to-pdf/web", "dev:desktop": "npm run dev -w @md-to-pdf/desktop", - "desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"", + "desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"", "desktop:package": "npm run package -w @md-to-pdf/desktop", "theme:import-typora": "node scripts/import-typora-theme.mjs", "verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine", + "verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine", "test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", "typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop" }, diff --git a/packages/application/README.md b/packages/application/README.md index 74581a5..602cf10 100644 --- a/packages/application/README.md +++ b/packages/application/README.md @@ -1,15 +1,17 @@ # @md-to-pdf/application Web Server 与 Electron Desktop 共用的应用服务层,组合 -`@md-to-pdf/core` 和 `@md-to-pdf/renderer`,统一处理 Markdown 渲染、 -主题发现、主题资源和图片资源。Fastify 与 Electron IPC 只负责传输和 -平台能力适配。 +`@md-to-pdf/core`、`@md-to-pdf/renderer` 和 +`@md-to-pdf/docx-engine`,统一处理 Markdown 渲染、主题发现、主题资源、 +图片资源和 DOCX 导出编排。Fastify 与 Electron IPC 只负责传输和平台 +能力适配。 ## 目录结构 ```text src/ application-service.ts 渲染、主题和资源用例入口 + docx-export-service.ts DOCX 并发、超时、错误和完整导出编排 docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装 image-resources.ts 本地、Base64 与受限远程图片处理 theme-registry.ts 内置及自定义主题扫描、校验与缓存 @@ -17,6 +19,7 @@ src/ tests/ application-service.test.ts bundled-themes.test.ts + docx-export-service.test.ts docx-media-service.test.ts image-resources.test.ts ``` @@ -53,6 +56,12 @@ Desktop 才会以 Markdown 所在目录为边界解析相对资源。 大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png` 清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。 +`DocxExportService` 串联 capability 探测、请求准备、平台媒体捕获、 +Pandoc 转换和最终 OOXML 校验。默认并发为 1、队列为 4、总超时为 90 秒; +可以通过 `DOCX_CONCURRENCY`、`DOCX_MAX_QUEUE` 和 `DOCX_TIMEOUT_MS` +配置。排队、探测、准备、媒体、模板、Pandoc、校验和总耗时使用共享协议 +返回;关闭服务时会取消活动任务、拒绝排队任务并等待清理完成。 + 内置主题来自仓库 `themes/`,当前名称为 Typora Github、 Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的 本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。 diff --git a/packages/application/package.json b/packages/application/package.json index b8494b2..f097891 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@md-to-pdf/core": "0.1.0", + "@md-to-pdf/docx-engine": "0.1.0", "@md-to-pdf/renderer": "0.1.0" }, "devDependencies": { diff --git a/packages/application/src/docx-export-service.ts b/packages/application/src/docx-export-service.ts new file mode 100644 index 0000000..85f8b1d --- /dev/null +++ b/packages/application/src/docx-export-service.ts @@ -0,0 +1,461 @@ +import { performance } from "node:perf_hooks"; +import { + createDocxFileName, + type DocxCapability, + type DocxExportErrorCode, + type DocxExportResult +} from "@md-to-pdf/core"; +import { + PandocDocxConversionError, + type PandocDocxConversionInput, + type PandocDocxConversionResult, + type PandocRuntimeResolution +} from "@md-to-pdf/docx-engine"; +import { + ApplicationRequestError, + type ApplicationService, + type PreparedDocxExport +} from "./application-service.js"; +import { + prepareDocxMedia, + type DocxMediaCaptureAdapter +} from "./docx-media-service.js"; +import type { ImageResolutionContext } from "./image-resources.js"; + +const DEFAULT_DOCX_CONCURRENCY = 1; +const DEFAULT_DOCX_MAX_QUEUE = 4; +const DEFAULT_DOCX_TIMEOUT_MS = 90_000; + +export interface DocxExportRuntimeLimits { + concurrency: number; + maxQueue: number; + timeoutMs: number; +} + +export interface DocxCapabilityProvider { + probe(): Promise; +} + +export interface DocxConversionPort { + convert( + input: PandocDocxConversionInput, + signal?: AbortSignal + ): Promise; +} + +export interface DocxExportServiceOptions { + application: Pick; + runtime: DocxCapabilityProvider; + converter: DocxConversionPort; + limits?: Partial; +} + +export interface GenerateDocxOptions { + mediaAdapter: DocxMediaCaptureAdapter; + imageContext?: ImageResolutionContext; + signal?: AbortSignal; +} + +export class DocxExportServiceError extends Error { + constructor( + readonly statusCode: 429 | 500 | 503 | 504, + readonly code: DocxExportErrorCode, + message: string, + readonly retryable: boolean, + options?: ErrorOptions + ) { + super(message, options); + this.name = "DocxExportServiceError"; + } +} + +class DocxQueueFullError extends Error {} +class DocxServiceClosedError extends Error {} + +interface QueueEntry { + resolve: () => void; + reject: (error: unknown) => void; + signal: AbortSignal; + abort: () => void; +} + +class DocxConcurrencyGate { + private active = 0; + private closed = false; + private readonly queue: QueueEntry[] = []; + + constructor( + private readonly limit: number, + private readonly maxQueue: number + ) {} + + async run( + task: () => Promise, + signal: AbortSignal + ): Promise { + await this.acquire(signal); + try { + return await task(); + } finally { + this.release(); + } + } + + close() { + this.closed = true; + for (const entry of this.queue.splice(0)) { + entry.signal.removeEventListener("abort", entry.abort); + entry.reject(new DocxServiceClosedError()); + } + } + + private acquire(signal: AbortSignal) { + if (this.closed) { + return Promise.reject(new DocxServiceClosedError()); + } + if (signal.aborted) { + return Promise.reject(signal.reason); + } + if (this.active < this.limit) { + this.active += 1; + return Promise.resolve(); + } + if (this.queue.length >= this.maxQueue) { + return Promise.reject(new DocxQueueFullError()); + } + return new Promise((resolve, reject) => { + const entry: QueueEntry = { + resolve, + reject, + signal, + abort: () => { + const index = this.queue.indexOf(entry); + if (index >= 0) { + this.queue.splice(index, 1); + } + reject(signal.reason); + } + }; + signal.addEventListener("abort", entry.abort, { once: true }); + this.queue.push(entry); + }); + } + + private release() { + this.active -= 1; + while (this.queue.length) { + const entry = this.queue.shift()!; + entry.signal.removeEventListener("abort", entry.abort); + if (entry.signal.aborted) { + entry.reject(entry.signal.reason); + continue; + } + this.active += 1; + entry.resolve(); + break; + } + } +} + +function readIntegerEnvironment( + environment: NodeJS.ProcessEnv, + name: string, + fallback: number, + minimum: number +) { + const raw = environment[name]; + if (raw === undefined || raw.trim() === "") { + return fallback; + } + if (!/^\d+$/u.test(raw)) { + throw new Error(`${name} 必须是整数`); + } + const value = Number.parseInt(raw, 10); + if (!Number.isSafeInteger(value) || value < minimum) { + throw new Error(`${name} 不能小于 ${minimum}`); + } + return value; +} + +export function readDocxExportRuntimeLimits( + environment: NodeJS.ProcessEnv = process.env +): DocxExportRuntimeLimits { + return { + concurrency: readIntegerEnvironment( + environment, + "DOCX_CONCURRENCY", + DEFAULT_DOCX_CONCURRENCY, + 1 + ), + maxQueue: readIntegerEnvironment( + environment, + "DOCX_MAX_QUEUE", + DEFAULT_DOCX_MAX_QUEUE, + 0 + ), + timeoutMs: readIntegerEnvironment( + environment, + "DOCX_TIMEOUT_MS", + DEFAULT_DOCX_TIMEOUT_MS, + 1_000 + ) + }; +} + +function resolveLimits( + values: Partial = {} +): DocxExportRuntimeLimits { + const limits = { + concurrency: values.concurrency ?? DEFAULT_DOCX_CONCURRENCY, + maxQueue: values.maxQueue ?? DEFAULT_DOCX_MAX_QUEUE, + timeoutMs: values.timeoutMs ?? DEFAULT_DOCX_TIMEOUT_MS + }; + if (!Number.isInteger(limits.concurrency) || limits.concurrency < 1) { + throw new Error("DOCX 并发数必须是正整数"); + } + if (!Number.isInteger(limits.maxQueue) || limits.maxQueue < 0) { + throw new Error("DOCX 排队上限必须是非负整数"); + } + if ( + !Number.isInteger(limits.timeoutMs) || + limits.timeoutMs < 10 || + limits.timeoutMs > 10 * 60_000 + ) { + throw new Error("DOCX 总超时必须在 10ms 到 10 分钟之间"); + } + return limits; +} + +function capabilityError(capability: Exclude< + DocxCapability, + { status: "available" } +>) { + const mapping = { + "not-found": "DOCX_RUNTIME_NOT_FOUND", + "version-mismatch": "DOCX_RUNTIME_VERSION_MISMATCH", + "not-executable": "DOCX_RUNTIME_NOT_EXECUTABLE", + "probe-timeout": "DOCX_RUNTIME_PROBE_TIMEOUT" + } as const; + return new DocxExportServiceError( + capability.status === "probe-timeout" ? 504 : 503, + mapping[capability.status], + capability.message, + capability.status === "probe-timeout" + ); +} + +function conversionError(error: PandocDocxConversionError) { + const statusCode = + error.code === "DOCX_RENDER_TIMEOUT" + ? 504 + : error.code === "DOCX_RUNTIME_NOT_FOUND" + ? 503 + : 500; + return new DocxExportServiceError( + statusCode, + error.code, + error.message, + error.code === "DOCX_RENDER_TIMEOUT", + { cause: error } + ); +} + +export class DocxExportService { + private readonly limits: DocxExportRuntimeLimits; + private readonly gate: DocxConcurrencyGate; + private readonly controllers = new Set(); + private readonly jobs = new Set>(); + private closePromise: Promise | undefined; + private closed = false; + + constructor(private readonly options: DocxExportServiceOptions) { + this.limits = resolveLimits(options.limits); + this.gate = new DocxConcurrencyGate( + this.limits.concurrency, + this.limits.maxQueue + ); + } + + async getCapability() { + return (await this.options.runtime.probe()).capability; + } + + generate( + request: unknown, + options: GenerateDocxOptions + ): Promise { + if (this.closed) { + return Promise.reject( + new DocxExportServiceError( + 503, + "DOCX_GENERATION_FAILED", + "DOCX 导出服务已关闭", + false + ) + ); + } + const job = this.runGeneration(request, options); + this.jobs.add(job); + void job.finally(() => this.jobs.delete(job)).catch(() => undefined); + return job; + } + + close() { + this.closePromise ??= this.closeInternal(); + return this.closePromise; + } + + private async closeInternal() { + this.closed = true; + this.gate.close(); + for (const controller of this.controllers) { + controller.abort(new DocxServiceClosedError()); + } + await Promise.allSettled([...this.jobs]); + } + + private async runGeneration( + request: unknown, + options: GenerateDocxOptions + ) { + const totalStarted = performance.now(); + const queuedAt = performance.now(); + const controller = new AbortController(); + this.controllers.add(controller); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(new Error("DOCX 总超时")); + }, this.limits.timeoutMs); + timeout.unref(); + const abortFromCaller = () => + controller.abort(options.signal?.reason); + options.signal?.addEventListener("abort", abortFromCaller, { + once: true + }); + if (options.signal?.aborted) { + abortFromCaller(); + } + + try { + return await this.gate.run(async () => { + const queueMs = performance.now() - queuedAt; + controller.signal.throwIfAborted(); + + const probeStarted = performance.now(); + const resolution = await this.options.runtime.probe(); + const probeMs = performance.now() - probeStarted; + if (resolution.capability.status !== "available") { + throw capabilityError(resolution.capability); + } + controller.signal.throwIfAborted(); + + const prepareStarted = performance.now(); + const prepared = + await this.options.application.prepareDocxExport( + request, + options.imageContext ?? {} + ); + const prepareMs = performance.now() - prepareStarted; + controller.signal.throwIfAborted(); + + const mediaStarted = performance.now(); + const media = await prepareDocxMedia( + prepared, + options.mediaAdapter, + controller.signal + ); + const mediaMs = performance.now() - mediaStarted; + controller.signal.throwIfAborted(); + + const conversion = await this.options.converter.convert( + this.createConversionInput(prepared, media), + controller.signal + ); + return { + docx: conversion.docx, + fileName: createDocxFileName(prepared.request.fileName), + diagnostics: { + warnings: media.warnings, + echartsErrors: media.echartsErrors, + mermaidErrors: media.mermaidErrors + }, + timings: { + queueMs, + probeMs, + prepareMs, + mediaMs, + referenceMs: conversion.timings.referenceMs, + pandocMs: conversion.timings.pandocMs, + validationMs: conversion.timings.validationMs, + totalMs: performance.now() - totalStarted + } + }; + }, controller.signal); + } catch (error) { + if ( + error instanceof ApplicationRequestError || + error instanceof DocxExportServiceError + ) { + throw error; + } + if (error instanceof PandocDocxConversionError) { + throw conversionError(error); + } + if (error instanceof DocxQueueFullError) { + throw new DocxExportServiceError( + 429, + "DOCX_QUEUE_FULL", + "DOCX 生成队列已满", + true + ); + } + if (error instanceof DocxServiceClosedError) { + throw new DocxExportServiceError( + 503, + "DOCX_GENERATION_FAILED", + "DOCX 导出服务已关闭", + false + ); + } + if (controller.signal.aborted) { + throw new DocxExportServiceError( + 504, + "DOCX_RENDER_TIMEOUT", + timedOut ? "DOCX 导出超过总超时" : "DOCX 导出已取消", + timedOut, + { cause: error } + ); + } + throw new DocxExportServiceError( + 500, + "DOCX_GENERATION_FAILED", + "DOCX 导出失败", + false, + { cause: error } + ); + } finally { + clearTimeout(timeout); + options.signal?.removeEventListener( + "abort", + abortFromCaller + ); + this.controllers.delete(controller); + } + } + + private createConversionInput( + prepared: PreparedDocxExport, + media: Awaited> + ): PandocDocxConversionInput { + return { + markdown: prepared.request.markdown, + fileName: prepared.request.fileName, + language: prepared.request.language, + exportConfig: prepared.request.exportConfig, + theme: prepared.theme.manifest, + metadata: prepared.document.metadata, + media + }; + } +} diff --git a/packages/application/src/docx-media-service.ts b/packages/application/src/docx-media-service.ts index 53f9c4c..edd0201 100644 --- a/packages/application/src/docx-media-service.ts +++ b/packages/application/src/docx-media-service.ts @@ -38,7 +38,8 @@ export interface DocxMediaCaptureOutput { export interface DocxMediaCaptureAdapter { capture( - request: DocxMediaCaptureRequest + request: DocxMediaCaptureRequest, + signal?: AbortSignal ): Promise; } @@ -161,18 +162,24 @@ function validateCaptures( export async function prepareDocxMedia( prepared: PreparedDocxExport, - adapter: DocxMediaCaptureAdapter + adapter: DocxMediaCaptureAdapter, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted(); 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 output = await adapter.capture( + { + payload: createPagedDocumentPayload({ + document: prepared.document, + fileName: prepared.request.fileName, + themeCss: prepared.theme.css, + exportConfig: prepared.request.exportConfig + }), + dimensions + }, + signal + ); + signal?.throwIfAborted(); const parsedPlan = docxMediaCapturePlanSchema.safeParse(output.plan); if (!parsedPlan.success) { throw new Error("DOCX 媒体捕获计划无效"); diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index df98652..9b0e4a4 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -30,6 +30,16 @@ export { type DocxMediaCaptureRequest, type DocxMediaRenderDimensions } from "./docx-media-service.js"; +export { + DocxExportService, + DocxExportServiceError, + readDocxExportRuntimeLimits, + type DocxCapabilityProvider, + type DocxConversionPort, + type DocxExportRuntimeLimits, + type DocxExportServiceOptions, + type GenerateDocxOptions +} from "./docx-export-service.js"; export { createThemeRegistry, type ThemeRecord, diff --git a/packages/application/tests/docx-export-service.test.ts b/packages/application/tests/docx-export-service.test.ts new file mode 100644 index 0000000..39d0e43 --- /dev/null +++ b/packages/application/tests/docx-export-service.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it, vi } from "vitest"; +import { + defaultExportConfig, + type DocxCapability, + type PreparedDocxMedia, + type ThemeManifest +} from "@md-to-pdf/core"; +import type { + PandocDocxConversionInput, + PandocDocxConversionResult, + PandocRuntimeResolution +} from "@md-to-pdf/docx-engine"; +import { + DocxExportService, + readDocxExportRuntimeLimits, + type DocxMediaCaptureAdapter, + type PreparedDocxExport +} from "../src/index.js"; + +function theme(): ThemeManifest { + return { + manifestVersion: 1, + id: "test-theme", + name: "测试主题", + version: "1.0.0", + description: "测试", + author: "测试", + license: "内部许可", + entry: "theme.css", + domPreset: "generic", + defaultFontSize: "16px", + supportedFeatures: [], + category: "general", + compatibleProfiles: [], + docxStyle: { preset: "technical" }, + bundled: true + }; +} + +const prepared: PreparedDocxExport = { + request: { + markdown: "# 测试", + fileName: "报告?.md", + language: "zh-CN", + resources: [], + exportConfig: { + ...defaultExportConfig, + themeId: "test-theme" + } + }, + document: { + rendererVersion: 1, + articleHtml: '

测试

', + bodyHtml: "

测试

", + metadata: { + title: "测试", + author: "测试人", + subject: "", + keywords: [], + language: "zh-CN" + }, + features: [], + warnings: [] + }, + theme: { + manifest: theme(), + source: "bundled", + css: "" + } +}; + +const availableCapability: DocxCapability = { + format: "docx", + status: "available", + expectedVersion: "3.9.0.2", + detectedVersion: "3.9.0.2" +}; + +function resolution( + capability: DocxCapability = availableCapability +): PandocRuntimeResolution { + return capability.status === "available" + ? { capability, executablePath: "pandoc" } + : { capability }; +} + +const emptyAdapter: DocxMediaCaptureAdapter = { + async capture() { + return { + plan: { + targets: [], + echartsErrors: [], + mermaidErrors: [] + }, + captures: [] + }; + } +}; + +function conversionResult(): PandocDocxConversionResult { + return { + docx: new Uint8Array([1, 2, 3]), + templateFingerprint: "a".repeat(64), + templateCacheKey: "b".repeat(64), + validation: { + partCount: 10, + xmlPartCount: 9, + relationshipCount: 3, + headerCount: 0, + footerCount: 1 + }, + timings: { + referenceMs: 3, + pandocMs: 4, + validationMs: 5 + } + }; +} + +function createService(options: { + convert?: ( + input: PandocDocxConversionInput, + signal?: AbortSignal + ) => Promise; + capability?: DocxCapability; + limits?: { + concurrency: number; + maxQueue: number; + timeoutMs: number; + }; +}) { + return new DocxExportService({ + application: { + prepareDocxExport: vi.fn().mockResolvedValue(prepared) + }, + runtime: { + probe: vi + .fn() + .mockResolvedValue(resolution(options.capability)) + }, + converter: { + convert: + options.convert ?? + vi.fn().mockResolvedValue(conversionResult()) + }, + limits: options.limits + }); +} + +describe("DOCX 共享导出服务", () => { + it("串联准备、媒体和转换并返回完整耗时", async () => { + const service = createService({}); + const result = await service.generate( + { markdown: "# 测试" }, + { mediaAdapter: emptyAdapter } + ); + + expect(result.docx).toEqual(new Uint8Array([1, 2, 3])); + expect(result.fileName).toBe("报告_.docx"); + expect(result.diagnostics).toEqual({ + warnings: [], + echartsErrors: [], + mermaidErrors: [] + }); + expect(result.timings).toMatchObject({ + referenceMs: 3, + pandocMs: 4, + validationMs: 5 + }); + for (const value of Object.values(result.timings)) { + expect(value).toBeGreaterThanOrEqual(0); + } + await service.close(); + }); + + it("限制并发和排队长度", async () => { + let releaseFirst!: () => void; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + let active = 0; + let maximumActive = 0; + let calls = 0; + const service = createService({ + limits: { + concurrency: 1, + maxQueue: 1, + timeoutMs: 5_000 + }, + convert: async () => { + calls += 1; + active += 1; + maximumActive = Math.max(maximumActive, active); + if (calls === 1) { + await firstBlocked; + } + active -= 1; + return conversionResult(); + } + }); + + const first = service.generate({}, { mediaAdapter: emptyAdapter }); + await vi.waitFor(() => expect(calls).toBe(1)); + const second = service.generate({}, { mediaAdapter: emptyAdapter }); + const third = service.generate({}, { mediaAdapter: emptyAdapter }); + await expect(third).rejects.toMatchObject({ + code: "DOCX_QUEUE_FULL", + retryable: true + }); + releaseFirst(); + await Promise.all([first, second]); + + expect(maximumActive).toBe(1); + expect(calls).toBe(2); + await service.close(); + }); + + it("总超时会传播取消信号并释放任务", async () => { + const abortAwareAdapter: DocxMediaCaptureAdapter = { + capture(_request, signal) { + return new Promise((_resolve, reject) => { + signal?.addEventListener( + "abort", + () => reject(signal.reason), + { once: true } + ); + }); + } + }; + const service = createService({ + limits: { + concurrency: 1, + maxQueue: 0, + timeoutMs: 30 + } + }); + + await expect( + service.generate({}, { mediaAdapter: abortAwareAdapter }) + ).rejects.toMatchObject({ + code: "DOCX_RENDER_TIMEOUT", + retryable: true + }); + await service.close(); + }); + + it("映射 capability 错误并解析运行限制", async () => { + const unavailable: DocxCapability = { + format: "docx", + status: "version-mismatch", + expectedVersion: "3.9.0.2", + detectedVersion: "3.9.0.1", + message: "版本不匹配" + }; + const service = createService({ capability: unavailable }); + await expect( + service.generate({}, { mediaAdapter: emptyAdapter }) + ).rejects.toMatchObject({ + statusCode: 503, + code: "DOCX_RUNTIME_VERSION_MISMATCH" + }); + await service.close(); + + expect( + readDocxExportRuntimeLimits({ + DOCX_CONCURRENCY: "2", + DOCX_MAX_QUEUE: "6", + DOCX_TIMEOUT_MS: "120000" + }) + ).toEqual({ + concurrency: 2, + maxQueue: 6, + timeoutMs: 120_000 + }); + expect(() => + readDocxExportRuntimeLimits({ DOCX_CONCURRENCY: "0" }) + ).toThrow("不能小于 1"); + }); +}); diff --git a/packages/core/src/docx.ts b/packages/core/src/docx.ts index 01e15ba..eff42dc 100644 --- a/packages/core/src/docx.ts +++ b/packages/core/src/docx.ts @@ -16,6 +16,7 @@ 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 MAXIMUM_DOCX_OUTPUT_BYTES = 64 * 1024 * 1024; export const documentExportFormatSchema = z.enum(["pdf", "docx"]); export type DocumentExportFormat = z.infer< @@ -182,6 +183,7 @@ export type DocxExportErrorResponse = z.infer< >; export interface DocxGenerationTimings { + queueMs: number; probeMs: number; prepareMs: number; mediaMs: number; diff --git a/packages/docx-engine/README.md b/packages/docx-engine/README.md index 9c3f8c4..17df818 100644 --- a/packages/docx-engine/README.md +++ b/packages/docx-engine/README.md @@ -3,7 +3,9 @@ 纯 Node.js 的 DOCX 模板和 Pandoc 编排核心。当前阶段负责读取固定 Pandoc 版本的默认 `reference.docx`、验证 ZIP 安全边界,将主题 DOCX 样式声明 解析为完整样式预设,并生成纸张、页边距、字体、段落、代码、表格、 -页眉、页脚和页码均已映射的动态模板。 +页眉、页脚和页码均已映射的动态模板。Pandoc 转换器通过静态 Lua Filter +将普通图片、Mermaid 和 ECharts 节点替换为受控 PNG,同时保留 Markdown +正文、标题、列表、表格、代码和公式的可编辑文档结构。 ## 设计边界 @@ -12,7 +14,10 @@ - OOXML 使用 `@xmldom/xmldom`,不拼接未经转义的用户 XML; - 不解析任意主题 CSS,主题通过 `docxStyle` 提供受限结构化覆盖; - 生成结果会复验全部 XML、包内关系、内容类型、最终节和关键样式; -- Pandoc 进程定位、超时和临时目录属于后续转换服务阶段。 +- 每次转换使用独立临时目录、隔离 Pandoc data 目录并在 `finally` 清理; +- 媒体映射检查顺序、PNG、像素、单图/总大小和物理显示尺寸; +- 并发排队、跨端用例编排和错误协议映射由 + `@md-to-pdf/application` 的 `DocxExportService` 统一负责。 ## 安全限制 @@ -21,6 +26,7 @@ - 解压后总大小不超过 64 MiB; - 拒绝加密、ZIP64、未知压缩方法、重复路径和路径穿越; - 必须包含文档、样式、字体、编号、设置、关系和内容类型等关键部件。 +- 最终 DOCX 不超过 64 MiB、512 个部件和 128 MiB 解压内容。 ## 验证 @@ -29,9 +35,10 @@ npm run test -w @md-to-pdf/docx-engine npm run typecheck -w @md-to-pdf/docx-engine npm run build -w @md-to-pdf/docx-engine npm run verify:docx-reference +npm run verify:docx-conversion ``` `verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过 -`MD_TO_PDF_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板, +`DOCX_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板, 分别验证公文 A4 与自定义横向模板,并在系统临时目录中完成转换和清理, 不会保留用户文档或验收产物。 diff --git a/packages/docx-engine/assets/docx-media-filter.lua b/packages/docx-engine/assets/docx-media-filter.lua new file mode 100644 index 0000000..bcf9b03 --- /dev/null +++ b/packages/docx-engine/assets/docx-media-filter.lua @@ -0,0 +1,110 @@ +local map_path = os.getenv("MD_TO_PDF_DOCX_MEDIA_MAP") + +if map_path == nil or map_path == "" then + error("DOCX media map path is missing") +end + +local map_file, open_error = io.open(map_path, "rb") +if map_file == nil then + error("DOCX media map cannot be opened: " .. tostring(open_error)) +end + +local map_content = map_file:read("*a") +map_file:close() + +local media_map = pandoc.json.decode(map_content, false) +local counters = { + image = 0, + mermaid = 0, + echarts = 0 +} + +local function contains_class(classes, expected) + for _, class_name in ipairs(classes) do + if class_name == expected then + return true + end + end + return false +end + +local function next_media(kind) + counters[kind] = counters[kind] + 1 + local media = media_map[kind][counters[kind]] + if media == nil then + error( + "DOCX media map does not contain " + .. kind + .. " #" + .. tostring(counters[kind]) + ) + end + return media +end + +local function media_image(media) + return pandoc.Image( + pandoc.Inlines { pandoc.Str(media.alt_text) }, + media.path, + "", + { + width = media.width, + height = media.height + } + ) +end + +local function replace_image(image) + local media = next_media("image") + image.src = media.path + image.caption = pandoc.Inlines { pandoc.Str(media.alt_text) } + image.attributes.width = media.width + image.attributes.height = media.height + return image +end + +local function replace_code_block(block) + local kind + if contains_class(block.classes, "mermaid") then + kind = "mermaid" + elseif contains_class(block.classes, "echarts") then + kind = "echarts" + else + return block + end + + local media = next_media(kind) + local image = media_image(media) + if media.caption ~= nil and media.caption ~= "" then + return pandoc.Figure( + { pandoc.Plain { image } }, + pandoc.Caption { + pandoc.Plain { pandoc.Str(media.caption) } + } + ) + end + return pandoc.Para { image } +end + +local function validate_counts() + for _, kind in ipairs({ "image", "mermaid", "echarts" }) do + if counters[kind] ~= #media_map[kind] then + error( + "DOCX media map contains unused " + .. kind + .. " resources" + ) + end + end +end + +return { + Pandoc = function(document) + local transformed = document:walk { + Image = replace_image, + CodeBlock = replace_code_block + } + validate_counts() + return transformed + end +} diff --git a/packages/docx-engine/fixtures/pandoc-conversion.md b/packages/docx-engine/fixtures/pandoc-conversion.md new file mode 100644 index 0000000..b6c651e --- /dev/null +++ b/packages/docx-engine/fixtures/pandoc-conversion.md @@ -0,0 +1,37 @@ +--- +title: Pandoc 媒体转换验收 +author: Markdown PDF 导出器研发组 +--- + +# 可编辑正文 + +这段中文正文在 DOCX 中必须继续作为标准文本编辑。 + +![普通图片](ignored-original-image.png) + +```mermaid +flowchart LR + A[开始] --> B[结束] +``` + +```echarts +version: 1 +option: + xAxis: + type: category + data: [一月, 二月] + yAxis: + type: value + series: + - type: bar + data: [10, 20] +``` + +| 项目 | 结果 | +| --- | --- | +| 文本 | 可编辑 | +| 媒体 | PNG | + +```typescript +export const editable = true; +``` diff --git a/packages/docx-engine/package.json b/packages/docx-engine/package.json index 449e59f..7637853 100644 --- a/packages/docx-engine/package.json +++ b/packages/docx-engine/package.json @@ -12,6 +12,7 @@ } }, "files": [ + "assets", "dist" ], "scripts": { @@ -19,7 +20,8 @@ "build": "tsc -p tsconfig.json", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", - "verify:pandoc": "node scripts/verify-pandoc-reference.mjs" + "verify:pandoc": "node scripts/verify-pandoc-reference.mjs", + "verify:conversion": "node scripts/verify-pandoc-conversion.mjs" }, "dependencies": { "@md-to-pdf/core": "0.1.0", diff --git a/packages/docx-engine/scripts/verify-pandoc-conversion.mjs b/packages/docx-engine/scripts/verify-pandoc-conversion.mjs new file mode 100644 index 0000000..55d57be --- /dev/null +++ b/packages/docx-engine/scripts/verify-pandoc-conversion.mjs @@ -0,0 +1,197 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + defaultExportConfig, + themeManifestSchema +} from "@md-to-pdf/core"; +import { + PandocDocxConverter, + PandocRuntime +} from "@md-to-pdf/docx-engine"; +import { unzipSync } from "fflate"; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const packageDirectory = path.resolve(directory, ".."); +const repositoryDirectory = path.resolve(packageDirectory, "../.."); +const markdown = fs.readFileSync( + path.join(packageDirectory, "fixtures", "pandoc-conversion.md"), + "utf8" +); +const theme = themeManifestSchema.parse( + JSON.parse( + fs.readFileSync( + path.join( + repositoryDirectory, + "themes", + "gov-red-standard", + "theme.json" + ), + "utf8" + ) + ) +); +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64" +); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function resource(kind, ordinal, dimensions, caption) { + return { + id: `docx-media-${ordinal}`, + kind, + ordinal, + kindOrdinal: 1, + altText: + kind === "image" + ? "普通图片" + : kind === "mermaid" + ? "Mermaid 流程图" + : "ECharts 柱状图", + ...(caption ? { caption } : {}), + displayWidthPx: dimensions.width, + displayHeightPx: dimensions.height, + captureX: 0, + captureY: 0, + captureWidthPx: dimensions.width, + captureHeightPx: dimensions.height, + rasterScale: 1, + fileName: `media-${ordinal}.png`, + contentType: "image/png", + content: png, + pixelWidth: 1, + pixelHeight: 1 + }; +} + +const runtime = new PandocRuntime( + process.env.DOCX_PANDOC_PATH?.trim() + ? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() } + : {} +); +const converter = new PandocDocxConverter({ runtime }); +const conversionInput = { + markdown, + fileName: "Pandoc 媒体转换验收.md", + language: "zh-CN", + exportConfig: { + ...defaultExportConfig, + themeId: theme.id + }, + theme, + metadata: { + title: "Pandoc 媒体转换验收", + author: "Markdown PDF 导出器研发组", + subject: "", + keywords: [], + language: "zh-CN" + }, + media: { + resources: [ + resource("image", 1, { width: 384, height: 192 }), + resource( + "mermaid", + 2, + { width: 480, height: 240 }, + "Mermaid 流程图" + ), + resource( + "echarts", + 3, + { width: 576, height: 288 }, + "ECharts 柱状图" + ) + ], + echartsErrors: [], + mermaidErrors: [], + warnings: [], + totalBytes: png.byteLength * 3 + } +}; +const result = await converter.convert(conversionInput); + +const entries = unzipSync(result.docx); +const documentXml = new TextDecoder().decode( + entries["word/document.xml"] +); +const mediaParts = Object.keys(entries).filter((name) => + /^word\/media\/.+\.png$/u.test(name) +); +const physicalExtents = Array.from( + documentXml.matchAll( + /]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/gu + ), + (match) => ({ + width: Number(match[1]), + height: Number(match[2]) + }) +); +const checks = { + editableText: documentXml.includes("标准文本编辑"), + editableTable: documentXml.includes(" + Math.abs( + extent.width - [3657600, 4572000, 5486400][index] + ) <= 360 && + Math.abs( + extent.height - [1828800, 2286000, 2743200][index] + ) <= 360 + ), + embeddedPng: mediaParts.length >= 1 +}; +for (const [name, passed] of Object.entries(checks)) { + assert( + passed, + `真实 DOCX 媒体转换检查失败:${name},实际尺寸 ${JSON.stringify(physicalExtents)}` + ); +} + +let mismatchedMediaRejected = false; +try { + await converter.convert({ + ...conversionInput, + media: { + ...conversionInput.media, + resources: conversionInput.media.resources.slice(0, 2), + totalBytes: png.byteLength * 2 + } + }); +} catch (error) { + mismatchedMediaRejected = + error?.code === "DOCX_GENERATION_FAILED"; +} +assert( + mismatchedMediaRejected, + "Lua Filter 未拒绝缺失的 ECharts PNG 映射" +); + +console.log( + JSON.stringify( + { + bytes: result.docx.byteLength, + templateFingerprint: result.templateFingerprint, + templateCacheKey: result.templateCacheKey, + validation: result.validation, + mediaParts, + physicalExtents, + mismatchedMediaRejected, + checks, + timings: result.timings + }, + null, + 2 + ) +); diff --git a/packages/docx-engine/scripts/verify-pandoc-reference.mjs b/packages/docx-engine/scripts/verify-pandoc-reference.mjs index 915fb13..1ec7a06 100644 --- a/packages/docx-engine/scripts/verify-pandoc-reference.mjs +++ b/packages/docx-engine/scripts/verify-pandoc-reference.mjs @@ -10,7 +10,8 @@ import { } from "@md-to-pdf/core"; import { createDynamicReferenceDocx, - validateDynamicReferenceDocx + PandocRuntime, + validateGeneratedDocx } from "@md-to-pdf/docx-engine"; import { unzipSync } from "fflate"; @@ -28,8 +29,19 @@ const themePath = path.join( "gov-red-standard", "theme.json" ); -const pandocExecutable = - process.env.MD_TO_PDF_PANDOC_PATH?.trim() || "pandoc"; +const runtime = new PandocRuntime( + process.env.DOCX_PANDOC_PATH?.trim() + ? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() } + : {} +); +const runtimeResolution = await runtime.probe(); +assert( + runtimeResolution.capability.status === "available", + runtimeResolution.capability.status === "available" + ? "" + : runtimeResolution.capability.message +); +const pandocExecutable = await runtime.getExecutablePath(); const maximumBuffer = 128 * 1024 * 1024; const decoder = new TextDecoder(); @@ -64,7 +76,7 @@ function countMatches(value, pattern) { } function inspectResult(content, expected) { - const validation = validateDynamicReferenceDocx(content); + const validation = validateGeneratedDocx(content); const entries = unzipSync(content); const documentXml = decoder.decode(entries["word/document.xml"]); const stylesXml = decoder.decode(entries["word/styles.xml"]); @@ -174,22 +186,13 @@ function createVariant( }; } -const versionResult = runPandoc(["--version"]); -const detectedVersion = - versionResult.stdout - .split(/\r?\n/u)[0] - ?.replace(/^pandoc\s+/u, "") - .trim() ?? ""; +const detectedVersion = runtimeResolution.capability.detectedVersion; assert( detectedVersion === DOCX_PANDOC_VERSION, `Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${detectedVersion || "未知"}` ); -const baselineResult = runPandoc( - ["--print-default-data-file", "reference.docx"], - { binary: true } -); -const baseline = new Uint8Array(baselineResult.stdout); +const baseline = await runtime.getDefaultReferenceDocx(); const theme = themeManifestSchema.parse( JSON.parse(fs.readFileSync(themePath, "utf8")) ); diff --git a/packages/docx-engine/src/index.ts b/packages/docx-engine/src/index.ts index ea6023d..63382a1 100644 --- a/packages/docx-engine/src/index.ts +++ b/packages/docx-engine/src/index.ts @@ -1,5 +1,10 @@ export * from "./header-footer-transform.js"; export * from "./ooxml.js"; +export * from "./pandoc-process.js"; +export * from "./pandoc-media.js"; +export * from "./pandoc-converter.js"; +export * from "./pandoc-runtime-manifest.js"; +export * from "./pandoc-runtime.js"; export * from "./reference-builder.js"; export * from "./reference-options.js"; export * from "./reference-package.js"; diff --git a/packages/docx-engine/src/pandoc-converter.ts b/packages/docx-engine/src/pandoc-converter.ts new file mode 100644 index 0000000..22be34a --- /dev/null +++ b/packages/docx-engine/src/pandoc-converter.ts @@ -0,0 +1,406 @@ +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { + MAXIMUM_DOCX_OUTPUT_BYTES, + type DocxExportErrorCode, + type ExportConfig, + type MarkdownDocumentMetadata, + type PreparedDocxMedia, + type ThemeManifest +} from "@md-to-pdf/core"; +import { + createDynamicReferenceCacheKey, + createDynamicReferenceDocx, + type DynamicReferenceDocxResult +} from "./reference-builder.js"; +import type { DynamicReferenceDocxOptions } from "./reference-options.js"; +import { preparePandocMedia } from "./pandoc-media.js"; +import { + runPandocProcess, + type PandocProcessRunner +} from "./pandoc-process.js"; +import { readReferenceDocxPackage } from "./reference-package.js"; +import type { PandocRuntime } from "./pandoc-runtime.js"; +import { + validateGeneratedDocx, + type DynamicReferenceValidation +} from "./validator.js"; + +const DEFAULT_CONVERSION_TIMEOUT_MS = 60_000; +const MAXIMUM_PANDOC_STDOUT_BYTES = 1024 * 1024; +const MAXIMUM_PANDOC_STDERR_BYTES = 64 * 1024; +const DEFAULT_REFERENCE_CACHE_SIZE = 32; +const temporaryDirectoryPrefix = "md-to-pdf-docx-"; +const mediaMapEnvironmentName = "MD_TO_PDF_DOCX_MEDIA_MAP"; +const luaFilterUrl = new URL( + "../assets/docx-media-filter.lua", + import.meta.url +); + +export interface PandocRuntimeProvider { + getExecutablePath(): Promise; + getDefaultReferenceDocx(): Promise; +} + +export interface PandocDocxConversionInput { + markdown: string; + fileName: string; + language: string; + exportConfig: ExportConfig; + theme: ThemeManifest; + metadata: MarkdownDocumentMetadata; + media: PreparedDocxMedia; +} + +export interface PandocDocxConversionTimings { + referenceMs: number; + pandocMs: number; + validationMs: number; +} + +export interface PandocDocxConversionResult { + docx: Uint8Array; + templateFingerprint: string; + templateCacheKey: string; + validation: DynamicReferenceValidation; + timings: PandocDocxConversionTimings; +} + +export interface PandocDocxConverterOptions { + runtime: PandocRuntimeProvider | PandocRuntime; + runner?: PandocProcessRunner; + environment?: NodeJS.ProcessEnv; + temporaryRoot?: string; + timeoutMs?: number; + maximumOutputBytes?: number; + referenceCacheSize?: number; +} + +export class PandocDocxConversionError extends Error { + constructor( + readonly code: Extract< + DocxExportErrorCode, + | "DOCX_RUNTIME_NOT_FOUND" + | "DOCX_RENDER_TIMEOUT" + | "DOCX_GENERATION_FAILED" + | "DOCX_OUTPUT_INVALID" + >, + message: string, + options?: ErrorOptions + ) { + super(message, options); + this.name = "PandocDocxConversionError"; + } +} + +let luaFilterContent: Promise | undefined; + +function loadLuaFilter() { + luaFilterContent ??= readFile(luaFilterUrl); + return luaFilterContent; +} + +function ensureOutputLimit(value: number | undefined) { + const limit = value ?? MAXIMUM_DOCX_OUTPUT_BYTES; + if ( + !Number.isInteger(limit) || + limit < 1024 || + limit > MAXIMUM_DOCX_OUTPUT_BYTES + ) { + throw new Error("DOCX 输出大小上限无效"); + } + return limit; +} + +function ensureTimeout(value: number | undefined) { + const timeout = value ?? DEFAULT_CONVERSION_TIMEOUT_MS; + if ( + !Number.isInteger(timeout) || + timeout < 1_000 || + timeout > 10 * 60_000 + ) { + throw new Error("DOCX 转换超时配置无效"); + } + return timeout; +} + +function referenceOptions( + input: PandocDocxConversionInput +): DynamicReferenceDocxOptions { + return { + exportConfig: input.exportConfig, + theme: input.theme, + fileName: input.fileName, + metadata: { + title: input.metadata.title, + author: input.metadata.author + } + }; +} + +function pandocArguments(paths: { + markdown: string; + output: string; + reference: string; + luaFilter: string; + resourceDirectory: string; + dataDirectory: string; + language: string; +}) { + return [ + paths.markdown, + "--from", + "markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars", + "--to", + "docx", + "--standalone", + "--reference-doc", + paths.reference, + "--lua-filter", + paths.luaFilter, + "--resource-path", + paths.resourceDirectory, + "--data-dir", + paths.dataDirectory, + "--metadata", + `lang=${paths.language}`, + "--output", + paths.output + ]; +} + +export class PandocDocxConverter { + private readonly runner: PandocProcessRunner; + private readonly timeoutMs: number; + private readonly maximumOutputBytes: number; + private readonly referenceCacheSize: number; + private readonly referenceCache = new Map< + string, + DynamicReferenceDocxResult + >(); + + constructor(private readonly options: PandocDocxConverterOptions) { + this.runner = options.runner ?? runPandocProcess; + this.timeoutMs = ensureTimeout(options.timeoutMs); + this.maximumOutputBytes = ensureOutputLimit( + options.maximumOutputBytes + ); + this.referenceCacheSize = + options.referenceCacheSize ?? DEFAULT_REFERENCE_CACHE_SIZE; + if ( + !Number.isInteger(this.referenceCacheSize) || + this.referenceCacheSize < 1 || + this.referenceCacheSize > 128 + ) { + throw new Error("DOCX 动态模板缓存容量无效"); + } + } + + async convert( + input: PandocDocxConversionInput, + signal?: AbortSignal + ): Promise { + let preparedMedia; + try { + preparedMedia = preparePandocMedia(input.media); + } catch (error) { + throw new PandocDocxConversionError( + "DOCX_GENERATION_FAILED", + "DOCX 媒体映射无效", + { cause: error } + ); + } + + const temporaryDirectory = await mkdtemp( + path.join( + this.options.temporaryRoot ?? os.tmpdir(), + temporaryDirectoryPrefix + ) + ); + try { + const referenceStarted = performance.now(); + const [executablePath, baseline, filterContent] = + await Promise.all([ + this.options.runtime.getExecutablePath(), + this.options.runtime.getDefaultReferenceDocx(), + loadLuaFilter() + ]); + const reference = this.getOrCreateReference(baseline, input); + const referenceMs = performance.now() - referenceStarted; + + const paths = { + markdown: path.join(temporaryDirectory, "document.md"), + output: path.join(temporaryDirectory, "document.docx"), + reference: path.join(temporaryDirectory, "reference.docx"), + luaFilter: path.join( + temporaryDirectory, + "docx-media-filter.lua" + ), + mediaMap: path.join(temporaryDirectory, "media-map.json"), + mediaDirectory: path.join(temporaryDirectory, "media"), + dataDirectory: path.join(temporaryDirectory, "pandoc-data") + }; + await Promise.all([ + mkdir(paths.mediaDirectory), + mkdir(paths.dataDirectory) + ]); + await Promise.all([ + writeFile(paths.markdown, input.markdown, "utf8"), + writeFile(paths.reference, reference.content), + writeFile(paths.luaFilter, filterContent), + writeFile( + paths.mediaMap, + JSON.stringify(preparedMedia.map), + "utf8" + ), + ...preparedMedia.files.map((file) => + writeFile( + path.join(temporaryDirectory, file.relativePath), + file.content + ) + ) + ]); + + const pandocStarted = performance.now(); + const processResult = await this.runner( + executablePath, + pandocArguments({ + markdown: paths.markdown, + output: paths.output, + reference: paths.reference, + luaFilter: paths.luaFilter, + resourceDirectory: temporaryDirectory, + dataDirectory: paths.dataDirectory, + language: input.language + }), + { + cwd: temporaryDirectory, + env: { + ...(this.options.environment ?? process.env), + [mediaMapEnvironmentName]: paths.mediaMap + }, + timeoutMs: this.timeoutMs, + maxStdoutBytes: MAXIMUM_PANDOC_STDOUT_BYTES, + maxStderrBytes: MAXIMUM_PANDOC_STDERR_BYTES, + ...(signal ? { signal } : {}) + } + ); + const pandocMs = performance.now() - pandocStarted; + if ( + processResult.outcome === "timeout" || + processResult.outcome === "aborted" + ) { + throw new PandocDocxConversionError( + "DOCX_RENDER_TIMEOUT", + "DOCX 转换超时或已取消" + ); + } + if ( + processResult.outcome !== "completed" || + processResult.exitCode !== 0 + ) { + throw new PandocDocxConversionError( + processResult.outcome === "not-found" + ? "DOCX_RUNTIME_NOT_FOUND" + : "DOCX_GENERATION_FAILED", + "Pandoc 未能生成 DOCX" + ); + } + + const validationStarted = performance.now(); + const outputStat = await stat(paths.output); + if ( + !outputStat.isFile() || + outputStat.size < 1 || + outputStat.size > this.maximumOutputBytes + ) { + throw new PandocDocxConversionError( + "DOCX_OUTPUT_INVALID", + "DOCX 输出大小无效" + ); + } + const docx = await readFile(paths.output); + let validation: DynamicReferenceValidation; + try { + validation = validateGeneratedDocx(docx); + } catch (error) { + throw new PandocDocxConversionError( + "DOCX_OUTPUT_INVALID", + "DOCX 输出结构无效", + { cause: error } + ); + } + const validationMs = performance.now() - validationStarted; + return { + docx, + templateFingerprint: reference.templateFingerprint, + templateCacheKey: reference.cacheKey, + validation, + timings: { + referenceMs, + pandocMs, + validationMs + } + }; + } catch (error) { + if (error instanceof PandocDocxConversionError) { + throw error; + } + throw new PandocDocxConversionError( + "DOCX_GENERATION_FAILED", + "DOCX 转换失败", + { cause: error } + ); + } finally { + await rm(temporaryDirectory, { + recursive: true, + force: true + }); + } + } + + clearReferenceCache() { + this.referenceCache.clear(); + } + + private getOrCreateReference( + baseline: Uint8Array, + input: PandocDocxConversionInput + ) { + const options = referenceOptions(input); + const baselineFingerprint = + readReferenceDocxPackage(baseline).fingerprint; + const cacheKey = createDynamicReferenceCacheKey( + baselineFingerprint, + options + ); + const cached = this.referenceCache.get(cacheKey); + if (cached) { + this.referenceCache.delete(cacheKey); + this.referenceCache.set(cacheKey, cached); + return cached; + } + const reference = createDynamicReferenceDocx( + baseline, + options + ); + this.referenceCache.set(cacheKey, reference); + while (this.referenceCache.size > this.referenceCacheSize) { + const oldest = this.referenceCache.keys().next().value; + if (oldest === undefined) { + break; + } + this.referenceCache.delete(oldest); + } + return reference; + } +} diff --git a/packages/docx-engine/src/pandoc-media.ts b/packages/docx-engine/src/pandoc-media.ts new file mode 100644 index 0000000..2d36f70 --- /dev/null +++ b/packages/docx-engine/src/pandoc-media.ts @@ -0,0 +1,151 @@ +import { + DOCX_MEDIA_CSS_DPI, + MAXIMUM_DOCX_MEDIA_BYTES, + MAXIMUM_DOCX_MEDIA_EDGE_PIXELS, + MAXIMUM_DOCX_MEDIA_PIXELS, + MAXIMUM_DOCX_RESOURCE_COUNT, + MAXIMUM_DOCX_TOTAL_MEDIA_BYTES, + type DocxMediaKind, + type PreparedDocxMedia +} from "@md-to-pdf/core"; + +export interface PandocMediaMapItem { + id: string; + path: string; + alt_text: string; + caption?: string; + width: string; + height: string; +} + +export interface PandocMediaMap { + image: PandocMediaMapItem[]; + mermaid: PandocMediaMapItem[]; + echarts: PandocMediaMapItem[]; +} + +export interface PandocMediaFile { + relativePath: string; + content: Uint8Array; +} + +export interface PreparedPandocMedia { + map: PandocMediaMap; + files: PandocMediaFile[]; +} + +function pixelsToMillimeters(value: number) { + return (value * 25.4) / DOCX_MEDIA_CSS_DPI; +} + +function physicalLength(value: number) { + if (!Number.isFinite(value) || value <= 0 || value > 10_000) { + throw new Error("DOCX 媒体显示尺寸无效"); + } + return `${pixelsToMillimeters(value).toFixed(3)}mm`; +} + +function assertPng(content: Uint8Array) { + const signature = [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a + ]; + if ( + content.byteLength < 24 || + signature.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 preparePandocMedia( + media: PreparedDocxMedia +): PreparedPandocMedia { + if (media.echartsErrors.length || media.mermaidErrors.length) { + throw new Error("DOCX 图表渲染存在错误,已中止转换"); + } + if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) { + throw new Error("DOCX 媒体数量超过限制"); + } + + const map: PandocMediaMap = { + image: [], + mermaid: [], + echarts: [] + }; + const files: PandocMediaFile[] = []; + const ids = new Set(); + let totalBytes = 0; + const kindCounts: Record = { + image: 0, + mermaid: 0, + echarts: 0 + }; + for (const [index, resource] of media.resources.entries()) { + if (ids.has(resource.id)) { + throw new Error(`DOCX 媒体 ID 重复:${resource.id}`); + } + ids.add(resource.id); + kindCounts[resource.kind] += 1; + if ( + resource.ordinal !== index + 1 || + resource.kindOrdinal !== kindCounts[resource.kind] + ) { + throw new Error("DOCX 媒体顺序与捕获计划不一致"); + } + if (resource.content.byteLength > MAXIMUM_DOCX_MEDIA_BYTES) { + throw new Error(`DOCX 媒体 ${resource.id} 超过单文件大小限制`); + } + totalBytes += resource.content.byteLength; + if (totalBytes > MAXIMUM_DOCX_TOTAL_MEDIA_BYTES) { + throw new Error("DOCX PNG 媒体总大小超过限制"); + } + const dimensions = assertPng(resource.content); + if ( + dimensions.width !== resource.pixelWidth || + dimensions.height !== resource.pixelHeight + ) { + throw new Error( + `DOCX 媒体 ${resource.id} 的 PNG 像素声明不一致` + ); + } + const relativePath = `media/media-${String( + resource.ordinal + ).padStart(3, "0")}.png`; + map[resource.kind].push({ + id: resource.id, + path: relativePath, + alt_text: resource.altText || `${resource.kind} 图片`, + ...(resource.caption + ? { caption: resource.caption } + : {}), + width: physicalLength(resource.displayWidthPx), + height: physicalLength(resource.displayHeightPx) + }); + files.push({ + relativePath, + content: resource.content + }); + } + if (media.totalBytes !== totalBytes) { + throw new Error("DOCX 媒体总大小声明不一致"); + } + return { map, files }; +} diff --git a/packages/docx-engine/src/pandoc-process.ts b/packages/docx-engine/src/pandoc-process.ts new file mode 100644 index 0000000..d090760 --- /dev/null +++ b/packages/docx-engine/src/pandoc-process.ts @@ -0,0 +1,124 @@ +import { spawn } from "node:child_process"; + +export type PandocProcessOutcome = + | "completed" + | "not-found" + | "not-executable" + | "timeout" + | "aborted" + | "output-limit"; + +export interface PandocProcessResult { + outcome: PandocProcessOutcome; + exitCode: number | null; + stdout: Uint8Array; + stderr: string; +} + +export interface PandocProcessOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + timeoutMs: number; + maxStdoutBytes: number; + maxStderrBytes: number; + signal?: AbortSignal; +} + +export type PandocProcessRunner = ( + executablePath: string, + arguments_: readonly string[], + options: PandocProcessOptions +) => Promise; + +function decodeStderr(chunks: Buffer[]) { + return Buffer.concat(chunks).toString("utf8"); +} + +export const runPandocProcess: PandocProcessRunner = ( + executablePath, + arguments_, + options +) => + new Promise((resolve) => { + const child = spawn(executablePath, [...arguments_], { + cwd: options.cwd, + env: options.env, + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"] + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let forcedOutcome: PandocProcessOutcome | undefined; + + const settle = ( + outcome: PandocProcessOutcome, + exitCode: number | null + ) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + options.signal?.removeEventListener("abort", abort); + resolve({ + outcome, + exitCode, + stdout: Buffer.concat(stdout), + stderr: decodeStderr(stderr) + }); + }; + const stop = (outcome: PandocProcessOutcome) => { + if (settled || forcedOutcome) { + return; + } + forcedOutcome = outcome; + child.kill(); + }; + const abort = () => stop("aborted"); + const timeout = setTimeout( + () => stop("timeout"), + options.timeoutMs + ); + timeout.unref(); + + if (options.signal?.aborted) { + abort(); + } else { + options.signal?.addEventListener("abort", abort, { + once: true + }); + } + + child.stdout.on("data", (chunk: Buffer) => { + stdoutBytes += chunk.byteLength; + if (stdoutBytes > options.maxStdoutBytes) { + stop("output-limit"); + return; + } + stdout.push(chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderrBytes += chunk.byteLength; + if (stderrBytes > options.maxStderrBytes) { + stop("output-limit"); + return; + } + stderr.push(chunk); + }); + child.on("error", (error: NodeJS.ErrnoException) => { + const outcome = + error.code === "ENOENT" + ? "not-found" + : error.code === "EACCES" || error.code === "EPERM" + ? "not-executable" + : "not-executable"; + settle(outcome, null); + }); + child.on("close", (exitCode) => { + settle(forcedOutcome ?? "completed", exitCode); + }); + }); diff --git a/packages/docx-engine/src/pandoc-runtime-manifest.ts b/packages/docx-engine/src/pandoc-runtime-manifest.ts new file mode 100644 index 0000000..66ff282 --- /dev/null +++ b/packages/docx-engine/src/pandoc-runtime-manifest.ts @@ -0,0 +1,54 @@ +import { DOCX_PANDOC_VERSION } from "@md-to-pdf/core"; + +export interface PandocRuntimeArtifact { + platform: "win32" | "linux"; + architecture: "x64"; + archiveType: "zip" | "tar.gz"; + downloadUrl: string; + sha256: string; + executableRelativePath: string; + licenseFiles: readonly string[]; +} + +export const pandocRuntimeManifest = { + version: DOCX_PANDOC_VERSION, + license: "GPL-2.0-or-later", + projectUrl: "https://pandoc.org/", + sourceArchiveUrl: + "https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-source.tar.gz", + artifacts: [ + { + platform: "win32", + architecture: "x64", + archiveType: "zip", + downloadUrl: + "https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-windows-x86_64.zip", + sha256: + "C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A", + executableRelativePath: "pandoc.exe", + licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"] + }, + { + platform: "linux", + architecture: "x64", + archiveType: "tar.gz", + downloadUrl: + "https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-linux-amd64.tar.gz", + sha256: + "A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508", + executableRelativePath: "bin/pandoc", + licenseFiles: ["COPYING.md", "COPYRIGHT"] + } + ] satisfies readonly PandocRuntimeArtifact[] +} as const; + +export function findPandocRuntimeArtifact( + platform: NodeJS.Platform, + architecture: string +) { + return pandocRuntimeManifest.artifacts.find( + (artifact) => + artifact.platform === platform && + artifact.architecture === architecture + ); +} diff --git a/packages/docx-engine/src/pandoc-runtime.ts b/packages/docx-engine/src/pandoc-runtime.ts new file mode 100644 index 0000000..c1e1737 --- /dev/null +++ b/packages/docx-engine/src/pandoc-runtime.ts @@ -0,0 +1,322 @@ +import path from "node:path"; +import { + DOCX_PANDOC_VERSION, + type DocxCapability, + type DocxRuntimeStatus +} from "@md-to-pdf/core"; +import { + MAXIMUM_REFERENCE_DOCX_BYTES, + readReferenceDocxPackage +} from "./reference-package.js"; +import { + runPandocProcess, + type PandocProcessOptions, + type PandocProcessResult, + type PandocProcessRunner +} from "./pandoc-process.js"; + +const DEFAULT_PROBE_TIMEOUT_MS = 3_000; +const DEFAULT_REFERENCE_TIMEOUT_MS = 5_000; +const MAXIMUM_PROBE_OUTPUT_BYTES = 16 * 1024; + +export interface PandocRuntimeResolution { + capability: DocxCapability; + executablePath?: string; +} + +export interface PandocRuntimeOptions { + configuredPath?: string; + desktopResourcesPath?: string; + platform?: NodeJS.Platform; + architecture?: string; + environment?: NodeJS.ProcessEnv; + probeTimeoutMs?: number; + referenceTimeoutMs?: number; + runner?: PandocProcessRunner; +} + +interface RuntimeCandidate { + executablePath: string; + exclusive: boolean; +} + +export class PandocRuntimeUnavailableError extends Error { + constructor( + readonly status: Exclude, + message: string + ) { + super(message); + this.name = "PandocRuntimeUnavailableError"; + } +} + +function unavailableCapability( + status: Exclude, + message: string, + detectedVersion?: string +): DocxCapability { + return { + format: "docx", + status, + expectedVersion: DOCX_PANDOC_VERSION, + ...(detectedVersion ? { detectedVersion } : {}), + message + }; +} + +function availableCapability(): DocxCapability { + return { + format: "docx", + status: "available", + expectedVersion: DOCX_PANDOC_VERSION, + detectedVersion: DOCX_PANDOC_VERSION + }; +} + +function parseDetectedVersion(result: PandocProcessResult) { + const firstLine = new TextDecoder() + .decode(result.stdout) + .split(/\r?\n/u)[0] + ?.trim(); + return firstLine?.replace(/^pandoc\s+/u, "").trim() ?? ""; +} + +function createProbeOptions( + timeoutMs: number, + environment: NodeJS.ProcessEnv +): PandocProcessOptions { + return { + timeoutMs, + maxStdoutBytes: MAXIMUM_PROBE_OUTPUT_BYTES, + maxStderrBytes: MAXIMUM_PROBE_OUTPUT_BYTES, + env: environment + }; +} + +function outcomeStatus(result: PandocProcessResult) { + if (result.outcome === "not-found") { + return "not-found" as const; + } + if ( + result.outcome === "timeout" || + result.outcome === "aborted" + ) { + return "probe-timeout" as const; + } + return "not-executable" as const; +} + +function createCandidates(options: PandocRuntimeOptions) { + const platform = options.platform ?? process.platform; + const architecture = options.architecture ?? process.arch; + const environment = options.environment ?? process.env; + const configuredPath = + options.configuredPath ?? + environment.DOCX_PANDOC_PATH?.trim() ?? + ""; + if (configuredPath) { + return [ + { + executablePath: configuredPath, + exclusive: true + } + ]; + } + + const candidates: RuntimeCandidate[] = []; + if ( + platform === "win32" && + architecture === "x64" && + options.desktopResourcesPath + ) { + candidates.push({ + executablePath: path.join( + options.desktopResourcesPath, + "pandoc", + DOCX_PANDOC_VERSION, + "windows-x86_64", + "pandoc.exe" + ), + exclusive: false + }); + } + if (platform === "linux" && architecture === "x64") { + candidates.push({ + executablePath: `/opt/pandoc/${DOCX_PANDOC_VERSION}/bin/pandoc`, + exclusive: false + }); + } + candidates.push({ + executablePath: platform === "win32" ? "pandoc.exe" : "pandoc", + exclusive: false + }); + return candidates.filter( + (candidate, index, all) => + all.findIndex( + (item) => item.executablePath === candidate.executablePath + ) === index + ); +} + +async function probeCandidate( + candidate: RuntimeCandidate, + runner: PandocProcessRunner, + environment: NodeJS.ProcessEnv, + timeoutMs: number +): Promise { + const result = await runner( + candidate.executablePath, + ["--version"], + createProbeOptions(timeoutMs, environment) + ); + if ( + result.outcome !== "completed" || + result.exitCode !== 0 + ) { + const status = + result.outcome === "completed" + ? "not-executable" + : outcomeStatus(result); + return { + capability: unavailableCapability( + status, + status === "probe-timeout" + ? "Pandoc 版本探测超时" + : status === "not-found" + ? "未找到 Pandoc 运行时" + : "Pandoc 运行时无法执行" + ) + }; + } + const detectedVersion = parseDetectedVersion(result); + if (detectedVersion !== DOCX_PANDOC_VERSION) { + return { + capability: unavailableCapability( + "version-mismatch", + `Pandoc 版本不匹配,需要 ${DOCX_PANDOC_VERSION}`, + detectedVersion || "未知" + ) + }; + } + return { + capability: availableCapability(), + executablePath: candidate.executablePath + }; +} + +export async function probePandocRuntime( + options: PandocRuntimeOptions = {} +): Promise { + const environment = options.environment ?? process.env; + const runner = options.runner ?? runPandocProcess; + let lastMissing: PandocRuntimeResolution | undefined; + for (const candidate of createCandidates(options)) { + const resolution = await probeCandidate( + candidate, + runner, + environment, + options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS + ); + if (resolution.capability.status === "available") { + return resolution; + } + if ( + resolution.capability.status !== "not-found" || + candidate.exclusive + ) { + return resolution; + } + lastMissing = resolution; + } + return ( + lastMissing ?? { + capability: unavailableCapability( + "not-found", + "未找到 Pandoc 运行时" + ) + } + ); +} + +export class PandocRuntime { + private resolution: + | Promise + | undefined = undefined; + private defaultReference: + | Promise + | undefined = undefined; + + constructor(private readonly options: PandocRuntimeOptions = {}) {} + + probe() { + this.resolution ??= probePandocRuntime(this.options); + return this.resolution; + } + + async getExecutablePath() { + const resolution = await this.probe(); + if (resolution.capability.status !== "available") { + throw new PandocRuntimeUnavailableError( + resolution.capability.status, + resolution.capability.message + ); + } + if (!resolution.executablePath) { + throw new PandocRuntimeUnavailableError( + "not-found", + "Pandoc capability 可用但缺少可执行路径" + ); + } + return resolution.executablePath; + } + + async getDefaultReferenceDocx() { + this.defaultReference ??= this.loadDefaultReferenceDocx(); + try { + return (await this.defaultReference).slice(); + } catch (error) { + this.defaultReference = undefined; + throw error; + } + } + + invalidate() { + this.resolution = undefined; + this.defaultReference = undefined; + } + + private async loadDefaultReferenceDocx() { + const executablePath = await this.getExecutablePath(); + const runner = this.options.runner ?? runPandocProcess; + const result = await runner( + executablePath, + ["--print-default-data-file", "reference.docx"], + { + timeoutMs: + this.options.referenceTimeoutMs ?? + DEFAULT_REFERENCE_TIMEOUT_MS, + maxStdoutBytes: MAXIMUM_REFERENCE_DOCX_BYTES, + maxStderrBytes: MAXIMUM_PROBE_OUTPUT_BYTES, + env: this.options.environment ?? process.env + } + ); + if ( + result.outcome !== "completed" || + result.exitCode !== 0 + ) { + const status = + result.outcome === "timeout" || + result.outcome === "aborted" + ? "probe-timeout" + : result.outcome === "not-found" + ? "not-found" + : "not-executable"; + throw new PandocRuntimeUnavailableError( + status, + "无法读取 Pandoc 默认 reference.docx" + ); + } + readReferenceDocxPackage(result.stdout); + return result.stdout; + } +} diff --git a/packages/docx-engine/src/reference-package.ts b/packages/docx-engine/src/reference-package.ts index 27c1530..171a56d 100644 --- a/packages/docx-engine/src/reference-package.ts +++ b/packages/docx-engine/src/reference-package.ts @@ -5,6 +5,21 @@ export const MAXIMUM_REFERENCE_DOCX_BYTES = 2 * 1024 * 1024; export const MAXIMUM_REFERENCE_ENTRY_COUNT = 256; export const MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES = 64 * 1024 * 1024; +export const MAXIMUM_GENERATED_DOCX_ENTRY_COUNT = 512; +export const MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES = + 128 * 1024 * 1024; + +export interface DocxPackageLimits { + maximumBytes: number; + maximumEntryCount: number; + maximumUncompressedBytes: number; +} + +export const referenceDocxPackageLimits: DocxPackageLimits = { + maximumBytes: MAXIMUM_REFERENCE_DOCX_BYTES, + maximumEntryCount: MAXIMUM_REFERENCE_ENTRY_COUNT, + maximumUncompressedBytes: MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES +}; export const requiredReferenceDocxParts = [ "[Content_Types].xml", @@ -28,6 +43,23 @@ interface ZipDirectoryEntry { uncompressedSize: number; } +function fingerprintEntries( + entries: ReadonlyMap +) { + const hash = createHash("sha256"); + for (const [name, content] of [...entries].sort( + ([left], [right]) => left.localeCompare(right) + )) { + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(content.byteLength)); + hash.update(name, "utf8"); + hash.update(Uint8Array.of(0)); + hash.update(length); + hash.update(content); + } + return hash.digest("hex"); +} + function findEndOfCentralDirectory(content: Uint8Array) { const view = new DataView( content.buffer, @@ -59,10 +91,13 @@ function validateEntryName(name: string) { } } -function inspectZipDirectory(content: Uint8Array) { +function inspectZipDirectory( + content: Uint8Array, + limits: DocxPackageLimits +) { if ( content.byteLength < 22 || - content.byteLength > MAXIMUM_REFERENCE_DOCX_BYTES + content.byteLength > limits.maximumBytes ) { throw new Error("reference.docx 压缩包大小超过限制或内容不完整"); } @@ -81,7 +116,7 @@ function inspectZipDirectory(content: Uint8Array) { if ( diskNumber !== 0 || directoryDisk !== 0 || - entryCount > MAXIMUM_REFERENCE_ENTRY_COUNT || + entryCount > limits.maximumEntryCount || entryCount === 0 || directoryOffset + directorySize > endOffset ) { @@ -129,7 +164,7 @@ function inspectZipDirectory(content: Uint8Array) { totalUncompressedBytes += uncompressedSize; if ( totalUncompressedBytes > - MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES + limits.maximumUncompressedBytes ) { throw new Error("reference.docx 解压后总大小超过限制"); } @@ -142,10 +177,11 @@ function inspectZipDirectory(content: Uint8Array) { return entries; } -export function readReferenceDocxPackage( - content: Uint8Array +export function readDocxPackage( + content: Uint8Array, + limits: DocxPackageLimits ): ReferenceDocxPackage { - const directory = inspectZipDirectory(content); + const directory = inspectZipDirectory(content, limits); const unzipped = unzipSync(content); const entries = new Map(); for (const descriptor of directory) { @@ -169,13 +205,17 @@ export function readReferenceDocxPackage( } } return { - fingerprint: createHash("sha256") - .update(content) - .digest("hex"), + fingerprint: fingerprintEntries(entries), entries }; } +export function readReferenceDocxPackage( + content: Uint8Array +): ReferenceDocxPackage { + return readDocxPackage(content, referenceDocxPackageLimits); +} + export function writeReferenceDocxPackage( entries: ReadonlyMap ) { diff --git a/packages/docx-engine/src/validator.ts b/packages/docx-engine/src/validator.ts index 04682e7..7b9f8cb 100644 --- a/packages/docx-engine/src/validator.ts +++ b/packages/docx-engine/src/validator.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { MAXIMUM_DOCX_OUTPUT_BYTES } from "@md-to-pdf/core"; import { CONTENT_TYPES_NAMESPACE, OFFICE_RELATIONSHIP_NAMESPACE, @@ -8,7 +9,12 @@ import { parseXmlPart, type XmlElement } from "./ooxml.js"; -import { readReferenceDocxPackage } from "./reference-package.js"; +import { + MAXIMUM_GENERATED_DOCX_ENTRY_COUNT, + MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES, + readDocxPackage, + readReferenceDocxPackage +} from "./reference-package.js"; const HEADER_RELATIONSHIP_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"; @@ -263,10 +269,18 @@ function validateStyles(entries: ReadonlyMap) { } } -export function validateDynamicReferenceDocx( - content: Uint8Array +function validateDocx( + content: Uint8Array, + generatedOutput: boolean ): DynamicReferenceValidation { - const reference = readReferenceDocxPackage(content); + const reference = generatedOutput + ? readDocxPackage(content, { + maximumBytes: MAXIMUM_DOCX_OUTPUT_BYTES, + maximumEntryCount: MAXIMUM_GENERATED_DOCX_ENTRY_COUNT, + maximumUncompressedBytes: + MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES + }) + : readReferenceDocxPackage(content); let xmlPartCount = 0; let relationshipCount = 0; const relationshipParts = new Map< @@ -345,3 +359,15 @@ export function validateDynamicReferenceDocx( footerCount }; } + +export function validateDynamicReferenceDocx( + content: Uint8Array +): DynamicReferenceValidation { + return validateDocx(content, false); +} + +export function validateGeneratedDocx( + content: Uint8Array +): DynamicReferenceValidation { + return validateDocx(content, true); +} diff --git a/packages/docx-engine/tests/pandoc-converter.test.ts b/packages/docx-engine/tests/pandoc-converter.test.ts new file mode 100644 index 0000000..05faf85 --- /dev/null +++ b/packages/docx-engine/tests/pandoc-converter.test.ts @@ -0,0 +1,184 @@ +import { + copyFile, + mkdtemp, + rm, + writeFile +} from "node:fs/promises"; +import { existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + defaultExportConfig, + type PreparedDocxMedia, + type ThemeManifest +} from "@md-to-pdf/core"; +import { + PandocDocxConversionError, + PandocDocxConverter, + type PandocProcessRunner, + type PandocRuntimeProvider +} from "../src/index.js"; +import { createTestBaselineReference } from "./reference-test-fixture.js"; + +function theme(): ThemeManifest { + return { + manifestVersion: 1, + id: "test-theme", + name: "测试主题", + version: "1.0.0", + description: "测试", + author: "测试", + license: "内部许可", + entry: "theme.css", + domPreset: "generic", + defaultFontSize: "16px", + supportedFeatures: [], + category: "general", + compatibleProfiles: [], + docxStyle: { preset: "technical" }, + bundled: true + }; +} + +const emptyMedia: PreparedDocxMedia = { + resources: [], + echartsErrors: [], + mermaidErrors: [], + warnings: [], + totalBytes: 0 +}; + +function input() { + return { + markdown: "# 测试", + fileName: "测试.md", + language: "zh-CN", + exportConfig: defaultExportConfig, + theme: theme(), + metadata: { + title: "测试", + author: "测试人", + subject: "", + keywords: [], + language: "zh-CN" + }, + media: emptyMedia + }; +} + +function argumentAfter(arguments_: readonly string[], name: string) { + const index = arguments_.indexOf(name); + if (index < 0 || !arguments_[index + 1]) { + throw new Error(`缺少参数 ${name}`); + } + return arguments_[index + 1]!; +} + +describe("Pandoc DOCX 转换器", () => { + let temporaryRoot: string; + const baseline = createTestBaselineReference(); + const runtime: PandocRuntimeProvider = { + async getExecutablePath() { + return "pandoc"; + }, + async getDefaultReferenceDocx() { + return baseline; + } + }; + + beforeEach(async () => { + temporaryRoot = await mkdtemp( + path.join(os.tmpdir(), "docx-converter-test-") + ); + }); + + afterEach(async () => { + await rm(temporaryRoot, { recursive: true, force: true }); + }); + + it("使用固定参数转换、校验并清理请求级临时目录", async () => { + let requestDirectory = ""; + const runner = vi.fn( + async (_executable, arguments_, options) => { + requestDirectory = options.cwd!; + expect(arguments_).toContain("--lua-filter"); + expect(arguments_).toContain("--data-dir"); + expect(arguments_).toContain("--resource-path"); + expect( + options.env?.MD_TO_PDF_DOCX_MEDIA_MAP + ).toContain("media-map.json"); + await copyFile( + argumentAfter(arguments_, "--reference-doc"), + argumentAfter(arguments_, "--output") + ); + return { + outcome: "completed", + exitCode: 0, + stdout: new Uint8Array(), + stderr: "" + }; + } + ); + const converter = new PandocDocxConverter({ + runtime, + runner, + temporaryRoot + }); + const result = await converter.convert(input()); + + expect(result.docx.byteLength).toBeGreaterThan(0); + expect(result.validation.partCount).toBeGreaterThan(8); + expect(runner).toHaveBeenCalledTimes(1); + expect(existsSync(requestDirectory)).toBe(false); + }); + + it("超时和无效输出均返回稳定错误并清理目录", async () => { + let timeoutDirectory = ""; + const timeoutRunner = vi.fn( + async (_executable, _arguments, options) => { + timeoutDirectory = options.cwd!; + return { + outcome: "timeout", + exitCode: null, + stdout: new Uint8Array(), + stderr: "" + }; + } + ); + await expect( + new PandocDocxConverter({ + runtime, + runner: timeoutRunner, + temporaryRoot + }).convert(input()) + ).rejects.toMatchObject({ + code: "DOCX_RENDER_TIMEOUT" + } satisfies Partial); + expect(existsSync(timeoutDirectory)).toBe(false); + + const invalidRunner = vi.fn( + async (_executable, arguments_) => { + await writeFile( + argumentAfter(arguments_, "--output"), + "not a docx" + ); + return { + outcome: "completed", + exitCode: 0, + stdout: new Uint8Array(), + stderr: "" + }; + } + ); + await expect( + new PandocDocxConverter({ + runtime, + runner: invalidRunner, + temporaryRoot + }).convert(input()) + ).rejects.toMatchObject({ + code: "DOCX_OUTPUT_INVALID" + } satisfies Partial); + }); +}); diff --git a/packages/docx-engine/tests/pandoc-media.test.ts b/packages/docx-engine/tests/pandoc-media.test.ts new file mode 100644 index 0000000..6ad378b --- /dev/null +++ b/packages/docx-engine/tests/pandoc-media.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import type { + DocxMediaKind, + DocxPngMediaResource, + PreparedDocxMedia +} from "@md-to-pdf/core"; +import { preparePandocMedia } from "../src/index.js"; + +const png = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01 +]); + +function resource( + kind: DocxMediaKind, + ordinal: number, + kindOrdinal: number +): DocxPngMediaResource { + return { + id: `docx-media-${ordinal}`, + kind, + ordinal, + kindOrdinal, + altText: `${kind} ${kindOrdinal}`, + caption: `${kind} 图注`, + displayWidthPx: 384, + displayHeightPx: 192, + captureX: 0, + captureY: 0, + captureWidthPx: 384, + captureHeightPx: 192, + rasterScale: 1, + fileName: `media-${ordinal}.png`, + contentType: "image/png", + content: png, + pixelWidth: 1, + pixelHeight: 1 + }; +} + +function prepared( + resources: DocxPngMediaResource[] +): PreparedDocxMedia { + return { + resources, + echartsErrors: [], + mermaidErrors: [], + warnings: [], + totalBytes: resources.reduce( + (total, item) => total + item.content.byteLength, + 0 + ) + }; +} + +describe("Pandoc DOCX 媒体映射", () => { + it("按媒体类型和捕获顺序生成受控路径与物理尺寸", () => { + const result = preparePandocMedia( + prepared([ + resource("image", 1, 1), + resource("mermaid", 2, 1), + resource("echarts", 3, 1) + ]) + ); + + expect(result.files.map((item) => item.relativePath)).toEqual([ + "media/media-001.png", + "media/media-002.png", + "media/media-003.png" + ]); + expect(result.map.image[0]).toMatchObject({ + path: "media/media-001.png", + width: "101.600mm", + height: "50.800mm" + }); + expect(result.map.mermaid).toHaveLength(1); + expect(result.map.echarts).toHaveLength(1); + }); + + it("拒绝顺序错位、无效 PNG 和图表错误", () => { + expect(() => + preparePandocMedia( + prepared([resource("image", 2, 1)]) + ) + ).toThrow("顺序"); + + expect(() => + preparePandocMedia( + prepared([ + { + ...resource("image", 1, 1), + content: new Uint8Array([1, 2, 3]) + } + ]) + ) + ).toThrow("有效 PNG"); + + expect(() => + preparePandocMedia({ + ...prepared([]), + mermaidErrors: ["图表错误"] + }) + ).toThrow("已中止转换"); + }); +}); diff --git a/packages/docx-engine/tests/pandoc-runtime.test.ts b/packages/docx-engine/tests/pandoc-runtime.test.ts new file mode 100644 index 0000000..2667afd --- /dev/null +++ b/packages/docx-engine/tests/pandoc-runtime.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; +import { zipSync } from "fflate"; +import { + DOCX_PANDOC_VERSION, + type DocxRuntimeStatus +} from "@md-to-pdf/core"; +import { + PandocRuntime, + probePandocRuntime, + type PandocProcessResult, + type PandocProcessRunner +} from "../src/index.js"; + +const encoder = new TextEncoder(); + +function processResult( + overrides: Partial = {} +): PandocProcessResult { + return { + outcome: "completed", + exitCode: 0, + stdout: encoder.encode(`pandoc ${DOCX_PANDOC_VERSION}\n`), + stderr: "", + ...overrides + }; +} + +function runnerWith(result: PandocProcessResult) { + return vi.fn().mockResolvedValue(result); +} + +function createReferenceDocx() { + const xml = (value: string) => encoder.encode(value); + return zipSync({ + "[Content_Types].xml": xml(""), + "_rels/.rels": xml(""), + "word/document.xml": xml(""), + "word/fontTable.xml": xml(""), + "word/numbering.xml": xml(""), + "word/settings.xml": xml(""), + "word/styles.xml": xml(""), + "word/_rels/document.xml.rels": xml(""), + "word/theme/theme1.xml": xml("") + }); +} + +describe("Pandoc 运行时探测", () => { + it.each([ + ["not-found", "not-found"], + ["not-executable", "not-executable"], + ["timeout", "probe-timeout"], + ["aborted", "probe-timeout"], + ["output-limit", "not-executable"] + ] satisfies Array<[PandocProcessResult["outcome"], DocxRuntimeStatus]>)( + "将 %s 映射为 %s", + async (outcome, expectedStatus) => { + const resolution = await probePandocRuntime({ + configuredPath: "C:\\trusted\\pandoc.exe", + runner: runnerWith( + processResult({ outcome, exitCode: null, stdout: new Uint8Array() }) + ) + }); + expect(resolution.capability.status).toBe(expectedStatus); + } + ); + + it("拒绝不同补丁版本", async () => { + const resolution = await probePandocRuntime({ + configuredPath: "C:\\trusted\\pandoc.exe", + runner: runnerWith( + processResult({ + stdout: encoder.encode("pandoc 3.9.0.1\n") + }) + ) + }); + expect(resolution.capability).toMatchObject({ + status: "version-mismatch", + detectedVersion: "3.9.0.1" + }); + }); + + it("显式路径失败时不静默回退 PATH", async () => { + const runner = runnerWith( + processResult({ + outcome: "not-found", + exitCode: null, + stdout: new Uint8Array() + }) + ); + const resolution = await probePandocRuntime({ + configuredPath: "C:\\missing\\pandoc.exe", + runner + }); + expect(resolution.capability.status).toBe("not-found"); + expect(runner).toHaveBeenCalledTimes(1); + }); + + it("内置路径不存在时回退到 PATH", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce( + processResult({ + outcome: "not-found", + exitCode: null, + stdout: new Uint8Array() + }) + ) + .mockResolvedValueOnce(processResult()); + const resolution = await probePandocRuntime({ + platform: "win32", + architecture: "x64", + desktopResourcesPath: "C:\\app\\resources", + environment: {}, + runner + }); + expect(resolution).toMatchObject({ + capability: { status: "available" }, + executablePath: "pandoc.exe" + }); + expect(runner.mock.calls[0]?.[0]).toContain( + `pandoc\\${DOCX_PANDOC_VERSION}\\windows-x86_64\\pandoc.exe` + ); + }); + + it("缓存探测和默认 reference.docx,并向调用方返回副本", async () => { + const reference = createReferenceDocx(); + const runner = vi + .fn() + .mockResolvedValueOnce(processResult()) + .mockResolvedValueOnce( + processResult({ stdout: reference }) + ); + const runtime = new PandocRuntime({ + configuredPath: "C:\\trusted\\pandoc.exe", + runner + }); + const first = await runtime.getDefaultReferenceDocx(); + first[0] = 0; + const second = await runtime.getDefaultReferenceDocx(); + + expect(second[0]).not.toBe(0); + expect(runner).toHaveBeenCalledTimes(2); + expect(runner.mock.calls[1]?.[1]).toEqual([ + "--print-default-data-file", + "reference.docx" + ]); + }); +}); diff --git a/packages/docx-engine/tests/reference-builder.test.ts b/packages/docx-engine/tests/reference-builder.test.ts index 329439a..8ca76ad 100644 --- a/packages/docx-engine/tests/reference-builder.test.ts +++ b/packages/docx-engine/tests/reference-builder.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { randomBytes } from "node:crypto"; import { unzipSync, zipSync } from "fflate"; import { defaultExportConfig, @@ -7,6 +8,7 @@ import { } from "@md-to-pdf/core"; import { createDynamicReferenceDocx, + validateGeneratedDocx, validateDynamicReferenceDocx, writeReferenceDocxPackage } from "../src/index.js"; @@ -272,4 +274,27 @@ describe("动态 reference.docx", () => { /footerReference 引用了无效关系/u ); }); + + it("最终 DOCX 使用媒体输出上限而非模板的 2 MiB 上限", () => { + const result = createDynamicReferenceDocx( + createBaselineReference(), + createOptions(defaultExportConfig) + ); + const entries = unzipSync(result.content); + const generated = zipSync( + { + ...entries, + "word/media/large-test.bin": randomBytes(3 * 1024 * 1024) + }, + { level: 0 } + ); + + expect(generated.byteLength).toBeGreaterThan(2 * 1024 * 1024); + expect(validateGeneratedDocx(generated).partCount).toBe( + result.partCount + 1 + ); + expect(() => validateDynamicReferenceDocx(generated)).toThrow( + /大小超过限制/u + ); + }); }); diff --git a/packages/docx-engine/tests/reference-package.test.ts b/packages/docx-engine/tests/reference-package.test.ts index 1414eb0..26a0a20 100644 --- a/packages/docx-engine/tests/reference-package.test.ts +++ b/packages/docx-engine/tests/reference-package.test.ts @@ -6,7 +6,8 @@ import { } from "../src/index.js"; function createReferencePackage( - overrides: Record = {} + overrides: Record = {}, + mtime = new Date("2020-01-01T00:00:00.000Z") ) { const entries = Object.fromEntries( requiredReferenceDocxParts.map((name) => [ @@ -14,14 +15,25 @@ function createReferencePackage( new TextEncoder().encode(``) ]) ); - return zipSync({ ...entries, ...overrides }); + return zipSync( + Object.fromEntries( + Object.entries({ ...entries, ...overrides }).map( + ([name, content]) => [name, [content, { mtime }]] + ) + ) + ); } describe("reference.docx 包", () => { it("预检中央目录并返回稳定指纹", () => { const content = createReferencePackage(); const first = readReferenceDocxPackage(content); - const second = readReferenceDocxPackage(content); + const second = readReferenceDocxPackage( + createReferencePackage( + {}, + new Date("2026-07-30T00:00:00.000Z") + ) + ); expect(first.entries.size).toBe(requiredReferenceDocxParts.length); expect(first.entries.has("word/styles.xml")).toBe(true); diff --git a/packages/docx-engine/tests/reference-test-fixture.ts b/packages/docx-engine/tests/reference-test-fixture.ts new file mode 100644 index 0000000..1b8df92 --- /dev/null +++ b/packages/docx-engine/tests/reference-test-fixture.ts @@ -0,0 +1,89 @@ +import { zipSync } from "fflate"; + +const encoder = new TextEncoder(); +const wordNamespace = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + +function xml(value: string) { + return encoder.encode(value); +} + +export function createTestBaselineReference() { + const styleIds = [ + "Normal", + "BodyText", + "FirstParagraph", + "Compact", + "Title", + "TitleChar", + "Subtitle", + "SubtitleChar", + "Heading1", + "Heading1Char", + "Heading2", + "Heading2Char", + "Heading3", + "Heading3Char", + "Heading4", + "Heading4Char", + "Heading5", + "Heading5Char", + "Heading6", + "Heading6Char", + "BlockText", + "FootnoteText", + "DefaultParagraphFont", + "Table", + "Definition", + "Caption", + "TableCaption", + "ImageCaption", + "Figure", + "BodyTextChar", + "VerbatimChar", + "Hyperlink" + ]; + const styles = styleIds + .map((styleId) => { + const type = + styleId === "Table" + ? "table" + : styleId.endsWith("Char") || + styleId === "DefaultParagraphFont" || + styleId === "VerbatimChar" || + styleId === "Hyperlink" + ? "character" + : "paragraph"; + return ``; + }) + .join(""); + return zipSync({ + "[Content_Types].xml": xml( + '' + ), + "_rels/.rels": xml( + '' + ), + "word/document.xml": xml( + `` + ), + "word/fontTable.xml": xml( + `` + ), + "word/numbering.xml": xml( + `` + ), + "word/settings.xml": xml( + `` + ), + "word/styles.xml": xml( + `${styles}` + ), + "word/_rels/document.xml.rels": xml( + '' + ), + "word/theme/theme1.xml": xml( + '' + ) + }); +}