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