test: 完成 DOCX 全主题视觉验收
This commit is contained in:
+25
-1
@@ -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 双向互存、外部主题兼容、体积和正式发布验收。
|
||||
|
||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||
后续阶段的功能实现。
|
||||
|
||||
@@ -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/<theme-id>.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
|
||||
<w:tblW w:type="auto" w:w="0"/>
|
||||
```
|
||||
|
||||
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
|
||||
正式发布提交或版本标签。
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(/<w:t(?:\s[^>]*)?>([\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:br[^>]*w:type="page"/gu
|
||||
);
|
||||
|
||||
return {
|
||||
bytes: content.byteLength,
|
||||
page: pageOptions,
|
||||
paragraphCount: countMatches(documentXml, /<w:p(?:\s|>)/gu),
|
||||
tableCount: countMatches(documentXml, /<w:tbl(?:\s|>)/gu),
|
||||
drawingCount: countMatches(documentXml, /<w:drawing(?:\s|>)/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("<w:tbl") && !footerXml.includes("<w:tbl"),
|
||||
pageField: footerXml.includes(" PAGE "),
|
||||
totalPagesField: footerXml.includes(" NUMPAGES "),
|
||||
altChunkCount: countMatches(documentXml, /<w:altChunk(?:\s|>)/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));
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -191,6 +191,13 @@ function validateReferencedParts(
|
||||
`${relationship.targetPart} 的页眉页脚根元素无效`
|
||||
);
|
||||
}
|
||||
if (
|
||||
root.getElementsByTagNameNS(WORD_NAMESPACE, "tbl").length > 0
|
||||
) {
|
||||
throw new Error(
|
||||
`${relationship.targetPart} 不得使用表格模拟页眉页脚布局`
|
||||
);
|
||||
}
|
||||
}
|
||||
return references.length;
|
||||
}
|
||||
|
||||
@@ -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("<w:tbl");
|
||||
expect(validateDynamicReferenceDocx(first.content)).toMatchObject({
|
||||
partCount: first.partCount,
|
||||
headerCount: 0,
|
||||
@@ -237,6 +240,16 @@ describe("动态 reference.docx", () => {
|
||||
expect(settingsXml).toContain("<w:evenAndOddHeaders");
|
||||
expect(headerXml).toContain("年度 <报告>");
|
||||
expect(headerXml).toContain("报告 & 计划.md");
|
||||
expect(headerXml).toContain('w:tab w:val="center"');
|
||||
expect(headerXml).toContain('w:tab w:val="right"');
|
||||
expect(headerXml).toContain('w:pos="6860"');
|
||||
expect(headerXml).toContain('w:pos="13720"');
|
||||
expect(headerXml).toContain("<w:pBdr>");
|
||||
expect(headerXml).toContain("<w:bottom");
|
||||
expect(headerXml).not.toContain("<w:tbl");
|
||||
expect(
|
||||
decoder.decode(entries["word/footer1.xml"])
|
||||
).not.toContain("<w:tbl");
|
||||
expect(relationships.match(/relationships\/header/gu)).toHaveLength(
|
||||
3
|
||||
);
|
||||
@@ -275,6 +288,28 @@ describe("动态 reference.docx", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
"</w:ftr>",
|
||||
"<w:tbl><w:tr><w:tc><w:p/></w:tc></w:tr></w:tbl></w:ftr>"
|
||||
);
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user