diff --git a/README.md b/README.md
index 8031d02..0daa2cc 100644
--- a/README.md
+++ b/README.md
@@ -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
```
-只有需要完整电子红头文件时,才加 `--letterhead digital`。红头和标题要使用目标电脑中实际安装的小标宋体;生成器缺少小标宋体或仿宋体时会在终端明确警告并写出替代字体,要求严格阻止替代输出时加 `--require-standard-fonts`。红线下标题会按版心网格预留两行,不会贴着分隔线。打开文档后,Word/WPS 的“引用→目录”可以按“标题1—标题4”生成并更新目录;是否显示第四级,在目录设置中选择。
+只有需要完整电子红头文件时,才加 `--letterhead digital`。红头和标题要使用目标电脑中实际安装的小标宋体;生成器缺少小标宋体或仿宋体时会在终端明确警告并写出替代字体,要求严格阻止替代输出时加 `--require-standard-fonts`。红线下标题会按版心网格预留两行,不会贴着分隔线;上行文的文号和签发人会在同一行编排。打开文档后,Word/WPS 的“引用→目录”可以按“标题1—标题4”生成并更新目录;是否显示第四级,在目录设置中选择。
+
+信函、命令(令)和纪要走专用分支:信函生成170mm上粗下细、下页边20mm处上细下粗的两条红色双线,底线放在页脚并关闭页码;命令(令)落实机关标志下空二行、令号下空二行;纪要把“出席”“请假”“列席”标签设为黑体、人员名单设为仿宋。附件可用重复的 `--attachment-file` 另页生成“附件”标签、第三行标题和正文,并保持正式页码连续。
## 跨平台安装
diff --git a/SKILL.md b/SKILL.md
index fab4d04..728913b 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -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 中插入已取得的图片或完成实体盖章;本生成器不内置印章图像。
@@ -37,6 +37,8 @@ node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --
`letter`、`command`、`minutes` 分别对应信函、命令(令)、纪要格式;需要时显式传入 `--format` 和相应字段。正式发文默认单双页页码;`standard` 为单页右、双页左,`center` 与 `none` 供普通材料选择。
+附件可重复传入 `--attachment-file attachment.md`。附件 Markdown 的首个一级标题会作为附件标题,第一页第一行写“附件1”(后续文件递增),第三行写标题,随后排正文;附件说明仍通过 `--attachment-note` 提供。复杂横排附件、不能与正文一起装订的附件和机关专用模板仍需在目标 Word/WPS 中核对。
+
Markdown 标题优先按序数识别:`一、`、`(一)`、`1.`、`(1)`;序数缺失时才按 Markdown 层级映射。正文、四级标题均左空二字,回行顶格。标题字体在运行机器缺失时会明确报告并使用可见替代字体;打开文件时仍须确认目标 Office/WPS 的实际字体,不把普通宋体视作小标宋的等价替代。
## 验证
diff --git a/dist/traework-gongwen-skill.zip b/dist/traework-gongwen-skill.zip
index 730e73b..f629e5d 100644
Binary files a/dist/traework-gongwen-skill.zip and b/dist/traework-gongwen-skill.zip differ
diff --git a/marketing/x-v2-release.md b/marketing/x-v2-release.md
index 1bd8df1..fe82b19 100644
--- a/marketing/x-v2-release.md
+++ b/marketing/x-v2-release.md
@@ -26,6 +26,12 @@
另一个以前看上去没问题的缺口也补了。四级标题原先有字体和字号,却没有真正写进 Word 和 WPS 的标题样式。现在标题一到标题四都有样式和大纲级别。后续插目录、更新目录、跳转章节,文件终于能把自己的结构说清楚。
+这次我又拿实际渲染图反看了一遍,发现“有红线”不等于“版式完成”。红线下面必须给标题留出两行版心网格,不能像旧图那样贴在一起。上行文的文号和签发人也不能上下分开,必须在同一行的左右区域排开。信函格式更不能拿普通红头分支凑数:上边缘30毫米、170毫米上粗下细双线、下页边20毫米处上细下粗双线,都写进了专用分支。命令和纪要的空行、字体也分别落到了生成结构里。
+
+字体这件事我也不再含糊。小标宋体和仿宋体是公文版式的一部分,不是“差不多的宋体”。当前这台机器没有方正小标宋和标准仿宋,生成器会直接在终端点名提示,严格模式会停止生成。用替代字体做结构和分页验证可以,但不能把替代字体的文件说成已经达到最终打印标准。
+
+附件也补上了可执行入口。给 `--attachment-file` 传入 Markdown,Skill 会在版记前另页排“附件”标签、第三行标题和附件正文,正式页码继续连排。横排表格、机关专用模板、印章和真实套打仍要拿到目标 Word/WPS 与打印条件下复核,这些不能靠一段 XML 假装已经完成。
+
这次也重做了校验逻辑。它会根据普通材料、正式发文、信函、命令、纪要分别检查,不再拿一套机械条件去误判所有文档。TraeWork 的安装包也重新打包,里面没有留下之前的审查记录。
我已经验证了普通稿、预印红头纸套打稿和完整电子红头稿的 DOCX 结构与 PDF 渲染,也跑过了 Codex、Claude Code、OpenCode、Trae Code、Kimi、TraeWork、WorkBuddy、ZCode 的安装测试。
diff --git a/references/formal-checklist.md b/references/formal-checklist.md
index 4850144..b886832 100644
--- a/references/formal-checklist.md
+++ b/references/formal-checklist.md
@@ -19,7 +19,7 @@
- [ ] 密级和保密期限、紧急程度适用时均为三号黑体,按份号、密级和期限、紧急程度顺序自上而下排列。
- [ ] 发文机关标志使用全称或规范简称,可加“文件”;居中、红色、推荐小标宋,上边缘距版心上边缘35mm。
- [ ] 发文字号位于机关标志下空二行;年份为全称阿拉伯数字并使用六角括号〔〕,序号不加“第”、不补零、后加“号”。
-- [ ] 上行文文号左空一字;签发人同区右空一字。“签发人:”为仿宋三号,姓名为楷体三号;多签发人按机关顺序编排。
+- [ ] 上行文文号左空一字;签发人与文号在同一行同一区域,右空一字。“签发人:”为仿宋三号,姓名为楷体三号;多签发人按机关顺序编排。
- [ ] 红色分隔线在文号下4mm处,与版心等宽,线高按模板核定(常用约0.35—0.5mm)。
## 3. 主体
@@ -33,6 +33,7 @@
- [ ] 署名、成文日期和印章已按“加盖印章/不加盖印章/签发人签名章/联合行文”之一完整处理;成文日期年月日用阿拉伯数字全称且月日不补零;没有空白印章或伪造印章。
- [ ] 附注适用时在成文日期下一行左空二字,以圆括号标注。
- [ ] 附件另面编排、置于版记之前;左上第一行为“附件”及序号黑体三号,标题在第三行居中;与正文一起装订的附件页码连续。
+- [ ] 使用 `--attachment-file` 生成的附件已逐个另页,附件标签在第一行、标题在第三行;无法一起装订时已人工补排发文字号和“附件”标识。
## 4. 版记、页码与特殊格式
@@ -41,4 +42,7 @@
- [ ] 印发机关和日期为四号仿宋,左空一字与右空一字;日期完整且不补零,后加“印发”。
- [ ] 页码为四号半角宋体阿拉伯数字,两侧各一字线,线距版心下边缘7mm;单页右空一字、双页左空一字;空白页、特定版记页和附件连续页码已按规则处理。
- [ ] 横排表格、信函、命令(令)、纪要、联合行文已使用相应专用版式;不能用普通打印稿替代。
+- [ ] 信函格式已核对上边缘30mm、上粗下细双线、下页边20mm处上细下粗双线,双线长度均170mm;首页无页码且版记不画分隔线。
+- [ ] 命令(令)格式已核对发文机关标志上边缘距版心20mm、令号下空二行、正文下空二行。
+- [ ] 纪要格式已核对标志上边缘距版心35mm,出席/请假/列席标签为黑体,人员为仿宋。
- [ ] 计量单位、标点符号、数字分别按 GB 3100、GB 3101、GB 3102、GB/T 15834、GB/T 15835 复核。
diff --git a/references/gbt9704-2012-summary.md b/references/gbt9704-2012-summary.md
index 69d1872..a8cebc6 100644
--- a/references/gbt9704-2012-summary.md
+++ b/references/gbt9704-2012-summary.md
@@ -101,7 +101,7 @@
| 紧急程度 | 3号黑体,与份号、密级按顺序排列 |
| 发文机关标志 | 机关全称或规范简称,可加“文件”;红色小标宋,居中 |
| 发文字号 | 年份全称、六角括号、序号不加“第”且不补零 |
-| 上行文签发人 | “签发人:”仿宋三号,姓名楷体三号;与文号同区编排 |
+| 上行文签发人 | “签发人:”仿宋三号,姓名楷体三号;必须与文号同一行同一区域编排 |
| 分隔线 | 文号下4mm,红色,与版心等宽;常用线高约0.35—0.5mm |
版头位置和红色要素可按用户提供的字段和模板进一步调整。
@@ -134,7 +134,9 @@
- A4横排表格的页码位置与其他页一致;单双页表头方向按标准处理。
- 信函、命令(令)、纪要均有专门格式,不能套用普通公文首页。
-- 信函首页不显示页码,版记规则也不同。
+- 信函首页不显示页码,版记规则也不同。上边缘距纸边30mm;发文机关标志下4mm处为170mm上粗下细红色双线,距下页边20mm处为170mm上细下粗红色双线;标题与其上最后一个要素相距二行。
+- 命令(令)发文机关标志上边缘距版心20mm,标志下空二行编排令号,令号下空二行编排正文。
+- 纪要标志上边缘距版心35mm;“出席”“请假”“列席”标签用黑体,人员名单用仿宋。
- 特定格式必须使用对应模板或专用生成逻辑,不得以普通报告模板代替。
## 九、技能能力边界
@@ -150,6 +152,7 @@
| 预印红头纸套打 | 是 | `--format formal --letterhead preprinted`,按纸样设置预留高度 |
| 完整电子红头 | 是 | `--format formal --letterhead digital` |
| 印章、签名章 | 文字版式之外 | 在 Word/WPS 中插入图片或实体盖章 |
+| 附件 | 通过 `--attachment-file` 另页生成标签、标题和正文 | 无法一起装订时的发文字号标注、复杂附件版式仍需人工复核 |
| 版记 | 是 | `--cc`、`--print-org`、`--print-date` 适用时生成 |
| 信函、命令、纪要 | 是 | `--format letter|command|minutes`,按实际字段生成 |
diff --git a/scripts/generate_gongwen_docx.mjs b/scripts/generate_gongwen_docx.mjs
index a12c286..89410e4 100755
--- a/scripts/generate_gongwen_docx.mjs
+++ b/scripts/generate_gongwen_docx.mjs
@@ -20,7 +20,41 @@ function installedFontFamilies() {
.filter(Boolean),
);
} 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() {
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:
- 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.
- --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.
- - 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.`);
}
function parseArgs(argv) {
const args = {};
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) {
const key = argv[i];
if (!key.startsWith("--")) continue;
@@ -86,7 +121,11 @@ function parseArgs(argv) {
args[name] = true;
continue;
}
- args[name] = argv[i + 1];
+ if (repeatable.has(name)) {
+ args[name] = [...(args[name] ?? []), argv[i + 1]];
+ } else {
+ args[name] = argv[i + 1];
+ }
i += 1;
}
return args;
@@ -196,6 +235,11 @@ function documentNumberParagraph(text, upward = false) {
});
}
+function upwardHeaderParagraph(docNo, signer) {
+ const tabPosition = CONTENT_W - FIRST_LINE_INDENT;
+ return `${run(docNo, { fontPreset: "body", size: "32" })}${run("签发人:", { fontPreset: "body", size: "32" })}${run(signer, { fontPreset: "h2", size: "32" })}`;
+}
+
function subtitleParagraph(text) {
return paragraph(text, {
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) {
return paragraph(text, {
align: "left",
@@ -272,33 +321,44 @@ function right(text) {
}
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 ``;
}
+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) => ` `;
+ const spacer = ``;
+ return `${row(upperMm)}${spacer}${row(lowerMm)}`;
+}
+
+function minutesPersonParagraph(label, people, before = "0") {
+ return `${run(`${label}:`, { fontPreset: "h1", size: "32" })}${run(people, { fontPreset: "body", size: "32" })}`;
+}
+
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 });
}
-function signerParagraph(name) {
- return `${run("签发人:", { fontPreset: "body", size: "32" })}${run(name, { fontPreset: "h2", size: "32" })}`;
-}
-
function attachmentNote(text) {
return paragraph(`附件:${text}`, { align: "left", fontPreset: "body", size: "32", before: "560", firstLine: FIRST_LINE_INDENT, left: "0" });
}
function colophon(args) {
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(redRule("2"));
+ if (args.cc) body.push(redRule("thin"));
if (args["print-org"] || args["print-date"]) {
const left = args["print-org"] ?? "";
const rightValue = args["print-date"] ? `${args["print-date"].replace(/印发$/, "")}印发` : "";
- body.push(`${run(left, { fontPreset: "body", size: "28" })}${run(rightValue, { fontPreset: "body", size: "28" })}`);
+ body.push(`${run(left, { fontPreset: "body", size: "28" })}${run(rightValue, { fontPreset: "body", size: "28" })}`);
}
- body.push(redRule("5"));
+ body.push(redRule("thick"));
return body;
}
@@ -463,6 +523,25 @@ function renderParagraph(block) {
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) {
if (args["no-page-number"] || args["no-page-numbers"]) return "none";
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)) {
mainSend = sourceBlocks.shift().text;
}
+ if (mainSend) mainSend = normalizeMainSend(mainSend);
const upward = args["upward"] === "true" || args["upward"] === true;
const formal = format === "formal";
if (formal) {
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.secret) body.push(headerFieldParagraph(args.secret));
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") {
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");
- body.push(letterheadReserveParagraph(reserve));
- }
- if (args["doc-no"]) body.push(documentNumberParagraph(args["doc-no"], upward));
- if (upward && args.signer) {
- body.push(signerParagraph(args.signer));
+ body.push(letterheadReserveParagraph(Math.max(37, reserve - headerFieldCount * 9.877)));
}
+ 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());
} else if (format === "letter") {
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(redRule());
+ body.push(redDoubleRule({ upperMm: "0.35", lowerMm: "0.25" }));
if (args["doc-no"]) body.push(headerFieldParagraph(args["doc-no"], "body", { align: "right" }));
} else if (format === "command") {
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" }));
- if (args["doc-no"]) body.push(paragraph(args["doc-no"], { align: "center", indent: false, fontPreset: "body", size: "32", 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: "1120" }));
} else if (format === "minutes") {
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" }));
@@ -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 (format === "minutes" && args.attendees) body.push(paragraph(`出席:${args.attendees}`, { align: "left", fontPreset: "body", size: "32", before: "560" }));
- if (format === "minutes" && args.absent) body.push(paragraph(`请假:${args.absent}`, { align: "left", fontPreset: "body", size: "32" }));
- if (format === "minutes" && args.observers) body.push(paragraph(`列席:${args.observers}`, { align: "left", fontPreset: "body", size: "32" }));
+ const attachmentFiles = Array.isArray(args["attachment-file"]) ? args["attachment-file"] : args["attachment-file"] ? [args["attachment-file"]] : [];
+ attachmentFiles.forEach((filePath, index) => {
+ 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));
- const footerRefs = pageNumberMode === "standard"
+ const letterFooter = format === "letter" ? '' : "";
+ const footerRefs = letterFooter || (pageNumberMode === "standard"
? ''
: pageNumberMode === "center"
? ''
- : "";
- body.push(`${footerRefs}`);
+ : "");
+ const footerDistance = format === "letter" ? "1134" : "1588";
+ body.push(`${footerRefs}`);
return `${body.join("")}`;
}
@@ -618,38 +707,48 @@ function footerXml(align = "center") {
return `${sideIndent}${pageRunProps}— ${pageRunProps}1${pageRunProps} —`;
}
+function letterFooterXml() {
+ return `${redDoubleRule({ upperMm: "0.25", lowerMm: "0.35" })}`;
+}
+
function settingsXml(pageNumberMode) {
const evenOdd = pageNumberMode === "standard" ? "" : "";
return `${evenOdd}`;
}
-function documentRelsXml(pageNumberMode) {
- const footerRels = pageNumberMode === "standard"
+function documentRelsXml(pageNumberMode, format) {
+ const footerRels = format === "letter"
+ ? ''
+ : pageNumberMode === "standard"
? ''
- : pageNumberMode === "center"
- ? ''
+ : pageNumberMode === "center"
+ ? ''
: "";
return `${footerRels}`;
}
-function writeDocx(output, documentXml, pageNumberMode) {
+function writeDocx(output, documentXml, pageNumberMode, format) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gongwen-docx-"));
fs.mkdirSync(path.join(dir, "_rels"), { recursive: true });
fs.mkdirSync(path.join(dir, "word"), { recursive: true });
fs.mkdirSync(path.join(dir, "word/_rels"), { recursive: true });
- const footerTypes = pageNumberMode === "standard"
+ const footerTypes = format === "letter"
+ ? ''
+ : pageNumberMode === "standard"
? ''
- : pageNumberMode === "center"
- ? ''
+ : pageNumberMode === "center"
+ ? ''
: "";
fs.writeFileSync(path.join(dir, "[Content_Types].xml"), `${footerTypes}`);
fs.writeFileSync(path.join(dir, "_rels/.rels"), ``);
- 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/styles.xml"), stylesXml());
fs.writeFileSync(path.join(dir, "word/fontTable.xml"), fontTableXml());
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/footerEven.xml"), footerXml("left"));
} else if (pageNumberMode === "center") {
@@ -672,9 +771,14 @@ try {
process.exit(2);
}
const md = fs.readFileSync(args.input, "utf8");
-const blocks = parseMarkdown(md);
-const documentXml = buildDocument(blocks, args);
-const format = ["ordinary", "formal", "letter", "command", "minutes"].includes(args.format) ? args.format : "ordinary";
-const pageNumberMode = resolvePageNumberMode(args, format);
-writeDocx(path.resolve(args.output), documentXml, pageNumberMode);
-console.log(`Generated: ${path.resolve(args.output)}`);
+try {
+ const blocks = parseMarkdown(md);
+ const documentXml = buildDocument(blocks, args);
+ const format = ["ordinary", "formal", "letter", "command", "minutes"].includes(args.format) ? args.format : "ordinary";
+ const pageNumberMode = resolvePageNumberMode(args, format);
+ writeDocx(path.resolve(args.output), documentXml, pageNumberMode, format);
+ console.log(`Generated: ${path.resolve(args.output)}`);
+} catch (error) {
+ console.error(`GENERATOR ERROR: ${error.message}`);
+ process.exit(2);
+}
diff --git a/scripts/verify_gongwen_docx.mjs b/scripts/verify_gongwen_docx.mjs
index f1e26e3..50b05a0 100755
--- a/scripts/verify_gongwen_docx.mjs
+++ b/scripts/verify_gongwen_docx.mjs
@@ -2,6 +2,8 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
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]");
@@ -24,7 +26,7 @@ function parseArgs(argv) {
}
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; }
}
@@ -40,7 +42,32 @@ const styles = readPart(args.input, "word/styles.xml");
const footerOdd = readPart(args.input, "word/footerOdd.xml", true);
const footerEven = readPart(args.input, "word/footerEven.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 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 = [];
function check(name, ok, detail) { checks.push({ name, ok, detail }); }
@@ -51,6 +78,10 @@ check("Document grid", /]*w:linePitch="560"/.test(doc), "28 pt impl
const bodyFontStandard = /w:eastAsia="(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312)"/.test(styles);
const bodyFontAvailable = bodyFontStandard || (!requireStandardFonts && /w:eastAsia="STFangsong"/.test(styles));
check("Default body font", bodyFontAvailable && / 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");
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("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") {
- 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 {
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");
@@ -83,8 +116,16 @@ if (profile === "formal") {
if (args.letterhead === "digital") {
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("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");
+ 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 {
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");
@@ -94,6 +135,21 @@ if (profile === "formal") {
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");
}
+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}`);
const failed = checks.filter((item) => !item.ok);
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
index d3e8036..f295a9c 100755
--- a/tests/run_tests.sh
+++ b/tests/run_tests.sh
@@ -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="1120"'
+cat >"$TMP_DIR/attachment.md" <<'EOF'
+# 附件一:测试附件
+
+附件正文。
+EOF
+"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/upward.docx" --format formal --letterhead digital --org "示例单位文件" --doc-no "示例发〔2026〕1号" --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 "示例〔2026〕1号" --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 "示例发〔2026〕1号" --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 ! fc-list -f '%{family}\n' | grep -Eq '方正小标宋简体|方正小标宋_GBK|FZXiaoBiaoSong'; then
echo "strict font mode unexpectedly succeeded without a small-standard-title font" >&2