diff --git a/README.md b/README.md index 80d4558..8031d02 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ 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`。打开文档后,Word/WPS 的“引用→目录”可以按“标题1—标题4”生成并更新目录;是否显示第四级,在目录设置中选择。 +只有需要完整电子红头文件时,才加 `--letterhead digital`。红头和标题要使用目标电脑中实际安装的小标宋体;生成器缺少小标宋体或仿宋体时会在终端明确警告并写出替代字体,要求严格阻止替代输出时加 `--require-standard-fonts`。红线下标题会按版心网格预留两行,不会贴着分隔线。打开文档后,Word/WPS 的“引用→目录”可以按“标题1—标题4”生成并更新目录;是否显示第四级,在目录设置中选择。 ## 跨平台安装 @@ -140,6 +140,8 @@ Windows: tests/run_tests.sh ``` +如果要把字体替代也视为失败,可在生成器和校验器上同时加 `--require-standard-fonts`;当前电脑没有小标宋体或标准仿宋体时,命令会停止并明确列出缺失字体。 + 这项测试证明生成器、校验器和可打印文件链路可运行。最终文件可按实际用途在目标 Word/WPS 与打印条件中检查。 ## 许可 diff --git a/SKILL.md b/SKILL.md index dec4b6f..fab4d04 100644 --- a/SKILL.md +++ b/SKILL.md @@ -25,7 +25,7 @@ GB/T 9704-2012《党政机关公文格式》现行,2025-05-30 复审继续有 node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --format ordinary --title "文档标题" ``` -普通材料使用 `--format ordinary`,默认居中页码,不绘制红头。正式发文须显式使用 `--format formal`;其默认 `--letterhead preprinted`,即首面预留红头纸区域、不在 DOCX 中重绘纸上已有的红色机关标志和红线。`--doc-no` 等本次需打印的黑色变量仍可保留。只有明确要求完整电子版时使用 `--letterhead digital`。 +普通材料使用 `--format ordinary`,默认居中页码,不绘制红头。正式发文须显式使用 `--format formal`;其默认 `--letterhead preprinted`,即首面预留红头纸区域、不在 DOCX 中重绘纸上已有的红色机关标志和红线。`--doc-no` 等本次需打印的黑色变量仍可保留。只有明确要求完整电子版时使用 `--letterhead digital`。完整电子红头要求目标环境存在可核对的小标宋体;生成器会在缺失时明确警告并记录实际替代字体,需要硬性阻止替代输出时加 `--require-standard-fonts`。红线下标题按28磅版心网格空二行。 ```bash # 预印红头纸套打;72mm 是默认机关标志区域高度,可按实际纸样调为 37—130mm @@ -37,7 +37,7 @@ node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx -- `letter`、`command`、`minutes` 分别对应信函、命令(令)、纪要格式;需要时显式传入 `--format` 和相应字段。正式发文默认单双页页码;`standard` 为单页右、双页左,`center` 与 `none` 供普通材料选择。 -Markdown 标题优先按序数识别:`一、`、`(一)`、`1.`、`(1)`;序数缺失时才按 Markdown 层级映射。正文、四级标题均左空二字,回行顶格。标题字体在运行机器缺失时,会保留请求的字体名;打开文件时仍须确认 Office 的实际替代字体,不把普通宋体视作小标宋的等价替代。 +Markdown 标题优先按序数识别:`一、`、`(一)`、`1.`、`(1)`;序数缺失时才按 Markdown 层级映射。正文、四级标题均左空二字,回行顶格。标题字体在运行机器缺失时会明确报告并使用可见替代字体;打开文件时仍须确认目标 Office/WPS 的实际字体,不把普通宋体视作小标宋的等价替代。 ## 验证 @@ -45,6 +45,8 @@ Markdown 标题优先按序数识别:`一、`、`(一)`、`1.`、`(1)` unzip -t output.docx soffice --headless --convert-to pdf --outdir /tmp output.docx node scripts/verify_gongwen_docx.mjs --input output.docx --profile formal --letterhead preprinted +# 目标电脑必须装有标准小标宋体和仿宋体时,再加严格字体校验 +node scripts/verify_gongwen_docx.mjs --input output.docx --profile formal --letterhead digital --require-standard-fonts ``` 校验器核验生成器可检查的版式要素,并按 `ordinary`、`formal`、`letter`、`command`、`minutes` 选择校验档案。输出后仍须在目标 Word/WPS 与实际打印条件下检查首页正文、分页、附件、表格、页码、红头纸套打和装订。 diff --git a/dist/traework-gongwen-skill.zip b/dist/traework-gongwen-skill.zip index b5f0ab6..730e73b 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 ab0a867..4850144 100644 --- a/references/formal-checklist.md +++ b/references/formal-checklist.md @@ -10,6 +10,7 @@ - [ ] A4 成品幅面为 210mm×297mm;天头 37mm±1mm、订口 28mm±1mm、版心 156mm×225mm。 - [ ] 无特殊说明时采用三号仿宋;一般每面22行、每行28字并撑满版心。不得把固定28pt当作国标唯一强制值。 - [ ] 文本颜色为黑色;需红色的发文机关标志、分隔线、印章或签名章已按规则处理。 +- [ ] 已检查目标电脑字体:发文机关标志和标题应使用可核对的小标宋体;正文应使用可用的仿宋体。生成器报告缺失时不得把替代字体当作国标字体,必要时使用 `--require-standard-fonts` 阻止生成。 - [ ] 制作环节已确认双面印刷、左侧装订、页码套正、钉位及成品质量要求;这些项目不能从 DOCX 自动推定。 ## 2. 版头 @@ -19,7 +20,7 @@ - [ ] 发文机关标志使用全称或规范简称,可加“文件”;居中、红色、推荐小标宋,上边缘距版心上边缘35mm。 - [ ] 发文字号位于机关标志下空二行;年份为全称阿拉伯数字并使用六角括号〔〕,序号不加“第”、不补零、后加“号”。 - [ ] 上行文文号左空一字;签发人同区右空一字。“签发人:”为仿宋三号,姓名为楷体三号;多签发人按机关顺序编排。 -- [ ] 红色分隔线在文号下4mm处,与版心等宽。 +- [ ] 红色分隔线在文号下4mm处,与版心等宽,线高按模板核定(常用约0.35—0.5mm)。 ## 3. 主体 diff --git a/references/gbt9704-2012-summary.md b/references/gbt9704-2012-summary.md index a1c4e8b..69d1872 100644 --- a/references/gbt9704-2012-summary.md +++ b/references/gbt9704-2012-summary.md @@ -25,7 +25,7 @@ ### 3. 预印红头纸套打与完整电子版 -预印红头纸套打是常见制作流程:DOCX 首页预留纸上已印制的机关标志、红线等区域,打印时放入对应红头纸;不在文档中重复绘制这些红色元素。标准规定最终版式,不规定只能使用预印纸。完整电子红头仅在用户明确要求时生成。套打区域高度以实际纸样为准,不能将“35mm”误作全部预留高度。 +预印红头纸套打是常见制作流程:DOCX 首页预留纸上已印制的机关标志、红线等区域,打印时放入对应红头纸;不在文档中重复绘制这些红色元素。标准规定最终版式,不规定只能使用预印纸。完整电子红头仅在用户明确要求时生成。套打区域高度以实际纸样为准,不能将“35mm”误作全部预留高度。电子红头的发文机关标志与标题必须在目标环境中使用可核对的小标宋体;若字体不存在,生成器会明确警告并显示替代字体,`--require-standard-fonts` 可阻止继续生成。红色分隔线后的标题按版心网格空二行,不以视觉贴近为准。 ## 三、纸张、版面和印制 @@ -102,7 +102,7 @@ | 发文机关标志 | 机关全称或规范简称,可加“文件”;红色小标宋,居中 | | 发文字号 | 年份全称、六角括号、序号不加“第”且不补零 | | 上行文签发人 | “签发人:”仿宋三号,姓名楷体三号;与文号同区编排 | -| 分隔线 | 文号下4mm,红色,与版心等宽 | +| 分隔线 | 文号下4mm,红色,与版心等宽;常用线高约0.35—0.5mm | 版头位置和红色要素可按用户提供的字段和模板进一步调整。 diff --git a/scripts/generate_gongwen_docx.mjs b/scripts/generate_gongwen_docx.mjs index a7973f8..a12c286 100755 --- a/scripts/generate_gongwen_docx.mjs +++ b/scripts/generate_gongwen_docx.mjs @@ -25,22 +25,44 @@ function installedFontFamilies() { } const INSTALLED_FONTS = installedFontFamilies(); -function preferredFont(candidates) { - return candidates.find((font) => INSTALLED_FONTS.has(font)) ?? candidates[0]; +function preferredFont(candidates, fallback) { + return candidates.find((font) => INSTALLED_FONTS.has(font)) ?? fallback ?? candidates[0]; } 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", "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: "宋体" }, + title: { eastAsia: preferredFont(["方正小标宋简体", "方正小标宋_GBK", "FZXiaoBiaoSong-B05S", "FZXiaoBiaoSong-B13S"], "Songti SC"), ascii: "Times New Roman", cs: "Times New Roman" }, + subtitle: { eastAsia: preferredFont(["仿宋", "仿宋_GB2312", "STFangsong"], "STFangsong"), ascii: "Times New Roman", cs: "Times New Roman" }, + body: { eastAsia: preferredFont(["仿宋", "仿宋_GB2312", "FangSong", "FangSong_GB2312", "STFangsong"], "STFangsong"), ascii: "Times New Roman", cs: "Times New Roman" }, + h1: { eastAsia: preferredFont(["黑体", "SimHei", "Heiti SC", "STHeiti"], "Heiti SC"), ascii: "Times New Roman", cs: "Times New Roman" }, + h2: { eastAsia: preferredFont(["楷体", "楷体_GB2312", "KaiTi", "Kaiti SC", "STKaiti"], "Kaiti SC"), ascii: "Times New Roman", cs: "Times New Roman" }, + song: { eastAsia: preferredFont(["宋体", "SimSun"], "Songti SC"), ascii: "宋体", cs: "宋体" }, }; +const STANDARD_FONT_CANDIDATES = { + title: ["方正小标宋简体", "方正小标宋_GBK", "FZXiaoBiaoSong-B05S", "FZXiaoBiaoSong-B13S"], + body: ["仿宋", "仿宋_GB2312", "FangSong", "FangSong_GB2312"], +}; + +function missingFonts() { + return Object.entries(STANDARD_FONT_CANDIDATES) + .filter(([, candidates]) => !candidates.some((font) => INSTALLED_FONTS.has(font))) + .map(([role]) => role); +} + +function reportFontStatus(args) { + const missing = missingFonts(); + if (!missing.length) return; + const details = []; + if (missing.includes("title")) details.push(`标题和发文机关标志使用“${FONT.title.eastAsia}”替代`); + if (missing.includes("body")) details.push(`正文使用“${FONT.body.eastAsia}”替代`); + const message = `当前环境缺少国标常用字体:${details.join(";")}。请安装方正小标宋简体、方正小标宋_GBK 或 FZXiaoBiaoSong,以及可用的仿宋字体后重新生成。`; + if (args["require-standard-fonts"]) throw new Error(`FONT ERROR: ${message}`); + console.error(`FONT WARNING: ${message}`); +} + 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] + 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] Notes: - Converts Markdown to a GB/T 9704-2012 page-layout DOCX. @@ -48,13 +70,14 @@ Notes: - 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. + - 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. - No npm dependencies; requires zip in PATH.`); } function parseArgs(argv) { const args = {}; - const flags = new Set(["help", "no-page-number", "no-page-numbers"]); + const flags = new Set(["help", "no-page-number", "no-page-numbers", "require-standard-fonts"]); for (let i = 2; i < argv.length; i += 1) { const key = argv[i]; if (!key.startsWith("--")) continue; @@ -114,26 +137,29 @@ function paragraph(text, opts = {}) { const keepNext = opts.keepNext ? "" : ""; const before = opts.before ?? "0"; const after = opts.after ?? "0"; + const line = opts.line ?? "560"; + const lineRule = opts.lineRule ?? "exact"; const style = opts.style ? `` : ""; const outline = opts.outlineLevel === undefined ? "" : ``; const pageBreak = opts.pageBreakBefore ? "" : ""; - const pPr = `${style}${keepNext}${pageBreak}${align}${indent}${outline}`; + const snapToGrid = opts.snapToGrid === false ? "" : ""; + const pPr = `${style}${keepNext}${pageBreak}${align}${indent}${outline}${snapToGrid}`; return `${pPr}${run(text, opts)}`; } -function titleParagraph(text) { +function titleParagraph(text, before = "0") { return paragraph(text, { align: "center", indent: false, fontPreset: "title", size: "44", - before: "0", + before, after: "560", style: "GongwenTitle", }); } -function agencyMarkParagraph(text) { +function agencyMarkParagraph(text, before = "0") { return paragraph(text, { align: "center", indent: false, @@ -141,7 +167,21 @@ function agencyMarkParagraph(text) { size: "56", color: "FF0000", after: "560", - before: "5953", + before, + }); +} + +function letterheadReserveParagraph(reserveMm) { + const before = Math.round((reserveMm - 37) * 56.692913); + return paragraph("\u00a0", { + align: "left", + indent: false, + fontPreset: "body", + size: "2", + line: "1", + snapToGrid: false, + before: String(before), + after: "0", }); } @@ -231,21 +271,26 @@ function right(text) { return paragraph(text, { align: "right", indent: false, right: FIRST_LINE_INDENT }); } -function redRule(thickness = "8") { - return ``; +function redRule(thickness = "5") { + const height = thickness === "5" ? "0.5mm" : "0.35mm"; + 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 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("4")]; + const body = [redRule("5")]; 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"]) { @@ -253,7 +298,7 @@ function colophon(args) { 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")); + body.push(redRule("5")); return body; } @@ -418,16 +463,21 @@ function renderParagraph(block) { return paragraph(block.text); } +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"]; + if (format === "letter") return "none"; + if (format === "formal" || format === "command" || format === "minutes") return "standard"; + return "center"; +} + 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"] - : format === "formal" || format === "letter" || format === "command" || format === "minutes" ? "standard" : "center"; + const pageNumberMode = resolvePageNumberMode(args, format); const body = []; + const pageMarginTop = format === "letter" ? 1701 : format === "command" ? 1134 : 2098; const sourceBlocks = [...blocks]; if (!title && sourceBlocks[0]?.type === "heading") { title = sourceBlocks.shift().text; @@ -444,34 +494,36 @@ function buildDocument(blocks, args) { 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"); 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 === "digital" && args.org) body.push(agencyMarkParagraph(args.org, "1984")); 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" })); + body.push(letterheadReserveParagraph(reserve)); } 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 })); + body.push(signerParagraph(args.signer)); } 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(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "0", 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" })); + 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" })); } 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" })); + body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "1984", after: "560" })); } - if (title) body.push(titleParagraph(title)); + const hasRedRule = (formal && (letterhead === "digital" || letterhead === "preprinted" || args["preprinted-rule"] === "true")) || format === "letter"; + if (title) body.push(titleParagraph(title, hasRedRule ? "1120" : "0")); if (args.subtitle) body.push(subtitleParagraph(args.subtitle)); if (mainSend) body.push(mainSendParagraph(mainSend)); @@ -512,7 +564,7 @@ function buildDocument(blocks, args) { : pageNumberMode === "center" ? '' : ""; - body.push(`${footerRefs}`); + body.push(`${footerRefs}`); return `${body.join("")}`; } @@ -613,13 +665,16 @@ if (args.help || !args.input || !args.output) { usage(); process.exit(args.help ? 0 : 1); } +try { + reportFontStatus(args); +} catch (error) { + console.error(error.message); + process.exit(2); +} const md = fs.readFileSync(args.input, "utf8"); const blocks = parseMarkdown(md); const documentXml = buildDocument(blocks, args); -const pageNumberMode = args["no-page-number"] || args["no-page-numbers"] - ? "none" - : ["center", "standard", "none"].includes(args["page-number"]) - ? args["page-number"] - : "center"; +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)}`); diff --git a/scripts/verify_gongwen_docx.mjs b/scripts/verify_gongwen_docx.mjs index 23a5b01..f1e26e3 100755 --- a/scripts/verify_gongwen_docx.mjs +++ b/scripts/verify_gongwen_docx.mjs @@ -4,14 +4,20 @@ 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 ordinary|formal|letter|command|minutes] [--letterhead preprinted|digital]"); + console.log("Usage: node verify_gongwen_docx.mjs --input file.docx [--profile ordinary|formal|letter|command|minutes] [--letterhead preprinted|digital] [--require-standard-fonts]"); } function parseArgs(argv) { const args = {}; + const flags = new Set(["require-standard-fonts"]); for (let i = 2; i < argv.length; i += 1) { if (!argv[i].startsWith("--")) continue; - args[argv[i].slice(2)] = argv[i + 1]; + const name = argv[i].slice(2); + if (flags.has(name)) { + args[name] = true; + continue; + } + args[name] = argv[i + 1]; i += 1; } return args; @@ -34,13 +40,17 @@ 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 requireStandardFonts = args["require-standard-fonts"] === true; const checks = []; 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"); +const expectedTopMargin = profile === "letter" ? "1701" : profile === "command" ? "1134" : "2098"; +check("Page margins", new RegExp(`]*w:top="${expectedTopMargin}"[^>]*w:right="1474"[^>]*w:bottom="1984"[^>]*w:left="1588"`).test(doc), `top ${expectedTopMargin === "2098" ? 37 : expectedTopMargin === "1701" ? 30 : 20} mm, bottom 35, left 28, right 26 mm`); 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, ""); @@ -55,6 +65,11 @@ for (const [pattern, style, font] of headingRules) { 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"); } +const titleParagraphs = paragraphs(doc).filter((p) => /w:pStyle w:val="GongwenTitle"/.test(p)); +const titleFontStandard = titleParagraphs.every((p) => /w:eastAsia="(?:方正小标宋简体|方正小标宋_GBK|FZXiaoBiaoSong-B05S|FZXiaoBiaoSong-B13S)"/.test(p)); +const titleFontAvailable = titleFontStandard || (!requireStandardFonts && titleParagraphs.every((p) => /w:eastAsia="Songti SC"/.test(p))); +check("Title style and font", titleFontAvailable, requireStandardFonts ? "small-standard-title font is required" : titleParagraphs.length ? `${titleParagraphs.length} title paragraph(s); Songti SC fallback is allowed unless strict mode is enabled` : "not present in this document"); + 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"); @@ -67,11 +82,18 @@ if (profile === "ordinary") { 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("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"); } 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"); + check("Title spacing below physical red rule", titleParagraphs.some((p) => /w:before="1120"/.test(p)), "title paragraph reserves two 28-point grid lines below the printed red rule"); } } +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"); +} 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 8d71c9e..d3e8036 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -24,11 +24,26 @@ unzip -p "$TMP_DIR/layout.docx" word/document.xml | grep -q 'w:pStyle w:val="Hea "$NODE" "$VERIFY" --input "$TMP_DIR/standard.docx" --profile formal --letterhead preprinted ! unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例单位文件' unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例发〔2026〕1号' +unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q 'w:before="1120"' "$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' +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"' + +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 + exit 1 + fi +elif ! grep -q 'FONT ERROR:' "$TMP_DIR/strict.out"; then + cat "$TMP_DIR/strict.out" >&2 + echo "strict font mode failed for an unexpected reason" >&2 + exit 1 +fi if command -v soffice >/dev/null 2>&1; then mkdir -p "$TMP_DIR/pdf"