补齐公文特定格式与版头要素

This commit is contained in:
mizzlelover
2026-09-10 20:50:22 +08:00
parent 39360ac647
commit b21365a991
9 changed files with 247 additions and 52 deletions
+3 -1
View File
@@ -104,7 +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`。红线下标题会按版心网格预留两行,不会贴着分隔线;上行文的文号和签发人会在同一行编排。打开文档后,Word/WPS 的“引用→目录”可以按“标题1—标题4”生成并更新目录;是否显示第四级,在目录设置中选择。
信函、命令(令)和纪要走专用分支:信函生成170mm上粗下细、下页边20mm处上细下粗的两条红色双线,底线放在页脚并关闭页码;命令(令)落实机关标志下空二行、令号下空二行;纪要把“出席”“请假”“列席”标签设为黑体、人员名单设为仿宋。附件可用重复的 `--attachment-file` 另页生成“附件”标签、第三行标题和正文,并保持正式页码连续。
## 跨平台安装 ## 跨平台安装
+3 -1
View File
@@ -15,7 +15,7 @@ GB/T 9704-2012《党政机关公文格式》现行,2025-05-30 复审继续有
本工具只处理排版,不判断单位身份、授权状态或后续盖章方式。它会根据用户明确选择的版式模式应用对应的排版规则,不因“正式”“国企”“报告”或仅提供机构名称而擅自使用红头。 本工具只处理排版,不判断单位身份、授权状态或后续盖章方式。它会根据用户明确选择的版式模式应用对应的排版规则,不因“正式”“国企”“报告”或仅提供机构名称而擅自使用红头。
生成器可输出 A4、156mm×225mm 版心、三号仿宋正文、小标宋二号标题、四级标题层次、两字缩进,以及居中或单双页页码。四级标题均写入 Word/WPS 标题样式和大纲级别,便于后续插入、更新目录;文章总标题使用独立“公文标题”样式,不进入目录。 生成器可输出 A4、156mm×225mm 版心、三号仿宋正文、小标宋二号标题、四级标题层次、两字缩进,以及居中或单双页页码。四级标题均写入 Word/WPS 标题样式和大纲级别,便于后续插入、更新目录;文章总标题使用独立“公文标题”样式,不进入目录。上行文的文号和签发人在同一行编排;信函、命令(令)、纪要使用各自专用版式;重复传入 `--attachment-file` 时,附件会另页生成并保持页码连续。
`references/formal-checklist.md` 是可选的版式复核清单。涉及印章或签名章时,使用者可在 Word/WPS 中插入已取得的图片或完成实体盖章;本生成器不内置印章图像。 `references/formal-checklist.md` 是可选的版式复核清单。涉及印章或签名章时,使用者可在 Word/WPS 中插入已取得的图片或完成实体盖章;本生成器不内置印章图像。
@@ -37,6 +37,8 @@ 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 中核对。
Markdown 标题优先按序数识别:`一、``(一)``1.``1`;序数缺失时才按 Markdown 层级映射。正文、四级标题均左空二字,回行顶格。标题字体在运行机器缺失时会明确报告并使用可见替代字体;打开文件时仍须确认目标 Office/WPS 的实际字体,不把普通宋体视作小标宋的等价替代。 Markdown 标题优先按序数识别:`一、``(一)``1.``1`;序数缺失时才按 Markdown 层级映射。正文、四级标题均左空二字,回行顶格。标题字体在运行机器缺失时会明确报告并使用可见替代字体;打开文件时仍须确认目标 Office/WPS 的实际字体,不把普通宋体视作小标宋的等价替代。
## 验证 ## 验证
BIN
View File
Binary file not shown.
+6
View File
@@ -26,6 +26,12 @@
另一个以前看上去没问题的缺口也补了。四级标题原先有字体和字号,却没有真正写进 Word 和 WPS 的标题样式。现在标题一到标题四都有样式和大纲级别。后续插目录、更新目录、跳转章节,文件终于能把自己的结构说清楚。 另一个以前看上去没问题的缺口也补了。四级标题原先有字体和字号,却没有真正写进 Word 和 WPS 的标题样式。现在标题一到标题四都有样式和大纲级别。后续插目录、更新目录、跳转章节,文件终于能把自己的结构说清楚。
这次我又拿实际渲染图反看了一遍,发现“有红线”不等于“版式完成”。红线下面必须给标题留出两行版心网格,不能像旧图那样贴在一起。上行文的文号和签发人也不能上下分开,必须在同一行的左右区域排开。信函格式更不能拿普通红头分支凑数:上边缘30毫米、170毫米上粗下细双线、下页边20毫米处上细下粗双线,都写进了专用分支。命令和纪要的空行、字体也分别落到了生成结构里。
字体这件事我也不再含糊。小标宋体和仿宋体是公文版式的一部分,不是“差不多的宋体”。当前这台机器没有方正小标宋和标准仿宋,生成器会直接在终端点名提示,严格模式会停止生成。用替代字体做结构和分页验证可以,但不能把替代字体的文件说成已经达到最终打印标准。
附件也补上了可执行入口。给 `--attachment-file` 传入 Markdown,Skill 会在版记前另页排“附件”标签、第三行标题和附件正文,正式页码继续连排。横排表格、机关专用模板、印章和真实套打仍要拿到目标 Word/WPS 与打印条件下复核,这些不能靠一段 XML 假装已经完成。
这次也重做了校验逻辑。它会根据普通材料、正式发文、信函、命令、纪要分别检查,不再拿一套机械条件去误判所有文档。TraeWork 的安装包也重新打包,里面没有留下之前的审查记录。 这次也重做了校验逻辑。它会根据普通材料、正式发文、信函、命令、纪要分别检查,不再拿一套机械条件去误判所有文档。TraeWork 的安装包也重新打包,里面没有留下之前的审查记录。
我已经验证了普通稿、预印红头纸套打稿和完整电子红头稿的 DOCX 结构与 PDF 渲染,也跑过了 Codex、Claude Code、OpenCode、Trae Code、Kimi、TraeWork、WorkBuddy、ZCode 的安装测试。 我已经验证了普通稿、预印红头纸套打稿和完整电子红头稿的 DOCX 结构与 PDF 渲染,也跑过了 Codex、Claude Code、OpenCode、Trae Code、Kimi、TraeWork、WorkBuddy、ZCode 的安装测试。
+5 -1
View File
@@ -19,7 +19,7 @@
- [ ] 密级和保密期限、紧急程度适用时均为三号黑体,按份号、密级和期限、紧急程度顺序自上而下排列。 - [ ] 密级和保密期限、紧急程度适用时均为三号黑体,按份号、密级和期限、紧急程度顺序自上而下排列。
- [ ] 发文机关标志使用全称或规范简称,可加“文件”;居中、红色、推荐小标宋,上边缘距版心上边缘35mm。 - [ ] 发文机关标志使用全称或规范简称,可加“文件”;居中、红色、推荐小标宋,上边缘距版心上边缘35mm。
- [ ] 发文字号位于机关标志下空二行;年份为全称阿拉伯数字并使用六角括号〔〕,序号不加“第”、不补零、后加“号”。 - [ ] 发文字号位于机关标志下空二行;年份为全称阿拉伯数字并使用六角括号〔〕,序号不加“第”、不补零、后加“号”。
- [ ] 上行文文号左空一字;签发人同区右空一字。“签发人:”为仿宋三号,姓名为楷体三号;多签发人按机关顺序编排。 - [ ] 上行文文号左空一字;签发人与文号在同一行同一区域,右空一字。“签发人:”为仿宋三号,姓名为楷体三号;多签发人按机关顺序编排。
- [ ] 红色分隔线在文号下4mm处,与版心等宽,线高按模板核定(常用约0.35—0.5mm)。 - [ ] 红色分隔线在文号下4mm处,与版心等宽,线高按模板核定(常用约0.35—0.5mm)。
## 3. 主体 ## 3. 主体
@@ -33,6 +33,7 @@
- [ ] 署名、成文日期和印章已按“加盖印章/不加盖印章/签发人签名章/联合行文”之一完整处理;成文日期年月日用阿拉伯数字全称且月日不补零;没有空白印章或伪造印章。 - [ ] 署名、成文日期和印章已按“加盖印章/不加盖印章/签发人签名章/联合行文”之一完整处理;成文日期年月日用阿拉伯数字全称且月日不补零;没有空白印章或伪造印章。
- [ ] 附注适用时在成文日期下一行左空二字,以圆括号标注。 - [ ] 附注适用时在成文日期下一行左空二字,以圆括号标注。
- [ ] 附件另面编排、置于版记之前;左上第一行为“附件”及序号黑体三号,标题在第三行居中;与正文一起装订的附件页码连续。 - [ ] 附件另面编排、置于版记之前;左上第一行为“附件”及序号黑体三号,标题在第三行居中;与正文一起装订的附件页码连续。
- [ ] 使用 `--attachment-file` 生成的附件已逐个另页,附件标签在第一行、标题在第三行;无法一起装订时已人工补排发文字号和“附件”标识。
## 4. 版记、页码与特殊格式 ## 4. 版记、页码与特殊格式
@@ -41,4 +42,7 @@
- [ ] 印发机关和日期为四号仿宋,左空一字与右空一字;日期完整且不补零,后加“印发”。 - [ ] 印发机关和日期为四号仿宋,左空一字与右空一字;日期完整且不补零,后加“印发”。
- [ ] 页码为四号半角宋体阿拉伯数字,两侧各一字线,线距版心下边缘7mm;单页右空一字、双页左空一字;空白页、特定版记页和附件连续页码已按规则处理。 - [ ] 页码为四号半角宋体阿拉伯数字,两侧各一字线,线距版心下边缘7mm;单页右空一字、双页左空一字;空白页、特定版记页和附件连续页码已按规则处理。
- [ ] 横排表格、信函、命令(令)、纪要、联合行文已使用相应专用版式;不能用普通打印稿替代。 - [ ] 横排表格、信函、命令(令)、纪要、联合行文已使用相应专用版式;不能用普通打印稿替代。
- [ ] 信函格式已核对上边缘30mm、上粗下细双线、下页边20mm处上细下粗双线,双线长度均170mm;首页无页码且版记不画分隔线。
- [ ] 命令(令)格式已核对发文机关标志上边缘距版心20mm、令号下空二行、正文下空二行。
- [ ] 纪要格式已核对标志上边缘距版心35mm,出席/请假/列席标签为黑体,人员为仿宋。
- [ ] 计量单位、标点符号、数字分别按 GB 3100、GB 3101、GB 3102、GB/T 15834、GB/T 15835 复核。 - [ ] 计量单位、标点符号、数字分别按 GB 3100、GB 3101、GB 3102、GB/T 15834、GB/T 15835 复核。
+5 -2
View File
@@ -101,7 +101,7 @@
| 紧急程度 | 3号黑体,与份号、密级按顺序排列 | | 紧急程度 | 3号黑体,与份号、密级按顺序排列 |
| 发文机关标志 | 机关全称或规范简称,可加“文件”;红色小标宋,居中 | | 发文机关标志 | 机关全称或规范简称,可加“文件”;红色小标宋,居中 |
| 发文字号 | 年份全称、六角括号、序号不加“第”且不补零 | | 发文字号 | 年份全称、六角括号、序号不加“第”且不补零 |
| 上行文签发人 | “签发人:”仿宋三号,姓名楷体三号;与文号同编排 | | 上行文签发人 | “签发人:”仿宋三号,姓名楷体三号;必须与文号同一行同一区域编排 |
| 分隔线 | 文号下4mm,红色,与版心等宽;常用线高约0.35—0.5mm | | 分隔线 | 文号下4mm,红色,与版心等宽;常用线高约0.35—0.5mm |
版头位置和红色要素可按用户提供的字段和模板进一步调整。 版头位置和红色要素可按用户提供的字段和模板进一步调整。
@@ -134,7 +134,9 @@
- A4横排表格的页码位置与其他页一致;单双页表头方向按标准处理。 - A4横排表格的页码位置与其他页一致;单双页表头方向按标准处理。
- 信函、命令(令)、纪要均有专门格式,不能套用普通公文首页。 - 信函、命令(令)、纪要均有专门格式,不能套用普通公文首页。
- 信函首页不显示页码,版记规则也不同。 - 信函首页不显示页码,版记规则也不同。上边缘距纸边30mm;发文机关标志下4mm处为170mm上粗下细红色双线,距下页边20mm处为170mm上细下粗红色双线;标题与其上最后一个要素相距二行。
- 命令(令)发文机关标志上边缘距版心20mm,标志下空二行编排令号,令号下空二行编排正文。
- 纪要标志上边缘距版心35mm;“出席”“请假”“列席”标签用黑体,人员名单用仿宋。
- 特定格式必须使用对应模板或专用生成逻辑,不得以普通报告模板代替。 - 特定格式必须使用对应模板或专用生成逻辑,不得以普通报告模板代替。
## 九、技能能力边界 ## 九、技能能力边界
@@ -150,6 +152,7 @@
| 预印红头纸套打 | 是 | `--format formal --letterhead preprinted`,按纸样设置预留高度 | | 预印红头纸套打 | 是 | `--format formal --letterhead preprinted`,按纸样设置预留高度 |
| 完整电子红头 | 是 | `--format formal --letterhead digital` | | 完整电子红头 | 是 | `--format formal --letterhead digital` |
| 印章、签名章 | 文字版式之外 | 在 Word/WPS 中插入图片或实体盖章 | | 印章、签名章 | 文字版式之外 | 在 Word/WPS 中插入图片或实体盖章 |
| 附件 | 通过 `--attachment-file` 另页生成标签、标题和正文 | 无法一起装订时的发文字号标注、复杂附件版式仍需人工复核 |
| 版记 | 是 | `--cc``--print-org``--print-date` 适用时生成 | | 版记 | 是 | `--cc``--print-org``--print-date` 适用时生成 |
| 信函、命令、纪要 | 是 | `--format letter|command|minutes`,按实际字段生成 | | 信函、命令、纪要 | 是 | `--format letter|command|minutes`,按实际字段生成 |
+138 -34
View File
@@ -20,7 +20,41 @@ function installedFontFamilies() {
.filter(Boolean), .filter(Boolean),
); );
} catch { } catch {
return new Set(); const families = new Set();
const aliases = {
simfang: "FangSong",
simfangb: "FangSong_GB2312",
"方正小标宋简体": "方正小标宋简体",
"方正小标宋_GBK": "方正小标宋_GBK",
"fzxiaobiaosong-b05s": "FZXiaoBiaoSong-B05S",
"fzxiaobiaosong-b13s": "FZXiaoBiaoSong-B13S",
fangsong: "FangSong",
fangsong_gb2312: "FangSong_GB2312",
};
const roots = [
process.env.WINDIR ? path.join(process.env.WINDIR, "Fonts") : "",
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "Microsoft", "Windows", "Fonts") : "",
path.join(os.homedir(), "Library", "Fonts"),
"/Library/Fonts",
"/usr/share/fonts",
"/usr/local/share/fonts",
].filter(Boolean);
const walk = (dir) => {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.(?:ttf|ttc|otf)$/i.test(entry.name)) {
const stem = path.basename(entry.name, path.extname(entry.name));
families.add(stem);
const alias = aliases[stem.toLowerCase()];
if (alias) families.add(alias);
}
}
};
roots.forEach(walk);
return families;
} }
} }
@@ -62,7 +96,7 @@ function reportFontStatus(args) {
function usage() { function usage() {
console.log(`Usage: console.log(`Usage:
node generate_gongwen_docx.mjs --input source.md --output out.docx [--format ordinary|formal|letter|command|minutes] [--letterhead preprinted|digital] [--org 发文机关] [--doc-no 发文字号] [--title 标题] [--subtitle 副标题] [--to 主送机关] [--sender 落款] [--date 日期] [--page-number center|standard|none] [--require-standard-fonts] node generate_gongwen_docx.mjs --input source.md --output out.docx [--format ordinary|formal|letter|command|minutes] [--letterhead preprinted|digital] [--org 发文机关] [--doc-no 发文字号] [--title 标题] [--subtitle 副标题] [--to 主送机关] [--sender 落款] [--date 日期] [--attachment-note 附件说明] [--attachment-file attachment.md ...] [--page-number center|standard|none] [--require-standard-fonts]
Notes: Notes:
- Converts Markdown to a GB/T 9704-2012 page-layout DOCX. - Converts Markdown to a GB/T 9704-2012 page-layout DOCX.
@@ -71,13 +105,14 @@ Notes:
- 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. - --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.
- 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, and pipe tables. - Supports headings, paragraphs, ordered/unordered lines, pipe tables, and repeatable Markdown attachment pages via --attachment-file.
- No npm dependencies; requires zip in PATH.`); - No npm dependencies; requires zip in PATH.`);
} }
function parseArgs(argv) { function parseArgs(argv) {
const args = {}; const args = {};
const flags = new Set(["help", "no-page-number", "no-page-numbers", "require-standard-fonts"]); const flags = new Set(["help", "no-page-number", "no-page-numbers", "require-standard-fonts"]);
const repeatable = new Set(["attachment-file"]);
for (let i = 2; i < argv.length; i += 1) { for (let i = 2; i < argv.length; i += 1) {
const key = argv[i]; const key = argv[i];
if (!key.startsWith("--")) continue; if (!key.startsWith("--")) continue;
@@ -86,7 +121,11 @@ function parseArgs(argv) {
args[name] = true; args[name] = true;
continue; continue;
} }
if (repeatable.has(name)) {
args[name] = [...(args[name] ?? []), argv[i + 1]];
} else {
args[name] = argv[i + 1]; args[name] = argv[i + 1];
}
i += 1; i += 1;
} }
return args; return args;
@@ -196,6 +235,11 @@ function documentNumberParagraph(text, upward = false) {
}); });
} }
function upwardHeaderParagraph(docNo, signer) {
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>`;
}
function subtitleParagraph(text) { function subtitleParagraph(text) {
return paragraph(text, { return paragraph(text, {
align: "center", align: "center",
@@ -215,6 +259,11 @@ function mainSendParagraph(text) {
}); });
} }
function normalizeMainSend(text) {
const value = String(text ?? "").trim();
return value && /[:]$/.test(value) ? value : `${value}`;
}
function h1(text) { function h1(text) {
return paragraph(text, { return paragraph(text, {
align: "left", align: "left",
@@ -272,33 +321,44 @@ function right(text) {
} }
function redRule(thickness = "5") { function redRule(thickness = "5") {
const height = thickness === "5" ? "0.5mm" : "0.35mm"; 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>`;
} }
function mmToDxa(mm) {
return Math.round(Number(mm) * 1440 / 25.4);
}
function redDoubleRule({ upperMm = "0.35", lowerMm = "0.25", widthMm = "170" } = {}) {
const width = mmToDxa(widthMm);
const row = (heightMm) => `<w:tr><w:trPr><w:trHeight w:val="${mmToDxa(heightMm)}" w:hRule="exact"/></w:trPr><w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/><w:shd w:val="clear" w:color="FF0000" w:fill="FF0000"/><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><w:p><w:pPr><w:spacing w:before="0" w:after="0" w:line="1" w:lineRule="exact"/></w:pPr><w:r><w:rPr><w:sz w:val="2"/></w:rPr><w:t xml:space="preserve"> </w:t></w:r></w:p></w:tc></w:tr>`;
const spacer = `<w:tr><w:trPr><w:trHeight w:val="${mmToDxa("3")}" w:hRule="exact"/></w:trPr><w:tc><w:tcPr><w:tcW w:w="${width}" 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><w:p><w:pPr><w:spacing w:before="0" w:after="0" w:line="1" w:lineRule="exact"/></w:pPr></w:p></w:tc></w:tr>`;
return `<w:tbl><w:tblPr><w:tblW w:w="${width}" w:type="dxa"/><w:jc w:val="center"/><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><w:tblGrid><w:gridCol w:w="${width}"/></w:tblGrid>${row(upperMm)}${spacer}${row(lowerMm)}</w:tbl>`;
}
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>`;
}
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", after: opts.after ?? "0", left: opts.left, right: opts.right });
} }
function signerParagraph(name) {
return `<w:p><w:pPr><w:jc w:val="right"/><w:ind w:right="${FIRST_LINE_INDENT}"/><w:spacing w:before="0" 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("签发人:", { fontPreset: "body", size: "32" })}${run(name, { fontPreset: "h2", size: "32" })}</w:p>`;
}
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(`附件:${text}`, { align: "left", fontPreset: "body", size: "32", before: "560", firstLine: FIRST_LINE_INDENT, left: "0" });
} }
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("5")]; const body = [redRule("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(paragraph(`抄送:${args.cc.replace(/[。.]?$/, "")}`, { align: "left", indent: false, left: "320", right: "320", fontPreset: "body", size: "28" }));
if (args.cc) body.push(redRule("2")); if (args.cc) body.push(redRule("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: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: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(redRule("5")); body.push(redRule("thick"));
return body; return body;
} }
@@ -463,6 +523,25 @@ function renderParagraph(block) {
return paragraph(block.text); return paragraph(block.text);
} }
function attachmentPage(blocks, index, filePath) {
const attachmentBlocks = [...blocks];
let attachmentTitle = path.basename(filePath, path.extname(filePath));
if (attachmentBlocks[0]?.type === "heading") {
attachmentTitle = attachmentBlocks.shift().text;
}
const body = [
paragraph(`附件${index}`, { align: "left", indent: false, fontPreset: "h1", size: "32", pageBreakBefore: true, keepNext: true }),
emptyLine(),
titleParagraph(attachmentTitle),
];
for (const block of attachmentBlocks) {
if (block.type === "heading") body.push(renderHeading(block));
else if (block.type === "table") body.push(tableXml(block.headers, block.rows), emptyLine());
else body.push(renderParagraph(block));
}
return body.join("");
}
function resolvePageNumberMode(args, format) { function resolvePageNumberMode(args, format) {
if (args["no-page-number"] || args["no-page-numbers"]) return "none"; if (args["no-page-number"] || args["no-page-numbers"]) return "none";
if (["center", "standard", "none"].includes(args["page-number"])) return args["page-number"]; if (["center", "standard", "none"].includes(args["page-number"])) return args["page-number"];
@@ -491,33 +570,34 @@ function buildDocument(blocks, args) {
if (!mainSend && sourceBlocks[0]?.type === "paragraph" && looksLikeMainSend(sourceBlocks[0].text)) { if (!mainSend && sourceBlocks[0]?.type === "paragraph" && looksLikeMainSend(sourceBlocks[0].text)) {
mainSend = sourceBlocks.shift().text; mainSend = sourceBlocks.shift().text;
} }
if (mainSend) mainSend = normalizeMainSend(mainSend);
const upward = args["upward"] === "true" || args["upward"] === true; const upward = args["upward"] === "true" || args["upward"] === true;
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"]) 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, "1984")); if (letterhead === "digital" && args.org) body.push(agencyMarkParagraph(args.org, String(Math.max(0, 1984 - headerFieldCount * 560))));
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(reserve)); body.push(letterheadReserveParagraph(Math.max(37, reserve - headerFieldCount * 9.877)));
}
if (args["doc-no"]) body.push(documentNumberParagraph(args["doc-no"], upward));
if (upward && args.signer) {
body.push(signerParagraph(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));
if (letterhead === "digital" || args["preprinted-rule"] === "true") body.push(redRule()); if (letterhead === "digital" || args["preprinted-rule"] === "true") body.push(redRule());
} 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(redRule()); 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"]) body.push(headerFieldParagraph(args["doc-no"], "body", { align: "right" }));
} 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: "560" })); body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "0", after: "1120" }));
if (args["doc-no"]) body.push(paragraph(args["doc-no"], { align: "center", indent: false, fontPreset: "body", size: "32", after: "560" })); if (args["doc-no"]) body.push(paragraph(args["doc-no"], { align: "center", indent: false, fontPreset: "body", size: "32", after: "1120" }));
} 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纪要)");
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" }));
@@ -555,16 +635,25 @@ function buildDocument(blocks, args) {
} }
} }
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" }));
if (format === "minutes" && args.attendees) body.push(paragraph(`出席:${args.attendees}`, { align: "left", fontPreset: "body", size: "32", before: "560" })); const attachmentFiles = Array.isArray(args["attachment-file"]) ? args["attachment-file"] : args["attachment-file"] ? [args["attachment-file"]] : [];
if (format === "minutes" && args.absent) body.push(paragraph(`请假:${args.absent}`, { align: "left", fontPreset: "body", size: "32" })); attachmentFiles.forEach((filePath, index) => {
if (format === "minutes" && args.observers) body.push(paragraph(`列席:${args.observers}`, { align: "left", fontPreset: "body", size: "32" })); const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) throw new Error(`attachment file not found: ${filePath}`);
const attachmentBlocks = parseMarkdown(fs.readFileSync(absolutePath, "utf8"));
body.push(attachmentPage(attachmentBlocks, index + 1, absolutePath));
});
if (format === "minutes" && args.attendees) body.push(minutesPersonParagraph("出席", args.attendees, "560"));
if (format === "minutes" && args.absent) body.push(minutesPersonParagraph("请假", args.absent));
if (format === "minutes" && args.observers) body.push(minutesPersonParagraph("列席", args.observers));
if (format !== "letter") body.push(...colophon(args)); if (format !== "letter") body.push(...colophon(args));
const footerRefs = pageNumberMode === "standard" const letterFooter = format === "letter" ? '<w:footerReference w:type="default" r:id="rIdLetterFooter"/>' : "";
const footerRefs = letterFooter || (pageNumberMode === "standard"
? '<w:footerReference w:type="default" r:id="rIdFooterOdd"/><w:footerReference w:type="even" r:id="rIdFooterEven"/>' ? '<w:footerReference w:type="default" r:id="rIdFooterOdd"/><w:footerReference w:type="even" r:id="rIdFooterEven"/>'
: pageNumberMode === "center" : pageNumberMode === "center"
? '<w:footerReference w:type="default" r:id="rIdFooterCenter"/>' ? '<w:footerReference w:type="default" r:id="rIdFooterCenter"/>'
: ""; : "");
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="1588" w:gutter="0"/><w:docGrid w:type="lines" w:linePitch="560"/></w:sectPr>`); const footerDistance = format === "letter" ? "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>`);
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>`;
} }
@@ -618,13 +707,19 @@ function footerXml(align = "center") {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:pPr><w:jc w:val="${align}"/>${sideIndent}<w:spacing w:before="0" w:after="0"/></w:pPr><w:r>${pageRunProps}<w:t xml:space="preserve">— </w:t></w:r><w:fldSimple w:instr="PAGE"><w:r>${pageRunProps}<w:t>1</w:t></w:r></w:fldSimple><w:r>${pageRunProps}<w:t xml:space="preserve"> —</w:t></w:r></w:p></w:ftr>`; return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:pPr><w:jc w:val="${align}"/>${sideIndent}<w:spacing w:before="0" w:after="0"/></w:pPr><w:r>${pageRunProps}<w:t xml:space="preserve">— </w:t></w:r><w:fldSimple w:instr="PAGE"><w:r>${pageRunProps}<w:t>1</w:t></w:r></w:fldSimple><w:r>${pageRunProps}<w:t xml:space="preserve"> —</w:t></w:r></w:p></w:ftr>`;
} }
function letterFooterXml() {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">${redDoubleRule({ upperMm: "0.25", lowerMm: "0.35" })}</w:ftr>`;
}
function settingsXml(pageNumberMode) { function settingsXml(pageNumberMode) {
const evenOdd = pageNumberMode === "standard" ? "<w:evenAndOddHeaders/>" : ""; const evenOdd = pageNumberMode === "standard" ? "<w:evenAndOddHeaders/>" : "";
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">${evenOdd}<w:updateFields w:val="true"/></w:settings>`; return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">${evenOdd}<w:updateFields w:val="true"/></w:settings>`;
} }
function documentRelsXml(pageNumberMode) { function documentRelsXml(pageNumberMode, format) {
const footerRels = pageNumberMode === "standard" const footerRels = format === "letter"
? '<Relationship Id="rIdLetterFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footerLetter.xml"/>'
: pageNumberMode === "standard"
? '<Relationship Id="rIdFooterOdd" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footerOdd.xml"/><Relationship Id="rIdFooterEven" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footerEven.xml"/>' ? '<Relationship Id="rIdFooterOdd" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footerOdd.xml"/><Relationship Id="rIdFooterEven" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footerEven.xml"/>'
: pageNumberMode === "center" : pageNumberMode === "center"
? '<Relationship Id="rIdFooterCenter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footerCenter.xml"/>' ? '<Relationship Id="rIdFooterCenter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footerCenter.xml"/>'
@@ -632,24 +727,28 @@ function documentRelsXml(pageNumberMode) {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdFontTable" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/><Relationship Id="rIdSettings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>${footerRels}</Relationships>`; return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdFontTable" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/><Relationship Id="rIdSettings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>${footerRels}</Relationships>`;
} }
function writeDocx(output, documentXml, pageNumberMode) { function writeDocx(output, documentXml, pageNumberMode, format) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gongwen-docx-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gongwen-docx-"));
fs.mkdirSync(path.join(dir, "_rels"), { recursive: true }); fs.mkdirSync(path.join(dir, "_rels"), { recursive: true });
fs.mkdirSync(path.join(dir, "word"), { recursive: true }); fs.mkdirSync(path.join(dir, "word"), { recursive: true });
fs.mkdirSync(path.join(dir, "word/_rels"), { recursive: true }); fs.mkdirSync(path.join(dir, "word/_rels"), { recursive: true });
const footerTypes = pageNumberMode === "standard" const footerTypes = format === "letter"
? '<Override PartName="/word/footerLetter.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>'
: pageNumberMode === "standard"
? '<Override PartName="/word/footerOdd.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/><Override PartName="/word/footerEven.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>' ? '<Override PartName="/word/footerOdd.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/><Override PartName="/word/footerEven.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>'
: pageNumberMode === "center" : pageNumberMode === "center"
? '<Override PartName="/word/footerCenter.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>' ? '<Override PartName="/word/footerCenter.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>'
: ""; : "";
fs.writeFileSync(path.join(dir, "[Content_Types].xml"), `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/><Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>${footerTypes}</Types>`); fs.writeFileSync(path.join(dir, "[Content_Types].xml"), `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/><Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>${footerTypes}</Types>`);
fs.writeFileSync(path.join(dir, "_rels/.rels"), `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`); fs.writeFileSync(path.join(dir, "_rels/.rels"), `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`);
fs.writeFileSync(path.join(dir, "word/_rels/document.xml.rels"), documentRelsXml(pageNumberMode)); fs.writeFileSync(path.join(dir, "word/_rels/document.xml.rels"), documentRelsXml(pageNumberMode, format));
fs.writeFileSync(path.join(dir, "word/document.xml"), documentXml); fs.writeFileSync(path.join(dir, "word/document.xml"), documentXml);
fs.writeFileSync(path.join(dir, "word/styles.xml"), stylesXml()); fs.writeFileSync(path.join(dir, "word/styles.xml"), stylesXml());
fs.writeFileSync(path.join(dir, "word/fontTable.xml"), fontTableXml()); fs.writeFileSync(path.join(dir, "word/fontTable.xml"), fontTableXml());
fs.writeFileSync(path.join(dir, "word/settings.xml"), settingsXml(pageNumberMode)); fs.writeFileSync(path.join(dir, "word/settings.xml"), settingsXml(pageNumberMode));
if (pageNumberMode === "standard") { if (format === "letter") {
fs.writeFileSync(path.join(dir, "word/footerLetter.xml"), letterFooterXml());
} else if (pageNumberMode === "standard") {
fs.writeFileSync(path.join(dir, "word/footerOdd.xml"), footerXml("right")); fs.writeFileSync(path.join(dir, "word/footerOdd.xml"), footerXml("right"));
fs.writeFileSync(path.join(dir, "word/footerEven.xml"), footerXml("left")); fs.writeFileSync(path.join(dir, "word/footerEven.xml"), footerXml("left"));
} else if (pageNumberMode === "center") { } else if (pageNumberMode === "center") {
@@ -672,9 +771,14 @@ try {
process.exit(2); process.exit(2);
} }
const md = fs.readFileSync(args.input, "utf8"); const md = fs.readFileSync(args.input, "utf8");
try {
const blocks = parseMarkdown(md); const blocks = parseMarkdown(md);
const documentXml = buildDocument(blocks, args); const documentXml = buildDocument(blocks, args);
const format = ["ordinary", "formal", "letter", "command", "minutes"].includes(args.format) ? args.format : "ordinary"; const format = ["ordinary", "formal", "letter", "command", "minutes"].includes(args.format) ? args.format : "ordinary";
const pageNumberMode = resolvePageNumberMode(args, format); const pageNumberMode = resolvePageNumberMode(args, format);
writeDocx(path.resolve(args.output), documentXml, pageNumberMode); writeDocx(path.resolve(args.output), documentXml, pageNumberMode, format);
console.log(`Generated: ${path.resolve(args.output)}`); console.log(`Generated: ${path.resolve(args.output)}`);
} catch (error) {
console.error(`GENERATOR ERROR: ${error.message}`);
process.exit(2);
}
+59 -3
View File
@@ -2,6 +2,8 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os";
import path from "node:path";
function usage() { function usage() {
console.log("Usage: node verify_gongwen_docx.mjs --input file.docx [--profile ordinary|formal|letter|command|minutes] [--letterhead preprinted|digital] [--require-standard-fonts]"); console.log("Usage: node verify_gongwen_docx.mjs --input file.docx [--profile ordinary|formal|letter|command|minutes] [--letterhead preprinted|digital] [--require-standard-fonts]");
@@ -24,7 +26,7 @@ function parseArgs(argv) {
} }
function readPart(file, part, optional = false) { function readPart(file, part, optional = false) {
try { return execFileSync("unzip", ["-p", file, part], { encoding: "utf8" }); } try { return execFileSync("unzip", ["-p", file, part], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); }
catch (error) { if (optional) return ""; throw error; } catch (error) { if (optional) return ""; throw error; }
} }
@@ -40,7 +42,32 @@ const styles = readPart(args.input, "word/styles.xml");
const footerOdd = readPart(args.input, "word/footerOdd.xml", true); const footerOdd = readPart(args.input, "word/footerOdd.xml", true);
const footerEven = readPart(args.input, "word/footerEven.xml", true); const footerEven = readPart(args.input, "word/footerEven.xml", true);
const footerCenter = readPart(args.input, "word/footerCenter.xml", true); const footerCenter = readPart(args.input, "word/footerCenter.xml", true);
const footerLetter = readPart(args.input, "word/footerLetter.xml", true);
const requireStandardFonts = args["require-standard-fonts"] === true; const requireStandardFonts = args["require-standard-fonts"] === true;
const installedFonts = (() => {
try {
return new Set(execFileSync("fc-list", ["-f", "%{family}\n"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split(/\r?\n/).flatMap((line) => line.split(",")).map((font) => font.trim()).filter(Boolean));
} catch {
const families = new Set();
const aliases = { simfang: "FangSong", simfangb: "FangSong_GB2312", fangsong: "FangSong", fangsong_gb2312: "FangSong_GB2312", "fzxiaobiaosong-b05s": "FZXiaoBiaoSong-B05S", "fzxiaobiaosong-b13s": "FZXiaoBiaoSong-B13S" };
const roots = [process.env.WINDIR ? path.join(process.env.WINDIR, "Fonts") : "", process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "Microsoft", "Windows", "Fonts") : "", path.join(os.homedir(), "Library", "Fonts"), "/Library/Fonts", "/usr/share/fonts", "/usr/local/share/fonts"].filter(Boolean);
const walk = (dir) => {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.(?:ttf|ttc|otf)$/i.test(entry.name)) {
const stem = path.basename(entry.name, path.extname(entry.name));
families.add(stem);
if (aliases[stem.toLowerCase()]) families.add(aliases[stem.toLowerCase()]);
}
}
};
roots.forEach(walk);
return families;
}
})();
const checks = []; const checks = [];
function check(name, ok, detail) { checks.push({ name, ok, detail }); } function check(name, ok, detail) { checks.push({ name, ok, detail }); }
@@ -51,6 +78,10 @@ check("Document grid", /<w:docGrid[^>]*w:linePitch="560"/.test(doc), "28 pt impl
const bodyFontStandard = /w:eastAsia="(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312)"/.test(styles); const bodyFontStandard = /w:eastAsia="(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312)"/.test(styles);
const bodyFontAvailable = bodyFontStandard || (!requireStandardFonts && /w:eastAsia="STFangsong"/.test(styles)); const bodyFontAvailable = bodyFontStandard || (!requireStandardFonts && /w:eastAsia="STFangsong"/.test(styles));
check("Default body font", bodyFontAvailable && /<w:sz w:val="32"/.test(styles), requireStandardFonts ? "3rd-size FangSong is required" : "3rd-size FangSong requested; STFangsong fallback is allowed unless strict mode is enabled"); check("Default body font", bodyFontAvailable && /<w:sz w:val="32"/.test(styles), requireStandardFonts ? "3rd-size FangSong is required" : "3rd-size FangSong requested; STFangsong fallback is allowed unless strict mode is enabled");
if (requireStandardFonts) {
check("Installed small-standard-title font", ["方正小标宋简体", "方正小标宋_GBK", "FZXiaoBiaoSong-B05S", "FZXiaoBiaoSong-B13S"].some((font) => installedFonts.has(font)), "target machine must provide a small-standard-title font");
check("Installed FangSong font", ["仿宋", "仿宋_GB2312", "FangSong", "FangSong_GB2312"].some((font) => installedFonts.has(font)), "target machine must provide a standard FangSong font");
}
check("Heading styles", [1, 2, 3, 4].every((level) => new RegExp(`w:styleId="Heading${level}"`).test(styles)), "Heading 1 through Heading 4 are defined"); check("Heading styles", [1, 2, 3, 4].every((level) => new RegExp(`w:styleId="Heading${level}"`).test(styles)), "Heading 1 through Heading 4 are defined");
const textOf = (xml) => xml.replace(/<[^>]+>/g, ""); const textOf = (xml) => xml.replace(/<[^>]+>/g, "");
@@ -74,7 +105,9 @@ 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, "letter test output suppresses first-page page number"); 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 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");
@@ -83,8 +116,16 @@ if (profile === "formal") {
if (args.letterhead === "digital") { if (args.letterhead === "digital") {
check("Digital red head", /w:color w:val="FF0000"/.test(doc), "digital formal output contains requested red elements"); check("Digital red head", /w:color w:val="FF0000"/.test(doc), "digital formal output contains requested red elements");
check("Red rule thickness", /height:0\.5mm/.test(doc), "header separator uses the recommended 0.35-0.5 mm range"); check("Red rule thickness", /height:0\.5mm/.test(doc), "header separator uses the recommended 0.35-0.5 mm range");
check("Digital red-head agency position", /w:before="1984"/.test(doc), "agency mark starts 35 mm below the type-area top"); 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 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;
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("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"); 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");
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");
}
} 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"); check("No red drawing for preprinted letterhead", !/w:color w:val="FF0000"/.test(doc), "preprinted mode does not redraw red letterhead or rule");
@@ -94,6 +135,21 @@ if (profile === "formal") {
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", titleParagraphs.some((p) => /w:before="1120"/.test(p)), "title paragraph reserves two 28-point grid lines below the red rule");
} }
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");
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");
}
if (profile === "minutes") {
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 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");
}
const redRules = doc.match(/height:(?:0\.35|0\.25|0\.5)mm/g) ?? [];
if (["formal", "command", "minutes"].includes(profile)) {
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");
if (hasColophon) check("Colophon print-row right tab", /w:tab w:val="right"/.test(doc), "印发机关和日期使用右制表位");
}
for (const item of checks) console.log(`${item.ok ? "PASS" : "FAIL"} ${item.name}: ${item.detail}`); for (const item of checks) console.log(`${item.ok ? "PASS" : "FAIL"} ${item.name}: ${item.detail}`);
const failed = checks.filter((item) => !item.ok); const failed = checks.filter((item) => !item.ok);
+18
View File
@@ -34,6 +34,24 @@ 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:before="1120"'
cat >"$TMP_DIR/attachment.md" <<'EOF'
# 附件一:测试附件
附件正文。
EOF
"$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
unzip -p "$TMP_DIR/upward.docx" word/document.xml | grep -q '签发人:'
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/letter.docx" --format letter --org "示例机关" --doc-no "示例〔20261号" --title "信函测试"
"$NODE" "$VERIFY" --input "$TMP_DIR/letter.docx" --profile letter
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"'
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/with-attachment.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --doc-no "示例发〔20261号" --title "附件测试" --attachment-note "附件一:测试附件" --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 'w:pageBreakBefore'
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