diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index c65923a..3aa2af1 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -134,6 +134,24 @@ PNG 关系、替代文本、页眉页脚、页码、关键样式及 `altChunk` 统一命令为 `npm run verify:docx-acceptance`,产物及 JSON 报告写入被 Git 忽略的 `output/docx-acceptance/`,供阶段 10 的 Word/WPS 双向互存使用。 +`v0.6.0` 阶段 10 已完成第一轮 Word 与全主题视觉审计:页眉页脚从表格 +模拟改为原生段落、制表位和页码字段,并增加禁止页眉页脚表格回退的 +OOXML 校验。14 套内置主题均可由 Word 正常打开和编辑,纸张、页边距、 +原生结构、页眉页脚和页码字段通过;使用 Word 原生排版引擎导出 14 份 +PDF,并逐页检查 22 个页面。审计同时确认当前实现尚未达到完整主题映射 +门禁:4 套独立封面均未生成,8 套结构化主题的 Front Matter 未完整进入 +Word,6 套主题存在重复标题,所有表格仍为自动收缩宽度,暗标和经典黑白 +主题存在错误的蓝色样式。完整结论见 +[DOCX 全主题视觉与结构审计](V0.6.0_DOCX_THEME_AUDIT.md),统一复现命令 +为 `npm run verify:docx-themes`。 + +下一阶段已冻结为建立独立 `packages/docx-theme-engine`:通过标准语义 +样式探针和 Chromium/Electron `getComputedStyle()` 将主题 CSS 转换为 +DOCX 样式令牌,支持自动映射、清单覆盖和预设降级;Front Matter 结构 +将通过与 HTML/PDF 共用的语义文档模型进入 Pandoc AST。转换逻辑不得按 +主题 ID 分支,普通外部主题即使没有 `docxStyle` 也应获得可诊断的基础 +DOCX 映射。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1056,7 +1074,13 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - 阶段 8:已实现 Web/Desktop DOCX 导出交互; - 阶段 9:已完成 DOCX 自动化结构、真实 Pandoc、Server HTTP 与 Desktop 保存验收; -- 阶段 10:完成 Word/WPS 双向互存、视觉、体积和正式发布验收。 +- 阶段 10:已完成 Word 原生页眉页脚和第一轮全主题视觉审计;完整主题 + 映射门禁未通过; +- 阶段 11:建立独立 `packages/docx-theme-engine`、标准样式探针和 + CSS 计算样式到 DOCX 令牌的通用映射; +- 阶段 12:建立统一语义文档模型,完成 Front Matter、封面、分节、 + 表格宽度和分页控制; +- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 后续阶段的功能实现。 diff --git a/docs/V0.6.0_DOCX_THEME_AUDIT.md b/docs/V0.6.0_DOCX_THEME_AUDIT.md new file mode 100644 index 0000000..81cfc20 --- /dev/null +++ b/docs/V0.6.0_DOCX_THEME_AUDIT.md @@ -0,0 +1,260 @@ +# v0.6.0 DOCX 全主题视觉与结构审计 + +审计日期:2026-07-30 + +## 1. 审计目标 + +本轮按 v0.6.0 已冻结的两条 DOCX 发布门禁检查全部内置主题: + +1. 导出的 DOCX 必须保留完整纸张设置与文本样式,包括字体、段落、标题、 + 列表、代码块、表格、图片、Mermaid、ECharts、封面和结构化公文元素。 +2. 导出的 DOCX 必须能被 Microsoft Word 与 WPS 正常打开、编辑和互存。 + +项目实际包含 14 套内置主题,而不是此前口头统计的 13 套。本轮没有只做 +OOXML 自动检查,还使用本机 Microsoft Word 原生排版引擎逐份导出 PDF, +并检查全部 22 个渲染页面。 + +## 2. 审计方法与证据 + +- 使用 Pandoc `3.9.0.2` 和当前动态 `reference.docx` 生成 14 份 DOCX。 +- 使用每套主题自己的 `samples/themes/.md` 和导出配置。 +- 检查 DOCX 包结构、纸张尺寸、四边页边距、样式、表格、图片关系、 + 页眉页脚、页码字段、分页符和 `altChunk`。 +- 使用 Microsoft Word 原生 COM 导出 14 份 PDF,共 22 页。 +- 将 Word PDF 以 150 DPI 栅格化,逐页按原始分辨率检查。 +- 从 Word PDF 提取实际使用字体,排查字体替换。 +- 检查 Front Matter 结构字段是否真正进入可编辑 Word 正文。 + +可复现命令: + +```powershell +npm run verify:docx-themes +``` + +本地审计产物位于: + +```text +output/docx-theme-matrix/ +├── *.docx +├── theme-matrix-report.json +├── word-pdf-isolated/ +└── word-png/ +``` + +`output/` 已被 Git 忽略,审计产物不会进入仓库。 + +## 3. 总体结论 + +当前实现通过了“原生可编辑 DOCX”基础能力,但没有通过“完整主题样式映射” +发布门禁。 + +| 检查项 | 结果 | 结论 | +| --- | ---: | --- | +| Word 可打开并原生排版 | 14/14 | 通过 | +| 正文、标题、列表、表格和代码为原生 OOXML | 14/14 | 通过 | +| 禁止 `altChunk` | 14/14 | 通过 | +| A4、方向和四边页边距符合主题配置 | 14/14 | 通过 | +| 页眉页脚和页码为 Word 原生区域/字段 | 14/14 | 通过 | +| 本机字体无异常替换 | 14/14 | 通过 | +| 独立封面完整生成 | 0/4 | 阻塞 | +| 结构化公文/简报 Front Matter 完整映射 | 0/4 | 阻塞 | +| 主题视觉完整映射 | 0/14 | 阻塞 | +| 全宽表格 | 0/14 个含表格主题 | 阻塞 | +| 无重复标题 | 8/14 | 需修复 | + +因此,本轮结论不是 Node.js 或 Pandoc 路线不可行,而是当前 +`reference.docx + 原始 Markdown 直送 Pandoc` 的实现层级不足。Pandoc +仍适合作为可编辑 OOXML 的底座,但必须增加项目自有的结构化 AST 转换、 +分节和主题专属 Word 样式映射,不能期待 Pandoc 自动理解浏览器主题 CSS +与项目 Front Matter。 + +## 4. 关键阻塞问题 + +### 4.1 结构化 Front Matter 没有进入 Word 正文 + +浏览器预览通过 `render-document-structure.ts` 将 `document.profile` 转成 +红头、文号、签发人、版记、封面等结构;DOCX 转换当前将原始 Markdown +直接交给 Pandoc,两条链路没有复用同一份结构化文档模型。 + +| 主题 | Profile | 结构字段覆盖率 | 主要缺失 | +| --- | --- | ---: | --- | +| enterprise-red-simple | official | 14% | 文号、签发人、日期、抄送、印发机关 | +| gov-red-letter | official | 17% | 文号、日期、抄送、印发机关 | +| gov-red-standard | official | 22% | 文号、紧急程度、签发人、日期、抄送、版记 | +| red-briefing | briefing | 17% | 期号、编发单位、日期、联系方式等 | +| formal-feasibility | project-report | 33% | 项目名、建设单位、版本、日期和独立封面 | +| tender-blind | tender | 20% | 正副本、项目名、编号、分册、投标人等封面字段 | +| tender-business-blue | tender | 14% | 正副本、项目名、编号、分册、代表、日期 | +| tender-classic | tender | 14% | 正副本、项目名、编号、分册、代表、日期 | + +四套声明独立封面的主题均没有生成封面,也没有显式分页符或分节: + +- `formal-feasibility` +- `tender-blind` +- `tender-business-blue` +- `tender-classic` + +### 4.2 14 套主题实际收敛成少量通用 Word 预设 + +当前 DOCX 样式主要来自 `official`、`formal`、`tender`、`technical` 和 +`general` 几个预设。多个网页视觉差异明显的主题,在 DOCX 中使用完全相同 +或近似的正文、标题和表格样式。 + +- `formal-regulation` 与 `formal-report` 基本相同。 +- `typora-github` 与 `typora-like` 基本相同。 +- `typora-pixyll` 与 `typora-whitey` 基本相同。 +- 三套红头公文正文样式基本相同,但没有各自红头版式结构。 +- 三套标书共用蓝色系表格预设,破坏暗标和经典黑白主题语义。 + +这说明当前是“按主题选择一个通用 Word 模板”,尚不是“完整映射每套主题”。 + +### 4.3 标题重复 + +以下 6 套样例的 YAML `title` 与正文第一个 H1 相同。Pandoc 同时输出文档 +标题块和 H1,Word 中出现重复标题: + +- `formal-regulation` +- `formal-report` +- `typora-github` +- `typora-like` +- `typora-pixyll` +- `typora-whitey` + +此外,文档标题当前基于 H1 再增加 4pt,实际约 26pt;在红头信函中产生 +过大的标题和不自然换行,例如“函”被孤立到第二行。 + +### 4.4 表格没有继承网页主题的全宽布局 + +Pandoc 生成的表格宽度是: + +```xml + +``` + +Word 会按内容自动收缩,导致所有表格明显窄于正文内容区。网页主题中的 +`width: 100%`、列宽和部分表头视觉没有被映射到 Word 表格网格。 + +### 4.5 分页质量不足 + +当前 14 份文档均没有显式分页符。Word 只能根据通用段落属性自动分页, +出现以下问题: + +- `typora-like` 第 2 页只剩最后一行审核人信息,属于严重孤行页。 +- `formal-feasibility`、`formal-regulation`、`formal-report` 和 + `tender-blind` 的末页内容很少,下半页大面积空白。 +- 代码块可以跨页,但没有专门的代码块续排或避免不佳断点策略。 +- 封面与正文无法通过 Word 分节控制首页页眉页脚、起始页码和封面不计页码。 + +### 4.6 字体在本机正确,但没有嵌入 + +Word 实际导出的 PDF 中可见字体符合当前样式意图: + +- 公文、正式报告和标书主要使用 `SimSun`、`SimHei`、 + `Times New Roman`、`Arial`。 +- 技术主题主要使用 `Microsoft YaHei`、`Arial`、`Consolas`。 + +但 14 份 DOCX 均没有嵌入字体文件,也没有嵌入字体引用。这意味着: + +- 在本机和安装了相同字体的公司电脑上表现稳定。 +- 在缺少宋体、黑体、微软雅黑或对应英文字体的环境中,Word/WPS 会替换字体, + 进而改变换行、页数和视觉。 +- 默认嵌入字体会显著增大文件,并涉及字体再分发许可,不适合作为默认策略。 + +建议明确“受支持字体清单 + fallback”,并在 Desktop/Docker 安装或内置 +所需可分发字体;除非业务明确要求,不默认嵌入字体到每份 DOCX。 + +## 5. 逐主题视觉结论 + +| 主题 | Word 页数 | 主要视觉与结构问题 | 门禁结论 | +| --- | ---: | --- | --- | +| enterprise-red-simple | 1 | 无企业红头、文号、签发和版记;呈现为通用 Word 文档;表格偏窄 | 阻塞 | +| formal-feasibility | 2 | 无独立封面;项目、建设单位、版本等封面字段缺失;末页留白大 | 阻塞 | +| formal-regulation | 2 | 标题重复;视觉为通用蓝灰报告;表格偏窄;末页留白大 | 阻塞 | +| formal-report | 2 | 标题重复;主题区分度弱;末页只有少量内容 | 阻塞 | +| gov-red-letter | 2 | 无红头、文号、落款和版记;超大标题换行不佳;第 2 页外侧公文页码正确 | 阻塞 | +| gov-red-standard | 2 | 无红头、文号、紧急程度、签发和版记;奇偶外侧公文页码正确 | 阻塞 | +| red-briefing | 1 | 无简报报头、期号、编发单位和联系信息;正文段落间视觉空隙偏大 | 阻塞 | +| tender-blind | 2 | 无封面;标题和表头为蓝色,违反技术暗标黑白要求;末页留白大 | 阻塞 | +| tender-business-blue | 1 | 无封面;蓝色体系基本协调,但仍是通用标书模板;表格偏窄 | 阻塞 | +| tender-classic | 1 | 无封面;标题过大;表头仍为浅蓝色,违反经典黑白语义 | 阻塞 | +| typora-github | 2 | 标题重复;代码样式可用;表格偏窄;审计占位图黑块不计入产品缺陷 | 需修复 | +| typora-like | 2 | 标题重复;正文、引用和代码基本可用;第 2 页仅一行,孤行严重 | 阻塞 | +| typora-pixyll | 1 | 标题重复;整体可用,但与 Whitey 的 Word 风格区分不足;表格偏窄 | 需修复 | +| typora-whitey | 1 | 标题重复;整体可用,但主题专属字体和细节映射不足;表格偏窄 | 需修复 | + +`typora-github` 审计图中的大黑块来自矩阵脚本使用 1×1 像素占位 PNG 后按 +计划尺寸拉伸,只用于验证图片关系和 Word 尺寸,不代表真实 Mermaid 导出。 +真实 Mermaid/ECharts PNG 管线已经在独立验收样例中验证,正式全主题回归 +仍应改用真实捕获媒体,避免视觉报告产生歧义。 + +## 6. 已确认正常的能力 + +- DOCX 是标准 OOXML,而不是 HTML 或 PDF 嵌套。 +- 正文、标题、列表、表格、代码和链接可在 Word 中继续编辑。 +- A4 尺寸、方向和主题声明的四边页边距正确。 +- 页眉页脚位于 Word 原生页眉区/页脚区,不再以正文表格模拟。 +- 页码使用 `PAGE`/`NUMPAGES` 字段。 +- `gov-red-letter` 首页不显示页码,第 2 页显示外侧 `— 2 —`。 +- `gov-red-standard` 奇偶页外侧公文页码正确。 +- 视觉上较淡的 `tender-classic` 和 `typora-whitey` 页码经 PDF 文本提取 + 确认存在,不是字段丢失。 +- 所有 DOCX 均未发现 `altChunk`,可编辑性基础路线正确。 + +## 7. 建议修复顺序 + +### P0:建立统一的结构化 DOCX 文档模型 + +在进入 Pandoc 前,将 Markdown AST、Front Matter 和主题结构合成为统一 +中间模型,再输出 Pandoc AST 或通过项目 Lua Filter 注入原生 Word 段落、 +表格、分页符和分节信息。公文、简报、项目报告和标书封面不能依赖 +`reference.docx` 自动生成。 + +### P0:实现封面与分节 + +- 封面使用原生段落和可编辑文字。 +- 封面结束后插入 Word 分节符。 +- 支持封面无页眉页脚、封面不计页码、正文从 1 开始。 +- 公文首页、奇偶页脚继续使用 Word 原生节属性。 + +### P0:扩展主题 DOCX 清单 + +为主题增加可显式覆盖的 Word 语义样式,而不是只选择通用预设: + +- 文档标题、作者和元数据块; +- 红头、文号、签发人、落款、抄送和版记; +- 封面项目名、分册、正副本和日期; +- H1~H6、正文、列表、引用、代码、图注; +- 表格宽度、列宽、表头、边框和底色; +- 段前段后、孤行控制、标题与下一段同页。 + +### P1:修复通用排版问题 + +- 对 YAML 标题与首个 H1 去重。 +- 表格按内容区宽度生成 `pct=5000` 或明确网格宽度。 +- 为标题设置 `keepNext`,为正文设置 widow/orphan 控制。 +- 收敛过大的 26pt 文档标题,并按主题单独配置。 +- 暗标和经典黑白主题禁止蓝色标题、表头和装饰。 + +### P1:完善字体可复现策略 + +- 固化每套主题的首选字体和 fallback。 +- 在发布说明中列出 Word/WPS 目标环境所需字体。 +- 仅使用许可证允许随应用分发的字体作为可选内置字体。 +- 增加“字体不可用”诊断,不默认把字体嵌入每份 DOCX。 + +### P2:升级全主题回归门禁 + +- 使用真实 Mermaid、ECharts 和普通图片,不再使用纯色占位图做视觉验收。 +- 自动检测标题重复、表格内容区占比、孤行页和封面分节。 +- Word 与 WPS 各完成一次打开、编辑、保存、重开。 +- 保存后再次校验页数、字体、页眉页脚、字段和原生结构。 + +## 8. 阶段判定 + +当前 DOCX 引擎可以认定为: + +> Pandoc 路线与 Node.js 编排可行,原生可编辑基础链路成立;但完整主题 +> 映射、结构化公文、封面、表格宽度和分页控制尚未达到 v0.6.0 发布门禁。 + +在完成 P0 项目前,不建议将阶段 10 标记为通过,也不建议创建 v0.6.0 +正式发布提交或版本标签。 diff --git a/package.json b/package.json index fd00a5a..88b9fbe 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "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-themes": "npm run build:web-runtime && npm run verify:themes -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", diff --git a/packages/docx-engine/package.json b/packages/docx-engine/package.json index cc8e249..cb02624 100644 --- a/packages/docx-engine/package.json +++ b/packages/docx-engine/package.json @@ -22,7 +22,8 @@ "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:acceptance": "node scripts/verify-docx-acceptance.mjs" + "verify:acceptance": "node scripts/verify-docx-acceptance.mjs", + "verify:themes": "node scripts/verify-theme-docx-matrix.mjs" }, "dependencies": { "@md-to-pdf/core": "0.1.0", diff --git a/packages/docx-engine/scripts/export-docx-with-word.ps1 b/packages/docx-engine/scripts/export-docx-with-word.ps1 new file mode 100644 index 0000000..7cd1a9a --- /dev/null +++ b/packages/docx-engine/scripts/export-docx-with-word.ps1 @@ -0,0 +1,68 @@ +param( + [Parameter(Mandatory = $true)] + [string]$InputPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +$resolvedInput = (Resolve-Path -LiteralPath $InputPath).Path +$resolvedOutput = [System.IO.Path]::GetFullPath($OutputPath) +$outputDirectory = [System.IO.Path]::GetDirectoryName($resolvedOutput) +if (-not [System.IO.Directory]::Exists($outputDirectory)) { + [System.IO.Directory]::CreateDirectory($outputDirectory) | Out-Null +} +if ([System.IO.File]::Exists($resolvedOutput)) { + throw "Word PDF 输出已存在:$resolvedOutput" +} + +$word = $null +$document = $null +try { + $word = New-Object -ComObject Word.Application + $word.Visible = $false + $word.DisplayAlerts = 0 + $word.AutomationSecurity = 3 + $word.Options.SaveNormalPrompt = $false + $document = $word.Documents.Open( + $resolvedInput, + $false, + $true, + $false, + '', + '', + $false, + '', + '', + 0, + 0, + $false, + $true, + 0, + $true + ) + $pageCount = $document.ComputeStatistics(2) + $document.ExportAsFixedFormat($resolvedOutput, 17) + [pscustomobject]@{ + input = $resolvedInput + output = $resolvedOutput + pages = $pageCount + bytes = (Get-Item -LiteralPath $resolvedOutput).Length + } | ConvertTo-Json -Compress +} finally { + if ($null -ne $document) { + $document.Close($false) + [System.Runtime.InteropServices.Marshal]::ReleaseComObject( + $document + ) | Out-Null + } + if ($null -ne $word) { + $word.Quit() + [System.Runtime.InteropServices.Marshal]::ReleaseComObject( + $word + ) | Out-Null + } + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} diff --git a/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs b/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs new file mode 100644 index 0000000..dd7547e --- /dev/null +++ b/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs @@ -0,0 +1,321 @@ +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, + resolveReferencePageOptions +} from "@md-to-pdf/docx-engine"; +import { renderMarkdown } from "@md-to-pdf/renderer"; +import { unzipSync } from "fflate"; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const packageDirectory = path.resolve(directory, ".."); +const repositoryDirectory = path.resolve(packageDirectory, "../.."); +const themesDirectory = path.join(repositoryDirectory, "themes"); +const samplesDirectory = path.join(repositoryDirectory, "samples", "themes"); +const outputDirectory = path.join( + repositoryDirectory, + "output", + "docx-theme-matrix" +); +const decoder = new TextDecoder(); +const placeholderPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64" +); + +function decodeXmlText(value) { + return value + .replace(/</gu, "<") + .replace(/>/gu, ">") + .replace(/"/gu, '"') + .replace(/'/gu, "'") + .replace(/&/gu, "&"); +} + +function extractWordText(xml) { + return [...xml.matchAll(/]*)?>([\s\S]*?)<\/w:t>/gu)] + .map((match) => decodeXmlText(match[1] ?? "")) + .join(""); +} + +function countMatches(value, pattern) { + return value.match(pattern)?.length ?? 0; +} + +function readThemeManifests() { + return fs + .readdirSync(themesDirectory, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + fs.existsSync( + path.join(themesDirectory, entry.name, "theme.json") + ) + ) + .map((entry) => + themeManifestSchema.parse( + JSON.parse( + fs.readFileSync( + path.join( + themesDirectory, + entry.name, + "theme.json" + ), + "utf8" + ) + ) + ) + ) + .sort((first, second) => + first.id.localeCompare(second.id, "en") + ); +} + +function structuralValues(document) { + if (!document) { + return []; + } + return Object.entries(document) + .filter(([name]) => name !== "profile") + .flatMap(([, value]) => + Array.isArray(value) ? value : value ? [value] : [] + ) + .map(String); +} + +function inspectDocument(content, theme, metadata, exportConfig) { + const entries = unzipSync(content); + const documentXml = decoder.decode(entries["word/document.xml"]); + const stylesXml = decoder.decode(entries["word/styles.xml"]); + const bodyText = extractWordText(documentXml); + const headerXml = Object.entries(entries) + .filter(([name]) => /^word\/header\d+\.xml$/u.test(name)) + .map(([, value]) => decoder.decode(value)) + .join("\n"); + const footerXml = Object.entries(entries) + .filter(([name]) => /^word\/footer\d+\.xml$/u.test(name)) + .map(([, value]) => decoder.decode(value)) + .join("\n"); + const expectedStructureValues = structuralValues(metadata.document); + const missingStructureValues = expectedStructureValues.filter( + (value) => !bodyText.includes(value) + ); + const pageOptions = resolveReferencePageOptions({ + exportConfig, + theme, + fileName: `${theme.id}.md`, + metadata + }); + const expectsStandaloneCover = + metadata.document?.profile === "project-report" || + metadata.document?.profile === "tender"; + const explicitPageBreaks = countMatches( + documentXml, + /]*w:type="page"/gu + ); + + return { + bytes: content.byteLength, + page: pageOptions, + paragraphCount: countMatches(documentXml, /)/gu), + tableCount: countMatches(documentXml, /)/gu), + drawingCount: countMatches(documentXml, /)/gu), + explicitPageBreaks, + headerPartCount: Object.keys(entries).filter((name) => + /^word\/header\d+\.xml$/u.test(name) + ).length, + footerPartCount: Object.keys(entries).filter((name) => + /^word\/footer\d+\.xml$/u.test(name) + ).length, + nativeHeaderFooter: + !headerXml.includes(")/gu), + styles: { + normal: stylesXml.includes('w:styleId="Normal"'), + heading1: stylesXml.includes('w:styleId="Heading1"'), + sourceCode: stylesXml.includes('w:styleId="SourceCode"'), + table: stylesXml.includes('w:styleId="Table"') + }, + profile: metadata.document?.profile ?? null, + expectedStructureValues, + missingStructureValues, + structureCoverage: + expectedStructureValues.length === 0 + ? 1 + : (expectedStructureValues.length - + missingStructureValues.length) / + expectedStructureValues.length, + expectsStandaloneCover, + standaloneCoverDetected: + expectsStandaloneCover && + explicitPageBreaks > 0 && + missingStructureValues.length === 0 + }; +} + +function createMediaResource(kind, ordinal, kindOrdinal) { + const dimensions = + kind === "image" + ? { width: 480, height: 270 } + : { width: 640, height: 360 }; + return { + id: `docx-media-${ordinal}`, + kind, + ordinal, + kindOrdinal, + altText: `${kind} 主题验收图 ${kindOrdinal}`, + ...(kind === "image" + ? {} + : { caption: `${kind} 主题验收图 ${kindOrdinal}` }), + displayWidthPx: dimensions.width, + displayHeightPx: dimensions.height, + captureX: 0, + captureY: 0, + captureWidthPx: dimensions.width, + captureHeightPx: dimensions.height, + rasterScale: 1, + fileName: `${kind}-${kindOrdinal}.png`, + contentType: "image/png", + content: placeholderPng, + pixelWidth: 1, + pixelHeight: 1 + }; +} + +function countMarkdownMedia(markdown, kind) { + const patterns = { + image: /!\[[^\]]*\]\([^)]*\)/gu, + mermaid: /^```mermaid(?:\s.*)?$/gimu, + echarts: /^```echarts(?:\s.*)?$/gimu + }; + return countMatches(markdown, patterns[kind]); +} + +function createMedia(markdown) { + const resources = []; + let ordinal = 0; + for (const kind of ["image", "mermaid", "echarts"]) { + const count = countMarkdownMedia(markdown, kind); + for (let kindOrdinal = 1; kindOrdinal <= count; kindOrdinal += 1) { + ordinal += 1; + resources.push( + createMediaResource(kind, ordinal, kindOrdinal) + ); + } + } + return { + resources, + echartsErrors: [], + mermaidErrors: [], + warnings: [], + totalBytes: placeholderPng.byteLength * resources.length + }; +} +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}` + ); +} + +fs.mkdirSync(outputDirectory, { recursive: true }); +const converter = new PandocDocxConverter({ runtime }); +const themes = readThemeManifests(); +const results = []; + +for (const theme of themes) { + console.error(`[DOCX theme matrix] generating ${theme.id}`); + const samplePath = path.join(samplesDirectory, `${theme.id}.md`); + if (!fs.existsSync(samplePath)) { + throw new Error(`主题缺少验收示例:${theme.id}`); + } + const markdown = fs.readFileSync(samplePath, "utf8"); + const rendered = renderMarkdown(markdown, { language: "zh-CN" }); + const exportConfig = { + ...defaultExportConfig, + name: `${theme.name} DOCX 主题验收`, + themeId: theme.id, + pageDecorationsMode: "theme", + paper: { + ...defaultExportConfig.paper, + format: "A4", + orientation: "portrait", + marginMode: "theme" + } + }; + let conversion; + try { + conversion = await converter.convert({ + markdown, + fileName: `${theme.id}.md`, + language: rendered.metadata.language, + exportConfig, + theme, + metadata: rendered.metadata, + media: createMedia(markdown) + }); + } catch (error) { + throw new Error(`主题 ${theme.id} 的 DOCX 转换失败`, { + cause: error + }); + } + const outputPath = path.join(outputDirectory, `${theme.id}.docx`); + fs.writeFileSync(outputPath, conversion.docx); + results.push({ + id: theme.id, + name: theme.name, + category: theme.category, + compatibleProfiles: theme.compatibleProfiles, + docxPreset: theme.docxStyle?.preset ?? null, + sample: path.relative(repositoryDirectory, samplePath), + outputFile: path.relative(repositoryDirectory, outputPath), + metadata: rendered.metadata, + conversion: { + templateFingerprint: conversion.templateFingerprint, + templateCacheKey: conversion.templateCacheKey, + timings: conversion.timings, + validation: conversion.validation + }, + inspection: inspectDocument( + conversion.docx, + theme, + rendered.metadata, + exportConfig + ) + }); +} + +const report = { + generatedAt: new Date().toISOString(), + pandocVersion: capability.capability.detectedVersion, + themeCount: themes.length, + outputDirectory: path.relative( + repositoryDirectory, + outputDirectory + ), + results +}; +const reportPath = path.join(outputDirectory, "theme-matrix-report.json"); +fs.writeFileSync( + reportPath, + `${JSON.stringify(report, null, 2)}\n`, + "utf8" +); +console.log(JSON.stringify(report, null, 2)); diff --git a/packages/docx-engine/src/header-footer-transform.ts b/packages/docx-engine/src/header-footer-transform.ts index 9ddebab..aa1536b 100644 --- a/packages/docx-engine/src/header-footer-transform.ts +++ b/packages/docx-engine/src/header-footer-transform.ts @@ -10,6 +10,7 @@ import { WORD_NAMESPACE, appendElement, colorValue, + millimetersToTwips, parseXmlPart, pointsToHalfPoints, serializeXmlPart, @@ -173,37 +174,50 @@ function appendFooterContent( } } -function appendThreeColumnTable( - root: XmlElement, +function appendParagraphProperties( + paragraph: XmlElement, options: { + alignment?: "left" | "center" | "right"; position: "header" | "footer"; divider: boolean; dividerColor: string; - alignments: readonly ["left", "center", "right"]; - render: ( - paragraph: XmlElement, - alignment: "left" | "center" | "right" - ) => void; + centerTabTwips?: number; + rightTabTwips?: number; } ) { - const table = appendElement(root, WORD_NAMESPACE, "w:tbl"); - const tableProperties = appendElement( - table, + const properties = appendElement( + paragraph, WORD_NAMESPACE, - "w:tblPr" + "w:pPr" ); - appendElement(tableProperties, WORD_NAMESPACE, "w:tblW", { - "w:w": "5000", - "w:type": "pct" - }); - appendElement(tableProperties, WORD_NAMESPACE, "w:tblLayout", { - "w:type": "fixed" + appendElement(properties, WORD_NAMESPACE, "w:spacing", { + "w:before": "0", + "w:after": "0" }); + if (options.alignment) { + appendElement(properties, WORD_NAMESPACE, "w:jc", { + "w:val": options.alignment + }); + } + if ( + options.centerTabTwips !== undefined && + options.rightTabTwips !== undefined + ) { + const tabs = appendElement(properties, WORD_NAMESPACE, "w:tabs"); + appendElement(tabs, WORD_NAMESPACE, "w:tab", { + "w:val": "center", + "w:pos": String(options.centerTabTwips) + }); + appendElement(tabs, WORD_NAMESPACE, "w:tab", { + "w:val": "right", + "w:pos": String(options.rightTabTwips) + }); + } if (options.divider) { const borders = appendElement( - tableProperties, + properties, WORD_NAMESPACE, - "w:tblBorders" + "w:pBdr" ); appendElement( borders, @@ -217,41 +231,46 @@ function appendThreeColumnTable( } ); } - const grid = appendElement(table, WORD_NAMESPACE, "w:tblGrid"); - for (let index = 0; index < 3; index += 1) { - appendElement(grid, WORD_NAMESPACE, "w:gridCol", { - "w:w": "2400" - }); - } - const row = appendElement(table, WORD_NAMESPACE, "w:tr"); - for (const alignment of options.alignments) { - const cell = appendElement(row, WORD_NAMESPACE, "w:tc"); - const cellProperties = appendElement( - cell, - WORD_NAMESPACE, - "w:tcPr" - ); - appendElement(cellProperties, WORD_NAMESPACE, "w:tcW", { - "w:w": "1667", - "w:type": "pct" - }); - const paragraph = appendElement(cell, WORD_NAMESPACE, "w:p"); - const paragraphProperties = appendElement( - paragraph, - WORD_NAMESPACE, - "w:pPr" - ); - appendElement(paragraphProperties, WORD_NAMESPACE, "w:jc", { - "w:val": alignment - }); - options.render(paragraph, alignment); +} + +function appendTab(paragraph: XmlElement) { + const run = appendElement(paragraph, WORD_NAMESPACE, "w:r"); + appendElement(run, WORD_NAMESPACE, "w:tab"); +} + +function appendHeaderParagraph( + root: XmlElement, + options: { + divider: boolean; + dividerColor: string; + contentWidthMm: number; + render: ( + paragraph: XmlElement, + alignment: "left" | "center" | "right" + ) => void; } +) { + const paragraph = appendElement(root, WORD_NAMESPACE, "w:p"); + const contentWidthTwips = millimetersToTwips(options.contentWidthMm); + appendParagraphProperties(paragraph, { + position: "header", + divider: options.divider, + dividerColor: options.dividerColor, + centerTabTwips: Math.round(contentWidthTwips / 2), + rightTabTwips: contentWidthTwips + }); + options.render(paragraph, "left"); + appendTab(paragraph); + options.render(paragraph, "center"); + appendTab(paragraph); + options.render(paragraph, "right"); } function createHeaderPart( header: HeaderConfig, options: DynamicReferenceDocxOptions, - fallbackFont: string + fallbackFont: string, + contentWidthMm: number ) { const document = createWordPart("hdr"); const style: RunStyle = { @@ -259,11 +278,10 @@ function createHeaderPart( sizePt: (lengthToMillimeters(header.fontSize) * 72) / 25.4, color: header.color }; - appendThreeColumnTable(document.documentElement!, { - position: "header", + appendHeaderParagraph(document.documentElement!, { divider: header.showDivider, dividerColor: header.color, - alignments: ["left", "center", "right"], + contentWidthMm, render: (paragraph, alignment) => { const slot = header[alignment]; if (slot.enabled) { @@ -290,17 +308,20 @@ function createFooterPart( sizePt: (lengthToMillimeters(footer.fontSize) * 72) / 25.4, color: footer.color }; - appendThreeColumnTable(document.documentElement!, { + const paragraph = appendElement( + document.documentElement!, + WORD_NAMESPACE, + "w:p" + ); + appendParagraphProperties(paragraph, { position: "footer", divider: footer.showDivider, dividerColor: footer.color, - alignments: ["left", "center", "right"], - render: (paragraph, cellAlignment) => { - if (!empty && cellAlignment === alignment) { - appendFooterContent(paragraph, footer, style); - } - } + alignment }); + if (!empty) { + appendFooterContent(paragraph, footer, style); + } return serializeXmlPart(document); } @@ -427,7 +448,8 @@ export function createHeaderFooterParts( options: DynamicReferenceDocxOptions, header: HeaderConfig, footer: FooterConfig, - fallbackFont: string + fallbackFont: string, + contentWidthMm: number ): HeaderFooterTransformResult { const entries = new Map(sourceEntries); removeExistingHeaderFooterParts(entries); @@ -447,7 +469,8 @@ export function createHeaderFooterParts( const headerContent = createHeaderPart( header, options, - fallbackFont + fallbackFont, + contentWidthMm ); for (const type of [ "default", diff --git a/packages/docx-engine/src/reference-builder.ts b/packages/docx-engine/src/reference-builder.ts index b9361b7..b5f3936 100644 --- a/packages/docx-engine/src/reference-builder.ts +++ b/packages/docx-engine/src/reference-builder.ts @@ -91,7 +91,8 @@ export function createDynamicReferenceDocx( options, page.header, page.footer, - style.body.fonts.eastAsia + style.body.fonts.eastAsia, + page.dimensions.width - page.margins.left - page.margins.right ); const entries = withDecorations.entries; entries.set( diff --git a/packages/docx-engine/src/validator.ts b/packages/docx-engine/src/validator.ts index 7b9f8cb..8dd5ba9 100644 --- a/packages/docx-engine/src/validator.ts +++ b/packages/docx-engine/src/validator.ts @@ -191,6 +191,13 @@ function validateReferencedParts( `${relationship.targetPart} 的页眉页脚根元素无效` ); } + if ( + root.getElementsByTagNameNS(WORD_NAMESPACE, "tbl").length > 0 + ) { + throw new Error( + `${relationship.targetPart} 不得使用表格模拟页眉页脚布局` + ); + } } return references.length; } diff --git a/packages/docx-engine/tests/reference-builder.test.ts b/packages/docx-engine/tests/reference-builder.test.ts index 8ca76ad..2f6d89f 100644 --- a/packages/docx-engine/tests/reference-builder.test.ts +++ b/packages/docx-engine/tests/reference-builder.test.ts @@ -176,6 +176,9 @@ describe("动态 reference.docx", () => { expect(stylesXml).toContain('w:eastAsia="Microsoft YaHei"'); expect(footerXml).toContain(" PAGE \\* MERGEFORMAT "); expect(footerXml).toContain(" NUMPAGES \\* MERGEFORMAT "); + expect(footerXml).toContain('w:jc w:val="center"'); + expect(footerXml).toContain('w:before="0"'); + expect(footerXml).not.toContain(" { expect(settingsXml).toContain(""); + expect(headerXml).toContain(" { ); }); + it("拒绝使用表格模拟页眉页脚布局", () => { + const result = createDynamicReferenceDocx( + createBaselineReference(), + createOptions(defaultExportConfig) + ); + const entries = new Map( + Object.entries(unzipSync(result.content)) + ); + const footer = decoder + .decode(entries.get("word/footer1.xml")!) + .replace( + "", + "" + ); + entries.set("word/footer1.xml", encoder.encode(footer)); + const damaged = writeReferenceDocxPackage(entries); + + expect(() => validateDynamicReferenceDocx(damaged)).toThrow( + /不得使用表格模拟页眉页脚布局/u + ); + }); + it("最终 DOCX 使用媒体输出上限而非模板的 2 MiB 上限", () => { const result = createDynamicReferenceDocx( createBaselineReference(),