严格落实公文版式全要素核验
This commit is contained in:
@@ -82,7 +82,7 @@ https://std.samr.gov.cn/gb/search/gbDetailed?id=lOIe27f77QU%3D&mode=p
|
|||||||
| 普通报告、方案、汇报 | 默认 `ordinary`,不因机构名称自动使用红头 |
|
| 普通报告、方案、汇报 | 默认 `ordinary`,不因机构名称自动使用红头 |
|
||||||
| 正式发文与红头纸 | 显式 `formal`;默认预印红头纸套打,首面留白、不重绘红色机关标志和红线 |
|
| 正式发文与红头纸 | 显式 `formal`;默认预印红头纸套打,首面留白、不重绘红色机关标志和红线 |
|
||||||
| 完整电子红头 | 显式 `formal --letterhead digital`,才在 DOCX 中绘制红头要素 |
|
| 完整电子红头 | 显式 `formal --letterhead digital`,才在 DOCX 中绘制红头要素 |
|
||||||
| 机构名称与文号 | 使用 `--org`、`--doc-no` 按输入排版;文号仅在相应版式中定位 |
|
| 机构名称与文号 | 使用 `--org`、`--doc-no` 按对应版式定位;正式版式会拒绝明显不符合年份、六角括号、顺序号和“号”规则的文号 |
|
||||||
| 标题与目录 | 四级标题写入 Word/WPS 标题样式及大纲级别,可插入自动目录 |
|
| 标题与目录 | 四级标题写入 Word/WPS 标题样式及大纲级别,可插入自动目录 |
|
||||||
| 页码 | 居中、单双页或不显示 |
|
| 页码 | 居中、单双页或不显示 |
|
||||||
| 印章、签名章 | 在生成的 DOCX 中按实际需要插入图片或完成实体盖章 |
|
| 印章、签名章 | 在生成的 DOCX 中按实际需要插入图片或完成实体盖章 |
|
||||||
@@ -104,9 +104,9 @@ node scripts/generate_gongwen_docx.mjs --input tests/fixture.md --output /tmp/fo
|
|||||||
node scripts/verify_gongwen_docx.mjs --input /tmp/formal.docx --profile formal --letterhead preprinted
|
node scripts/verify_gongwen_docx.mjs --input /tmp/formal.docx --profile formal --letterhead preprinted
|
||||||
```
|
```
|
||||||
|
|
||||||
只有需要完整电子红头文件时,才加 `--letterhead digital`。红头和标题要使用目标电脑中实际安装的小标宋体;生成器缺少小标宋体或仿宋体时会在终端明确警告并写出替代字体,要求严格阻止替代输出时加 `--require-standard-fonts`。红线下标题会按版心网格预留两行,不会贴着分隔线;上行文的文号和签发人会在同一行编排。打开文档后,Word/WPS 的“引用→目录”可以按“标题1—标题4”生成并更新目录;是否显示第四级,在目录设置中选择。
|
只有需要完整电子红头文件时,才加 `--letterhead digital`。红头和标题要使用目标电脑中实际安装的小标宋体;生成器缺少小标宋体或仿宋体时会在终端明确警告并写出替代字体,要求严格阻止替代输出时加 `--require-standard-fonts`。红线下标题会写入两个显式的28磅版心空行,不会依赖渲染器自行解释段前距;发文机关标志到文号也按相同方式留出两行。上行文的文号和签发人会在同一行编排。打开文档后,Word/WPS 的“引用→目录”可以按“标题1—标题4”生成并更新目录;是否显示第四级,在目录设置中选择。
|
||||||
|
|
||||||
信函、命令(令)和纪要走专用分支:信函生成170mm上粗下细、下页边20mm处上细下粗的两条红色双线,底线放在页脚并关闭页码;命令(令)落实机关标志下空二行、令号下空二行;纪要把“出席”“请假”“列席”标签设为黑体、人员名单设为仿宋。附件可用重复的 `--attachment-file` 另页生成“附件”标签、第三行标题和正文,并保持正式页码连续。
|
信函、命令(令)和纪要走专用分支:信函生成170mm上粗下细、下页边20mm处上细下粗的两条红色双线,底线放在页脚并关闭页码;命令(令)落实机关标志下空二行、令号下空二行;纪要把“出席”“请假”“列席”标签设为黑体、人员名单设为仿宋。附件可用重复的 `--attachment-file` 另页生成“附件”标签、第三行标题和正文,并保持正式页码连续。带抄送或印发信息时,版记浮动锚定在最后一页版心底部,首末粗线约0.35mm、中间细线约0.25mm。
|
||||||
|
|
||||||
## 跨平台安装
|
## 跨平台安装
|
||||||
|
|
||||||
@@ -130,6 +130,7 @@ Windows:
|
|||||||
|
|
||||||
- [SKILL.md](SKILL.md):使用指引与版式参数。
|
- [SKILL.md](SKILL.md):使用指引与版式参数。
|
||||||
- [标准执行摘要](references/gbt9704-2012-summary.md):国家标准的可执行要点。
|
- [标准执行摘要](references/gbt9704-2012-summary.md):国家标准的可执行要点。
|
||||||
|
- [逐条执行矩阵](references/gbt9704-audit-matrix.md):按条款记录自动生成、结构校验和必须人工复核的边界。
|
||||||
- [正式公文核验表](references/formal-checklist.md):模板完成后的全要素检查。
|
- [正式公文核验表](references/formal-checklist.md):模板完成后的全要素检查。
|
||||||
- [生成器](scripts/generate_gongwen_docx.mjs):根据输入生成 DOCX 版式。
|
- [生成器](scripts/generate_gongwen_docx.mjs):根据输入生成 DOCX 版式。
|
||||||
- [校验器](scripts/verify_gongwen_docx.mjs):检查生成器可承诺的 DOCX 版式要素。
|
- [校验器](scripts/verify_gongwen_docx.mjs):检查生成器可承诺的 DOCX 版式要素。
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ description: 将中文正式材料生成参照 GB/T 9704-2012 的公文版式 DO
|
|||||||
|
|
||||||
GB/T 9704-2012《党政机关公文格式》现行,2025-05-30 复审继续有效。其适用于党政机关制发公文;其他机关、单位可以参照执行。
|
GB/T 9704-2012《党政机关公文格式》现行,2025-05-30 复审继续有效。其适用于党政机关制发公文;其他机关、单位可以参照执行。
|
||||||
|
|
||||||
先读 `references/gbt9704-2012-summary.md`;需要逐项复核时再读 `references/formal-checklist.md`。标准规定纸张、版面、要素编排和印制装订要求。
|
先读 `references/gbt9704-2012-summary.md`;需要逐项复核时再读 `references/gbt9704-audit-matrix.md` 和 `references/formal-checklist.md`。标准规定纸张、版面、要素编排和印制装订要求。
|
||||||
|
|
||||||
## 工具定位
|
## 工具定位
|
||||||
|
|
||||||
本工具只处理排版,不判断单位身份、授权状态或后续盖章方式。它会根据用户明确选择的版式模式应用对应的排版规则,不因“正式”“国企”“报告”或仅提供机构名称而擅自使用红头。
|
本工具只处理排版,不判断单位身份、授权状态或后续盖章方式。它会根据用户明确选择的版式模式应用对应的排版规则,不因“正式”“国企”“报告”或仅提供机构名称而擅自使用红头。
|
||||||
|
|
||||||
生成器可输出 A4、156mm×225mm 版心、三号仿宋正文、小标宋二号标题、四级标题层次、两字缩进,以及居中或单双页页码。四级标题均写入 Word/WPS 标题样式和大纲级别,便于后续插入、更新目录;文章总标题使用独立“公文标题”样式,不进入目录。上行文的文号和签发人在同一行编排;信函、命令(令)、纪要使用各自专用版式;重复传入 `--attachment-file` 时,附件会另页生成并保持页码连续。
|
生成器可输出 A4、156mm×225mm 版心、三号仿宋正文、小标宋二号标题、四级标题层次、两字缩进,以及居中或单双页页码。四级标题均写入 Word/WPS 标题样式和大纲级别,便于后续插入、更新目录;文章总标题使用独立“公文标题”样式,不进入目录。上行文的文号和签发人在同一行编排;信函、命令(令)、纪要使用各自专用版式;重复传入 `--attachment-file` 时,附件会另页生成并保持页码连续。版记会锚定最后一页版心底部,正式页码按版心下边缘下7mm的位置生成。
|
||||||
|
|
||||||
`references/formal-checklist.md` 是可选的版式复核清单。涉及印章或签名章时,使用者可在 Word/WPS 中插入已取得的图片或完成实体盖章;本生成器不内置印章图像。
|
`references/formal-checklist.md` 是可选的版式复核清单。涉及印章或签名章时,使用者可在 Word/WPS 中插入已取得的图片或完成实体盖章;本生成器不内置印章图像。
|
||||||
|
|
||||||
@@ -37,7 +37,9 @@ node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --
|
|||||||
|
|
||||||
`letter`、`command`、`minutes` 分别对应信函、命令(令)、纪要格式;需要时显式传入 `--format` 和相应字段。正式发文默认单双页页码;`standard` 为单页右、双页左,`center` 与 `none` 供普通材料选择。
|
`letter`、`command`、`minutes` 分别对应信函、命令(令)、纪要格式;需要时显式传入 `--format` 和相应字段。正式发文默认单双页页码;`standard` 为单页右、双页左,`center` 与 `none` 供普通材料选择。
|
||||||
|
|
||||||
附件可重复传入 `--attachment-file attachment.md`。附件 Markdown 的首个一级标题会作为附件标题,第一页第一行写“附件1”(后续文件递增),第三行写标题,随后排正文;附件说明仍通过 `--attachment-note` 提供。复杂横排附件、不能与正文一起装订的附件和机关专用模板仍需在目标 Word/WPS 中核对。
|
附件可重复传入 `--attachment-file attachment.md`。附件 Markdown 的首个一级标题会作为附件标题,第一页第一行写“附件1”(后续文件递增),第三行写标题,随后排正文;附件说明仍通过 `--attachment-note` 提供,工具会统一为“附件:”并去除末尾标点。复杂横排附件、不能与正文一起装订的附件和机关专用模板仍需在目标 Word/WPS 中核对。
|
||||||
|
|
||||||
|
正式版式的 `--doc-no` 采用“单位发〔2026〕1号”这类标准写法;`--date`、`--print-date` 采用“2026年9月9日”,月份和日期不补零。明显不符合这两类规则的输入会被拒绝,避免把错误字段排成看似规范的文件。
|
||||||
|
|
||||||
Markdown 标题优先按序数识别:`一、`、`(一)`、`1.`、`(1)`;序数缺失时才按 Markdown 层级映射。正文、四级标题均左空二字,回行顶格。标题字体在运行机器缺失时会明确报告并使用可见替代字体;打开文件时仍须确认目标 Office/WPS 的实际字体,不把普通宋体视作小标宋的等价替代。
|
Markdown 标题优先按序数识别:`一、`、`(一)`、`1.`、`(1)`;序数缺失时才按 Markdown 层级映射。正文、四级标题均左空二字,回行顶格。标题字体在运行机器缺失时会明确报告并使用可见替代字体;打开文件时仍须确认目标 Office/WPS 的实际字体,不把普通宋体视作小标宋的等价替代。
|
||||||
|
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -21,6 +21,7 @@
|
|||||||
- [ ] 发文字号位于机关标志下空二行;年份为全称阿拉伯数字并使用六角括号〔〕,序号不加“第”、不补零、后加“号”。
|
- [ ] 发文字号位于机关标志下空二行;年份为全称阿拉伯数字并使用六角括号〔〕,序号不加“第”、不补零、后加“号”。
|
||||||
- [ ] 上行文文号左空一字;签发人与文号在同一行同一区域,右空一字。“签发人:”为仿宋三号,姓名为楷体三号;多签发人按机关顺序编排。
|
- [ ] 上行文文号左空一字;签发人与文号在同一行同一区域,右空一字。“签发人:”为仿宋三号,姓名为楷体三号;多签发人按机关顺序编排。
|
||||||
- [ ] 红色分隔线在文号下4mm处,与版心等宽,线高按模板核定(常用约0.35—0.5mm)。
|
- [ ] 红色分隔线在文号下4mm处,与版心等宽,线高按模板核定(常用约0.35—0.5mm)。
|
||||||
|
- [ ] 用 PDF 或 Word/WPS 实测确认机关标志到文号、红线到标题均为两个版心网格空行;不能只看 XML 中是否存在某个段前距数值。
|
||||||
|
|
||||||
## 3. 主体
|
## 3. 主体
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
|
|
||||||
## 4. 版记、页码与特殊格式
|
## 4. 版记、页码与特殊格式
|
||||||
|
|
||||||
- [ ] 版记首末分隔线为粗线、中间线为细线;末条线与最后一面版心下边缘重合。
|
- [ ] 版记首末分隔线为粗线、中间线为细线;生成器会把版记锚定到最后一面版心下边缘,仍需在长文、多表格和 Word/WPS 中抽检。
|
||||||
- [ ] 抄送机关适用时为四号仿宋、左右各空一字、回行与冒号后首字对齐、末尾句号;主送移入版记时按“主送”规则处理。
|
- [ ] 抄送机关适用时为四号仿宋、左右各空一字、回行与冒号后首字对齐、末尾句号;主送移入版记时按“主送”规则处理。
|
||||||
- [ ] 印发机关和日期为四号仿宋,左空一字与右空一字;日期完整且不补零,后加“印发”。
|
- [ ] 印发机关和日期为四号仿宋,左空一字与右空一字;日期完整且不补零,后加“印发”。
|
||||||
- [ ] 页码为四号半角宋体阿拉伯数字,两侧各一字线,线距版心下边缘7mm;单页右空一字、双页左空一字;空白页、特定版记页和附件连续页码已按规则处理。
|
- [ ] 页码为四号半角宋体阿拉伯数字,两侧各一字线,线距版心下边缘7mm;单页右空一字、双页左空一字;空白页、特定版记页和附件连续页码已按规则处理。
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
- 抄送机关一般用4号仿宋,左右各空一字;回行与冒号后首字对齐,末尾用句号。
|
- 抄送机关一般用4号仿宋,左右各空一字;回行与冒号后首字对齐,末尾用句号。
|
||||||
- 印发机关左空一字,印发日期右空一字,日期不补零,后加“印发”。
|
- 印发机关左空一字,印发日期右空一字,日期不补零,后加“印发”。
|
||||||
- 版记分隔线有粗细区分,并与版心等宽。
|
- 版记分隔线有粗细区分,并与版心等宽。
|
||||||
- 版记字段可按实际需要在生成后的文件中补充或调整。
|
- 版记字段在生成器中锚定到最后一页版心底部;字段内容、超长抄送回行和机关专用模板仍需在目标 Word/WPS 中复核。
|
||||||
|
|
||||||
## 七、页码
|
## 七、页码
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
| A4、版心、字体、行距、正文缩进 | 是 | 打印设备误差复核 |
|
| A4、版心、字体、行距、正文缩进 | 是 | 打印设备误差复核 |
|
||||||
| 四级正文标题 | 是 | 特殊机关模板调整 |
|
| 四级正文标题 | 是 | 特殊机关模板调整 |
|
||||||
| 报告居中页码 | 是 | 无 |
|
| 报告居中页码 | 是 | 无 |
|
||||||
| 正式单双页页码 | 是 | `--page-number standard` |
|
| 正式单双页页码 | 是 | `--page-number standard`;页脚按版心下边缘下7mm的位置生成 |
|
||||||
| 普通表格 | 是 | 横排表格方向和复杂跨页表格复核 |
|
| 普通表格 | 是 | 横排表格方向和复杂跨页表格复核 |
|
||||||
| 普通材料 | 是 | `--format ordinary`,不绘制红头 |
|
| 普通材料 | 是 | `--format ordinary`,不绘制红头 |
|
||||||
| 预印红头纸套打 | 是 | `--format formal --letterhead preprinted`,按纸样设置预留高度 |
|
| 预印红头纸套打 | 是 | `--format formal --letterhead preprinted`,按纸样设置预留高度 |
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# GB/T 9704-2012 逐条执行矩阵
|
||||||
|
|
||||||
|
本矩阵把标准条款拆成三类:生成器可以稳定写入并由校验器检查的项目,生成器能够提供版式但仍需打开 Word/WPS 复核的项目,以及只能在纸张、打印或机关模板上完成的项目。它不把“生成了 DOCX”写成“所有制作环节均已合规”。条文依据为[全国标准信息公共服务平台的现行状态页](https://std.samr.gov.cn/gb/search/gbDetailed?id=lOIe27f77QU%3D&mode=p)和[GB/T 9704-2012 条文全文转载页](https://dzb.cumtb.edu.cn/info/1040/1184.htm)。
|
||||||
|
|
||||||
|
| 条款 | 规则 | 当前实现 | 证据/剩余人工核对 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 适用范围 | 说明文档明确 `ordinary`、`formal` 和特定格式的使用场景,不因机构名称自动画红头 | `SKILL.md`、`README.md`;仍由使用者确认文种和机关模板 |
|
||||||
|
| 2 | 规范性引用文件 | 在摘要和清单中列出 GB/T 148、GB 3100/3101/3102、GB/T 15834、GB/T 15835 | `references/gbt9704-2012-summary.md`;计量单位、标点和数字的语义仍需人工复核 |
|
||||||
|
| 3 | 字、行等术语 | 以 3 号字和 28 磅版心网格实现常用“行” | `w:docGrid`、正文段落;特殊机关模板可调整 |
|
||||||
|
| 4 | 用纸技术指标 | 不在 DOCX 中伪造纸张克重、白度、耐折度、不透明度、pH | `formal-checklist.md`;采购纸张和成品检测人工完成 |
|
||||||
|
| 5.1 | A4 成品幅面 | 固定 210mm×297mm | `verify_gongwen_docx.mjs` 的 A4 检查;仍需检查实际裁切误差 |
|
||||||
|
| 5.2.1 | 天头、订口、版心 | 37mm、28mm、156mm×225mm写入 DOCX | 版心和页边检查;打印缩放必须关闭并现场复核 |
|
||||||
|
| 5.2.2 | 默认 3 号仿宋 | 运行环境检测仿宋;缺失时明确警告并写入替代字体,严格模式直接失败 | `--require-standard-fonts`;当前机器若缺小标宋/仿宋不能把替代字体当标准字体 |
|
||||||
|
| 5.2.3 | 一般 22 行、28 字并撑满版心 | 3 号、28 磅固定网格和版心参数已写入 | `w:docGrid` 与 PDF 实测;长标题、表格和特殊内容仍需看最终分页 |
|
||||||
|
| 5.2.4 | 默认黑色 | 正文、标题层级、字段默认为黑色,红色只用于规定的版头/特定格式/版记线 | 生成器和 PDF 视觉检查 |
|
||||||
|
| 6.1 | 制版清洁、字迹、尺寸、版心误差 | 不在 XML 中虚报制版质量 | PDF/打印成品人工检查 |
|
||||||
|
| 6.2 | 双面印刷、套正、油墨和印品质量 | 正式模式生成单页右、双页左页码;不控制打印机和油墨 | `--page-number standard`;双面打印和套正人工检查 |
|
||||||
|
| 6.3 | 左侧装订、钉位、裁切和成品 | 不生成虚假的装订结果 | `formal-checklist.md`;成品人工检查 |
|
||||||
|
| 7.1 | 版头、主体、版记三区和页码在版心外 | 版式分支、页脚和版记结构分别生成 | DOCX/PDF 逐页检查 |
|
||||||
|
| 7.2.1 | 份号六位、3 号、左上第一行 | `--copy-no` 只接受 1—6 位阿拉伯数字并补足六位 | 结构检查;实际是否需要份号由发文机关确认 |
|
||||||
|
| 7.2.2 | 密级和保密期限第二行、3 号黑体 | `--secret` 按黑体 3 号写入 | 文字内容和期限写法人工复核 |
|
||||||
|
| 7.2.3 | 紧急程度及排列顺序 | `--urgent` 按黑体 3 号写入,并按份号、密级、紧急程度顺序下移 | 适用性和紧急级别人工确认 |
|
||||||
|
| 7.2.4 | 发文机关标志居中、红色、版心上边缘下 35mm、小标宋 | `formal --letterhead digital` 才绘制;预印模式只留纸样区域;联合行文仍需模板 | `verify_gongwen_docx.mjs`、PDF;目标电脑字体和联合行文人工核对 |
|
||||||
|
| 7.2.5 | 文号下空二行、六角括号、全年份、顺序号不加“第”不补零 | `formal`/`letter` 对 `--doc-no` 做格式校验,并写入两个显式 28 磅空行 | 文号语义由机关编号规则确认 |
|
||||||
|
| 7.2.6 | 上行文签发人同文号一行;标签仿宋、姓名楷体 | `--upward true --signer` 同行生成单一签发人;多签发人、联合行文转人工模板 | 结构校验和 PDF;多个签发人需按机关顺序人工排布 |
|
||||||
|
| 7.2.7 | 文号下 4mm、版心等宽红线 | `redRule()` 生成 156mm 红线,文号段后 4mm;预印模式不重绘 | PDF 量测;红色油墨和实际纸样人工核对 |
|
||||||
|
| 7.3.1 | 标题 2 号小标宋,红线下空二行、居中 | 标题使用 `GongwenTitle`,红线/预印线后插入两个显式 28 磅空段落 | XML + PDF 视觉门;小标宋缺失时严格模式失败 |
|
||||||
|
| 7.3.2 | 主送机关标题下空一行、顶格、全角冒号 | `--to` 或首段识别后写入一行并规范化末尾全角冒号 | 过多主送机关移版记仍需人工决定 |
|
||||||
|
| 7.3.3 | 首页有正文;正文 3 号仿宋、自然段左空二字;四级序数和字体 | Markdown 序数映射 `Heading1`—`Heading4`、大纲级别和字体已写入 | Word/WPS 目录可引用;特殊层次和分页人工复核 |
|
||||||
|
| 7.3.4 | 附件说明正文下空一行、左空二字、名称末无标点 | `--attachment-note` 统一为“附件:”并去除末尾标点,表格后不重复空行 | 长名称回行和多附件编号仍需 PDF/模板复核 |
|
||||||
|
| 7.3.5 | 署名、成文日期、印章/签名章 | 不加章署名日期按左右空字规则生成;日期校验为全年月日且月日不补零;不伪造印章 | `--seal-mode` 仍需把实体章或已取得的图片放入 Word/WPS;联合行文人工完成 |
|
||||||
|
| 7.3.6 | 附注在日期下一行左空二字、圆括号 | `--note` 规范化为全角圆括号 | 日期与附注的最终分页人工核对 |
|
||||||
|
| 7.3.7 | 附件另面、第一行附件标签黑体、第三行标题、版记前、页码连续 | `--attachment-file` 用分页符、显式空行和标题样式生成,附件共用连续页脚 | 不能一起装订时的文号+附件标识、复杂附件版式人工处理 |
|
||||||
|
| 7.4.1 | 版记首末粗线、中间细线,末线贴最后一页版心下边缘 | 版记改为浮动表,锚定最后一页版心底部;粗线 0.35mm、细线 0.25mm | 已用长文 PDF 截图实测;Word/WPS 和极端分页仍需抽检 |
|
||||||
|
| 7.4.2 | 抄送 4 号仿宋、左右一字、回行对齐、末尾句号 | `--cc` 生成 4 号、左右一字、全角冒号和句号 | 多行抄送回行和主送移版记人工复核 |
|
||||||
|
| 7.4.3 | 印发机关/日期 4 号仿宋,左右一字,日期后“印发”,细线隔开 | `--print-org`、`--print-date` 生成右制表位、日期校验和末条粗线 | 实际机关名称长度和目标软件分页人工复核 |
|
||||||
|
| 7.5 | 4 号半角宋体页码、两侧一字线、距版心下边缘 7mm、单双页位置、附件连续 | 正式页脚用半角宋体、单双页字段,footer 距离调整为 7mm 位置;信函关闭页码 | XML + PDF bbox;空白页/特定版记页的例外仍需人工复核 |
|
||||||
|
| 8 | 横排表格页码与表头方向 | 普通 Markdown 表格按纵向版心生成,不假装自动完成横排 | 横排表格须在 Word/WPS 采用专用横向模板并人工检查 |
|
||||||
|
| 9 | 计量单位、标点、数字按引用标准 | 结构字段(文号、日期、附件冒号、句号)做边界校验 | 正文语义、单位、标点和数字用法需人工或专门文本审校 |
|
||||||
|
| 10.1 | 信函上下双红线、30mm/20mm、170mm、标题二行、无首页页码、无版记线 | `--format letter` 生成双线、专用边距、文号距离、标题空行、底部双线页脚和无页码 | PDF 实测;联合行文及模板字段人工复核 |
|
||||||
|
| 10.2 | 命令(令)标志上边缘 20mm、令号下空二行、正文下空二行 | `--format command` 要求 `--org`、`--doc-no`,两个间隔均为显式 28 磅空行 | 令号文字和签名章/日期按专用模板人工复核 |
|
||||||
|
| 10.3 | 纪要标志 35mm;出席/请假/列席标签黑体,人员仿宋 | `--format minutes` 要求机关标志以“纪要”结尾,三类人员分 run 写入对应字体 | 纪要具体版式可按机关制度调整 |
|
||||||
|
| 11 | 标准式样图 | 各分支按条款参数实现,不把截图当作唯一依据 | 最终仍需与机关现行式样、Word/WPS 和打印样张逐页比对 |
|
||||||
|
|
||||||
|
## 本轮硬性修正
|
||||||
|
|
||||||
|
- 红线下标题不再只依赖 `w:before` 的段前属性,而是写入两个显式、28 磅、无文字的版心网格段落;这解决了不同 Word/WPS 渲染时标题贴线的问题。
|
||||||
|
- 发文机关标志到文号、命令标志到令号、令号到正文同样使用显式空行;校验器不再把“存在某个段前数值”当成视觉合规证据。
|
||||||
|
- 版记不再跟在正文最后一行后面,而是锚定到最后一页版心底部;正式页码 footer 改为版心下边缘下 7mm 的位置。
|
||||||
|
- 文号、成文日期、附件说明的结构边界现在会拒绝明显不符合标准的输入;缺失小标宋或仿宋时仍会明确告警,严格模式会停止生成。
|
||||||
@@ -103,9 +103,10 @@ Notes:
|
|||||||
- ordinary is the default for reports, plans and other editable materials. It never creates a red header from --org alone.
|
- ordinary is the default for reports, plans and other editable materials. It never creates a red header from --org alone.
|
||||||
- formal is used only when a formal issuing-document layout is explicitly requested. It defaults to preprinted letterhead: red elements are reserved, not redrawn. Use --letterhead digital only for a complete electronic red-head layout.
|
- formal is used only when a formal issuing-document layout is explicitly requested. It defaults to preprinted letterhead: red elements are reserved, not redrawn. Use --letterhead digital only for a complete electronic red-head layout.
|
||||||
- letter, command and minutes use their dedicated national-standard layout branches. Their required fields must be supplied explicitly.
|
- letter, command and minutes use their dedicated national-standard layout branches. Their required fields must be supplied explicitly.
|
||||||
- --org and --doc-no are printed exactly as supplied. The tool formats document content; it does not infer missing information or decide the document's use.
|
- formal and letter document numbers are checked for full-year〔〕sequence号 syntax; dates are checked for YYYY年M月D日 without zero padding.
|
||||||
|
- --org and --doc-no are printed as supplied after boundary validation. The tool formats document content; it does not infer missing information or decide the document's use.
|
||||||
- The generator reports when the required small-standard-title or FangSong font is unavailable and uses a visible fallback. Add --require-standard-fonts to refuse generation until the required font is installed.
|
- The generator reports when the required small-standard-title or FangSong font is unavailable and uses a visible fallback. Add --require-standard-fonts to refuse generation until the required font is installed.
|
||||||
- Supports headings, paragraphs, ordered/unordered lines, pipe tables, and repeatable Markdown attachment pages via --attachment-file.
|
- Supports headings, paragraphs, ordered/unordered lines, pipe tables, and repeatable Markdown attachment pages via --attachment-file. The colophon is anchored to the bottom of the final page's type area.
|
||||||
- No npm dependencies; requires zip in PATH.`);
|
- No npm dependencies; requires zip in PATH.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,14 +199,14 @@ function titleParagraph(text, before = "0") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function agencyMarkParagraph(text, before = "0") {
|
function agencyMarkParagraph(text, before = "0", after = "0") {
|
||||||
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: "560",
|
after,
|
||||||
before,
|
before,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -237,7 +238,10 @@ function documentNumberParagraph(text, upward = false) {
|
|||||||
|
|
||||||
function upwardHeaderParagraph(docNo, signer) {
|
function upwardHeaderParagraph(docNo, signer) {
|
||||||
const tabPosition = CONTENT_W - FIRST_LINE_INDENT;
|
const tabPosition = CONTENT_W - FIRST_LINE_INDENT;
|
||||||
return `<w:p><w:pPr><w:jc w:val="both"/><w:ind w:left="${FIRST_LINE_INDENT}" w:right="${FIRST_LINE_INDENT}"/><w:tabs><w:tab w:val="right" w:pos="${tabPosition}"/></w:tabs><w:spacing w:before="0" w:after="227" w:line="560" w:lineRule="exact"/><w:adjustRightInd w:val="true"/><w:snapToGrid w:val="true"/><w:kinsoku w:val="true"/></w:pPr>${run(docNo, { fontPreset: "body", size: "32" })}<w:r><w:tab/></w:r>${run("签发人:", { fontPreset: "body", size: "32" })}${run(signer, { fontPreset: "h2", size: "32" })}</w:p>`;
|
const signers = String(signer).split(/[、,,;;\s]+/).map((name) => name.trim()).filter(Boolean);
|
||||||
|
if (signers.length > 2) throw new Error("upward --signer supports up to two names; use the authority's joint-signature template for more signers");
|
||||||
|
const signerRuns = signers.map((name, index) => `${index ? run(" ", { fontPreset: "h2", size: "32" }) : ""}${run(name, { fontPreset: "h2", size: "32" })}`).join("");
|
||||||
|
return `<w:p><w:pPr><w:jc w:val="both"/><w:ind w:left="${FIRST_LINE_INDENT}" w:right="${FIRST_LINE_INDENT}"/><w:tabs><w:tab w:val="right" w:pos="${tabPosition}"/></w:tabs><w:spacing w:before="0" w:after="227" w:line="560" w:lineRule="exact"/><w:adjustRightInd w:val="true"/><w:snapToGrid w:val="true"/><w:kinsoku w:val="true"/></w:pPr>${run(docNo, { fontPreset: "body", size: "32" })}<w:r><w:tab/></w:r>${run("签发人:", { fontPreset: "body", size: "32" })}${signerRuns}</w:p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function subtitleParagraph(text) {
|
function subtitleParagraph(text) {
|
||||||
@@ -264,6 +268,38 @@ function normalizeMainSend(text) {
|
|||||||
return value && /[::]$/.test(value) ? value : `${value}:`;
|
return value && /[::]$/.test(value) ? value : `${value}:`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDocumentNumber(text, label = "--doc-no") {
|
||||||
|
const value = String(text ?? "").trim();
|
||||||
|
if (!value) return value;
|
||||||
|
if (!/^[^〔〕\[\]【】]+〔\d{4}〕[1-9]\d*号$/.test(value) || /第/.test(value)) {
|
||||||
|
throw new Error(`${label} must use 年份全称、六角括号、非虚位顺序号且以“号”结尾,例如“示例发〔2026〕1号”`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChineseDate(text, label = "--date", allowPrintSuffix = false) {
|
||||||
|
const raw = String(text ?? "").trim();
|
||||||
|
const value = allowPrintSuffix ? raw.replace(/印发$/, "") : raw;
|
||||||
|
const match = value.match(/^(\d{4})年((?:[1-9]|1[0-2]))月((?:[1-9]|[12]\d|3[01]))日$/);
|
||||||
|
if (!match) throw new Error(`${label} must use YYYY年M月D日 and must not zero-pad month/day`);
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
|
||||||
|
throw new Error(`${label} is not a valid calendar date`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttachmentNote(text) {
|
||||||
|
const value = String(text ?? "").trim().replace(/[。;;]+$/, "");
|
||||||
|
if (!value) throw new Error("--attachment-note cannot be empty");
|
||||||
|
const names = value.replace(/^附件[::]\s*/, "").trim();
|
||||||
|
if (!names) throw new Error("--attachment-note must contain an attachment name after 附件:");
|
||||||
|
return `附件:${names}`;
|
||||||
|
}
|
||||||
|
|
||||||
function h1(text) {
|
function h1(text) {
|
||||||
return paragraph(text, {
|
return paragraph(text, {
|
||||||
align: "left",
|
align: "left",
|
||||||
@@ -320,6 +356,10 @@ function right(text) {
|
|||||||
return paragraph(text, { align: "right", indent: false, right: FIRST_LINE_INDENT });
|
return paragraph(text, { align: "right", indent: false, right: FIRST_LINE_INDENT });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rightWithIndent(text, rightIndent) {
|
||||||
|
return paragraph(text, { align: "right", indent: false, right: String(rightIndent) });
|
||||||
|
}
|
||||||
|
|
||||||
function redRule(thickness = "5") {
|
function redRule(thickness = "5") {
|
||||||
const height = thickness === "thick" ? "0.35mm" : thickness === "thin" ? "0.25mm" : "0.5mm";
|
const height = thickness === "thick" ? "0.35mm" : thickness === "thin" ? "0.25mm" : "0.5mm";
|
||||||
return `<w:p><w:pPr><w:spacing w:before="0" w:after="0" w:line="80" w:lineRule="exact"/></w:pPr><w:r><w:rPr><w:color w:val="FF0000"/></w:rPr><w:pict><v:rect xmlns:v="urn:schemas-microsoft-com:vml" style="width:156mm;height:${height}" fillcolor="#FF0000" stroked="f"/></w:pict></w:r></w:p>`;
|
return `<w:p><w:pPr><w:spacing w:before="0" w:after="0" w:line="80" w:lineRule="exact"/></w:pPr><w:r><w:rPr><w:color w:val="FF0000"/></w:rPr><w:pict><v:rect xmlns:v="urn:schemas-microsoft-com:vml" style="width:156mm;height:${height}" fillcolor="#FF0000" stroked="f"/></w:pict></w:r></w:p>`;
|
||||||
@@ -341,31 +381,94 @@ function minutesPersonParagraph(label, people, before = "0") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function headerFieldParagraph(text, fontPreset = "h1", opts = {}) {
|
function headerFieldParagraph(text, fontPreset = "h1", opts = {}) {
|
||||||
return paragraph(text, { align: opts.align ?? "left", indent: false, fontPreset, size: "32", after: opts.after ?? "0", left: opts.left, right: opts.right });
|
return paragraph(text, { align: opts.align ?? "left", indent: false, fontPreset, size: "32", before: opts.before ?? "0", after: opts.after ?? "0", left: opts.left, right: opts.right });
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachmentNote(text) {
|
function attachmentNote(text) {
|
||||||
return paragraph(`附件:${text}`, { align: "left", fontPreset: "body", size: "32", before: "560", firstLine: FIRST_LINE_INDENT, left: "0" });
|
return paragraph(normalizeAttachmentNote(text), { align: "left", fontPreset: "body", size: "32", before: "560", firstLine: "-960", left: "1600" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimatedTextWidthDxa(text) {
|
||||||
|
return [...String(text ?? "")].reduce((sum, char) => sum + (/^[\x00-\x7f]$/.test(char) ? 160 : 320), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function signatureParagraphs(args) {
|
||||||
|
if (args["seal-mode"] === "signed") {
|
||||||
|
if (!args.signer || !args["signer-title"]) throw new Error("signed seal mode requires --signer and --signer-title");
|
||||||
|
const lines = [rightWithIndent(`${args["signer-title"]} ${args.signer}`, FIRST_LINE_INDENT * 2)];
|
||||||
|
if (args.date) lines.push(rightWithIndent(args.date, FIRST_LINE_INDENT * 2));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
if (args["seal-mode"] === "seal") {
|
||||||
|
if (!args.sender && !args.date) return [];
|
||||||
|
const lines = [];
|
||||||
|
if (args.sender) lines.push(rightWithIndent(args.sender, FIRST_LINE_INDENT * 2));
|
||||||
|
if (args.date) lines.push(rightWithIndent(args.date, FIRST_LINE_INDENT * 2));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
if (!args.sender && !args.date) return [];
|
||||||
|
if (!args.sender || !args.date) return [right(args.sender || args.date)];
|
||||||
|
const senderWidth = estimatedTextWidthDxa(args.sender);
|
||||||
|
const dateWidth = estimatedTextWidthDxa(args.date);
|
||||||
|
let senderRight = FIRST_LINE_INDENT;
|
||||||
|
let dateRight = Math.max(0, senderWidth - dateWidth);
|
||||||
|
if (dateWidth > senderWidth) {
|
||||||
|
dateRight = FIRST_LINE_INDENT;
|
||||||
|
senderRight = dateWidth - senderWidth + FIRST_LINE_INDENT;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
paragraph(args.sender, { align: "right", indent: false, right: String(senderRight) }),
|
||||||
|
paragraph(args.date, { align: "right", indent: false, right: String(dateRight) }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function colophonTextRow(text, align = "left") {
|
||||||
|
return `<w:tr><w:tc><w:tcPr><w:tcW w:w="${CONTENT_W}" w:type="dxa"/><w:tcMar><w:top w:w="0" w:type="dxa"/><w:left w:w="0" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tcMar></w:tcPr>${paragraph(text, { align, firstLine: "-960", left: "1280", right: "320", fontPreset: "body", size: "28" })}</w:tc></w:tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colophonRuleRow(thickness) {
|
||||||
|
const heightMm = thickness === "thick" ? "0.35" : "0.25";
|
||||||
|
return `<w:tr><w:trPr><w:cantSplit/><w:trHeight w:val="${mmToDxa(heightMm)}" w:hRule="exact"/></w:trPr><w:tc><w:tcPr><w:tcW w:w="${CONTENT_W}" w:type="dxa"/><w:tcMar><w:top w:w="0" w:type="dxa"/><w:left w:w="0" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tcMar></w:tcPr>${redRule(thickness)}</w:tc></w:tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function colophon(args) {
|
function colophon(args) {
|
||||||
if (!args["cc"] && !args["print-org"] && !args["print-date"]) return [];
|
if (!args["cc"] && !args["print-org"] && !args["print-date"]) return [];
|
||||||
const body = [redRule("thick")];
|
const body = [colophonRuleRow("thick")];
|
||||||
if (args.cc) body.push(paragraph(`抄送:${args.cc.replace(/[。.]?$/, "")}。`, { align: "left", indent: false, left: "320", right: "320", fontPreset: "body", size: "28" }));
|
if (args.cc) body.push(colophonTextRow(`抄送:${args.cc.replace(/[。..]+$/, "")}。`));
|
||||||
if (args.cc) body.push(redRule("thin"));
|
if (args.cc) body.push(colophonRuleRow("thin"));
|
||||||
if (args["print-org"] || args["print-date"]) {
|
if (args["print-org"] || args["print-date"]) {
|
||||||
const left = args["print-org"] ?? "";
|
const left = args["print-org"] ?? "";
|
||||||
const rightValue = args["print-date"] ? `${args["print-date"].replace(/印发$/, "")}印发` : "";
|
const rightValue = args["print-date"] ? `${args["print-date"].replace(/印发$/, "")}印发` : "";
|
||||||
body.push(`<w:p><w:pPr><w:jc w:val="both"/><w:ind w:left="320" w:right="320"/><w:tabs><w:tab w:val="right" w:pos="${CONTENT_W - 320}"/></w:tabs><w:spacing w:before="0" w:after="0" w:line="560" w:lineRule="exact"/></w:pPr>${run(left, { fontPreset: "body", size: "28" })}<w:r><w:tab/></w:r>${run(rightValue, { fontPreset: "body", size: "28" })}</w:p>`);
|
const printRow = `<w:p><w:pPr><w:jc w:val="both"/><w:ind w:left="320" w:right="320"/><w:tabs><w:tab w:val="right" w:pos="${CONTENT_W - 320}"/></w:tabs><w:spacing w:before="0" w:after="0" w:line="560" w:lineRule="exact"/></w:pPr>${run(left, { fontPreset: "body", size: "28" })}<w:r><w:tab/></w:r>${run(rightValue, { fontPreset: "body", size: "28" })}</w:p>`;
|
||||||
|
body.push(`<w:tr><w:tc><w:tcPr><w:tcW w:w="${CONTENT_W}" w:type="dxa"/><w:tcMar><w:top w:w="0" w:type="dxa"/><w:left w:w="0" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tcMar></w:tcPr>${printRow}</w:tc></w:tr>`);
|
||||||
}
|
}
|
||||||
body.push(redRule("thick"));
|
body.push(colophonRuleRow("thick"));
|
||||||
return body;
|
const tablePr = `<w:tblPr><w:tblpPr w:leftFromText="0" w:rightFromText="0" w:topFromText="0" w:bottomFromText="0" w:vertAnchor="margin" w:horzAnchor="margin" w:tblpXSpec="center" w:tblpYSpec="bottom"/><w:tblOverlap w:val="never"/><w:tblW w:w="${CONTENT_W}" w:type="dxa"/><w:tblLayout w:type="fixed"/><w:tblBorders><w:top w:val="nil"/><w:left w:val="nil"/><w:bottom w:val="nil"/><w:right w:val="nil"/><w:insideH w:val="nil"/><w:insideV w:val="nil"/></w:tblBorders><w:tblCellMar><w:top w:w="0" w:type="dxa"/><w:left w:w="0" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tblCellMar></w:tblPr>`;
|
||||||
|
return [`<w:tbl>${tablePr}<w:tblGrid><w:gridCol w:w="${CONTENT_W}"/></w:tblGrid>${body.join("")}</w:tbl>`];
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyLine() {
|
function emptyLine() {
|
||||||
return `<w:p><w:pPr><w:spacing w:line="560" w:lineRule="exact"/></w:pPr></w:p>`;
|
return `<w:p><w:pPr><w:spacing w:line="560" w:lineRule="exact"/></w:pPr></w:p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blankGridLine() {
|
||||||
|
return paragraph("\u00a0", {
|
||||||
|
align: "left",
|
||||||
|
indent: false,
|
||||||
|
fontPreset: "body",
|
||||||
|
size: "2",
|
||||||
|
line: "560",
|
||||||
|
lineRule: "exact",
|
||||||
|
snapToGrid: false,
|
||||||
|
before: "0",
|
||||||
|
after: "0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankGridLines(count = 2) {
|
||||||
|
return Array.from({ length: count }, () => blankGridLine());
|
||||||
|
}
|
||||||
|
|
||||||
function tableXml(headers, rows) {
|
function tableXml(headers, rows) {
|
||||||
const colCount = headers.length;
|
const colCount = headers.length;
|
||||||
const colWidth = Math.floor(CONTENT_W / colCount);
|
const colWidth = Math.floor(CONTENT_W / colCount);
|
||||||
@@ -466,10 +569,21 @@ function isMatchingParagraph(block, text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isChineseDateParagraph(block) {
|
function isChineseDateParagraph(block) {
|
||||||
return (
|
if (block?.type !== "paragraph") return false;
|
||||||
block?.type === "paragraph" &&
|
try {
|
||||||
/^\d{4}年\d{1,2}月\d{1,2}日$/.test(normalizeSignatureText(block.text))
|
normalizeChineseDate(normalizeSignatureText(block.text), "source date");
|
||||||
);
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDateParagraphs(blocks) {
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block?.type === "paragraph" && /^\d{4}年\d{1,2}月\d{1,2}日$/.test(normalizeSignatureText(block.text))) {
|
||||||
|
normalizeChineseDate(normalizeSignatureText(block.text), "source date");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripTrailingMatchingSignature(blocks, args) {
|
function stripTrailingMatchingSignature(blocks, args) {
|
||||||
@@ -556,6 +670,7 @@ function buildDocument(blocks, args) {
|
|||||||
const letterhead = args.letterhead === "digital" ? "digital" : "preprinted";
|
const letterhead = args.letterhead === "digital" ? "digital" : "preprinted";
|
||||||
const pageNumberMode = resolvePageNumberMode(args, format);
|
const pageNumberMode = resolvePageNumberMode(args, format);
|
||||||
const body = [];
|
const body = [];
|
||||||
|
let titleGapLines = 0;
|
||||||
const pageMarginTop = format === "letter" ? 1701 : format === "command" ? 1134 : 2098;
|
const pageMarginTop = format === "letter" ? 1701 : format === "command" ? 1134 : 2098;
|
||||||
const sourceBlocks = [...blocks];
|
const sourceBlocks = [...blocks];
|
||||||
if (!title && sourceBlocks[0]?.type === "heading") {
|
if (!title && sourceBlocks[0]?.type === "heading") {
|
||||||
@@ -564,6 +679,7 @@ function buildDocument(blocks, args) {
|
|||||||
if (title && sourceBlocks[0]?.type === "heading" && sourceBlocks[0].text.trim() === title.trim()) {
|
if (title && sourceBlocks[0]?.type === "heading" && sourceBlocks[0].text.trim() === title.trim()) {
|
||||||
sourceBlocks.shift();
|
sourceBlocks.shift();
|
||||||
}
|
}
|
||||||
|
validateDateParagraphs(sourceBlocks);
|
||||||
stripTrailingMatchingSignature(sourceBlocks, args);
|
stripTrailingMatchingSignature(sourceBlocks, args);
|
||||||
let mainSend = args.to;
|
let mainSend = args.to;
|
||||||
if (mainSend && isMatchingParagraph(sourceBlocks[0], mainSend)) sourceBlocks.shift();
|
if (mainSend && isMatchingParagraph(sourceBlocks[0], mainSend)) sourceBlocks.shift();
|
||||||
@@ -580,30 +696,49 @@ function buildDocument(blocks, args) {
|
|||||||
if (args["copy-no"]) body.push(headerFieldParagraph(String(args["copy-no"]).padStart(6, "0"), "body"));
|
if (args["copy-no"]) body.push(headerFieldParagraph(String(args["copy-no"]).padStart(6, "0"), "body"));
|
||||||
if (args.secret) body.push(headerFieldParagraph(args.secret));
|
if (args.secret) body.push(headerFieldParagraph(args.secret));
|
||||||
if (args.urgent) body.push(headerFieldParagraph(args.urgent));
|
if (args.urgent) body.push(headerFieldParagraph(args.urgent));
|
||||||
if (letterhead === "digital" && args.org) body.push(agencyMarkParagraph(args.org, String(Math.max(0, 1984 - headerFieldCount * 560))));
|
if (letterhead === "digital" && args.org) {
|
||||||
|
body.push(agencyMarkParagraph(args.org, String(Math.max(0, 1984 - headerFieldCount * 560)), "0"));
|
||||||
|
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)));
|
body.push(letterheadReserveParagraph(Math.max(37, reserve - headerFieldCount * 9.877)));
|
||||||
}
|
}
|
||||||
|
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));
|
||||||
else if (args["doc-no"]) body.push(documentNumberParagraph(args["doc-no"], upward));
|
else if (args["doc-no"]) body.push(documentNumberParagraph(args["doc-no"], upward));
|
||||||
if (letterhead === "digital" || args["preprinted-rule"] === "true") body.push(redRule());
|
if (letterhead === "digital" || args["preprinted-rule"] === "true") body.push(redRule());
|
||||||
|
titleGapLines = 2;
|
||||||
} else if (format === "letter") {
|
} else if (format === "letter") {
|
||||||
if (!args.org) throw new Error("letter format requires --org");
|
if (!args.org) throw new Error("letter format requires --org");
|
||||||
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "0", after: "227" }));
|
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "0", after: "227" }));
|
||||||
body.push(redDoubleRule({ upperMm: "0.35", lowerMm: "0.25" }));
|
body.push(redDoubleRule({ upperMm: "0.35", lowerMm: "0.25" }));
|
||||||
if (args["doc-no"]) body.push(headerFieldParagraph(args["doc-no"], "body", { align: "right" }));
|
if (args["doc-no"]) {
|
||||||
|
args["doc-no"] = normalizeDocumentNumber(args["doc-no"]);
|
||||||
|
body.push(headerFieldParagraph(args["doc-no"], "body", { align: "right", before: "280" }));
|
||||||
|
}
|
||||||
|
titleGapLines = 2;
|
||||||
} else if (format === "command") {
|
} else if (format === "command") {
|
||||||
if (!args.org) throw new Error("command format requires --org");
|
if (!args.org) throw new Error("command format requires --org");
|
||||||
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "0", after: "1120" }));
|
if (!args["doc-no"]) throw new Error("command format requires --doc-no for the 令号");
|
||||||
if (args["doc-no"]) body.push(paragraph(args["doc-no"], { align: "center", indent: false, fontPreset: "body", size: "32", after: "1120" }));
|
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "0", after: "0" }));
|
||||||
|
if (args["doc-no"]) {
|
||||||
|
body.push(...blankGridLines(2));
|
||||||
|
body.push(paragraph(args["doc-no"], { align: "center", indent: false, fontPreset: "body", size: "32", after: "0" }));
|
||||||
|
body.push(...blankGridLines(2));
|
||||||
|
}
|
||||||
} else if (format === "minutes") {
|
} else if (format === "minutes") {
|
||||||
if (!args.org) throw new Error("minutes format requires --org (for XXXXX纪要)");
|
if (!args.org) throw new Error("minutes format requires --org (for XXXXX纪要)");
|
||||||
|
if (!/纪要$/.test(String(args.org).trim())) throw new Error("minutes --org must end with“纪要”");
|
||||||
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "1984", after: "560" }));
|
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "1984", after: "560" }));
|
||||||
}
|
}
|
||||||
const hasRedRule = (formal && (letterhead === "digital" || letterhead === "preprinted" || args["preprinted-rule"] === "true")) || format === "letter";
|
if (args.date) args.date = normalizeChineseDate(args.date);
|
||||||
if (title) body.push(titleParagraph(title, hasRedRule ? "1120" : "0"));
|
if (args["print-date"]) args["print-date"] = normalizeChineseDate(args["print-date"], "--print-date", true);
|
||||||
|
if (title) {
|
||||||
|
if (titleGapLines) body.push(...blankGridLines(titleGapLines));
|
||||||
|
body.push(titleParagraph(title));
|
||||||
|
}
|
||||||
if (args.subtitle) body.push(subtitleParagraph(args.subtitle));
|
if (args.subtitle) body.push(subtitleParagraph(args.subtitle));
|
||||||
if (mainSend) body.push(mainSendParagraph(mainSend));
|
if (mainSend) body.push(mainSendParagraph(mainSend));
|
||||||
|
|
||||||
@@ -619,20 +754,13 @@ function buildDocument(blocks, args) {
|
|||||||
}
|
}
|
||||||
body.push(renderParagraph(block));
|
body.push(renderParagraph(block));
|
||||||
}
|
}
|
||||||
if (args["attachment-note"]) body.push(attachmentNote(args["attachment-note"]));
|
if (args["attachment-note"]) {
|
||||||
|
if (body.at(-1) === emptyLine()) body.pop();
|
||||||
|
body.push(attachmentNote(args["attachment-note"]));
|
||||||
|
}
|
||||||
if (args.sender || args.date) {
|
if (args.sender || args.date) {
|
||||||
body.push(emptyLine());
|
body.push(emptyLine());
|
||||||
if (args["seal-mode"] === "signed") {
|
body.push(...signatureParagraphs(args));
|
||||||
if (!args.signer || !args["signer-title"]) throw new Error("signed seal mode requires --signer and --signer-title");
|
|
||||||
body.push(right(`${args["signer-title"]} ${args.signer}`));
|
|
||||||
if (args.date) body.push(right(args.date));
|
|
||||||
} else if (args["seal-mode"] === "seal") {
|
|
||||||
if (args.sender) body.push(right(args.sender));
|
|
||||||
if (args.date) body.push(right(args.date));
|
|
||||||
} else {
|
|
||||||
if (args.sender) body.push(right(args.sender));
|
|
||||||
if (args.date) body.push(right(args.date));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (args.note) body.push(paragraph(`(${args.note.replace(/^(|)$/g, "")})`, { align: "left", fontPreset: "body", size: "32" }));
|
if (args.note) body.push(paragraph(`(${args.note.replace(/^(|)$/g, "")})`, { align: "left", fontPreset: "body", size: "32" }));
|
||||||
const attachmentFiles = Array.isArray(args["attachment-file"]) ? args["attachment-file"] : args["attachment-file"] ? [args["attachment-file"]] : [];
|
const attachmentFiles = Array.isArray(args["attachment-file"]) ? args["attachment-file"] : args["attachment-file"] ? [args["attachment-file"]] : [];
|
||||||
@@ -652,7 +780,7 @@ function buildDocument(blocks, args) {
|
|||||||
: pageNumberMode === "center"
|
: pageNumberMode === "center"
|
||||||
? '<w:footerReference w:type="default" r:id="rIdFooterCenter"/>'
|
? '<w:footerReference w:type="default" r:id="rIdFooterCenter"/>'
|
||||||
: "");
|
: "");
|
||||||
const footerDistance = format === "letter" ? "1134" : "1588";
|
const footerDistance = format === "letter" || pageNumberMode === "standard" ? "1134" : "1588";
|
||||||
body.push(`<w:sectPr>${footerRefs}<w:pgSz w:w="${PAGE_W}" w:h="${PAGE_H}"/><w:pgMar w:top="${pageMarginTop}" w:right="${MARGIN.right}" w:bottom="${MARGIN.bottom}" w:left="${MARGIN.left}" w:header="720" w:footer="${footerDistance}" w:gutter="0"/><w:docGrid w:type="lines" w:linePitch="560"/></w:sectPr>`);
|
body.push(`<w:sectPr>${footerRefs}<w:pgSz w:w="${PAGE_W}" w:h="${PAGE_H}"/><w:pgMar w:top="${pageMarginTop}" w:right="${MARGIN.right}" w:bottom="${MARGIN.bottom}" w:left="${MARGIN.left}" w:header="720" w:footer="${footerDistance}" w:gutter="0"/><w:docGrid w:type="lines" w:linePitch="560"/></w:sectPr>`);
|
||||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:v="urn:schemas-microsoft-com:vml"><w:body>${body.join("")}</w:body></w:document>`;
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:v="urn:schemas-microsoft-com:vml"><w:body>${body.join("")}</w:body></w:document>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,16 +101,26 @@ const titleFontStandard = titleParagraphs.every((p) => /w:eastAsia="(?:方正小
|
|||||||
const titleFontAvailable = titleFontStandard || (!requireStandardFonts && titleParagraphs.every((p) => /w:eastAsia="Songti SC"/.test(p)));
|
const titleFontAvailable = titleFontStandard || (!requireStandardFonts && titleParagraphs.every((p) => /w:eastAsia="Songti SC"/.test(p)));
|
||||||
check("Title style and font", titleFontAvailable, requireStandardFonts ? "small-standard-title font is required" : titleParagraphs.length ? `${titleParagraphs.length} title paragraph(s); Songti SC fallback is allowed unless strict mode is enabled` : "not present in this document");
|
check("Title style and font", titleFontAvailable, requireStandardFonts ? "small-standard-title font is required" : titleParagraphs.length ? `${titleParagraphs.length} title paragraph(s); Songti SC fallback is allowed unless strict mode is enabled` : "not present in this document");
|
||||||
|
|
||||||
|
const isExplicitBlankGridLine = (p) => /w:spacing w:before="0" w:after="0" w:line="560" w:lineRule="exact"/.test(p) && /w:sz w:val="2"/.test(p) && /<w:t[^>]*> <\/w:t>/.test(p);
|
||||||
|
const firstTitleIndex = paragraphs(doc).findIndex((p) => /w:pStyle w:val="GongwenTitle"/.test(p));
|
||||||
|
const titlePrecedingParagraphs = firstTitleIndex < 0 ? [] : paragraphs(doc).slice(0, firstTitleIndex);
|
||||||
|
const titleGapParagraphs = titlePrecedingParagraphs.slice(-2);
|
||||||
|
const hasExplicitTwoLineTitleGap = titleGapParagraphs.length === 2 && titleGapParagraphs.every(isExplicitBlankGridLine);
|
||||||
|
check("Title has two explicit blank grid lines", !["formal", "letter"].includes(profile) || hasExplicitTwoLineTitleGap, "title is preceded by two exact 28-point blank paragraphs");
|
||||||
|
if (/附件:/.test(textOf(doc))) check("Attachment-note continuation indent", /w:firstLine="-960"[^>]*w:left="1600"/.test(doc), "附件说明回行与名称首字对齐");
|
||||||
|
|
||||||
if (profile === "ordinary") {
|
if (profile === "ordinary") {
|
||||||
check("No formal red-head drawing", !/w:color w:val="FF0000"/.test(doc), "ordinary output has no red head or red rule");
|
check("No formal red-head drawing", !/w:color w:val="FF0000"/.test(doc), "ordinary output has no red head or red rule");
|
||||||
check("Centered layout footer", /w:jc w:val="center"/.test(footerCenter) && /w:instr="PAGE"/.test(footerCenter), "ordinary output uses centered page number");
|
check("Centered layout footer", /w:jc w:val="center"/.test(footerCenter) && /w:instr="PAGE"/.test(footerCenter), "ordinary output uses centered page number");
|
||||||
} else if (profile === "letter") {
|
} else if (profile === "letter") {
|
||||||
check("Letter first-page page number", !footerCenter && !footerOdd && !footerEven && !/w:instr="PAGE"/.test(footerLetter), "letter output suppresses page numbers");
|
check("Letter first-page page number", !footerCenter && !footerOdd && !footerEven && !/w:instr="PAGE"/.test(footerLetter), "letter output suppresses page numbers");
|
||||||
check("Letter upper red double rule", (footerLetter.match(/w:fill="FF0000"/g) ?? []).length >= 2 && /w:tblW[^>]*w:w="9638"/.test(doc), "letter output contains 170 mm red double rules");
|
check("Letter upper red double rule", (footerLetter.match(/w:fill="FF0000"/g) ?? []).length >= 2 && /w:tblW[^>]*w:w="9638"/.test(doc), "letter output contains 170 mm red double rules");
|
||||||
|
check("Letter upper rule distance", /w:after="227"/.test(paragraphs(doc)[0] ?? ""), "top red double rule is 4 mm below the agency mark");
|
||||||
check("Letter bottom rule footer", /rIdLetterFooter/.test(doc) && /w:footer="1134"/.test(doc), "bottom red double rule is anchored 20 mm from the paper edge");
|
check("Letter bottom rule footer", /rIdLetterFooter/.test(doc) && /w:footer="1134"/.test(doc), "bottom red double rule is anchored 20 mm from the paper edge");
|
||||||
} else {
|
} else {
|
||||||
check("Odd/even page fields", /w:instr="PAGE"/.test(footerOdd) && /w:instr="PAGE"/.test(footerEven), "odd and even page footers exist");
|
check("Odd/even page fields", /w:instr="PAGE"/.test(footerOdd) && /w:instr="PAGE"/.test(footerEven), "odd and even page footers exist");
|
||||||
check("Odd/even page positions", /w:jc w:val="right"/.test(footerOdd) && /w:jc w:val="left"/.test(footerEven), "odd right and even left");
|
check("Odd/even page positions", /w:jc w:val="right"/.test(footerOdd) && /w:jc w:val="left"/.test(footerEven), "odd right and even left");
|
||||||
|
check("Formal page-number distance", /w:footer="1134"/.test(doc), "page-number footer is 7 mm below the 225 mm type area");
|
||||||
}
|
}
|
||||||
if (profile === "formal") {
|
if (profile === "formal") {
|
||||||
if (args.letterhead === "digital") {
|
if (args.letterhead === "digital") {
|
||||||
@@ -122,22 +132,37 @@ if (profile === "formal") {
|
|||||||
const headerFieldCount = agencyIndex < 0 ? 0 : docParagraphs.slice(0, agencyIndex).filter((p) => /\d{6}|机密|秘密|特急|加急/.test(textOf(p))).length;
|
const headerFieldCount = agencyIndex < 0 ? 0 : docParagraphs.slice(0, agencyIndex).filter((p) => /\d{6}|机密|秘密|特急|加急/.test(textOf(p))).length;
|
||||||
const expectedAgencyBefore = 1984 - headerFieldCount * 560;
|
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)`);
|
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)`);
|
||||||
check("Two blank lines below red rule", titleParagraphs.some((p) => /w:before="1120"/.test(p)), "title paragraph reserves two 28-point grid lines below the red rule");
|
const docNoIndex = docParagraphs.findIndex((p) => /〔\d{4}〕[1-9]\d*号/.test(textOf(p)));
|
||||||
|
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");
|
||||||
|
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 (args.upward === "true") {
|
if (args.upward === "true") {
|
||||||
check("Upward document number and signer share one row", paragraphs(doc).some((p) => /签发人:/.test(textOf(p)) && /〔|\[|文号|发/.test(textOf(p))), "signer is in the same paragraph row as the document number");
|
check("Upward document number and signer share one row", paragraphs(doc).some((p) => /签发人:/.test(textOf(p)) && /〔|\[|文号|发/.test(textOf(p))), "signer is in the same paragraph row as the document number");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
check("Preprinted letterhead reserve", /w:before="\d{4,}"/.test(doc), "first-page top reserve is present");
|
check("Preprinted letterhead reserve", /w:before="\d{4,}"/.test(doc), "first-page top reserve is present");
|
||||||
check("No red drawing for preprinted letterhead", !/w:color w:val="FF0000"/.test(doc), "preprinted mode does not redraw red letterhead or rule");
|
const preprintedParagraphs = paragraphs(doc);
|
||||||
check("Title spacing below physical red rule", titleParagraphs.some((p) => /w:before="1120"/.test(p)), "title paragraph reserves two 28-point grid lines below the printed red rule");
|
const titleXmlStart = firstTitleIndex < 0 ? doc.length : doc.indexOf(preprintedParagraphs[firstTitleIndex]);
|
||||||
|
const preprintedHeaderXml = titleXmlStart < 0 ? doc : doc.slice(0, titleXmlStart);
|
||||||
|
check("No red drawing for preprinted letterhead", !/w:color w:val="FF0000"/.test(preprintedHeaderXml), "preprinted mode does not redraw red letterhead or rule");
|
||||||
|
check("Title spacing below physical red rule", hasExplicitTwoLineTitleGap, "title is preceded by two exact 28-point blank paragraphs below the printed red rule");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (profile === "letter") {
|
if (profile === "letter") {
|
||||||
check("Letter title spacing", titleParagraphs.some((p) => /w:before="1120"/.test(p)), "title paragraph reserves two 28-point grid lines below the red rule");
|
check("Letter title spacing", hasExplicitTwoLineTitleGap, "title is preceded by two exact 28-point blank paragraphs below the red rule");
|
||||||
|
const letterDocNo = paragraphs(doc).find((p) => /〔\d{4}〕[1-9]\d*号/.test(textOf(p)));
|
||||||
|
if (letterDocNo) check("Letter document number distance", /w:before="280"/.test(letterDocNo), "document number is 3号汉字高度7/8 below the first red double line");
|
||||||
}
|
}
|
||||||
if (profile === "command") {
|
if (profile === "command") {
|
||||||
check("Command agency-to-order spacing", /w:after="1120"/.test(paragraphs(doc)[0] ?? ""), "command agency mark is followed by two blank lines");
|
const commandParagraphs = paragraphs(doc);
|
||||||
check("Command order-to-body spacing", paragraphs(doc).slice(1).some((p) => /w:after="1120"/.test(p) && /w:jc w:val="center"/.test(p)), "command number is followed by two blank lines");
|
const commandAgencyIndex = commandParagraphs.findIndex((p) => /w:color w:val="FF0000"/.test(p));
|
||||||
|
const commandOrderIndex = commandParagraphs.findIndex((p, index) => index > commandAgencyIndex && /w:jc w:val="center"/.test(p) && /w:sz w:val="32"/.test(p) && !/w:color w:val="FF0000"/.test(p));
|
||||||
|
const agencyToOrder = commandAgencyIndex >= 0 && commandOrderIndex > commandAgencyIndex ? commandParagraphs.slice(commandAgencyIndex + 1, commandOrderIndex) : [];
|
||||||
|
check("Command agency-to-order spacing", commandOrderIndex < 0 || (agencyToOrder.length === 2 && agencyToOrder.every(isExplicitBlankGridLine)), "command agency mark is followed by two exact 28-point grid lines");
|
||||||
|
const commandTitleIndex = firstTitleIndex;
|
||||||
|
const orderToBody = commandOrderIndex >= 0 && commandTitleIndex > commandOrderIndex ? commandParagraphs.slice(commandOrderIndex + 1, commandTitleIndex) : [];
|
||||||
|
check("Command order-to-body spacing", commandTitleIndex < 0 || (orderToBody.length === 2 && orderToBody.every(isExplicitBlankGridLine)), "command number is followed by two exact 28-point grid lines");
|
||||||
}
|
}
|
||||||
if (profile === "minutes") {
|
if (profile === "minutes") {
|
||||||
const attendance = paragraphs(doc).filter((p) => /(?:出席|请假|列席):/.test(textOf(p)));
|
const attendance = paragraphs(doc).filter((p) => /(?:出席|请假|列席):/.test(textOf(p)));
|
||||||
@@ -148,6 +173,8 @@ 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)) {
|
||||||
const hasColophon = /抄送:|印发/.test(textOf(doc));
|
const hasColophon = /抄送:|印发/.test(textOf(doc));
|
||||||
check("Colophon line thickness model", !hasColophon || (redRules.includes("height:0.35mm") && redRules.includes("height:0.25mm")), "版记粗线0.35 mm、细线0.25 mm");
|
check("Colophon line thickness model", !hasColophon || (redRules.includes("height:0.35mm") && redRules.includes("height:0.25mm")), "版记粗线0.35 mm、细线0.25 mm");
|
||||||
|
if (hasColophon) check("Colophon bottom anchor", /w:vertAnchor="margin"[^>]*w:horzAnchor="margin"[^>]*w:tblpYSpec="bottom"/.test(doc), "版记浮动表锚定在最后一页版心底部且禁止重叠");
|
||||||
|
if (/抄送:/.test(textOf(doc))) check("Colophon continuation indent", /w:firstLine="-960"[^>]*w:left="1280"/.test(doc), "抄送回行与冒号后的首字对齐");
|
||||||
if (hasColophon) check("Colophon print-row right tab", /w:tab w:val="right"/.test(doc), "印发机关和日期使用右制表位");
|
if (hasColophon) check("Colophon print-row right tab", /w:tab w:val="right"/.test(doc), "印发机关和日期使用右制表位");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-4
@@ -24,7 +24,7 @@ unzip -p "$TMP_DIR/layout.docx" word/document.xml | grep -q 'w:pStyle w:val="Hea
|
|||||||
"$NODE" "$VERIFY" --input "$TMP_DIR/standard.docx" --profile formal --letterhead preprinted
|
"$NODE" "$VERIFY" --input "$TMP_DIR/standard.docx" --profile formal --letterhead preprinted
|
||||||
! unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例单位文件'
|
! unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例单位文件'
|
||||||
unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例发〔2026〕1号'
|
unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例发〔2026〕1号'
|
||||||
unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q 'w:before="1120"'
|
unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q 'w:snapToGrid w:val="false"'
|
||||||
|
|
||||||
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/digital.docx" --format formal --letterhead digital --org "示例单位文件" --doc-no "示例发〔2026〕1号" --title "电子红头测试" --page-number standard
|
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/digital.docx" --format formal --letterhead digital --org "示例单位文件" --doc-no "示例发〔2026〕1号" --title "电子红头测试" --page-number standard
|
||||||
"$NODE" "$VERIFY" --input "$TMP_DIR/digital.docx" --profile formal --letterhead digital
|
"$NODE" "$VERIFY" --input "$TMP_DIR/digital.docx" --profile formal --letterhead digital
|
||||||
@@ -32,7 +32,7 @@ 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:before="1984"'
|
||||||
unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'w:before="1120"'
|
unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'w:snapToGrid w:val="false"'
|
||||||
|
|
||||||
cat >"$TMP_DIR/attachment.md" <<'EOF'
|
cat >"$TMP_DIR/attachment.md" <<'EOF'
|
||||||
# 附件一:测试附件
|
# 附件一:测试附件
|
||||||
@@ -48,10 +48,31 @@ unzip -p "$TMP_DIR/upward.docx" word/document.xml | grep -q '签发人:'
|
|||||||
unzip -l "$TMP_DIR/letter.docx" | grep -q 'word/footerLetter.xml'
|
unzip -l "$TMP_DIR/letter.docx" | grep -q 'word/footerLetter.xml'
|
||||||
unzip -p "$TMP_DIR/letter.docx" word/footerLetter.xml | grep -q 'w:fill="FF0000"'
|
unzip -p "$TMP_DIR/letter.docx" word/footerLetter.xml | grep -q 'w:fill="FF0000"'
|
||||||
|
|
||||||
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/with-attachment.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --doc-no "示例发〔2026〕1号" --title "附件测试" --attachment-note "附件一:测试附件" --attachment-file "$TMP_DIR/attachment.md"
|
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/with-attachment.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --doc-no "示例发〔2026〕1号" --title "附件测试" --attachment-note "1. 测试附件" --attachment-file "$TMP_DIR/attachment.md"
|
||||||
unzip -p "$TMP_DIR/with-attachment.docx" word/document.xml | grep -q '附件一:测试附件'
|
unzip -p "$TMP_DIR/with-attachment.docx" word/document.xml | grep -q '附件:1. 测试附件'
|
||||||
unzip -p "$TMP_DIR/with-attachment.docx" word/document.xml | grep -q 'w:pageBreakBefore'
|
unzip -p "$TMP_DIR/with-attachment.docx" word/document.xml | grep -q 'w:pageBreakBefore'
|
||||||
|
|
||||||
|
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/command.docx" --format command --org "示例机关命令" --doc-no "第1号" --title "命令正文"
|
||||||
|
"$NODE" "$VERIFY" --input "$TMP_DIR/command.docx" --profile command
|
||||||
|
|
||||||
|
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/minutes.docx" --format minutes --org "示例机关纪要" --title "纪要正文" --attendees "张三、李四" --absent "王五"
|
||||||
|
"$NODE" "$VERIFY" --input "$TMP_DIR/minutes.docx" --profile minutes
|
||||||
|
|
||||||
|
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/seal-layout.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --title "签章位置测试" --sender "示例机关" --date "2026年9月10日" --seal-mode seal
|
||||||
|
unzip -p "$TMP_DIR/seal-layout.docx" word/document.xml | grep -q 'w:right="1280"'
|
||||||
|
|
||||||
|
if "$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/bad-doc-no.docx" --format formal --letterhead digital --org "示例单位文件" --doc-no "示例发〔2026〕01号" --title "非法文号" >"$TMP_DIR/bad-doc-no.out" 2>&1; then
|
||||||
|
echo "invalid document number unexpectedly succeeded" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q 'GENERATOR ERROR:' "$TMP_DIR/bad-doc-no.out"
|
||||||
|
|
||||||
|
if "$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/bad-date.docx" --format formal --letterhead preprinted --date "2026年09月10日" --title "非法日期" >"$TMP_DIR/bad-date.out" 2>&1; then
|
||||||
|
echo "invalid date unexpectedly succeeded" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q 'GENERATOR ERROR:' "$TMP_DIR/bad-date.out"
|
||||||
|
|
||||||
if "$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/strict.docx" --format formal --letterhead digital --org "示例单位文件" --title "严格字体测试" --require-standard-fonts >"$TMP_DIR/strict.out" 2>&1; then
|
if "$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/strict.docx" --format formal --letterhead digital --org "示例单位文件" --title "严格字体测试" --require-standard-fonts >"$TMP_DIR/strict.out" 2>&1; then
|
||||||
if ! fc-list -f '%{family}\n' | grep -Eq '方正小标宋简体|方正小标宋_GBK|FZXiaoBiaoSong'; then
|
if ! fc-list -f '%{family}\n' | grep -Eq '方正小标宋简体|方正小标宋_GBK|FZXiaoBiaoSong'; then
|
||||||
echo "strict font mode unexpectedly succeeded without a small-standard-title font" >&2
|
echo "strict font mode unexpectedly succeeded without a small-standard-title font" >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user