diff --git a/apps/desktop/src/desktop-application-controller.ts b/apps/desktop/src/desktop-application-controller.ts index 4d5db00..58406d3 100644 --- a/apps/desktop/src/desktop-application-controller.ts +++ b/apps/desktop/src/desktop-application-controller.ts @@ -52,6 +52,7 @@ import { toDesktopDocxExportFailure, type DesktopDocxExportOutcome } from "./docx-contract.js"; +import { saveDesktopDocx } from "./desktop-docx-save.js"; import { ElectronDocxMediaEngine } from "./electron-docx-media-engine.js"; import { parseMarkdownRenderRequest, @@ -987,16 +988,14 @@ export class DesktopApplicationController { timings: generated.timings }; } - const targetPath = selection.filePath - .toLowerCase() - .endsWith(".docx") - ? selection.filePath - : `${selection.filePath}.docx`; - await writeFile(targetPath, generated.docx); + const saved = await saveDesktopDocx( + selection.filePath, + generated.docx + ); return { ok: true, saved: true, - fileName: path.basename(targetPath), + fileName: saved.fileName, diagnostics: generated.diagnostics, timings: generated.timings }; diff --git a/apps/desktop/src/desktop-docx-save.ts b/apps/desktop/src/desktop-docx-save.ts new file mode 100644 index 0000000..f7277a3 --- /dev/null +++ b/apps/desktop/src/desktop-docx-save.ts @@ -0,0 +1,23 @@ +import path from "node:path"; +import { writeFile } from "node:fs/promises"; + +export function resolveDesktopDocxTargetPath(selectedPath: string) { + return selectedPath.toLowerCase().endsWith(".docx") + ? selectedPath + : `${selectedPath}.docx`; +} + +export async function saveDesktopDocx( + selectedPath: string, + content: Uint8Array +) { + if (!selectedPath || content.byteLength === 0) { + throw new Error("DOCX 保存参数无效"); + } + const targetPath = resolveDesktopDocxTargetPath(selectedPath); + await writeFile(targetPath, content); + return { + targetPath, + fileName: path.basename(targetPath) + }; +} diff --git a/apps/desktop/tests/desktop-docx-save.test.ts b/apps/desktop/tests/desktop-docx-save.test.ts new file mode 100644 index 0000000..aa83fab --- /dev/null +++ b/apps/desktop/tests/desktop-docx-save.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + resolveDesktopDocxTargetPath, + saveDesktopDocx +} from "../src/desktop-docx-save.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ); +}); + +async function createTemporaryDirectory() { + const directory = await mkdtemp( + path.join(os.tmpdir(), "md-to-pdf-desktop-docx-save-") + ); + temporaryDirectories.push(directory); + return directory; +} + +describe("Desktop DOCX 原生保存", () => { + it("在用户未填写扩展名时补充 .docx 并原样落盘", async () => { + const directory = await createTemporaryDirectory(); + const content = Uint8Array.of(80, 75, 3, 4, 1, 2, 3); + const selectedPath = path.join(directory, "桌面验收"); + + const saved = await saveDesktopDocx(selectedPath, content); + + expect(saved).toEqual({ + targetPath: `${selectedPath}.docx`, + fileName: "桌面验收.docx" + }); + expect(await readFile(saved.targetPath)).toEqual(Buffer.from(content)); + }); + + it("大小写不敏感地保留已有 DOCX 扩展名", () => { + expect(resolveDesktopDocxTargetPath("报告.DOCX")).toBe( + "报告.DOCX" + ); + }); + + it("拒绝空路径或空内容", async () => { + await expect( + saveDesktopDocx("", Uint8Array.of(80, 75)) + ).rejects.toThrow("DOCX 保存参数无效"); + await expect( + saveDesktopDocx("报告.docx", new Uint8Array()) + ).rejects.toThrow("DOCX 保存参数无效"); + }); +}); diff --git a/apps/server/package.json b/apps/server/package.json index 501cf2b..a924034 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,7 +8,8 @@ "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "test": "vitest run", - "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", + "verify:docx-http": "node scripts/verify-docx-http.mjs" }, "dependencies": { "@md-to-pdf/application": "0.4.1", diff --git a/apps/server/scripts/verify-docx-http.mjs b/apps/server/scripts/verify-docx-http.mjs new file mode 100644 index 0000000..d65e3f4 --- /dev/null +++ b/apps/server/scripts/verify-docx-http.mjs @@ -0,0 +1,237 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + DOCX_MIME_TYPE, + defaultExportConfig +} from "@md-to-pdf/core"; +import { inspectDocxAcceptance } from "@md-to-pdf/docx-engine"; +import { buildApp } from "../dist/app.js"; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryDirectory = path.resolve(directory, "../../.."); +const outputDirectory = path.join( + repositoryDirectory, + "output", + "docx-acceptance" +); +const temporaryPrefix = "md-to-pdf-docx-"; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function temporaryDirectories() { + return new Set( + fs + .readdirSync(os.tmpdir(), { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && entry.name.startsWith(temporaryPrefix) + ) + .map((entry) => entry.name) + ); +} + +const markdown = `--- +title: Server HTTP DOCX 验收 +author: Markdown PDF 导出器研发组 +--- + +# HTTP 导出保持可编辑 + +这是一段由真实 Fastify 路由和 Pandoc 生成的可编辑中文正文,包含 +[验收链接](https://example.invalid/server-docx)。 + +1. 原生编号第一项 +2. 原生编号第二项 + +| HTTP 门禁 | 预期 | +| --- | --- | +| MIME | DOCX | +| 内容 | 可编辑 | + +脚注必须保留。[^server-footnote] + +公式 $a^2+b^2=c^2$ 必须使用 OMML。 + +[^server-footnote]: Server HTTP 验收脚注。 +`; +const exportConfig = { + ...defaultExportConfig, + themeId: "typora-like", + pageDecorationsMode: "custom", + paper: { + ...defaultExportConfig.paper, + marginMode: "custom", + margins: { + top: "16mm", + right: "16mm", + bottom: "16mm", + left: "16mm" + } + } +}; +const mediaAdapter = { + async capture() { + return { + plan: { + targets: [], + echartsErrors: [], + mermaidErrors: [] + }, + captures: [] + }; + }, + async close() {} +}; +const pdfGenerator = { + async generate() { + throw new Error("DOCX HTTP 验收不得调用 PDF"); + }, + async close() {} +}; +const beforeTemporaryDirectories = temporaryDirectories(); +const app = buildApp({ + logger: false, + pdfGenerator, + docxMediaAdapter: mediaAdapter +}); + +let response; +try { + const capabilityResponse = await app.inject({ + method: "GET", + url: "/api/docx/capability" + }); + assert(capabilityResponse.statusCode === 200, "DOCX capability 请求失败"); + assert( + capabilityResponse.json().status === "available", + `DOCX capability 不可用:${capabilityResponse.body}` + ); + + response = await app.inject({ + method: "POST", + url: "/api/docx", + payload: { + markdown, + fileName: "目录/Server HTTP 验收.md", + language: "zh-CN", + resources: [], + exportConfig + } + }); +} finally { + await app.close(); +} + +assert(response.statusCode === 200, `DOCX HTTP 返回 ${response.statusCode}`); +assert( + response.headers["content-type"]?.includes(DOCX_MIME_TYPE), + `DOCX MIME 无效:${response.headers["content-type"]}` +); +assert( + response.headers["content-disposition"]?.includes( + "Server%20HTTP%20%E9%AA%8C%E6%94%B6.docx" + ), + `DOCX 文件名无效:${response.headers["content-disposition"]}` +); +assert( + response.headers["cache-control"] === "no-store", + "DOCX HTTP 响应必须禁用缓存" +); +for (const timing of [ + "runtime-probe", + "prepare", + "media", + "reference", + "pandoc", + "validation", + "total" +]) { + assert( + response.headers["server-timing"]?.includes(`${timing};dur=`), + `Server-Timing 缺少 ${timing}` + ); +} +assert( + response.headers["x-docx-warning-count"] === "0", + "无媒体 HTTP 验收不应产生 DOCX 警告" +); + +const report = inspectDocxAcceptance(response.rawPayload, { + id: "server-http", + page: { + widthTwips: 11906, + heightTwips: 16838, + orientation: "portrait", + marginsTwips: { + top: 907, + right: 907, + bottom: 907, + left: 907 + } + }, + requiredText: [ + "真实 Fastify 路由", + "原生编号第一项", + "HTTP 门禁" + ], + requiredFootnoteText: ["Server HTTP 验收脚注"], + minimumParagraphs: 8, + minimumTextRuns: 12, + minimumTables: 1, + minimumNumberedParagraphs: 2, + minimumHyperlinks: 1, + minimumFootnoteReferences: 1, + minimumMathObjects: 1, + requiredStyleIds: [ + "Normal", + "Heading1", + "SourceCode", + "Table", + "Caption" + ], + requirePageField: true +}); + +const afterTemporaryDirectories = temporaryDirectories(); +const leakedTemporaryDirectories = [...afterTemporaryDirectories].filter( + (name) => !beforeTemporaryDirectories.has(name) +); +assert( + leakedTemporaryDirectories.length === 0, + `DOCX HTTP 遗留临时目录:${leakedTemporaryDirectories.join(", ")}` +); + +fs.mkdirSync(outputDirectory, { recursive: true }); +const outputPath = path.join(outputDirectory, "server-http.docx"); +const reportPath = path.join( + outputDirectory, + "server-http-report.json" +); +fs.writeFileSync(outputPath, response.rawPayload); +const result = { + outputFile: path.relative(repositoryDirectory, outputPath), + bytes: response.rawPayload.byteLength, + headers: { + contentType: response.headers["content-type"], + contentDisposition: response.headers["content-disposition"], + cacheControl: response.headers["cache-control"], + serverTiming: response.headers["server-timing"], + warningCount: response.headers["x-docx-warning-count"], + echartsErrorCount: response.headers["x-echarts-error-count"], + mermaidErrorCount: response.headers["x-mermaid-error-count"] + }, + leakedTemporaryDirectories, + acceptance: report +}; +fs.writeFileSync( + reportPath, + `${JSON.stringify(result, null, 2)}\n`, + "utf8" +); +console.log(JSON.stringify(result, null, 2)); diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 44db741..c65923a 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -124,6 +124,16 @@ Toast 与非确定进度条,不污染预览状态或 PDF 精确预览缓存。 退出会等待 PDF、DOCX、Pandoc 与隐藏媒体窗口完成清理,Lua Filter 作为 明确构建资源进入桌面主进程目录。 +`v0.6.0` 阶段 9 已建立 DOCX 自动化验收门禁:综合样例覆盖可编辑正文、 +标题、字符样式、列表、表格、代码、链接、脚注、OMML、普通图片、 +Mermaid 和 ECharts;OOXML 验收器检查纸张、方向、四边页边距、原生结构、 +PNG 关系、替代文本、页眉页脚、页码、关键样式及 `altChunk` 禁用。 +真实 Pandoc 3.9.0.2 矩阵生成技术文档 A4、公文 A4 和技术文档 Letter +横向三套固定产物;Server HTTP 验收覆盖 MIME、UTF-8 文件名、诊断头、 +分阶段耗时和临时目录清理,Desktop 原生保存覆盖扩展名与字节完整性。 +统一命令为 `npm run verify:docx-acceptance`,产物及 JSON 报告写入被 Git +忽略的 `output/docx-acceptance/`,供阶段 10 的 Word/WPS 双向互存使用。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1044,7 +1054,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证; - 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务; - 阶段 8:已实现 Web/Desktop DOCX 导出交互; -- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。 +- 阶段 9:已完成 DOCX 自动化结构、真实 Pandoc、Server HTTP 与 + Desktop 保存验收; +- 阶段 10:完成 Word/WPS 双向互存、视觉、体积和正式发布验收。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 后续阶段的功能实现。 diff --git a/docs/V0.6.0_DESIGN.md b/docs/V0.6.0_DESIGN.md index 042143e..7d642ed 100644 --- a/docs/V0.6.0_DESIGN.md +++ b/docs/V0.6.0_DESIGN.md @@ -404,8 +404,8 @@ DOCX 沿用当前无历史数据库原则。Markdown、图片、中间 PNG、动 6. 动态 `reference.docx`; 7. Pandoc DOCX 转换服务; 8. Web 与 Desktop 导出交互; -9. 自动化测试与 Word/WPS 验收; -10. 体积、文档和正式发布。 +9. DOCX 自动化结构、真实 Pandoc、Server HTTP 与 Desktop 保存验收; +10. Word/WPS 双向互存、视觉、体积、文档和正式发布。 每个阶段遵循: diff --git a/package.json b/package.json index 6d6f7bc..fd00a5a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "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", + "verify:docx-matrix": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine", + "verify:docx-acceptance": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop && npm run verify:pandoc -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine && npm run verify:docx-http -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop -- desktop-docx-save.test.ts", + "verify:docx-http": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-http -w @md-to-pdf/server", "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/docx-engine/README.md b/packages/docx-engine/README.md index 17df818..f8f7078 100644 --- a/packages/docx-engine/README.md +++ b/packages/docx-engine/README.md @@ -36,9 +36,18 @@ 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 +npm run verify:docx-acceptance ``` `verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过 `DOCX_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板, 分别验证公文 A4 与自定义横向模板,并在系统临时目录中完成转换和清理, 不会保留用户文档或验收产物。 + +`verify:docx-acceptance` 使用综合 Markdown 夹具和固定 Pandoc 生成技术 +文档 A4、公文 A4、技术文档 Letter 横向三套 DOCX,自动检查纸张、页边距、 +原生段落、标题、编号、表格、链接、脚注、OMML、PNG、页眉页脚、页码、 +关键样式和 `altChunk` 禁用门禁。DOCX 与 JSON 报告写入被 Git 忽略的 +`output/docx-acceptance/`,供 Word/WPS 互操作验收使用。根级命令还会 +依次执行动态模板、媒体转换、Server HTTP 和 Desktop 原生保存验收; +仅需重跑三配置矩阵时可使用 `npm run verify:docx-matrix`。 diff --git a/packages/docx-engine/fixtures/docx-acceptance.md b/packages/docx-engine/fixtures/docx-acceptance.md new file mode 100644 index 0000000..838c195 --- /dev/null +++ b/packages/docx-engine/fixtures/docx-acceptance.md @@ -0,0 +1,75 @@ +--- +title: v0.6.0 DOCX 自动验收 +author: Markdown PDF 导出器研发组 +subject: Word 与 WPS 可编辑性验收 +keywords: [DOCX, Pandoc, OOXML] +lang: zh-CN +--- + +# 一级标题:可编辑文档 + +这是一段可编辑的中文正文,包含 **加粗文本**、*斜体文本*、 +~~删除线文本~~、`inlineCode()` 和 +[外部链接](https://example.invalid/docx-acceptance)。 + +## 二级标题:段落结构 + +> 引用段落用于确认内容仍是 Word 原生段落,而不是页面截图。 + +- 无序列表第一项 + - 嵌套无序列表 +- 无序列表第二项 + +1. 有序列表第一项 +2. 有序列表第二项 + +- [x] 已完成任务 +- [ ] 未完成任务 + +### 三级标题:表格与代码 + +| 验收项 | Word 结构 | 预期结果 | +| :--- | :---: | ---: | +| 正文 | 段落与文本 Run | 可编辑 | +| 表格 | 原生表格与单元格 | 可增删内容 | +| 图表 | 高分辨率 PNG | 可调整图片 | + +```typescript +export function editableDocument(value: string) { + return `DOCX 内容仍可编辑:${value}`; +} +``` + +脚注引用仍应保留原生结构。[^editable-footnote] + +行内公式 $E = mc^2$ 与块级公式: + +$$ +\int_0^1 x^2\,dx = \frac{1}{3} +$$ + +![普通图片](ignored-original-image.png) + +```mermaid +flowchart LR + A[Markdown] --> B[Pandoc] + B --> C[可编辑 DOCX] +``` + +```echarts +version: 1 +caption: ECharts 柱状图 +option: + xAxis: + type: category + data: [一月, 二月, 三月] + yAxis: + type: value + series: + - type: bar + data: [12, 20, 16] +``` + +最后一段用于确认正文没有通过 `altChunk` 嵌入 HTML,也没有整体图片化。 + +[^editable-footnote]: 这是可以继续编辑的 Word 原生脚注。 diff --git a/packages/docx-engine/package.json b/packages/docx-engine/package.json index 7637853..cc8e249 100644 --- a/packages/docx-engine/package.json +++ b/packages/docx-engine/package.json @@ -21,7 +21,8 @@ "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "verify:pandoc": "node scripts/verify-pandoc-reference.mjs", - "verify:conversion": "node scripts/verify-pandoc-conversion.mjs" + "verify:conversion": "node scripts/verify-pandoc-conversion.mjs", + "verify:acceptance": "node scripts/verify-docx-acceptance.mjs" }, "dependencies": { "@md-to-pdf/core": "0.1.0", diff --git a/packages/docx-engine/scripts/verify-docx-acceptance.mjs b/packages/docx-engine/scripts/verify-docx-acceptance.mjs new file mode 100644 index 0000000..0ffdfe6 --- /dev/null +++ b/packages/docx-engine/scripts/verify-docx-acceptance.mjs @@ -0,0 +1,333 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + DOCX_PANDOC_VERSION, + defaultExportConfig, + themeManifestSchema +} from "@md-to-pdf/core"; +import { + PandocDocxConverter, + PandocRuntime, + inspectDocxAcceptance +} from "@md-to-pdf/docx-engine"; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const packageDirectory = path.resolve(directory, ".."); +const repositoryDirectory = path.resolve(packageDirectory, "../.."); +const outputDirectory = path.join( + repositoryDirectory, + "output", + "docx-acceptance" +); +const markdown = fs.readFileSync( + path.join(packageDirectory, "fixtures", "docx-acceptance.md"), + "utf8" +); +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64" +); + +function loadTheme(id) { + return themeManifestSchema.parse( + JSON.parse( + fs.readFileSync( + path.join(repositoryDirectory, "themes", id, "theme.json"), + "utf8" + ) + ) + ); +} + +function mediaResource(kind, ordinal, dimensions, altText, caption) { + return { + id: `docx-media-${ordinal}`, + kind, + ordinal, + kindOrdinal: 1, + altText, + ...(caption ? { caption } : {}), + displayWidthPx: dimensions.width, + displayHeightPx: dimensions.height, + captureX: 0, + captureY: 0, + captureWidthPx: dimensions.width, + captureHeightPx: dimensions.height, + rasterScale: 1, + fileName: `${kind}-${ordinal}.png`, + contentType: "image/png", + content: png, + pixelWidth: 1, + pixelHeight: 1 + }; +} + +function createMedia() { + const resources = [ + mediaResource( + "image", + 1, + { width: 384, height: 192 }, + "普通图片" + ), + mediaResource( + "mermaid", + 2, + { width: 480, height: 240 }, + "Mermaid 流程图", + "Mermaid 流程图" + ), + mediaResource( + "echarts", + 3, + { width: 576, height: 288 }, + "ECharts 柱状图", + "ECharts 柱状图" + ) + ]; + return { + resources, + echartsErrors: [], + mermaidErrors: [], + warnings: [], + totalBytes: png.byteLength * resources.length + }; +} + +const technicalTheme = loadTheme("typora-like"); +const officialTheme = loadTheme("gov-red-standard"); +const commonExpectation = { + requiredText: [ + "可编辑的中文正文", + "原生表格与单元格", + "editableDocument", + "正文没有通过" + ], + minimumParagraphs: 18, + minimumTextRuns: 24, + minimumTables: 1, + minimumNumberedParagraphs: 6, + minimumHyperlinks: 1, + minimumFootnoteReferences: 1, + minimumMathObjects: 2, + minimumDrawings: 3, + minimumPngImages: 1, + requiredImageAltText: [ + "普通图片", + "Mermaid 流程图", + "ECharts 柱状图" + ], + requiredStyleIds: [ + "Normal", + "Heading1", + "Heading2", + "Heading3", + "SourceCode", + "Table", + "Caption" + ], + requirePageField: true +}; + +const technicalA4Config = { + ...defaultExportConfig, + name: "技术文档 A4 纵向验收", + themeId: technicalTheme.id, + pageDecorationsMode: "custom", + paper: { + ...defaultExportConfig.paper, + format: "A4", + orientation: "portrait", + marginMode: "custom", + margins: { + top: "16mm", + right: "16mm", + bottom: "16mm", + left: "16mm" + } + } +}; +const officialA4Config = { + ...defaultExportConfig, + name: "公文 A4 纵向验收", + themeId: officialTheme.id, + pageDecorationsMode: "theme", + paper: { + ...defaultExportConfig.paper, + format: "A4", + orientation: "portrait", + marginMode: "theme" + } +}; +const landscapeLetterConfig = { + ...defaultExportConfig, + name: "技术文档 Letter 横向验收", + themeId: technicalTheme.id, + pageDecorationsMode: "custom", + paper: { + ...defaultExportConfig.paper, + format: "Letter", + orientation: "landscape", + marginMode: "custom", + margins: { + top: "20mm", + right: "18mm", + bottom: "22mm", + left: "24mm" + } + }, + header: { + ...defaultExportConfig.header, + enabled: true, + left: { + enabled: true, + content: "${title}" + }, + right: { + enabled: true, + content: "${filename}" + } + }, + footer: { + ...defaultExportConfig.footer, + alignment: "right", + format: "page-total", + startFrom: 3 + } +}; + +const variants = [ + { + id: "technical-a4-portrait", + theme: technicalTheme, + exportConfig: technicalA4Config, + expectation: { + ...commonExpectation, + id: "technical-a4-portrait", + page: { + widthTwips: 11906, + heightTwips: 16838, + orientation: "portrait", + marginsTwips: { + top: 907, + right: 907, + bottom: 907, + left: 907 + } + } + } + }, + { + id: "official-a4-portrait", + theme: officialTheme, + exportConfig: officialA4Config, + expectation: { + ...commonExpectation, + id: "official-a4-portrait", + page: { + widthTwips: 11906, + heightTwips: 16838, + orientation: "portrait", + marginsTwips: { + top: 2098, + right: 1474, + bottom: 1984, + left: 1587 + } + } + } + }, + { + id: "technical-letter-landscape", + theme: technicalTheme, + exportConfig: landscapeLetterConfig, + expectation: { + ...commonExpectation, + id: "technical-letter-landscape", + page: { + widthTwips: 15817, + heightTwips: 12246, + orientation: "landscape", + marginsTwips: { + top: 1134, + right: 1020, + bottom: 1247, + left: 1361 + } + } + } + } +]; + +const runtime = new PandocRuntime( + process.env.DOCX_PANDOC_PATH?.trim() + ? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() } + : {} +); +const capability = await runtime.probe(); +if (capability.capability.status !== "available") { + throw new Error(capability.capability.message); +} +if ( + capability.capability.detectedVersion !== DOCX_PANDOC_VERSION +) { + throw new Error( + `Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${capability.capability.detectedVersion}` + ); +} +const converter = new PandocDocxConverter({ runtime }); +fs.mkdirSync(outputDirectory, { recursive: true }); + +const results = []; +for (const variant of variants) { + const result = await converter.convert({ + markdown, + fileName: `${variant.id}.md`, + language: "zh-CN", + exportConfig: variant.exportConfig, + theme: variant.theme, + metadata: { + title: "v0.6.0 DOCX 自动验收", + author: "Markdown PDF 导出器研发组", + subject: "Word 与 WPS 可编辑性验收", + keywords: ["DOCX", "Pandoc", "OOXML"], + language: "zh-CN" + }, + media: createMedia() + }); + const report = inspectDocxAcceptance( + result.docx, + variant.expectation + ); + const outputPath = path.join(outputDirectory, `${variant.id}.docx`); + fs.writeFileSync(outputPath, result.docx); + results.push({ + id: variant.id, + themeId: variant.theme.id, + outputFile: path.relative(repositoryDirectory, outputPath), + bytes: result.docx.byteLength, + templateFingerprint: result.templateFingerprint, + templateCacheKey: result.templateCacheKey, + conversionValidation: result.validation, + conversionTimings: result.timings, + acceptance: report + }); +} + +const acceptanceReport = { + generatedAt: new Date().toISOString(), + pandocVersion: capability.capability.detectedVersion, + fixture: path.relative( + repositoryDirectory, + path.join(packageDirectory, "fixtures", "docx-acceptance.md") + ), + variants: results +}; +const reportPath = path.join(outputDirectory, "acceptance-report.json"); +fs.writeFileSync( + reportPath, + `${JSON.stringify(acceptanceReport, null, 2)}\n`, + "utf8" +); +console.log(JSON.stringify(acceptanceReport, null, 2)); diff --git a/packages/docx-engine/src/acceptance-validator.ts b/packages/docx-engine/src/acceptance-validator.ts new file mode 100644 index 0000000..9017b9a --- /dev/null +++ b/packages/docx-engine/src/acceptance-validator.ts @@ -0,0 +1,555 @@ +import path from "node:path"; +import { MAXIMUM_DOCX_OUTPUT_BYTES } from "@md-to-pdf/core"; +import { + OFFICE_RELATIONSHIP_NAMESPACE, + PACKAGE_RELATIONSHIP_NAMESPACE, + WORD_NAMESPACE, + parseXmlPart, + type XmlElement +} from "./ooxml.js"; +import { + MAXIMUM_GENERATED_DOCX_ENTRY_COUNT, + MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES, + readDocxPackage +} from "./reference-package.js"; +import { + validateGeneratedDocx, + type DynamicReferenceValidation +} from "./validator.js"; + +const OFFICE_MATH_NAMESPACE = + "http://schemas.openxmlformats.org/officeDocument/2006/math"; +const WORDPROCESSING_DRAWING_NAMESPACE = + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; +const IMAGE_RELATIONSHIP_TYPE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; +const HYPERLINK_RELATIONSHIP_TYPE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; +const PNG_SIGNATURE = Uint8Array.of( + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a +); + +export interface DocxAcceptancePageExpectation { + widthTwips: number; + heightTwips: number; + orientation: "portrait" | "landscape"; + marginsTwips: { + top: number; + right: number; + bottom: number; + left: number; + }; +} + +export interface DocxAcceptanceExpectation { + id: string; + page: DocxAcceptancePageExpectation; + requiredText: readonly string[]; + requiredFootnoteText?: readonly string[]; + minimumParagraphs?: number; + minimumTextRuns?: number; + minimumTables?: number; + minimumNumberedParagraphs?: number; + minimumHyperlinks?: number; + minimumFootnoteReferences?: number; + minimumMathObjects?: number; + minimumDrawings?: number; + minimumPngImages?: number; + requiredImageAltText?: readonly string[]; + requiredStyleIds?: readonly string[]; + requirePageField?: boolean; +} + +export interface DocxAcceptanceReport { + id: string; + package: DynamicReferenceValidation; + fingerprint: string; + page: { + widthTwips: number; + heightTwips: number; + orientation: "portrait" | "landscape"; + marginsTwips: { + top: number; + right: number; + bottom: number; + left: number; + }; + }; + structure: { + paragraphCount: number; + textRunCount: number; + tableCount: number; + numberedParagraphCount: number; + hyperlinkCount: number; + footnoteReferenceCount: number; + mathObjectCount: number; + drawingCount: number; + pngImageCount: number; + pageFieldCount: number; + altChunkCount: number; + }; + imageAltText: string[]; + styleIds: string[]; + checks: Record; +} + +interface Relationship { + id: string; + type: string; + target: string; + external: boolean; +} + +function fail(id: string, check: string, detail: string): never { + throw new Error(`${id} 的 DOCX 自动验收失败:${check}(${detail})`); +} + +function numericAttribute( + element: XmlElement, + localName: string, + id: string, + check: string +) { + const value = element.getAttributeNS(WORD_NAMESPACE, localName); + if (!value || !/^\d+$/u.test(value)) { + fail(id, check, `缺少有效的 w:${localName}`); + } + return Number(value); +} + +function assertEqual( + id: string, + check: string, + actual: unknown, + expected: unknown +) { + if (actual !== expected) { + fail( + id, + check, + `期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)}` + ); + } +} + +function assertMinimum( + id: string, + check: string, + actual: number, + expected = 0 +) { + if (actual < expected) { + fail(id, check, `期望至少 ${expected},实际 ${actual}`); + } +} + +function parseRelationshipPart( + content: Uint8Array, + partName: string +) { + const document = parseXmlPart(content, partName); + const relationships: Relationship[] = []; + for (const element of Array.from( + document.getElementsByTagNameNS( + PACKAGE_RELATIONSHIP_NAMESPACE, + "Relationship" + ) + )) { + const id = element.getAttribute("Id"); + const type = element.getAttribute("Type"); + const target = element.getAttribute("Target"); + if (!id || !type || !target) { + throw new Error(`${partName} 包含不完整的关系声明`); + } + relationships.push({ + id, + type, + target, + external: + (element.getAttribute("TargetMode") ?? "").toLowerCase() === + "external" + }); + } + return relationships; +} + +function resolveDocumentTarget(target: string) { + return path.posix.normalize( + path.posix.join("word", target.replace(/^\/+/u, "")) + ); +} + +function hasPngSignature(content: Uint8Array) { + return ( + content.byteLength >= PNG_SIGNATURE.byteLength && + PNG_SIGNATURE.every((value, index) => content[index] === value) + ); +} + +function collectPageFieldCount( + entries: ReadonlyMap +) { + let count = 0; + for (const [partName, content] of entries) { + if (!/^word\/(?:document|header\d+|footer\d+)\.xml$/u.test(partName)) { + continue; + } + const document = parseXmlPart(content, partName); + for (const element of Array.from( + document.getElementsByTagNameNS(WORD_NAMESPACE, "instrText") + )) { + if (/\bPAGE\b/u.test(element.textContent ?? "")) { + count += 1; + } + } + } + return count; +} + +export function inspectDocxAcceptance( + content: Uint8Array, + expectation: DocxAcceptanceExpectation +): DocxAcceptanceReport { + const packageValidation = validateGeneratedDocx(content); + const packageContent = readDocxPackage(content, { + maximumBytes: MAXIMUM_DOCX_OUTPUT_BYTES, + maximumEntryCount: MAXIMUM_GENERATED_DOCX_ENTRY_COUNT, + maximumUncompressedBytes: + MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES + }); + const entries = packageContent.entries; + const documentPart = entries.get("word/document.xml")!; + const document = parseXmlPart(documentPart, "word/document.xml"); + const sections = Array.from( + document.getElementsByTagNameNS(WORD_NAMESPACE, "sectPr") + ); + const section = sections.at(-1); + if (!section) { + fail(expectation.id, "page-section", "缺少最终节"); + } + const pageSize = Array.from( + section.getElementsByTagNameNS(WORD_NAMESPACE, "pgSz") + )[0]; + const pageMargins = Array.from( + section.getElementsByTagNameNS(WORD_NAMESPACE, "pgMar") + )[0]; + if (!pageSize || !pageMargins) { + fail(expectation.id, "page-layout", "缺少纸张或页边距设置"); + } + const actualPage = { + widthTwips: numericAttribute( + pageSize, + "w", + expectation.id, + "page-width" + ), + heightTwips: numericAttribute( + pageSize, + "h", + expectation.id, + "page-height" + ), + orientation: + pageSize.getAttributeNS(WORD_NAMESPACE, "orient") === "landscape" + ? ("landscape" as const) + : ("portrait" as const), + marginsTwips: { + top: numericAttribute( + pageMargins, + "top", + expectation.id, + "margin-top" + ), + right: numericAttribute( + pageMargins, + "right", + expectation.id, + "margin-right" + ), + bottom: numericAttribute( + pageMargins, + "bottom", + expectation.id, + "margin-bottom" + ), + left: numericAttribute( + pageMargins, + "left", + expectation.id, + "margin-left" + ) + } + }; + assertEqual( + expectation.id, + "page-width", + actualPage.widthTwips, + expectation.page.widthTwips + ); + assertEqual( + expectation.id, + "page-height", + actualPage.heightTwips, + expectation.page.heightTwips + ); + assertEqual( + expectation.id, + "page-orientation", + actualPage.orientation, + expectation.page.orientation + ); + for (const side of ["top", "right", "bottom", "left"] as const) { + assertEqual( + expectation.id, + `margin-${side}`, + actualPage.marginsTwips[side], + expectation.page.marginsTwips[side] + ); + } + + const paragraphCount = document.getElementsByTagNameNS( + WORD_NAMESPACE, + "p" + ).length; + const textRunCount = document.getElementsByTagNameNS( + WORD_NAMESPACE, + "t" + ).length; + const tableCount = document.getElementsByTagNameNS( + WORD_NAMESPACE, + "tbl" + ).length; + const numberedParagraphCount = document.getElementsByTagNameNS( + WORD_NAMESPACE, + "numPr" + ).length; + const hyperlinkCount = document.getElementsByTagNameNS( + WORD_NAMESPACE, + "hyperlink" + ).length; + const footnoteReferenceCount = document.getElementsByTagNameNS( + WORD_NAMESPACE, + "footnoteReference" + ).length; + const mathObjectCount = document.getElementsByTagNameNS( + OFFICE_MATH_NAMESPACE, + "oMath" + ).length; + const drawingCount = document.getElementsByTagNameNS( + WORD_NAMESPACE, + "drawing" + ).length; + const altChunkCount = document.getElementsByTagNameNS( + WORD_NAMESPACE, + "altChunk" + ).length; + assertMinimum( + expectation.id, + "paragraphs", + paragraphCount, + expectation.minimumParagraphs + ); + assertMinimum( + expectation.id, + "text-runs", + textRunCount, + expectation.minimumTextRuns + ); + assertMinimum( + expectation.id, + "tables", + tableCount, + expectation.minimumTables + ); + assertMinimum( + expectation.id, + "numbering", + numberedParagraphCount, + expectation.minimumNumberedParagraphs + ); + assertMinimum( + expectation.id, + "hyperlinks", + hyperlinkCount, + expectation.minimumHyperlinks + ); + assertMinimum( + expectation.id, + "footnotes", + footnoteReferenceCount, + expectation.minimumFootnoteReferences + ); + assertMinimum( + expectation.id, + "math", + mathObjectCount, + expectation.minimumMathObjects + ); + assertMinimum( + expectation.id, + "drawings", + drawingCount, + expectation.minimumDrawings + ); + assertEqual(expectation.id, "altChunk", altChunkCount, 0); + + const bodyText = document.documentElement?.textContent ?? ""; + for (const requiredText of expectation.requiredText) { + if (!bodyText.includes(requiredText)) { + fail( + expectation.id, + "editable-text", + `缺少文本 ${JSON.stringify(requiredText)}` + ); + } + } + if ((expectation.requiredFootnoteText?.length ?? 0) > 0) { + const footnotesPart = entries.get("word/footnotes.xml"); + if (!footnotesPart) { + fail(expectation.id, "footnote-text", "缺少 word/footnotes.xml"); + } + const footnotes = parseXmlPart( + footnotesPart, + "word/footnotes.xml" + ); + const footnoteText = footnotes.documentElement?.textContent ?? ""; + for (const requiredText of expectation.requiredFootnoteText ?? []) { + if (!footnoteText.includes(requiredText)) { + fail( + expectation.id, + "footnote-text", + `缺少脚注文本 ${JSON.stringify(requiredText)}` + ); + } + } + } + + const relationshipPart = entries.get( + "word/_rels/document.xml.rels" + )!; + const relationships = parseRelationshipPart( + relationshipPart, + "word/_rels/document.xml.rels" + ); + const hyperlinkRelationships = relationships.filter( + (relationship) => + relationship.type === HYPERLINK_RELATIONSHIP_TYPE && + relationship.external + ).length; + assertMinimum( + expectation.id, + "hyperlink-relationships", + hyperlinkRelationships, + expectation.minimumHyperlinks + ); + const imageRelationships = relationships.filter( + (relationship) => + relationship.type === IMAGE_RELATIONSHIP_TYPE && + !relationship.external + ); + const pngParts = new Set(); + for (const relationship of imageRelationships) { + const partName = resolveDocumentTarget(relationship.target); + const image = entries.get(partName); + if (!image) { + fail( + expectation.id, + "image-relationship", + `${relationship.id} 指向缺失部件 ${partName}` + ); + } + if (partName.toLowerCase().endsWith(".png") && hasPngSignature(image)) { + pngParts.add(partName); + } + } + assertMinimum( + expectation.id, + "png-images", + pngParts.size, + expectation.minimumPngImages + ); + + const imageAltText = Array.from( + document.getElementsByTagNameNS( + WORDPROCESSING_DRAWING_NAMESPACE, + "docPr" + ) + ) + .flatMap((element) => [ + element.getAttribute("descr"), + element.getAttribute("title") + ]) + .filter((value): value is string => Boolean(value)); + for (const requiredAltText of expectation.requiredImageAltText ?? []) { + if (!imageAltText.includes(requiredAltText)) { + fail( + expectation.id, + "image-alt-text", + `缺少替代文本 ${JSON.stringify(requiredAltText)}` + ); + } + } + + const styles = parseXmlPart( + entries.get("word/styles.xml")!, + "word/styles.xml" + ); + const styleIds = Array.from( + styles.getElementsByTagNameNS(WORD_NAMESPACE, "style") + ) + .map((element) => + element.getAttributeNS(WORD_NAMESPACE, "styleId") + ) + .filter((value): value is string => Boolean(value)); + for (const requiredStyleId of expectation.requiredStyleIds ?? []) { + if (!styleIds.includes(requiredStyleId)) { + fail( + expectation.id, + "styles", + `缺少样式 ${requiredStyleId}` + ); + } + } + + const pageFieldCount = collectPageFieldCount(entries); + if (expectation.requirePageField) { + assertMinimum(expectation.id, "page-field", pageFieldCount, 1); + } + return { + id: expectation.id, + package: packageValidation, + fingerprint: packageContent.fingerprint, + page: actualPage, + structure: { + paragraphCount, + textRunCount, + tableCount, + numberedParagraphCount, + hyperlinkCount, + footnoteReferenceCount, + mathObjectCount, + drawingCount, + pngImageCount: pngParts.size, + pageFieldCount, + altChunkCount + }, + imageAltText, + styleIds, + checks: { + package: true, + page: true, + editableStructure: true, + relationships: true, + pngMedia: true, + styles: true, + noAltChunk: true + } + }; +} diff --git a/packages/docx-engine/src/index.ts b/packages/docx-engine/src/index.ts index 63382a1..f9c96ad 100644 --- a/packages/docx-engine/src/index.ts +++ b/packages/docx-engine/src/index.ts @@ -1,3 +1,4 @@ +export * from "./acceptance-validator.js"; export * from "./header-footer-transform.js"; export * from "./ooxml.js"; export * from "./pandoc-process.js"; diff --git a/packages/docx-engine/tests/acceptance-validator.test.ts b/packages/docx-engine/tests/acceptance-validator.test.ts new file mode 100644 index 0000000..a665832 --- /dev/null +++ b/packages/docx-engine/tests/acceptance-validator.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { zipSync } from "fflate"; +import { inspectDocxAcceptance } from "../src/index.js"; + +const encoder = new TextEncoder(); +const word = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; +const relationships = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const packageRelationships = + "http://schemas.openxmlformats.org/package/2006/relationships"; +const math = + "http://schemas.openxmlformats.org/officeDocument/2006/math"; +const drawing = + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; +const png = Uint8Array.of( + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0, + 0, + 0, + 0 +); + +function xml(value: string) { + return encoder.encode(value); +} + +function createAcceptanceDocx(overrides: { altChunk?: boolean } = {}) { + return zipSync({ + "[Content_Types].xml": xml( + '' + ), + "_rels/.rels": xml( + `` + ), + "word/document.xml": xml( + `可编辑正文x原生表格${overrides.altChunk ? '' : ""}` + ), + "word/styles.xml": xml( + `${["Normal", "Heading1", "SourceCode", "Table", "Caption"].map((id) => ``).join("")}` + ), + "word/settings.xml": xml(``), + "word/fontTable.xml": xml(``), + "word/numbering.xml": xml(``), + "word/theme/theme1.xml": xml( + '' + ), + "word/footer1.xml": xml( + ` PAGE \\* MERGEFORMAT ` + ), + "word/footnotes.xml": xml( + `脚注` + ), + "word/media/image1.png": png, + "word/_rels/document.xml.rels": xml( + `${overrides.altChunk ? '' : ""}` + ), + ...(overrides.altChunk + ? { "word/chunk.html": encoder.encode("

整体 HTML

") } + : {}) + }); +} + +const expectation = { + id: "unit-fixture", + page: { + widthTwips: 11906, + heightTwips: 16838, + orientation: "portrait" as const, + marginsTwips: { + top: 907, + right: 907, + bottom: 907, + left: 907 + } + }, + requiredText: ["可编辑正文", "原生表格"], + requiredFootnoteText: ["脚注"], + minimumParagraphs: 3, + minimumTextRuns: 2, + minimumTables: 1, + minimumNumberedParagraphs: 1, + minimumHyperlinks: 1, + minimumFootnoteReferences: 1, + minimumMathObjects: 1, + minimumPngImages: 1, + requiredImageAltText: ["普通图片"], + requiredStyleIds: ["Normal", "Heading1", "SourceCode", "Table"], + requirePageField: true +}; + +describe("DOCX 自动验收器", () => { + it("验证纸张、可编辑结构、关系、PNG 和字段", () => { + const report = inspectDocxAcceptance( + createAcceptanceDocx(), + expectation + ); + + expect(report.page.orientation).toBe("portrait"); + expect(report.structure).toMatchObject({ + tableCount: 1, + numberedParagraphCount: 1, + hyperlinkCount: 1, + footnoteReferenceCount: 1, + mathObjectCount: 1, + pngImageCount: 1, + pageFieldCount: 1, + altChunkCount: 0 + }); + expect(report.checks).toEqual( + expect.objectContaining({ + editableStructure: true, + pngMedia: true, + noAltChunk: true + }) + ); + }); + + it("拒绝通过 altChunk 嵌入的正文 HTML", () => { + expect(() => + inspectDocxAcceptance( + createAcceptanceDocx({ altChunk: true }), + expectation + ) + ).toThrow("altChunk"); + }); +});