diff --git a/README.md b/README.md
index 417f42d..1fde7dc 100644
--- a/README.md
+++ b/README.md
@@ -75,21 +75,32 @@ https://std.samr.gov.cn/gb/search/gbDetailed?id=lOIe27f77QU%3D&mode=p
| 场景 | 本项目的处理方式 |
| --- | --- |
| 文档主体 | A4、版心、标题、正文、层次、署名和日期排版 |
-| 机构名称与文号 | 使用 `--org`、`--doc-no` 按输入排版 |
+| 普通报告、方案、汇报 | 默认 `ordinary`,不因机构名称自动使用红头 |
+| 正式发文与红头纸 | 显式 `formal`;默认预印红头纸套打,首面留白、不重绘红色机关标志和红线 |
+| 完整电子红头 | 显式 `formal --letterhead digital`,才在 DOCX 中绘制红头要素 |
+| 机构名称与文号 | 使用 `--org`、`--doc-no` 按输入排版;文号仅在相应版式中定位 |
+| 标题与目录 | 四级标题写入 Word/WPS 标题样式及大纲级别,可插入自动目录 |
| 页码 | 居中、单双页或不显示 |
| 印章、签名章 | 在生成的 DOCX 中按实际需要插入图片或完成实体盖章 |
-| 特定格式 | 可在此版式基础上继续编辑,或使用对应模板 |
+| 特定格式 | `letter`、`command`、`minutes` 使用对应生成分支 |
GB/T 9704-2012 当前为现行标准,2025-05-30 复审继续有效。标准适用于党政机关制发公文,其他机关和单位可以参照执行。[全国标准信息公共服务平台](https://std.samr.gov.cn/gb/search/gbDetailed?id=lOIe27f77QU%3D&mode=p)
## 快速开始
```bash
-node scripts/generate_gongwen_docx.mjs --input tests/fixture.md --output /tmp/gongwen.docx --org "示例单位文件" --doc-no "示例发〔2026〕1号" --title "公文格式回归测试" --page-number standard
-node scripts/verify_gongwen_docx.mjs --input /tmp/gongwen.docx --profile standard-pages
+node scripts/generate_gongwen_docx.mjs --input tests/fixture.md --output /tmp/gongwen.docx --format ordinary --title "公文格式回归测试"
+node scripts/verify_gongwen_docx.mjs --input /tmp/gongwen.docx --profile ordinary
```
-然后用 Word 或 WPS 打开,并转换 PDF 检查标题、正文、表格、分页和页码。生成器支持 `center`、`standard` 和 `none` 页码模式,机构名称和文号按输入写入文档。
+需要正式发文时,必须明确选择版式。下面的命令生成预印红头纸套打稿;首页只留出纸上已有红头和红线区域,DOCX 不会重绘它们。`72` 是默认预留高度,应按本单位实际红头纸改为 `37—130` 毫米。
+
+```bash
+node scripts/generate_gongwen_docx.mjs --input tests/fixture.md --output /tmp/formal.docx --format formal --letterhead preprinted --letterhead-reserve-mm 72 --org "示例单位文件" --doc-no "示例发〔2026〕1号" --title "公文格式回归测试"
+node scripts/verify_gongwen_docx.mjs --input /tmp/formal.docx --profile formal --letterhead preprinted
+```
+
+只有需要完整电子红头文件时,才加 `--letterhead digital`。打开文档后,Word/WPS 的“引用→目录”可以按“标题1—标题4”生成并更新目录;是否显示第四级,在目录设置中选择。
## 跨平台安装
diff --git a/SKILL.md b/SKILL.md
index 1d41784..dec4b6f 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -13,19 +13,29 @@ GB/T 9704-2012《党政机关公文格式》现行,2025-05-30 复审继续有
## 工具定位
-本工具只处理排版。它不判断用户材料的用途、单位身份、授权状态或后续盖章方式,也不推断用户未提供的要素。机构名称、文号、主送机关、署名、日期和页码模式均按用户输入排入 DOCX。
+本工具只处理排版,不判断单位身份、授权状态或后续盖章方式。它会根据用户明确选择的版式模式应用对应的排版规则,不因“正式”“国企”“报告”或仅提供机构名称而擅自使用红头。
-生成器可输出 A4、156mm×225mm 版心、三号仿宋正文、小标宋二号标题、四级标题层次、两字缩进,以及居中或单双页页码。
+生成器可输出 A4、156mm×225mm 版心、三号仿宋正文、小标宋二号标题、四级标题层次、两字缩进,以及居中或单双页页码。四级标题均写入 Word/WPS 标题样式和大纲级别,便于后续插入、更新目录;文章总标题使用独立“公文标题”样式,不进入目录。
`references/formal-checklist.md` 是可选的版式复核清单。涉及印章或签名章时,使用者可在 Word/WPS 中插入已取得的图片或完成实体盖章;本生成器不内置印章图像。
## 生成打印稿
```bash
-node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --org "发文机关名称" --doc-no "单位发〔2026〕1号" --title "文档标题" --sender "落款单位" --date "2026年9月9日" --page-number standard
+node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --format ordinary --title "文档标题"
```
-支持 `center`、`standard` 与 `none` 三种页码模式,默认 `center`。`standard` 使用单页右、双页左的页码位置。`--org` 以红色居中机构名称排版,`--doc-no` 在其下方排版。
+普通材料使用 `--format ordinary`,默认居中页码,不绘制红头。正式发文须显式使用 `--format formal`;其默认 `--letterhead preprinted`,即首面预留红头纸区域、不在 DOCX 中重绘纸上已有的红色机关标志和红线。`--doc-no` 等本次需打印的黑色变量仍可保留。只有明确要求完整电子版时使用 `--letterhead digital`。
+
+```bash
+# 预印红头纸套打;72mm 是默认机关标志区域高度,可按实际纸样调为 37—130mm
+node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --format formal --letterhead preprinted --letterhead-reserve-mm 72 --org "发文机关名称" --doc-no "单位发〔2026〕1号" --title "文档标题" --sender "落款单位" --date "2026年9月9日"
+
+# 完整电子红头版,仅在明确要求时使用
+node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --format formal --letterhead digital --org "发文机关名称" --doc-no "单位发〔2026〕1号" --title "文档标题"
+```
+
+`letter`、`command`、`minutes` 分别对应信函、命令(令)、纪要格式;需要时显式传入 `--format` 和相应字段。正式发文默认单双页页码;`standard` 为单页右、双页左,`center` 与 `none` 供普通材料选择。
Markdown 标题优先按序数识别:`一、`、`(一)`、`1.`、`(1)`;序数缺失时才按 Markdown 层级映射。正文、四级标题均左空二字,回行顶格。标题字体在运行机器缺失时,会保留请求的字体名;打开文件时仍须确认 Office 的实际替代字体,不把普通宋体视作小标宋的等价替代。
@@ -34,10 +44,10 @@ Markdown 标题优先按序数识别:`一、`、`(一)`、`1.`、`(1)`
```bash
unzip -t output.docx
soffice --headless --convert-to pdf --outdir /tmp output.docx
-node scripts/verify_gongwen_docx.mjs --input output.docx --profile layout
+node scripts/verify_gongwen_docx.mjs --input output.docx --profile formal --letterhead preprinted
```
-校验器核验生成器可检查的版式要素。`layout` 用于居中页码,`standard-pages` 用于单双页页码。输出后可在目标 Word/WPS 与实际打印条件下检查首页正文、分页、附件、表格、页码和装订。
+校验器核验生成器可检查的版式要素,并按 `ordinary`、`formal`、`letter`、`command`、`minutes` 选择校验档案。输出后仍须在目标 Word/WPS 与实际打印条件下检查首页正文、分页、附件、表格、页码、红头纸套打和装订。
## 输出说明
diff --git a/dist/traework-gongwen-skill.zip b/dist/traework-gongwen-skill.zip
index 3d48a1d..b5f0ab6 100644
Binary files a/dist/traework-gongwen-skill.zip and b/dist/traework-gongwen-skill.zip differ
diff --git a/references/formal-checklist.md b/references/formal-checklist.md
index 84ae5fe..ab0a867 100644
--- a/references/formal-checklist.md
+++ b/references/formal-checklist.md
@@ -4,6 +4,8 @@
## 1. 前提与制作
+- [ ] 已明确选择 `ordinary`、`formal`、`letter`、`command` 或 `minutes`;报告、方案、汇报材料不因出现机构名称而自动采用红头。
+- [ ] 正式发文已确认采用预印红头纸套打或完整电子红头。套打稿以实际纸样核定首页预留高度,不重复打印纸上既有的红色机关标志、红线。
- [ ] 已确认发文机关、文种、行文关系、主送范围、发文字号、签发权限及机关现行模板。
- [ ] A4 成品幅面为 210mm×297mm;天头 37mm±1mm、订口 28mm±1mm、版心 156mm×225mm。
- [ ] 无特殊说明时采用三号仿宋;一般每面22行、每行28字并撑满版心。不得把固定28pt当作国标唯一强制值。
@@ -25,6 +27,7 @@
- [ ] 主送机关在标题下空一行顶格,回行顶格,最后名称后用全角冒号;过多而影响首页正文时移入版记。
- [ ] 首页显示正文;正文一般三号仿宋,每个自然段左空二字、回行顶格。
- [ ] 层次序数按“一、”“(一)”“1.”“(1)”使用;第一层黑体、第二层楷体、第三和第四层仿宋,均为三号。
+- [ ] 四级标题分别应用 Word/WPS 的“标题1—标题4”样式和相应大纲级别;文档总标题不进入目录。需要目录时,已在目标软件中插入并更新自动目录。
- [ ] 附件说明位于正文下空一行、左空二字;“附件:”后名称无标点;多附件编号正确,长名称回行与首字对齐。
- [ ] 署名、成文日期和印章已按“加盖印章/不加盖印章/签发人签名章/联合行文”之一完整处理;成文日期年月日用阿拉伯数字全称且月日不补零;没有空白印章或伪造印章。
- [ ] 附注适用时在成文日期下一行左空二字,以圆括号标注。
diff --git a/references/gbt9704-2012-summary.md b/references/gbt9704-2012-summary.md
index 37782a5..a1c4e8b 100644
--- a/references/gbt9704-2012-summary.md
+++ b/references/gbt9704-2012-summary.md
@@ -11,17 +11,21 @@
- 标准状态页:https://std.samr.gov.cn/gb/search/gbDetailed?id=lOIe27f77QU%3D&mode=p
- 公文处理依据:https://www.gov.cn/zwgk/2013-02/22/content_2337704.htm
-本摘要用于生成和校验,不代替标准原文。工具按用户提供的字段排版,不判断材料用途或替用户补充未提供的信息。
+本摘要用于生成和校验,不代替标准原文。工具按用户提供的字段排版,不判断单位身份、授权状态或替用户补充未提供的信息;版式模式必须由用户明确选择。
## 二、常见使用场景
### 1. 公文版式打印稿
-适用于方案、报告、说明、备忘材料和内部汇报。采用A4纸、标准版心、字号、行距、标题层级和页码,可按输入加入机构名称、文号、主送机关、署名和日期。
+适用于方案、报告、说明、备忘材料和内部汇报。采用A4纸、标准版心、字号、行距、标题层级和页码。出现机构名称不等于采用红头;普通材料不使用版头专属规则。
### 2. 正式党政机关公文
-除页面和正文格式外,还可按实际需要处理版头、主体、版记、页码、印章或签名章。生成器排版用户提供的文字字段;印章和签名章可在生成后的 Word/WPS 文件中按需插入。
+在用户明确要求正式发文版式、并提供相应要素时使用。除页面和正文格式外,处理版头、主体、版记、页码、印章或签名章。生成器排版用户提供的文字字段;印章和签名章可在生成后的 Word/WPS 文件中按需插入。
+
+### 3. 预印红头纸套打与完整电子版
+
+预印红头纸套打是常见制作流程:DOCX 首页预留纸上已印制的机关标志、红线等区域,打印时放入对应红头纸;不在文档中重复绘制这些红色元素。标准规定最终版式,不规定只能使用预印纸。完整电子红头仅在用户明确要求时生成。套打区域高度以实际纸样为准,不能将“35mm”误作全部预留高度。
## 三、纸张、版面和印制
@@ -142,10 +146,12 @@
| 报告居中页码 | 是 | 无 |
| 正式单双页页码 | 是 | `--page-number standard` |
| 普通表格 | 是 | 横排表格方向和复杂跨页表格复核 |
-| 红色机构名称、文号 | 是 | `--org`、`--doc-no` |
+| 普通材料 | 是 | `--format ordinary`,不绘制红头 |
+| 预印红头纸套打 | 是 | `--format formal --letterhead preprinted`,按纸样设置预留高度 |
+| 完整电子红头 | 是 | `--format formal --letterhead digital` |
| 印章、签名章 | 文字版式之外 | 在 Word/WPS 中插入图片或实体盖章 |
-| 版记 | 可编辑 | 按实际字段补充 |
-| 信函、命令、纪要 | 可编辑 | 按实际版式继续调整 |
+| 版记 | 是 | `--cc`、`--print-org`、`--print-date` 适用时生成 |
+| 信函、命令、纪要 | 是 | `--format letter|command|minutes`,按实际字段生成 |
## 十、建议检查
diff --git a/scripts/generate_gongwen_docx.mjs b/scripts/generate_gongwen_docx.mjs
index ec6ea83..a7973f8 100755
--- a/scripts/generate_gongwen_docx.mjs
+++ b/scripts/generate_gongwen_docx.mjs
@@ -32,7 +32,7 @@ function preferredFont(candidates) {
const FONT = {
title: { eastAsia: preferredFont(["方正小标宋简体", "方正小标宋_GBK", "FZXiaoBiaoSong-B05S"]), ascii: "Times New Roman", cs: "Times New Roman" },
subtitle: { eastAsia: preferredFont(["仿宋", "仿宋_GB2312", "STFangsong"]), ascii: "Times New Roman", cs: "Times New Roman" },
- body: { eastAsia: preferredFont(["仿宋", "仿宋_GB2312", "STFangsong"]), ascii: "Times New Roman", cs: "Times New Roman" },
+ body: { eastAsia: preferredFont(["仿宋", "仿宋_GB2312", "FangSong", "FangSong_GB2312", "STFangsong"]), ascii: "Times New Roman", cs: "Times New Roman" },
h1: { eastAsia: preferredFont(["黑体", "SimHei", "Heiti SC", "STHeiti"]), ascii: "Times New Roman", cs: "Times New Roman" },
h2: { eastAsia: preferredFont(["楷体", "楷体_GB2312", "KaiTi", "Kaiti SC", "STKaiti"]), ascii: "Times New Roman", cs: "Times New Roman" },
song: { eastAsia: preferredFont(["宋体", "SimSun"]), ascii: "宋体", cs: "宋体" },
@@ -40,11 +40,13 @@ const FONT = {
function usage() {
console.log(`Usage:
- node generate_gongwen_docx.mjs --input source.md --output out.docx [--org 发文机关] [--doc-no 发文字号] [--title 标题] [--subtitle 副标题] [--to 主送机关] [--sender 落款] [--date 日期] [--page-number center|standard|none]
+ 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]
Notes:
- Converts Markdown to a GB/T 9704-2012 page-layout DOCX.
- - Generates a DOCX from the supplied content and formatting fields. Its default page number is centered; use standard for odd/even page-number placement or none to suppress it.
+ - 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.
+ - 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.
- Supports headings, paragraphs, ordered/unordered lines, and pipe tables.
- No npm dependencies; requires zip in PATH.`);
@@ -112,7 +114,10 @@ function paragraph(text, opts = {}) {
const keepNext = opts.keepNext ? "" : "";
const before = opts.before ?? "0";
const after = opts.after ?? "0";
- const pPr = `${keepNext}${align}${indent}`;
+ const style = opts.style ? `` : "";
+ const outline = opts.outlineLevel === undefined ? "" : ``;
+ const pageBreak = opts.pageBreakBefore ? "" : "";
+ const pPr = `${style}${keepNext}${pageBreak}${align}${indent}${outline}`;
return `${pPr}${run(text, opts)}`;
}
@@ -124,6 +129,7 @@ function titleParagraph(text) {
size: "44",
before: "0",
after: "560",
+ style: "GongwenTitle",
});
}
@@ -134,17 +140,19 @@ function agencyMarkParagraph(text) {
fontPreset: "title",
size: "56",
color: "FF0000",
- after: "280",
+ after: "560",
+ before: "5953",
});
}
-function documentNumberParagraph(text) {
+function documentNumberParagraph(text, upward = false) {
return paragraph(text, {
- align: "left",
+ align: upward ? "left" : "center",
indent: false,
fontPreset: "body",
size: "32",
- after: "160",
+ after: "227",
+ left: upward ? FIRST_LINE_INDENT : undefined,
});
}
@@ -175,6 +183,8 @@ function h1(text) {
keepNext: true,
before: "160",
after: "0",
+ style: "Heading1",
+ outlineLevel: 0,
});
}
@@ -186,6 +196,8 @@ function h2(text) {
keepNext: true,
before: "80",
after: "0",
+ style: "Heading2",
+ outlineLevel: 1,
});
}
@@ -197,6 +209,8 @@ function h3(text) {
keepNext: true,
before: "40",
after: "0",
+ style: "Heading3",
+ outlineLevel: 2,
});
}
@@ -208,6 +222,8 @@ function h4(text) {
keepNext: true,
before: "0",
after: "0",
+ style: "Heading4",
+ outlineLevel: 3,
});
}
@@ -215,6 +231,32 @@ function right(text) {
return paragraph(text, { align: "right", indent: false, right: FIRST_LINE_INDENT });
}
+function redRule(thickness = "8") {
+ return ``;
+}
+
+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 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("4")];
+ 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["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(redRule("4"));
+ return body;
+}
+
function emptyLine() {
return ``;
}
@@ -371,16 +413,20 @@ function renderHeading(block) {
function renderParagraph(block) {
if (isFirstLayerHeadingText(block.text)) return h1(block.text);
if (isSecondLayerHeadingText(block.text)) return h2(block.text);
+ if (isThirdLayerHeadingText(block.text)) return h3(block.text);
+ if (isFourthLayerHeadingText(block.text)) return h4(block.text);
return paragraph(block.text);
}
function buildDocument(blocks, args) {
let title = args.title;
+ const format = ["ordinary", "formal", "letter", "command", "minutes"].includes(args.format) ? args.format : "ordinary";
+ const letterhead = args.letterhead === "digital" ? "digital" : "preprinted";
const pageNumberMode = args["no-page-number"] || args["no-page-numbers"]
? "none"
: ["center", "standard", "none"].includes(args["page-number"])
? args["page-number"]
- : "center";
+ : format === "formal" || format === "letter" || format === "command" || format === "minutes" ? "standard" : "center";
const body = [];
const sourceBlocks = [...blocks];
if (!title && sourceBlocks[0]?.type === "heading") {
@@ -395,8 +441,36 @@ function buildDocument(blocks, args) {
if (!mainSend && sourceBlocks[0]?.type === "paragraph" && looksLikeMainSend(sourceBlocks[0].text)) {
mainSend = sourceBlocks.shift().text;
}
- if (args.org) body.push(agencyMarkParagraph(args.org));
- if (args["doc-no"]) body.push(documentNumberParagraph(args["doc-no"]));
+ const upward = args["upward"] === "true" || args["upward"] === true;
+ const formal = format === "formal";
+ if (formal) {
+ 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));
+ 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(paragraph("", { indent: false, before: String(Math.round((reserve - 37) * 56.7)), after: "0", fontPreset: "body" }));
+ }
+ if (args["doc-no"]) body.push(documentNumberParagraph(args["doc-no"], upward));
+ if (upward && args.signer) {
+ body.push(headerFieldParagraph(`签发人:${args.signer}`, "h2", { align: "right", right: FIRST_LINE_INDENT }));
+ }
+ 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: "4252", after: "227" }));
+ body.push(redRule());
+ 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: "3402", after: "560" }));
+ if (args["doc-no"]) body.push(paragraph(args["doc-no"], { align: "center", indent: false, fontPreset: "body", size: "32", after: "560" }));
+ } 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: "5953", after: "560" }));
+ }
if (title) body.push(titleParagraph(title));
if (args.subtitle) body.push(subtitleParagraph(args.subtitle));
if (mainSend) body.push(mainSendParagraph(mainSend));
@@ -413,22 +487,39 @@ function buildDocument(blocks, args) {
}
body.push(renderParagraph(block));
}
+ if (args["attachment-note"]) body.push(attachmentNote(args["attachment-note"]));
if (args.sender || args.date) {
body.push(emptyLine());
- if (args.sender) body.push(right(args.sender));
- if (args.date) body.push(right(args.date));
+ if (args["seal-mode"] === "signed") {
+ 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 (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" }));
+ if (format !== "letter") body.push(...colophon(args));
const footerRefs = pageNumberMode === "standard"
? ''
: pageNumberMode === "center"
? ''
: "";
body.push(`${footerRefs}`);
- return `${body.join("")}`;
+ return `${body.join("")}`;
}
function stylesXml() {
- return ``;
+ const style = (id, name, preset, size, outline, before = "0") => ``;
+ const title = ``;
+ return `${title}${style("Heading1", "标题 1", "h1", "32", 0, "160")}${style("Heading2", "标题 2", "h2", "32", 1, "80")}${style("Heading3", "标题 3", "body", "32", 2, "40")}${style("Heading4", "标题 4", "body", "32", 3)}`;
}
function fontDef(name, altName, family = "roman", charset = "86") {
diff --git a/scripts/install.sh b/scripts/install.sh
index 0c6b7c5..eea20ea 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -76,6 +76,7 @@ package_traework() {
output="$skill_dir/dist/traework-gongwen-skill.zip"
mkdir -p "$skill_dir/dist" "$stage/gongwen"
cp -R "$skill_dir/SKILL.md" "$skill_dir/scripts" "$skill_dir/references" "$stage/gongwen/"
+ rm -f "$output"
(cd "$stage" && zip -qr "$output" gongwen)
unzip -t "$output" >/dev/null
rm -rf "$stage"
diff --git a/scripts/verify_gongwen_docx.mjs b/scripts/verify_gongwen_docx.mjs
index 2680a5b..23a5b01 100755
--- a/scripts/verify_gongwen_docx.mjs
+++ b/scripts/verify_gongwen_docx.mjs
@@ -4,7 +4,7 @@ import { execFileSync } from "node:child_process";
import fs from "node:fs";
function usage() {
- console.log("Usage: node verify_gongwen_docx.mjs --input file.docx [--profile layout|standard-pages]");
+ console.log("Usage: node verify_gongwen_docx.mjs --input file.docx [--profile ordinary|formal|letter|command|minutes] [--letterhead preprinted|digital]");
}
function parseArgs(argv) {
@@ -18,74 +18,62 @@ function parseArgs(argv) {
}
function readPart(file, part, optional = false) {
- try {
- return execFileSync("unzip", ["-p", file, part], { encoding: "utf8" });
- } catch (error) {
- if (optional) return "";
- throw error;
- }
+ try { return execFileSync("unzip", ["-p", file, part], { encoding: "utf8" }); }
+ catch (error) { if (optional) return ""; throw error; }
}
-function paragraphsForText(xml, pattern) {
- const paragraphs = xml.match(/]*)?>[\s\S]*?<\/w:p>/g) ?? [];
- return paragraphs.filter((paragraph) => pattern.test(paragraph.replace(/<[^>]+>/g, "")));
-}
+function paragraphs(xml) { return xml.match(/]*)?>[\s\S]*?<\/w:p>/g) ?? []; }
const args = parseArgs(process.argv);
-if (!args.input || !fs.existsSync(args.input)) {
- usage();
- process.exit(2);
-}
+if (!args.input || !fs.existsSync(args.input)) { usage(); process.exit(2); }
+const profile = args.profile ?? "ordinary";
+if (!["ordinary", "formal", "letter", "command", "minutes"].includes(profile)) { console.error("Unsupported profile."); process.exit(2); }
-const profile = args.profile ?? "layout";
-if (!["layout", "standard-pages"].includes(profile)) {
- console.error("Unsupported profile. Use layout or standard-pages.");
- process.exit(2);
-}
const doc = readPart(args.input, "word/document.xml");
const styles = readPart(args.input, "word/styles.xml");
-const h1 = paragraphsForText(doc, /^[一二三四五六七八九十]+、/);
-const h2 = paragraphsForText(doc, /^([一二三四五六七八九十]+)/);
-const h3 = paragraphsForText(doc, /(?:^|\s)\d+[..]/);
-const h4 = paragraphsForText(doc, /(\d+)/);
+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 checks = [];
-
-function check(name, ok, detail) {
- checks.push({ name, ok, detail });
-}
+function check(name, ok, detail) { checks.push({ name, ok, detail }); }
check("A4 page size", /]*w:w="11906"[^>]*w:h="16838"/.test(doc), "210 mm x 297 mm");
check("Page margins", /]*w:top="2098"[^>]*w:right="1474"[^>]*w:bottom="1984"[^>]*w:left="1588"/.test(doc), "top 37, bottom 35, left 28, right 26 mm");
-check("Document grid", /]*w:linePitch="560"/.test(doc), "28 pt grid");
-const fangSong = /w:eastAsia="(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312|STFangsong)"/;
-const xiaoBiaoSong = /w:eastAsia="(?:方正小标宋简体|方正小标宋_GBK|FZXiaoBiaoSong-B05S)"/;
-const heiTi = /w:eastAsia="(?:黑体|SimHei|Heiti SC|STHeiti)"/;
-const kaiTi = /w:eastAsia="(?:楷体|楷体_GB2312|KaiTi|KaiTi_GB2312|Kaiti SC|STKaiti)"/;
-check("Default body font", fangSong.test(styles) && / paragraphs.length > 0 && paragraphs.every(matcher);
-check("Title font", xiaoBiaoSong.test(doc) && / /w:firstLine="640"/.test(paragraph) && heiTi.test(paragraph) && /w:sz w:val="32"/.test(paragraph)), "every 一、 heading is Heiti-compatible 16 pt with two-character indent");
-check("Level-2 headings", allMatch(h2, (paragraph) => /w:firstLine="640"/.test(paragraph) && kaiTi.test(paragraph) && /w:sz w:val="32"/.test(paragraph)), "every (一) heading is Kaiti-compatible 16 pt with two-character indent");
-check("Level-3 headings", allMatch(h3, (paragraph) => /w:firstLine="640"/.test(paragraph) && fangSong.test(paragraph) && /w:sz w:val="32"/.test(paragraph)), "every 1. heading is FangSong-compatible 16 pt with two-character indent");
-check("Level-4 headings", allMatch(h4, (paragraph) => /w:firstLine="640"/.test(paragraph) && fangSong.test(paragraph) && /w:sz w:val="32"/.test(paragraph)), "every (1) heading is FangSong-compatible 16 pt with two-character indent");
-if (profile === "standard-pages") {
- const oddFooter = readPart(args.input, "word/footerOdd.xml", true);
- const evenFooter = readPart(args.input, "word/footerEven.xml", true);
- check("Odd-page footer", /w:jc w:val="right"/.test(oddFooter) && /w:instr="PAGE"/.test(oddFooter) && /w:ascii="(?:宋体|SimSun)"/.test(oddFooter), "right-aligned page number in Songti-compatible font");
- check("Even-page footer", /w:jc w:val="left"/.test(evenFooter) && /w:instr="PAGE"/.test(evenFooter) && /w:ascii="(?:宋体|SimSun)"/.test(evenFooter), "left-aligned page number in Songti-compatible font");
+check("Document grid", /]*w:linePitch="560"/.test(doc), "28 pt implementation grid");
+check("Default body font", /w:eastAsia="(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312|STFangsong)"/.test(styles) && / new RegExp(`w:styleId="Heading${level}"`).test(styles)), "Heading 1 through Heading 4 are defined");
+
+const textOf = (xml) => xml.replace(/<[^>]+>/g, "");
+const headingRules = [
+ [/^[一二三四五六七八九十百]+、/, "Heading1", /(?:黑体|SimHei|Heiti SC|STHeiti)/],
+ [/^([一二三四五六七八九十百]+)/, "Heading2", /(?:楷体|楷体_GB2312|KaiTi|KaiTi_GB2312|Kaiti SC|STKaiti)/],
+ [/^\d+[..]/, "Heading3", /(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312|STFangsong)/],
+ [/^(\d+)/, "Heading4", /(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312|STFangsong)/],
+];
+for (const [pattern, style, font] of headingRules) {
+ const matches = paragraphs(doc).filter((p) => pattern.test(textOf(p).trim()));
+ check(`${style} applied when present`, matches.every((p) => new RegExp(`w:pStyle w:val="${style}"`).test(p) && /w:firstLine="640"/.test(p) && font.test(p)), matches.length ? `${matches.length} matching paragraph(s)` : "not present in this document");
+}
+
+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");
} else {
- const footer = readPart(args.input, "word/footerCenter.xml", true);
- check("Layout page footer", /w:jc w:val="center"/.test(footer) && /w:instr="PAGE"/.test(footer) && /w:ascii="(?:宋体|SimSun)"/.test(footer), "centered page number in Songti-compatible font");
-}
-
-for (const item of checks) {
- console.log(`${item.ok ? "PASS" : "FAIL"} ${item.name}: ${item.detail}`);
+ 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");
+}
+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");
+ } 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");
+ }
}
+for (const item of checks) console.log(`${item.ok ? "PASS" : "FAIL"} ${item.name}: ${item.detail}`);
const failed = checks.filter((item) => !item.ok);
-if (failed.length) {
- console.error(`Verification failed: ${failed.length} item(s).`);
- process.exit(1);
-}
-
+if (failed.length) { console.error(`Verification failed: ${failed.length} item(s).`); process.exit(1); }
console.log(`Verification passed (${profile} profile): ${checks.length} item(s).`);
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
index e5b3657..8d71c9e 100755
--- a/tests/run_tests.sh
+++ b/tests/run_tests.sh
@@ -13,15 +13,23 @@ FIXTURE="$SKILL_DIR/tests/fixture.md"
"$NODE" --check "$GEN"
"$NODE" --check "$VERIFY"
-"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/layout.docx" --title "公文格式回归测试" --page-number center
-"$NODE" "$VERIFY" --input "$TMP_DIR/layout.docx" --profile layout
+"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/layout.docx" --format ordinary --org "不应变红头的单位" --title "公文格式回归测试" --page-number center
+"$NODE" "$VERIFY" --input "$TMP_DIR/layout.docx" --profile ordinary
unzip -t "$TMP_DIR/layout.docx" >/dev/null
+! unzip -p "$TMP_DIR/layout.docx" word/document.xml | grep -q 'FF0000'
+unzip -p "$TMP_DIR/layout.docx" word/styles.xml | grep -q 'styleId="Heading4"'
+unzip -p "$TMP_DIR/layout.docx" word/document.xml | grep -q 'w:pStyle w:val="Heading4"'
-"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/standard.docx" --org "示例单位文件" --doc-no "示例发〔2026〕1号" --title "公文格式回归测试" --page-number standard
-"$NODE" "$VERIFY" --input "$TMP_DIR/standard.docx" --profile standard-pages
-unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例单位文件'
+"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/standard.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --org "示例单位文件" --doc-no "示例发〔2026〕1号" --title "公文格式回归测试" --page-number standard
+"$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 '示例发〔2026〕1号'
+"$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
+unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q '示例单位文件'
+unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'FF0000'
+
if command -v soffice >/dev/null 2>&1; then
mkdir -p "$TMP_DIR/pdf"
soffice --headless --convert-to pdf --outdir "$TMP_DIR/pdf" "$TMP_DIR/layout.docx" >/dev/null 2>&1