补齐公文全条款视觉核验与版头定位

This commit is contained in:
mizzlelover
2026-09-10 22:07:19 +08:00
parent 3060da65ba
commit baf0407583
18 changed files with 325 additions and 23 deletions
+4 -1
View File
@@ -135,17 +135,20 @@ Windows
- [生成器](scripts/generate_gongwen_docx.mjs):根据输入生成 DOCX 版式。 - [生成器](scripts/generate_gongwen_docx.mjs):根据输入生成 DOCX 版式。
- [校验器](scripts/verify_gongwen_docx.mjs):检查生成器可承诺的 DOCX 版式要素。 - [校验器](scripts/verify_gongwen_docx.mjs):检查生成器可承诺的 DOCX 版式要素。
- [回归测试](tests/run_tests.sh):运行居中与单双页页码生成、DOCX 包结构与 PDF 转换验证。 - [回归测试](tests/run_tests.sh):运行居中与单双页页码生成、DOCX 包结构与 PDF 转换验证。
- [全条款视觉核验](references/gbt9704-visual-audit.md):实际生成 12 份 DOCX、渲染 23 张 PNG,并逐条记录截图证据与人工边界。
- [跨平台安装测试](tests/test-install.sh):隔离环境中验证九个本地目录和 TraeWork 导入包。 - [跨平台安装测试](tests/test-install.sh):隔离环境中验证九个本地目录和 TraeWork 导入包。
## 验证 ## 验证
```bash ```bash
tests/run_tests.sh tests/run_tests.sh
# 需要截图核对时,再运行完整视觉审计
bash tests/visual-audit.sh /tmp/gongwen-visual-audit
``` ```
如果要把字体替代也视为失败,可在生成器和校验器上同时加 `--require-standard-fonts`;当前电脑没有小标宋体或标准仿宋体时,命令会停止并明确列出缺失字体。 如果要把字体替代也视为失败,可在生成器和校验器上同时加 `--require-standard-fonts`;当前电脑没有小标宋体或标准仿宋体时,命令会停止并明确列出缺失字体。
这项测试证明生成器、校验器和可打印文件链路可运行。最终文件按实际用途在目标 Word/WPS 与打印条件中检查。 这项测试证明生成器、校验器和可打印文件链路可运行。视觉审计会把每个场景转成 PDF 和 PNG,按截图检查版头、标题间距、首页正文、单双页页码、附件和版记。最终文件仍须按实际用途在目标 Word/WPS 与打印条件中检查。
## 许可 ## 许可
+5 -1
View File
@@ -15,7 +15,7 @@ GB/T 9704-2012《党政机关公文格式》现行,2025-05-30 复审继续有
本工具只处理排版,不判断单位身份、授权状态或后续盖章方式。它会根据用户明确选择的版式模式应用对应的排版规则,不因“正式”“国企”“报告”或仅提供机构名称而擅自使用红头。 本工具只处理排版,不判断单位身份、授权状态或后续盖章方式。它会根据用户明确选择的版式模式应用对应的排版规则,不因“正式”“国企”“报告”或仅提供机构名称而擅自使用红头。
生成器可输出 A4、156mm×225mm 版心、三号仿宋正文、小标宋二号标题、四级标题层次、两字缩进,以及居中或单双页页码。四级标题均写入 Word/WPS 标题样式和大纲级别,便于后续插入、更新目录;文章总标题使用独立“公文标题”样式,不进入目录。上行文的文号和签发人在同一行编排;信函、命令(令)、纪要使用各自专用版式;重复传入 `--attachment-file` 时,附件会另页生成并保持页码连续。版记会锚定最后一页版心底部,正式页码按版心下边缘下7mm的位置生成。 生成器可输出 A4、156mm×225mm 版心、三号仿宋正文、小标宋二号标题、四级标题层次、两字缩进,以及居中或单双页页码。四级标题均写入 Word/WPS 标题样式和大纲级别,便于后续插入、更新目录;文章总标题使用独立“公文标题”样式,不进入目录。上行文的文号和签发人在同一行编排;信函、命令(令)、纪要使用各自专用版式;重复传入 `--attachment-file` 时,附件会另页生成并保持页码连续。版记会锚定最后一页版心底部,正式页码按版心下边缘下7mm的位置生成。正式版头的份号、密级、紧急程度和电子机关标志使用首面固定定位,不会因为左上字段而把机关标志或预印红头留白向下推移。
`references/formal-checklist.md` 是可选的版式复核清单。涉及印章或签名章时,使用者可在 Word/WPS 中插入已取得的图片或完成实体盖章;本生成器不内置印章图像。 `references/formal-checklist.md` 是可选的版式复核清单。涉及印章或签名章时,使用者可在 Word/WPS 中插入已取得的图片或完成实体盖章;本生成器不内置印章图像。
@@ -51,8 +51,12 @@ soffice --headless --convert-to pdf --outdir /tmp output.docx
node scripts/verify_gongwen_docx.mjs --input output.docx --profile formal --letterhead preprinted node scripts/verify_gongwen_docx.mjs --input output.docx --profile formal --letterhead preprinted
# 目标电脑必须装有标准小标宋体和仿宋体时,再加严格字体校验 # 目标电脑必须装有标准小标宋体和仿宋体时,再加严格字体校验
node scripts/verify_gongwen_docx.mjs --input output.docx --profile formal --letterhead digital --require-standard-fonts node scripts/verify_gongwen_docx.mjs --input output.docx --profile formal --letterhead digital --require-standard-fonts
# 逐场景生成 DOCX、PDF 和 PNG,人工按截图核对版式
bash tests/visual-audit.sh /tmp/gongwen-visual-audit
``` ```
全条款视觉证据和未自动化边界见[GB/T 9704-2012 全条款视觉核验记录](references/gbt9704-visual-audit.md)。横排表格、联合行文、实体印章、纸张和装订不能只由 DOCX 生成器证明。
校验器核验生成器可检查的版式要素,并按 `ordinary``formal``letter``command``minutes` 选择校验档案。输出后仍须在目标 Word/WPS 与实际打印条件下检查首页正文、分页、附件、表格、页码、红头纸套打和装订。 校验器核验生成器可检查的版式要素,并按 `ordinary``formal``letter``command``minutes` 选择校验档案。输出后仍须在目标 Word/WPS 与实际打印条件下检查首页正文、分页、附件、表格、页码、红头纸套打和装订。
## 输出说明 ## 输出说明
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

+2 -1
View File
@@ -15,13 +15,14 @@
## 2. 版头 ## 2. 版头
- [ ] 份号适用时为6位三号阿拉伯数字,左上角第一行顶格。 - [ ] 份号适用时为6位三号阿拉伯数字,左上角第一行顶格;与密级、紧急程度一样固定在首面版头区域,不因流式段落把机关标志或预印留白向下推移
- [ ] 密级和保密期限、紧急程度适用时均为三号黑体,按份号、密级和期限、紧急程度顺序自上而下排列。 - [ ] 密级和保密期限、紧急程度适用时均为三号黑体,按份号、密级和期限、紧急程度顺序自上而下排列。
- [ ] 发文机关标志使用全称或规范简称,可加“文件”;居中、红色、推荐小标宋,上边缘距版心上边缘35mm。 - [ ] 发文机关标志使用全称或规范简称,可加“文件”;居中、红色、推荐小标宋,上边缘距版心上边缘35mm。
- [ ] 发文字号位于机关标志下空二行;年份为全称阿拉伯数字并使用六角括号〔〕,序号不加“第”、不补零、后加“号”。 - [ ] 发文字号位于机关标志下空二行;年份为全称阿拉伯数字并使用六角括号〔〕,序号不加“第”、不补零、后加“号”。
- [ ] 上行文文号左空一字;签发人与文号在同一行同一区域,右空一字。“签发人:”为仿宋三号,姓名为楷体三号;多签发人按机关顺序编排。 - [ ] 上行文文号左空一字;签发人与文号在同一行同一区域,右空一字。“签发人:”为仿宋三号,姓名为楷体三号;多签发人按机关顺序编排。
- [ ] 红色分隔线在文号下4mm处,与版心等宽,线高按模板核定(常用约0.35—0.5mm)。 - [ ] 红色分隔线在文号下4mm处,与版心等宽,线高按模板核定(常用约0.35—0.5mm)。
- [ ] 用 PDF 或 Word/WPS 实测确认机关标志到文号、红线到标题均为两个版心网格空行;不能只看 XML 中是否存在某个段前距数值。 - [ ] 用 PDF 或 Word/WPS 实测确认机关标志到文号、红线到标题均为两个版心网格空行;不能只看 XML 中是否存在某个段前距数值。
- [ ] 已运行 `bash tests/visual-audit.sh /tmp/gongwen-visual-audit` 或按同等方法逐页渲染,并按[全条款视觉核验记录](gbt9704-visual-audit.md)登记截图、人工边界和目标软件复核结果。
## 3. 主体 ## 3. 主体
+6 -3
View File
@@ -20,7 +20,7 @@
| 7.2.1 | 份号六位、3 号、左上第一行 | `--copy-no` 只接受 1—6 位阿拉伯数字并补足六位 | 结构检查;实际是否需要份号由发文机关确认 | | 7.2.1 | 份号六位、3 号、左上第一行 | `--copy-no` 只接受 1—6 位阿拉伯数字并补足六位 | 结构检查;实际是否需要份号由发文机关确认 |
| 7.2.2 | 密级和保密期限第二行、3 号黑体 | `--secret` 按黑体 3 号写入 | 文字内容和期限写法人工复核 | | 7.2.2 | 密级和保密期限第二行、3 号黑体 | `--secret` 按黑体 3 号写入 | 文字内容和期限写法人工复核 |
| 7.2.3 | 紧急程度及排列顺序 | `--urgent` 按黑体 3 号写入,并按份号、密级、紧急程度顺序下移 | 适用性和紧急级别人工确认 | | 7.2.3 | 紧急程度及排列顺序 | `--urgent` 按黑体 3 号写入,并按份号、密级、紧急程度顺序下移 | 适用性和紧急级别人工确认 |
| 7.2.4 | 发文机关标志居中、红色、版心上边缘下 35mm、小标宋 | `formal --letterhead digital` 才绘制;预印模式只留纸样区域;联合行文仍需模板 | `verify_gongwen_docx.mjs`PDF;目标电脑字体和联合行文人工核对 | | 7.2.4 | 发文机关标志居中、红色、版心上边缘下 35mm、小标宋 | `formal --letterhead digital` 才绘制;预印模式只留纸样区域;首面字段用固定定位,不推动机关标志或预留区;联合行文仍需模板 | `verify_gongwen_docx.mjs``references/gbt9704-visual-audit.md``formal-digital-all-1.png`/`formal-preprinted-1.png`;目标电脑字体和联合行文人工核对 |
| 7.2.5 | 文号下空二行、六角括号、全年份、顺序号不加“第”不补零 | `formal`/`letter``--doc-no` 做格式校验,并写入两个显式 28 磅空行 | 文号语义由机关编号规则确认 | | 7.2.5 | 文号下空二行、六角括号、全年份、顺序号不加“第”不补零 | `formal`/`letter``--doc-no` 做格式校验,并写入两个显式 28 磅空行 | 文号语义由机关编号规则确认 |
| 7.2.6 | 上行文签发人同文号一行;标签仿宋、姓名楷体 | `--upward true --signer` 同行生成单一签发人;多签发人、联合行文转人工模板 | 结构校验和 PDF;多个签发人需按机关顺序人工排布 | | 7.2.6 | 上行文签发人同文号一行;标签仿宋、姓名楷体 | `--upward true --signer` 同行生成单一签发人;多签发人、联合行文转人工模板 | 结构校验和 PDF;多个签发人需按机关顺序人工排布 |
| 7.2.7 | 文号下 4mm、版心等宽红线 | `redRule()` 生成 156mm 红线,文号段后 4mm;预印模式不重绘 | PDF 量测;红色油墨和实际纸样人工核对 | | 7.2.7 | 文号下 4mm、版心等宽红线 | `redRule()` 生成 156mm 红线,文号段后 4mm;预印模式不重绘 | PDF 量测;红色油墨和实际纸样人工核对 |
@@ -35,11 +35,11 @@
| 7.4.2 | 抄送 4 号仿宋、左右一字、回行对齐、末尾句号 | `--cc` 生成 4 号、左右一字、全角冒号和句号 | 多行抄送回行和主送移版记人工复核 | | 7.4.2 | 抄送 4 号仿宋、左右一字、回行对齐、末尾句号 | `--cc` 生成 4 号、左右一字、全角冒号和句号 | 多行抄送回行和主送移版记人工复核 |
| 7.4.3 | 印发机关/日期 4 号仿宋,左右一字,日期后“印发”,细线隔开 | `--print-org``--print-date` 生成右制表位、日期校验和末条粗线 | 实际机关名称长度和目标软件分页人工复核 | | 7.4.3 | 印发机关/日期 4 号仿宋,左右一字,日期后“印发”,细线隔开 | `--print-org``--print-date` 生成右制表位、日期校验和末条粗线 | 实际机关名称长度和目标软件分页人工复核 |
| 7.5 | 4 号半角宋体页码、两侧一字线、距版心下边缘 7mm、单双页位置、附件连续 | 正式页脚用半角宋体、单双页字段,footer 距离调整为 7mm 位置;信函关闭页码 | XML + PDF bbox;空白页/特定版记页的例外仍需人工复核 | | 7.5 | 4 号半角宋体页码、两侧一字线、距版心下边缘 7mm、单双页位置、附件连续 | 正式页脚用半角宋体、单双页字段,footer 距离调整为 7mm 位置;信函关闭页码 | XML + PDF bbox;空白页/特定版记页的例外仍需人工复核 |
| 8 | 横排表格页码与表头方向 | 普通 Markdown 表格按纵向版心生成,不假装自动完成横排 | 横排表格须在 Word/WPS 采用专用横向模板并人工检查 | | 8 | 横排表格页码与表头方向 | 普通 Markdown 表格按纵向版心生成;视觉审计保留 `table-1.png` 作为未自动化证据,不把纵向表格冒充横排 | 横排表格须在 Word/WPS 采用专用横向模板并人工检查 |
| 9 | 计量单位、标点、数字按引用标准 | 结构字段(文号、日期、附件冒号、句号)做边界校验 | 正文语义、单位、标点和数字用法需人工或专门文本审校 | | 9 | 计量单位、标点、数字按引用标准 | 结构字段(文号、日期、附件冒号、句号)做边界校验 | 正文语义、单位、标点和数字用法需人工或专门文本审校 |
| 10.1 | 信函上下双红线、30mm/20mm、170mm、标题二行、无首页页码、无版记线 | `--format letter` 生成双线、专用边距、文号距离、标题空行、底部双线页脚和无页码 | PDF 实测;联合行文及模板字段人工复核 | | 10.1 | 信函上下双红线、30mm/20mm、170mm、标题二行、无首页页码、无版记线 | `--format letter` 生成双线、专用边距、文号距离、标题空行、底部双线页脚和无页码 | PDF 实测;联合行文及模板字段人工复核 |
| 10.2 | 命令(令)标志上边缘 20mm、令号下空二行、正文下空二行 | `--format command` 要求 `--org``--doc-no`,两个间隔均为显式 28 磅空行 | 令号文字和签名章/日期按专用模板人工复核 | | 10.2 | 命令(令)标志上边缘 20mm、令号下空二行、正文下空二行 | `--format command` 要求 `--org``--doc-no`,两个间隔均为显式 28 磅空行 | 令号文字和签名章/日期按专用模板人工复核 |
| 10.3 | 纪要标志 35mm;出席/请假/列席标签黑体,人员仿宋 | `--format minutes` 要求机关标志以“纪要”结尾,三类人员分 run 写入对应字体 | 纪要具体版式可按机关制度调整 | | 10.3 | 纪要标志 35mm;出席/请假/列席标签黑体,人员仿宋 | `--format minutes` 要求机关标志以“纪要”结尾,三类人员分 run 写入对应字体,并按两字起排、悬挂缩进处理回行 | `references/gbt9704-visual-audit.md``minutes-1.png`/`minutes-2.png`纪要具体版式可按机关制度调整 |
| 11 | 标准式样图 | 各分支按条款参数实现,不把截图当作唯一依据 | 最终仍需与机关现行式样、Word/WPS 和打印样张逐页比对 | | 11 | 标准式样图 | 各分支按条款参数实现,不把截图当作唯一依据 | 最终仍需与机关现行式样、Word/WPS 和打印样张逐页比对 |
## 本轮硬性修正 ## 本轮硬性修正
@@ -48,3 +48,6 @@
- 发文机关标志到文号、命令标志到令号、令号到正文同样使用显式空行;校验器不再把“存在某个段前数值”当成视觉合规证据。 - 发文机关标志到文号、命令标志到令号、令号到正文同样使用显式空行;校验器不再把“存在某个段前数值”当成视觉合规证据。
- 版记不再跟在正文最后一行后面,而是锚定到最后一页版心底部;正式页码 footer 改为版心下边缘下 7mm 的位置。 - 版记不再跟在正文最后一行后面,而是锚定到最后一页版心底部;正式页码 footer 改为版心下边缘下 7mm 的位置。
- 文号、成文日期、附件说明的结构边界现在会拒绝明显不符合标准的输入;缺失小标宋或仿宋时仍会明确告警,严格模式会停止生成。 - 文号、成文日期、附件说明的结构边界现在会拒绝明显不符合标准的输入;缺失小标宋或仿宋时仍会明确告警,严格模式会停止生成。
本矩阵的实际渲染证据、截图文件名和逐条人工边界见[GB/T 9704-2012 全条款视觉核验记录](gbt9704-visual-audit.md)。
+97
View File
@@ -0,0 +1,97 @@
# GB/T 9704-2012 全条款视觉核验记录
本记录用于回答“生成的 DOCX 是否真的按标准版式呈现”,不是把 XML 中存在某个属性当成视觉合规证明。标准状态以[全国标准信息公共服务平台](https://std.samr.gov.cn/gb/search/gbDetailed?id=lOIe27f77QU%3D&mode=p)为准;该页面显示 GB/T 9704-2012 仍为现行标准,2025 年 5 月 30 日复审结论为继续有效。条文逐项对照了[标准全文转载页](https://dzb.cumtb.edu.cn/info/1040/1184.htm)。
## 核验方法
2026 年 9 月 10 日运行:
```bash
bash tests/visual-audit.sh /tmp/gongwen-visual-audit-latest
```
脚本实际生成 12 份 DOCX,逐份通过校验器,使用 LibreOffice 转 PDF,再用 `pdftoppm` 输出 23 张 PNG。人工检查以 PNG 为准,XML 和校验器只作为辅助证据。当前批次的文件清单保存在运行目录的 `manifest.tsv`,每份文件的校验日志保存在 `logs/`
![各场景首页视觉核验](assets/visual-audit/first-pages.png)
![全部核验页缩略图](assets/visual-audit/all-pages.png)
![单双页页码与末页版记](assets/visual-audit/long-pages-colophon.png)
## 场景证据
| 场景 | 截图 | 实际观察 |
| --- | --- | --- |
| 普通材料 | `ordinary-1.png` | 有 A4 版心、正文和居中页码,没有机关标志或红线;仅提供机构名称不会自动变红头。 |
| 完整电子红头 | `formal-digital-all-1.png``formal-digital-all-2.png` | 份号、密级、紧急程度在左上;机关标志固定在版心上边缘下 35 mm 的首面位置;文号、红线、红线下两行空白、标题、主送机关和首页正文连续出现。字段不会再把机关标志向下推移。 |
| 上行文 | `formal-digital-upward-1.png` | 文号和签发人同一行,两个签发人可见;第二页页码移到左侧。 |
| 预印红头纸套打 | `formal-preprinted-1.png``formal-preprinted-2.png` | DOCX 不绘制红色机关标志和红线;左上黑色字段固定在首面,文号和标题从预留区域之后开始。红线必须由实际红头纸提供。 |
| 附件 | `formal-attachments-1.png``formal-attachments-3.png``formal-attachments-4.png` | 附件另页,第一行是“附件1/附件2”,第三行是标题,附件页码连续。 |
| 长文版记 | `formal-long-1.png``formal-long-2.png``formal-long-3.png` | 单页右、双页左页码连续;末页版记贴近版心底部,粗细线顺序可见。 |
| 盖章/签名章位置 | `formal-seal-2.png``formal-signed-2.png` | 文字落款和日期位置已生成;实体印章或签名章图片没有被伪造,需在 Word/WPS 或打印环节完成。 |
| 信函 | `letter-1.png` | 上粗下细双红线、文号、标题和页末上细下粗双红线可见;不显示页码。 |
| 命令(令) | `command-1.png` | 机关标志、令号下空二行、正文前再空二行可见。 |
| 纪要 | `minutes-1.png``minutes-2.png` | “出席/请假/列席”标签为黑体,人员为仿宋;人员标签左空二字,回行对齐。 |
| 横排表格 | `table-1.png` | 当前示例仍是纵向表格。这一张图是故意保留的缺口证据,不能把普通纵向表格宣称为标准横排表格。 |
## 条款逐项对照
状态含义:
- **截图通过**:本次生成并渲染后,已在 PNG 上看到对应版式结果。
- **结构通过,仍需人工**:DOCX 能写入或校验结构,但标准要求的纸张、模板、语义或目标软件行为不能由生成器单独证明。
- **当前未自动化**:工具不会冒充完成,必须使用机关模板或在 Word/WPS、打印环节处理。
| 条款 | 核对内容 | 本次结论 | 视觉证据或剩余动作 |
| --- | --- | --- | --- |
| 1 | 适用范围 | 结构通过,仍需人工 | `ordinary``formal``letter``command``minutes` 分开;文种和机关现行模板仍由使用者确认。 |
| 2 | 规范性引用文件 | 结构通过,仍需人工 | 摘要和清单列出 GB/T 148、GB 3100/3101/3102、GB/T 15834、GB/T 15835;正文单位、标点、数字仍需文本审校。 |
| 3 | 字、行术语 | 截图通过 | 3 号字、28 磅实现网格可在 `formal-digital-all-1.png``ordinary-1.png` 观察;特殊模板可以调整。 |
| 4 | 纸张技术指标 | 当前未自动化 | 克重 60—80 g/m²、白度、耐折度、不透明度、pH 属纸张采购和成品检测,DOCX/PDF 无法证明。 |
| 5.1 | A4 210 mm×297 mm | 截图通过 | 所有场景由 PDF 页面尺寸核对为 A4;仍需现场检查裁切误差。 |
| 5.2.1 | 天头 37 mm、订口 28 mm、版心 156 mm×225 mm | 结构通过,仍需人工 | 生成器写入固定页边距;打印缩放必须关闭并实测。 |
| 5.2.2 | 默认 3 号仿宋 | 结构通过,仍需人工 | 本机缺少标准仿宋,生成器已明确警告并使用 STFangsong 替代;`--require-standard-fonts` 会阻止输出。 |
| 5.2.3 | 一般 22 行、28 字 | 结构通过,仍需人工 | 28 磅网格和版心已写入;长标题、表格、字体替代造成的换行必须看最终 PDF/WPS。固定 28 磅不是把所有文稿都强行塞成 22 行的声明。 |
| 5.2.4 | 默认黑色 | 截图通过 | `ordinary-1.png` 和正文区域为黑色;红色仅出现在电子红头、信函和版记线。 |
| 6.1 | 制版清洁、字迹、尺寸和版心误差 | 当前未自动化 | 需检查实际印品,不从 PDF 颜色或 XML 推断。 |
| 6.2 | 双面印刷、套正、油墨和印品质量 | 当前未自动化 | `formal-long-2.png` 只证明双面页码方向的 PDF 模拟效果,不能替代双面打印和套正检查。 |
| 6.3 | 左侧装订、钉位、裁切、成品 | 当前未自动化 | 按 `references/formal-checklist.md` 在成品上检查。 |
| 7.1 | 版头、主体、版记三区和页码 | 截图通过 | `formal-digital-all-1.png``formal-long-3.png` 展示首面和末页三区关系。 |
| 7.2.1 | 份号六位、左上第一行 | 截图通过 | `formal-digital-all-1.png``formal-preprinted-1.png` 显示 `000007`;首面字段采用固定定位。 |
| 7.2.2 | 密级和保密期限第二行、3 号黑体 | 截图通过 | `formal-digital-all-1.png` 显示左上第二行;保密期限具体写法仍需机关确认。 |
| 7.2.3 | 紧急程度及顺序 | 截图通过 | `formal-digital-all-1.png` 显示份号、密级、紧急程度顺序;是否适用由发文机关决定。 |
| 7.2.4 | 机关标志居中、红色、距版心上边缘 35 mm | 截图通过 | `formal-digital-all-1.png` 以渲染坐标检查;左上字段不再推动机关标志。完整电子红头缺少小标宋时严格模式失败。联合行文标志仍需专用模板。 |
| 7.2.5 | 文号下空二行、年份、六角括号、序号规则 | 截图通过 | `formal-digital-all-1.png``formal-digital-upward-1.png`;输入验证拒绝“第”和前导零。 |
| 7.2.6 | 上行文签发人及多签发人 | 结构通过,仍需人工 | `formal-digital-upward-1.png` 已见同一行和两个签发人;超过两个签发人或联合行文转机关模板。 |
| 7.2.7 | 文号下 4 mm、版心等宽红线 | 截图通过 | `formal-digital-all-1.png` 红线与版心同宽;预印模式不重绘,改由纸样提供。 |
| 7.3.1 | 标题二号小标宋、红线下空二行、居中 | 截图通过 | `formal-digital-all-1.png``formal-preprinted-1.png`;本机缺小标宋时使用替代并报警,不能把替代字体称为标准字体。 |
| 7.3.2 | 主送机关标题下空一行、顶格、全角冒号 | 截图通过 | `formal-digital-all-1.png` 只出现一次“各主送机关:”,重复传入带冒号或不带冒号均归一化。 |
| 7.3.3 | 首页正文、三号仿宋、两字缩进、层次字体 | 截图通过 | `formal-digital-all-1.png``ordinary-1.png`;四级标题均有 Heading1—Heading4 和大纲级别。 |
| 7.3.4 | 附件说明 | 结构通过,仍需人工 | `--attachment-note` 统一冒号和末标点;长名称回行、复杂多附件需目标软件核对。 |
| 7.3.5 | 署名、日期、印章/签名章 | 结构通过,仍需人工 | `formal-seal-2.png``formal-signed-2.png`;日期校验已覆盖不补零,印章和签名章必须使用真实图片或实体章。联合行文人工处理。 |
| 7.3.6 | 附注 | 结构通过,仍需人工 | `--note` 生成全角圆括号并接在日期后;最终分页仍需抽检。 |
| 7.3.7 | 附件另面、标签、标题、版记前、页码连续 | 截图通过 | `formal-attachments-3.png``formal-attachments-4.png`;不能一起装订的附件需要补排发文字号和附件标识。 |
| 7.4.1 | 版记粗细线和末线位置 | 截图通过 | `formal-long-3.png` 显示粗—细—粗结构和末页底部锚定;极端分页仍需抽检。 |
| 7.4.2 | 抄送机关字号、缩进、句号 | 截图通过 | `formal-long-3.png`;多行和主送移版记仍需结合机关模板。 |
| 7.4.3 | 印发机关、日期、印发和细线 | 截图通过 | `formal-long-3.png`;实际名称过长时仍需在目标软件确认。 |
| 7.5 | 页码字号、单双页位置、7 mm、附件连续 | 截图通过 | `formal-long-2.png``formal-long-3.png``formal-attachments-4.png`;空白页及特殊版记例外需人工复核。 |
| 8 | 横排表格 | 当前未自动化 | `table-1.png` 明确显示当前生成器输出纵向表格;横排 A4 表格、表头方向和页码方向必须使用 Word/WPS 专用横向模板。 |
| 9 | 计量单位、标点、数字 | 结构通过,仍需人工 | 字段边界已校验;正文语义必须按引用标准审校。 |
| 10.1 | 信函格式 | 截图通过 | `letter-1.png` 显示 170 mm 上粗下细、下粗上细双线、文号、标题、无首页页码和无版记分隔线。 |
| 10.2 | 命令(令)格式 | 截图通过 | `command-1.png` 显示机关标志、令号下空二行和正文下空二行。签名章仍需人工。 |
| 10.3 | 纪要格式 | 截图通过 | `minutes-1.png``minutes-2.png`;机关标志 35 mm、人员标签字体和两字起排已观察。 |
| 11 | 标准式样图 | 结构通过,仍需人工 | 本记录使用标准条文和实际截图;最终发文仍须对照本单位现行式样图或授权模板。 |
## 发现并修正的实现问题
1. 版头字段曾以普通段落排列,份号、密级、紧急程度会把电子红头机关标志向下推移,预印红头套打的留白也会被挤开。现在这些字段改为首面固定定位,版心中保留等高占位;`formal-digital-all-1.png``formal-preprinted-1.png` 已重新观察。
2. 红线下标题曾只依赖段前距,部分渲染器会把标题贴在线上。现在红线或纸上红线之后写入两个显式 28 磅空段落;截图能看到标题与红线之间的实际距离。
3. 主送机关带不带末尾冒号时曾可能重复,现在归一化为一行全角冒号。
4. 纪要出席名单曾从版心左边缘开始,现改为左空二字并用悬挂缩进让回行对齐。
## 仍然不能由本工具单独保证的事项
- 本机没有方正小标宋和标准仿宋字体。生成器会报告替代字体;要交付标准字体稿,先安装字体并运行严格模式。
- 纸张克重、白度、印刷油墨、双面套正、左侧装订、裁切和盖章属于实体制作环节。
- 联合行文机关标志、多签发人复杂布局、不能与正文一起装订的附件、机关专用模板,以及横排表格没有被伪装成通用自动化功能。
- 正文内容的政策口径、单位、标点、数字和文种适用性仍需人工审校。
+54 -12
View File
@@ -9,6 +9,14 @@ const PAGE_H = 16838;
const MARGIN = { top: 2098, bottom: 1984, left: 1588, right: 1474 }; const MARGIN = { top: 2098, bottom: 1984, left: 1588, right: 1474 };
const CONTENT_W = PAGE_W - MARGIN.left - MARGIN.right; const CONTENT_W = PAGE_W - MARGIN.left - MARGIN.right;
const FIRST_LINE_INDENT = 640; const FIRST_LINE_INDENT = 640;
// Word frame coordinates are relative to the type-area margin in the Office
// and LibreOffice renderers used by this skill. These values keep first-page
// header fields and the centered agency mark independent of one another.
const HEADER_FIELD_BEFORE = 1760;
const AGENCY_FRAME_BEFORE = 2015;
const AGENCY_FLOW_BEFORE = 1880;
const HEADER_FIELD_Y = 275;
const AGENCY_FRAME_Y = 2126;
function installedFontFamilies() { function installedFontFamilies() {
try { try {
@@ -181,9 +189,10 @@ function paragraph(text, opts = {}) {
const lineRule = opts.lineRule ?? "exact"; const lineRule = opts.lineRule ?? "exact";
const style = opts.style ? `<w:pStyle w:val="${attr(opts.style)}"/>` : ""; const style = opts.style ? `<w:pStyle w:val="${attr(opts.style)}"/>` : "";
const outline = opts.outlineLevel === undefined ? "" : `<w:outlineLvl w:val="${opts.outlineLevel}"/>`; const outline = opts.outlineLevel === undefined ? "" : `<w:outlineLvl w:val="${opts.outlineLevel}"/>`;
const frame = opts.frame ? `<w:framePr w:wrap="none" w:hAnchor="${attr(opts.frame.hAnchor ?? "page")}" w:vAnchor="${attr(opts.frame.vAnchor ?? "page")}"${opts.frame.xAlign ? ` w:xAlign="${attr(opts.frame.xAlign)}"` : ` w:x="${attr(opts.frame.x ?? 0)}"`} w:y="${attr(opts.frame.y ?? 0)}" w:w="${attr(opts.frame.w ?? CONTENT_W)}" w:h="${attr(opts.frame.h ?? 560)}"/>` : "";
const pageBreak = opts.pageBreakBefore ? "<w:pageBreakBefore/>" : ""; const pageBreak = opts.pageBreakBefore ? "<w:pageBreakBefore/>" : "";
const snapToGrid = opts.snapToGrid === false ? "<w:snapToGrid w:val=\"false\"/>" : "<w:snapToGrid w:val=\"true\"/>"; const snapToGrid = opts.snapToGrid === false ? "<w:snapToGrid w:val=\"false\"/>" : "<w:snapToGrid w:val=\"true\"/>";
const pPr = `<w:pPr>${style}${keepNext}${pageBreak}${align}${indent}${outline}<w:spacing w:before="${before}" w:after="${after}" w:line="${line}" w:lineRule="${lineRule}"/><w:adjustRightInd w:val="true"/>${snapToGrid}<w:kinsoku w:val="true"/></w:pPr>`; const pPr = `<w:pPr>${style}${keepNext}${pageBreak}${frame}${align}${indent}${outline}<w:spacing w:before="${before}" w:after="${after}" w:line="${line}" w:lineRule="${lineRule}"/><w:adjustRightInd w:val="true"/>${snapToGrid}<w:kinsoku w:val="true"/></w:pPr>`;
return `<w:p>${pPr}${run(text, opts)}</w:p>`; return `<w:p>${pPr}${run(text, opts)}</w:p>`;
} }
@@ -199,15 +208,39 @@ function titleParagraph(text, before = "0") {
}); });
} }
function agencyMarkParagraph(text, before = "0", after = "0") { function floatingHeaderFieldParagraph(text, fontPreset, index) {
return paragraph(text, {
align: "left",
indent: false,
fontPreset,
size: "32",
before: HEADER_FIELD_BEFORE,
frame: { hAnchor: "margin", vAnchor: "page", xAlign: "left", y: HEADER_FIELD_Y + index * 560, w: CONTENT_W, h: 560 },
});
}
function floatingAgencyMarkParagraph(text) {
return paragraph(text, { return paragraph(text, {
align: "center", align: "center",
indent: false, indent: false,
fontPreset: "title", fontPreset: "title",
size: "56", size: "56",
color: "FF0000", color: "FF0000",
after, before: AGENCY_FRAME_BEFORE,
before, frame: { hAnchor: "margin", vAnchor: "page", xAlign: "center", y: AGENCY_FRAME_Y, w: CONTENT_W, h: 800 },
});
}
function agencyMarkFlowSpacer() {
return paragraph("\u00a0", {
align: "left",
indent: false,
fontPreset: "body",
size: "2",
line: "560",
snapToGrid: false,
before: AGENCY_FLOW_BEFORE,
after: "0",
}); });
} }
@@ -377,7 +410,9 @@ function redDoubleRule({ upperMm = "0.35", lowerMm = "0.25", widthMm = "170" } =
} }
function minutesPersonParagraph(label, people, before = "0") { function minutesPersonParagraph(label, people, before = "0") {
return `<w:p><w:pPr><w:jc w:val="left"/><w:ind w:firstLine="0"/><w:spacing w:before="${before}" w:after="0" w:line="560" w:lineRule="exact"/><w:adjustRightInd w:val="true"/><w:snapToGrid w:val="true"/><w:kinsoku w:val="true"/></w:pPr>${run(`${label}`, { fontPreset: "h1", size: "32" })}${run(people, { fontPreset: "body", size: "32" })}</w:p>`; // The label starts two characters into the type area; wrapped names align
// after the four-character label “出席:/请假:/列席:”.
return `<w:p><w:pPr><w:jc w:val="left"/><w:ind w:firstLine="-1280" w:left="1920"/><w:spacing w:before="${before}" w:after="0" w:line="560" w:lineRule="exact"/><w:adjustRightInd w:val="true"/><w:snapToGrid w:val="true"/><w:kinsoku w:val="true"/></w:pPr>${run(`${label}`, { fontPreset: "h1", size: "32" })}${run(people, { fontPreset: "body", size: "32" })}</w:p>`;
} }
function headerFieldParagraph(text, fontPreset = "h1", opts = {}) { function headerFieldParagraph(text, fontPreset = "h1", opts = {}) {
@@ -558,7 +593,7 @@ function parseMarkdown(md) {
} }
function normalizeSignatureText(text) { function normalizeSignatureText(text) {
return String(text ?? "").replace(/\s+/g, ""); return String(text ?? "").replace(/\s+/g, "").replace(/[:]$/, "");
} }
function isMatchingParagraph(block, text) { function isMatchingParagraph(block, text) {
@@ -691,19 +726,26 @@ function buildDocument(blocks, args) {
const formal = format === "formal"; const formal = format === "formal";
if (formal) { if (formal) {
if (letterhead === "digital" && !args.org) throw new Error("formal digital letterhead requires --org"); if (letterhead === "digital" && !args.org) throw new Error("formal digital letterhead requires --org");
const headerFieldCount = [args["copy-no"], args.secret, args.urgent].filter(Boolean).length;
if (args["copy-no"] && !/^\d{1,6}$/.test(String(args["copy-no"]))) throw new Error("--copy-no must contain one to six Arabic digits"); if (args["copy-no"] && !/^\d{1,6}$/.test(String(args["copy-no"]))) throw new Error("--copy-no must contain one to six Arabic digits");
if (args["copy-no"]) body.push(headerFieldParagraph(String(args["copy-no"]).padStart(6, "0"), "body")); let headerFieldIndex = 0;
if (args.secret) body.push(headerFieldParagraph(args.secret)); if (args["copy-no"]) body.push(floatingHeaderFieldParagraph(String(args["copy-no"]).padStart(6, "0"), "body", headerFieldIndex++));
if (args.urgent) body.push(headerFieldParagraph(args.urgent)); if (args.secret) body.push(floatingHeaderFieldParagraph(args.secret, "h1", headerFieldIndex++));
if (args.urgent) body.push(floatingHeaderFieldParagraph(args.urgent, "h1", headerFieldIndex++));
if (letterhead === "digital" && args.org) { if (letterhead === "digital" && args.org) {
body.push(agencyMarkParagraph(args.org, String(Math.max(0, 1984 - headerFieldCount * 560)), "0")); // GB/T 9704-2012 7.2.4 fixes the agency mark at 35 mm below the
// type-area top. Left-corner fields occupy their own column and must
// not move the centered mark upward.
body.push(floatingAgencyMarkParagraph(args.org));
body.push(agencyMarkFlowSpacer());
if (args["doc-no"]) body.push(...blankGridLines(2)); if (args["doc-no"]) body.push(...blankGridLines(2));
} }
if (letterhead === "preprinted") { if (letterhead === "preprinted") {
const reserve = Number(args["letterhead-reserve-mm"] ?? 72); const reserve = Number(args["letterhead-reserve-mm"] ?? 72);
if (!Number.isFinite(reserve) || reserve < 37 || reserve > 130) throw new Error("--letterhead-reserve-mm must be between 37 and 130"); if (!Number.isFinite(reserve) || reserve < 37 || reserve > 130) throw new Error("--letterhead-reserve-mm must be between 37 and 130");
body.push(letterheadReserveParagraph(Math.max(37, reserve - headerFieldCount * 9.877))); // The physical red-head paper already contains the agency mark and
// separator. Its reserve is a paper-coordinate boundary, so optional
// copy/secret/urgency fields must not shorten it.
body.push(letterheadReserveParagraph(reserve));
} }
if (args["doc-no"]) args["doc-no"] = normalizeDocumentNumber(args["doc-no"]); if (args["doc-no"]) args["doc-no"] = normalizeDocumentNumber(args["doc-no"]);
if (upward && args["doc-no"] && args.signer) body.push(upwardHeaderParagraph(args["doc-no"], args.signer)); if (upward && args["doc-no"] && args.signer) body.push(upwardHeaderParagraph(args["doc-no"], args.signer));
+5 -4
View File
@@ -129,12 +129,12 @@ if (profile === "formal") {
check("Red rule width", /style="width:156mm;height:0\.5mm"/.test(doc), "header separator spans the 156 mm type area"); check("Red rule width", /style="width:156mm;height:0\.5mm"/.test(doc), "header separator spans the 156 mm type area");
const docParagraphs = paragraphs(doc); const docParagraphs = paragraphs(doc);
const agencyIndex = docParagraphs.findIndex((p) => /w:sz w:val="56"/.test(p) && /w:color w:val="FF0000"/.test(p)); const agencyIndex = docParagraphs.findIndex((p) => /w:sz w:val="56"/.test(p) && /w:color w:val="FF0000"/.test(p));
const headerFieldCount = agencyIndex < 0 ? 0 : docParagraphs.slice(0, agencyIndex).filter((p) => /\d{6}|机密|秘密|特急|加急/.test(textOf(p))).length; check("Digital red-head agency position", agencyIndex >= 0 && /w:framePr[^>]*w:hAnchor="margin"[^>]*w:vAnchor="page"[^>]*w:xAlign="center"[^>]*w:y="2126"/.test(docParagraphs[agencyIndex]) && /w:before="2015"/.test(docParagraphs[agencyIndex]), "agency mark is independently fixed at the 35 mm type-area position");
const expectedAgencyBefore = 1984 - headerFieldCount * 560;
check("Digital red-head agency position", new RegExp(`w:before="${expectedAgencyBefore}"`).test(doc), `agency mark starts 35 mm below the type-area top after ${headerFieldCount} header field line(s)`);
const docNoIndex = docParagraphs.findIndex((p) => /\d{4}[1-9]\d*/.test(textOf(p))); const docNoIndex = docParagraphs.findIndex((p) => /\d{4}[1-9]\d*/.test(textOf(p)));
const agencyToDocNo = agencyIndex >= 0 && docNoIndex > agencyIndex ? docParagraphs.slice(agencyIndex + 1, docNoIndex) : []; const agencyToDocNo = agencyIndex >= 0 && docNoIndex > agencyIndex ? docParagraphs.slice(agencyIndex + 1, docNoIndex) : [];
check("Agency mark to document number two blank lines", docNoIndex < 0 || (agencyToDocNo.length === 2 && agencyToDocNo.every(isExplicitBlankGridLine)), "document number is two exact 28-point grid lines below the agency mark"); const agencyFlowSpacer = agencyToDocNo.filter((p) => /w:before="1880"/.test(p));
const agencyGapLines = agencyToDocNo.slice(-2);
check("Agency mark to document number two blank lines", docNoIndex < 0 || (agencyFlowSpacer.length === 1 && agencyGapLines.length === 2 && agencyGapLines.every(isExplicitBlankGridLine)), "document number follows the fixed agency mark with two exact 28-point grid lines");
check("Two blank lines below red rule", hasExplicitTwoLineTitleGap, "title is preceded by two exact 28-point blank paragraphs below the red rule"); check("Two blank lines below red rule", hasExplicitTwoLineTitleGap, "title is preceded by two exact 28-point blank paragraphs below the red rule");
if (docNoIndex >= 0) check("Document number syntax", /\d{4}[1-9]\d*号/.test(textOf(docParagraphs[docNoIndex])) && !/第/.test(textOf(docParagraphs[docNoIndex])), "year uses full digits, sequence has no 第 or leading zero, and ends with 号"); if (docNoIndex >= 0) check("Document number syntax", /\d{4}[1-9]\d*号/.test(textOf(docParagraphs[docNoIndex])) && !/第/.test(textOf(docParagraphs[docNoIndex])), "year uses full digits, sequence has no 第 or leading zero, and ends with 号");
if (args.upward === "true") { if (args.upward === "true") {
@@ -168,6 +168,7 @@ if (profile === "minutes") {
const attendance = paragraphs(doc).filter((p) => /(?:出席|请假|列席)/.test(textOf(p))); const attendance = paragraphs(doc).filter((p) => /(?:出席|请假|列席)/.test(textOf(p)));
check("Minutes attendance label font", attendance.every((p) => /w:eastAsia="(?:黑体|SimHei|Heiti SC|STHeiti)"/.test(p)), attendance.length ? `${attendance.length} attendance paragraph(s)` : "not present in this document"); check("Minutes attendance label font", attendance.every((p) => /w:eastAsia="(?:黑体|SimHei|Heiti SC|STHeiti)"/.test(p)), attendance.length ? `${attendance.length} attendance paragraph(s)` : "not present in this document");
check("Minutes attendee font", attendance.every((p) => /w:eastAsia="(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312|STFangsong)"/.test(p)), attendance.length ? "people runs use FangSong" : "not present in this document"); check("Minutes attendee font", attendance.every((p) => /w:eastAsia="(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312|STFangsong)"/.test(p)), attendance.length ? "people runs use FangSong" : "not present in this document");
check("Minutes attendance indentation", attendance.every((p) => /w:firstLine="-1280"[^>]*w:left="1920"/.test(p)), attendance.length ? "labels start two characters in and wrapped names align after the label" : "not present in this document");
} }
const redRules = doc.match(/height:(?:0\.35|0\.25|0\.5)mm/g) ?? []; const redRules = doc.match(/height:(?:0\.35|0\.25|0\.5)mm/g) ?? [];
if (["formal", "command", "minutes"].includes(profile)) { if (["formal", "command", "minutes"].includes(profile)) {
+21
View File
@@ -0,0 +1,21 @@
各主送机关:
这是一份用于逐条实测公文版式的正文。首页必须显示正文,正文自然段左空二字,回行顶格。
一、第一层标题
第一层标题后的正文,用于观察黑体层级、正文颜色和固定网格。
(一)第二层标题
第二层标题后的正文,用于观察楷体层级。
1. 第三层标题
第三层标题后的正文,用于观察仿宋层级。
1)第四层标题
第四层标题后的正文,用于观察标题样式和目录级别。
这是第二个自然段。计量单位示例:10 kg、5 m、3.14;标点和数字仅作为版式审阅内容。
+8
View File
@@ -0,0 +1,8 @@
# 附件一:数据清单
附件正文第一段,用于观察附件首页标签、标题和连续页码。
| 项目 | 数量 |
| --- | --- |
| 摄像头 | 105 |
| 点位 | 156 |
+3
View File
@@ -0,0 +1,3 @@
# 附件二:实施计划
附件正文第二段,用于观察多个附件的分页和编号。
+6
View File
@@ -0,0 +1,6 @@
各主送机关:
| 项目 | 数量 | 备注 |
| --- | --- | --- |
| 摄像头 | 105 | 存量 |
| 点位 | 156 | 一期 |
+27
View File
@@ -0,0 +1,27 @@
各主送机关:
这是一份多页正文,用于观察首页正文、单双页页码、版记锚定和分页行为。这里重复文字以形成足够长度。
一、主体内容
这是用于形成多页输出的段落。公文排版需要在实际分页后检查正文区域、版记位置、页码连续性以及附件衔接。重复内容如下:公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。
(二)延伸内容
这是用于形成多页输出的段落。公文排版需要在实际分页后检查正文区域、版记位置、页码连续性以及附件衔接。重复内容如下:公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。
(三)继续内容
这是用于形成多页输出的段落。公文排版需要在实际分页后检查正文区域、版记位置、页码连续性以及附件衔接。重复内容如下:公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。
(四)继续内容
这是用于形成多页输出的段落。公文排版需要在实际分页后检查正文区域、版记位置、页码连续性以及附件衔接。重复内容如下:公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。
(五)继续内容
这是用于形成多页输出的段落。公文排版需要在实际分页后检查正文区域、版记位置、页码连续性以及附件衔接。重复内容如下:公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。
(六)继续内容
这是用于形成多页输出的段落。公文排版需要在实际分页后检查正文区域、版记位置、页码连续性以及附件衔接。重复内容如下:公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。公文格式要素包括版头、主体、版记和页码,正文一般使用三号仿宋,段落左空二字,回行顶格。
+19 -1
View File
@@ -31,14 +31,32 @@ unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q 'w:snapToGrid w:va
unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q '示例单位文件' unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q '示例单位文件'
unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'FF0000' unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'FF0000'
unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'height:0.5mm' unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'height:0.5mm'
unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'w:before="1984"' unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'w:framePr.*w:xAlign="center".*w:y="2126"'
unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'w:before="2015"'
unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'w:snapToGrid w:val="false"' unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'w:snapToGrid w:val="false"'
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/digital-fields.docx" --format formal --letterhead digital --copy-no 7 --secret "机密★3年" --urgent "特急" --org "示例单位文件" --doc-no "示例发〔20263号" --title "版头字段定位测试"
"$NODE" "$VERIFY" --input "$TMP_DIR/digital-fields.docx" --profile formal --letterhead digital
unzip -p "$TMP_DIR/digital-fields.docx" word/document.xml | grep -q 'w:framePr.*w:xAlign="left".*w:y="275"'
unzip -p "$TMP_DIR/digital-fields.docx" word/document.xml | grep -q 'w:before="2015"'
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/preprinted-fields.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --copy-no 7 --secret "机密★3年" --urgent "特急" --doc-no "示例发〔20264号" --title "预印字段定位测试"
"$NODE" "$VERIFY" --input "$TMP_DIR/preprinted-fields.docx" --profile formal --letterhead preprinted
unzip -p "$TMP_DIR/preprinted-fields.docx" word/document.xml | grep -q 'w:before="1984"'
cat >"$TMP_DIR/attachment.md" <<'EOF' cat >"$TMP_DIR/attachment.md" <<'EOF'
# 附件一:测试附件 # 附件一:测试附件
附件正文。 附件正文。
EOF EOF
cat >"$TMP_DIR/main-send.md" <<'EOF'
各主送机关:
主送机关只应在输出中出现一次。
EOF
"$NODE" "$GEN" --input "$TMP_DIR/main-send.md" --output "$TMP_DIR/main-send.docx" --format formal --letterhead digital --org "示例单位文件" --doc-no "示例发〔20262号" --title "主送机关匹配测试" --to "各主送机关"
test "$(unzip -p "$TMP_DIR/main-send.docx" word/document.xml | grep -o '各主送机关' | wc -l | tr -d ' ')" -eq 1
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/upward.docx" --format formal --letterhead digital --org "示例单位文件" --doc-no "示例发〔20261号" --title "上行文测试" --upward true --signer "张三" "$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/upward.docx" --format formal --letterhead digital --org "示例单位文件" --doc-no "示例发〔20261号" --title "上行文测试" --upward true --signer "张三"
"$NODE" "$VERIFY" --input "$TMP_DIR/upward.docx" --profile formal --letterhead digital --upward true "$NODE" "$VERIFY" --input "$TMP_DIR/upward.docx" --profile formal --letterhead digital --upward true
unzip -p "$TMP_DIR/upward.docx" word/document.xml | grep -q '签发人:' unzip -p "$TMP_DIR/upward.docx" word/document.xml | grep -q '签发人:'
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
skill_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
if [[ $# -gt 1 ]]; then
echo "Usage: $0 [output-directory]" >&2
exit 2
fi
out_dir=${1:-$(mktemp -d "${TMPDIR:-/tmp}/gongwen-visual-audit.XXXXXX")}
mkdir -p "$out_dir/docx" "$out_dir/pdf" "$out_dir/png" "$out_dir/logs"
gen="$skill_dir/scripts/generate_gongwen_docx.mjs"
verify="$skill_dir/scripts/verify_gongwen_docx.mjs"
all="$skill_dir/tests/fixtures/visual-all.md"
long="$skill_dir/tests/fixtures/visual-long.md"
attach_a="$skill_dir/tests/fixtures/visual-attachment-a.md"
attach_b="$skill_dir/tests/fixtures/visual-attachment-b.md"
horizontal="$skill_dir/tests/fixtures/visual-horizontal.md"
node "$gen" --input "$all" --output "$out_dir/docx/ordinary.docx" --format ordinary --title "普通材料全要素实测" --page-number center
node "$gen" --input "$all" --output "$out_dir/docx/formal-digital-all.docx" --format formal --letterhead digital --copy-no 7 --secret "机密★3年" --urgent "特急" --org "示例单位文件" --doc-no "示例发〔20267号" --title "正式公文全要素实测" --to "各主送机关" --sender "示例单位" --date "2026年9月10日" --note "此件公开" --attachment-note "1. 数据清单;2. 实施计划。" --cc "办公室、财务部" --print-org "示例印发机关" --print-date "2026年9月10日" --page-number standard
node "$gen" --input "$all" --output "$out_dir/docx/formal-digital-upward.docx" --format formal --letterhead digital --org "示例单位文件" --doc-no "示例发〔20268号" --title "上行文双签发人实测" --upward true --signer "张三、李四" --page-number standard
node "$gen" --input "$all" --output "$out_dir/docx/formal-preprinted.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --copy-no 7 --secret "机密★3年" --urgent "特急" --doc-no "示例发〔20269号" --title "预印红头套打实测" --page-number standard
node "$gen" --input "$all" --output "$out_dir/docx/formal-seal.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --title "盖章公文实测" --sender "示例单位" --date "2026年9月10日" --seal-mode seal --page-number standard
node "$gen" --input "$all" --output "$out_dir/docx/formal-signed.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --title "签名章公文实测" --sender "示例单位" --date "2026年9月10日" --seal-mode signed --signer-title "主要负责人" --signer "张三" --page-number standard
node "$gen" --input "$all" --output "$out_dir/docx/formal-attachments.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --doc-no "示例发〔202610号" --title "多附件实测" --attachment-note "1. 数据清单;2. 实施计划。" --attachment-file "$attach_a" --attachment-file "$attach_b" --page-number standard
node "$gen" --input "$long" --output "$out_dir/docx/formal-long.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --doc-no "示例发〔202611号" --title "长文版记与单双页实测" --cc "办公室、财务部、审计部、采购部、法务部、信息中心" --print-org "示例印发机关" --print-date "2026年9月10日" --page-number standard
node "$gen" --input "$all" --output "$out_dir/docx/letter.docx" --format letter --org "示例机关" --doc-no "示例〔202612号" --title "信函格式实测" --page-number none
node "$gen" --input "$all" --output "$out_dir/docx/command.docx" --format command --org "示例机关命令" --doc-no "第1号" --title "命令格式实测" --page-number standard
node "$gen" --input "$all" --output "$out_dir/docx/minutes.docx" --format minutes --org "示例机关纪要" --title "纪要格式实测" --attendees "张三(办公室)、李四(财务部)" --absent "王五(审计部)" --observers "赵六(采购部)" --page-number standard
node "$gen" --input "$horizontal" --output "$out_dir/docx/table.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --doc-no "示例发〔202613号" --title "表格版式实测" --page-number standard
for file in "$out_dir/docx"/*.docx; do
base=$(basename "$file" .docx)
case "$base" in
ordinary) profile=ordinary ;;
letter) profile=letter ;;
command) profile=command ;;
minutes) profile=minutes ;;
*) profile=formal ;;
esac
case "$base" in
formal-digital-*) letterhead=digital ;;
*) letterhead=preprinted ;;
esac
node "$verify" --input "$file" --profile "$profile" --letterhead "$letterhead" >"$out_dir/logs/$base.verify.log"
done
if ! command -v soffice >/dev/null 2>&1 || ! command -v pdftoppm >/dev/null 2>&1; then
echo "LibreOffice (soffice) and pdftoppm are required for screenshot audit." >&2
exit 3
fi
soffice --headless --convert-to pdf --outdir "$out_dir/pdf" "$out_dir/docx"/*.docx >"$out_dir/logs/soffice.log" 2>&1
for file in "$out_dir/pdf"/*.pdf; do
base=$(basename "$file" .pdf)
pdftoppm -png -r 150 "$file" "$out_dir/png/$base" >"$out_dir/logs/$base.pdftoppm.log" 2>&1
done
{
echo -e "profile\tdocx\tpdf\tscreenshot_prefix"
for file in "$out_dir/docx"/*.docx; do
base=$(basename "$file" .docx)
pages=$(pdfinfo "$out_dir/pdf/$base.pdf" | awk '/^Pages:/ {print $2}')
echo -e "$base\t$file\t$out_dir/pdf/$base.pdf\t$out_dir/png/$base-1.png ... $out_dir/png/$base-$pages.png"
done
} >"$out_dir/manifest.tsv"
echo "Visual audit complete: $out_dir"
cat "$out_dir/manifest.tsv"