完善国标公文全条款核验与特定版式

This commit is contained in:
mizzlelover
2026-09-11 07:51:42 +08:00
parent ac994d4881
commit de1fd6574d
44 changed files with 319 additions and 77 deletions
+52 -26
View File
@@ -108,7 +108,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 日期] [--attachment-note 附件说明] [--attachment-file attachment.md ...] [--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|horizontal-table] [--letterhead preprinted|digital] [--org 发文机关] [--joint-org 联署机关 ...] [--doc-no 发文字号] [--title 标题] [--subtitle 副标题] [--to 主送机关] [--sender 落款] [--date 日期] [--attachment-note 附件说明] [--attachment-file attachment.md ...] [--attachment-detached] [--page-number center|standard|none] [--require-standard-fonts]
Notes:
- Converts Markdown to a GB/T 9704-2012 page-layout DOCX.
@@ -124,8 +124,8 @@ Notes:
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"]);
const flags = new Set(["help", "no-page-number", "no-page-numbers", "require-standard-fonts", "attachment-detached"]);
const repeatable = new Set(["attachment-file", "joint-org"]);
for (let i = 2; i < argv.length; i += 1) {
const key = argv[i];
if (!key.startsWith("--")) continue;
@@ -175,7 +175,8 @@ function run(text, opts = {}) {
const size = opts.size ?? "32";
const bold = opts.bold ? "<w:b/>" : "";
const color = opts.color ?? "000000";
return `<w:r><w:rPr><w:rFonts ${fontAttrs(opts)}/>${bold}<w:color w:val="${color}"/><w:sz w:val="${size}"/><w:szCs w:val="${size}"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/></w:rPr><w:t xml:space="preserve">${esc(text)}</w:t></w:r>`;
const textXml = String(text ?? "").split("\n").map((line, index) => `${index ? "<w:br/>" : ""}<w:t xml:space="preserve">${esc(line)}</w:t>`).join("");
return `<w:r><w:rPr><w:rFonts ${fontAttrs(opts)}/>${bold}<w:color w:val="${color}"/><w:sz w:val="${size}"/><w:szCs w:val="${size}"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/></w:rPr>${textXml}</w:r>`;
}
function paragraph(text, opts = {}) {
@@ -223,19 +224,19 @@ function floatingHeaderFieldParagraph(text, fontPreset, index) {
});
}
function floatingAgencyMarkParagraph(text) {
function floatingAgencyMarkParagraph(text, lineCount = 1, size = "56") {
return paragraph(text, {
align: "center",
indent: false,
fontPreset: "title",
size: "56",
size,
color: "FF0000",
before: AGENCY_FRAME_BEFORE,
frame: { hAnchor: "margin", vAnchor: "margin", xAlign: "center", y: AGENCY_FRAME_Y, w: CONTENT_W, h: 800 },
frame: { hAnchor: "margin", vAnchor: "margin", xAlign: "center", y: AGENCY_FRAME_Y, w: CONTENT_W, h: 800 * Math.max(1, lineCount) },
});
}
function agencyMarkFlowSpacer() {
function agencyMarkFlowSpacer(extraDxa = 0) {
return paragraph("\u00a0", {
align: "left",
indent: false,
@@ -243,7 +244,7 @@ function agencyMarkFlowSpacer() {
size: "2",
line: "560",
snapToGrid: false,
before: AGENCY_FLOW_BEFORE,
before: AGENCY_FLOW_BEFORE + extraDxa,
after: "0",
});
}
@@ -547,15 +548,15 @@ function blankGridLines(count = 2) {
return Array.from({ length: count }, () => blankGridLine());
}
function tableXml(headers, rows) {
function tableXml(headers, rows, width = CONTENT_W) {
const colCount = headers.length;
const colWidth = Math.floor(CONTENT_W / colCount);
const colWidth = Math.floor(width / colCount);
const grid = Array.from({ length: colCount }, () => `<w:gridCol w:w="${colWidth}"/>`).join("");
const tr = (cells, header = false) => {
const trPr = header ? "<w:trPr><w:tblHeader/></w:trPr>" : "";
return `<w:tr>${trPr}${cells.map((cell) => tableCell(cell, colWidth, header)).join("")}</w:tr>`;
};
return `<w:tbl><w:tblPr><w:tblW w:w="${CONTENT_W}" w:type="dxa"/><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/></w:tblBorders><w:tblLook w:firstRow="1" w:lastRow="0" w:firstColumn="0" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/></w:tblPr><w:tblGrid>${grid}</w:tblGrid>${tr(headers, true)}${rows.map((row) => tr(row)).join("")}</w:tbl>`;
return `<w:tbl><w:tblPr><w:tblW w:w="${width}" w:type="dxa"/><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/></w:tblBorders><w:tblLook w:firstRow="1" w:lastRow="0" w:firstColumn="0" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/></w:tblPr><w:tblGrid>${grid}</w:tblGrid>${tr(headers, true)}${rows.map((row) => tr(row)).join("")}</w:tbl>`;
}
function tableCell(text, width, header) {
@@ -715,14 +716,14 @@ function renderParagraph(block) {
return paragraph(block.text);
}
function attachmentPage(blocks, index, filePath) {
function attachmentPage(blocks, index, filePath, detachedDocNo = "") {
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 }),
paragraph(detachedDocNo ? `${detachedDocNo} 附件${index}` : `附件${index}`, { align: "left", indent: false, fontPreset: "h1", size: "32", pageBreakBefore: true, keepNext: true }),
emptyLine(),
titleParagraph(attachmentTitle),
];
@@ -738,18 +739,22 @@ 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";
if (format === "formal" || format === "command" || format === "minutes" || format === "horizontal-table") 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 format = ["ordinary", "formal", "letter", "command", "minutes", "horizontal-table"].includes(args.format) ? args.format : "ordinary";
const letterhead = args.letterhead === "digital" ? "digital" : "preprinted";
const pageNumberMode = resolvePageNumberMode(args, format);
const horizontalTable = format === "horizontal-table" || args["horizontal-table"] === true || args["horizontal-table"] === "true";
const horizontalContentWidth = mmToDxa(225);
const body = [];
let titleGapLines = 0;
const pageMarginTop = format === "letter" ? 1701 : format === "command" ? 1134 : 2098;
// All formats keep the standard 37 mm top white margin. Special formats
// offset their marks inside the type area, measured from that boundary.
const pageMarginTop = format === "letter" ? 1701 : 2098;
const sourceBlocks = [...blocks];
if (!title && sourceBlocks[0]?.type === "heading") {
title = sourceBlocks.shift().text;
@@ -779,7 +784,18 @@ function buildDocument(blocks, args) {
// GB/T 9704-2012 7.2.4 fixes the agency mark at 35 mm below the
// type-area top. Left-corner fields occupy their own column and must
// not move the centered mark upward.
body.push(floatingAgencyMarkParagraph(args.org));
const jointOrganizations = Array.isArray(args["joint-org"]) ? args["joint-org"].map((name) => String(name).trim()).filter(Boolean) : [];
const agencyNames = [String(args.org).trim(), ...jointOrganizations];
// GB/T 9704-2012 7.2.4.2 keeps joint agency names on one centered
// mark row, with the host agency first. Full-width spaces preserve a
// visible separation without creating extra logo rows that would move
// the document number and title downward.
const host = String(args.org).trim();
const jointText = jointOrganizations.length && host.endsWith("文件")
? [...[host.slice(0, -2), ...jointOrganizations], "文件"].join(" ")
: agencyNames.join(" ");
const agencyMarkSize = agencyNames.length > 1 ? (jointText.length > 16 ? "44" : "48") : "56";
body.push(floatingAgencyMarkParagraph(jointText, 1, agencyMarkSize));
body.push(agencyMarkFlowSpacer());
if (args["doc-no"]) body.push(...blankGridLines(2));
}
@@ -819,7 +835,9 @@ function buildDocument(blocks, args) {
if (!args.org) throw new Error("command format requires --org");
if (!/(?:命令|令)$/.test(String(args.org).trim())) throw new Error("command --org must end with“命令”or“令”");
if (!args["doc-no"]) throw new Error("command format requires --doc-no for the 令号");
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "0", after: "0" }));
// GB/T 9704-2012 10.2 measures the command mark from the top edge of
// the type area, not from the paper edge: 37 mm + 20 mm from the page.
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: String(mmToDxa(20)), after: "0" }));
if (args["doc-no"]) {
body.push(...blankGridLines(2));
body.push(paragraph(args["doc-no"], { align: "center", indent: false, fontPreset: "body", size: "32", after: "0" }));
@@ -845,7 +863,7 @@ function buildDocument(blocks, args) {
continue;
}
if (block.type === "table") {
body.push(tableXml(block.headers, block.rows));
body.push(tableXml(block.headers, block.rows, horizontalTable ? horizontalContentWidth : CONTENT_W));
body.push(emptyLine());
continue;
}
@@ -855,6 +873,13 @@ function buildDocument(blocks, args) {
if (body.at(-1) === emptyLine()) body.pop();
body.push(attachmentNote(args["attachment-note"]));
}
// GB/T 9704-2012 10.3 places attendance information one line below the
// minutes body (or its attachment note), before any trailing signature or
// note material. Keeping it here also makes the screenshot evidence match
// the standard's “正文或附件说明下空一行” rule.
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 (args.sender || args.date) {
body.push(...(args["seal-mode"] === "signed" ? blankGridLines(2) : [emptyLine()]));
body.push(...signatureParagraphs(args));
@@ -867,13 +892,11 @@ function buildDocument(blocks, args) {
return absolutePath;
});
validateAttachmentConsistency(args["attachment-note"], absoluteAttachmentFiles);
if (args["attachment-detached"] && !args["doc-no"]) throw new Error("--attachment-detached requires --doc-no so the detached attachment can carry the document number");
absoluteAttachmentFiles.forEach((absolutePath, index) => {
const attachmentBlocks = parseMarkdown(fs.readFileSync(absolutePath, "utf8"));
body.push(attachmentPage(attachmentBlocks, index + 1, absolutePath));
body.push(attachmentPage(attachmentBlocks, index + 1, absolutePath, args["attachment-detached"] ? args["doc-no"] : ""));
});
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 letterFooter = format === "letter" ? '<w:footerReference w:type="default" r:id="rIdLetterFooter"/>' : "";
const footerRefs = letterFooter || (pageNumberMode === "standard"
@@ -882,7 +905,10 @@ function buildDocument(blocks, args) {
? '<w:footerReference w:type="default" r:id="rIdFooterCenter"/>'
: "");
const footerDistance = format === "letter" || pageNumberMode === "standard" ? "1134" : "1588";
body.push(`<w:sectPr>${footerRefs}<w:pgSz w:w="${PAGE_W}" w:h="${PAGE_H}"/><w:pgMar w:top="${pageMarginTop}" w:right="${MARGIN.right}" w:bottom="${MARGIN.bottom}" w:left="${MARGIN.left}" w:header="720" w:footer="${footerDistance}" w:gutter="0"/><w:docGrid w:type="lines" w:linePitch="560"/></w:sectPr>`);
const pageSize = horizontalTable
? `<w:pgSz w:w="${PAGE_H}" w:h="${PAGE_W}" w:orient="landscape"/>`
: `<w:pgSz w:w="${PAGE_W}" w:h="${PAGE_H}"/>`;
body.push(`<w:sectPr>${footerRefs}${pageSize}<w:pgMar w:top="${pageMarginTop}" w:right="${MARGIN.right}" w:bottom="${MARGIN.bottom}" w:left="${MARGIN.left}" w:header="720" w:footer="${footerDistance}" w:gutter="0"/><w:docGrid w:type="lines" w:linePitch="560"/></w:sectPr>`);
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:v="urn:schemas-microsoft-com:vml"><w:body>${body.join("")}</w:body></w:document>`;
}
@@ -1003,7 +1029,7 @@ const md = fs.readFileSync(args.input, "utf8");
try {
const blocks = parseMarkdown(md);
const documentXml = buildDocument(blocks, args);
const format = ["ordinary", "formal", "letter", "command", "minutes"].includes(args.format) ? args.format : "ordinary";
const format = ["ordinary", "formal", "letter", "command", "minutes", "horizontal-table"].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)}`);
+12 -7
View File
@@ -6,7 +6,7 @@ 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]");
console.log("Usage: node verify_gongwen_docx.mjs --input file.docx [--profile ordinary|formal|letter|command|minutes|horizontal-table] [--letterhead preprinted|digital] [--require-standard-fonts]");
}
function parseArgs(argv) {
@@ -35,7 +35,7 @@ function paragraphs(xml) { return xml.match(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/g)
const args = parseArgs(process.argv);
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); }
if (!["ordinary", "formal", "letter", "command", "minutes", "horizontal-table"].includes(profile)) { console.error("Unsupported profile."); process.exit(2); }
const doc = readPart(args.input, "word/document.xml");
const styles = readPart(args.input, "word/styles.xml");
@@ -71,9 +71,10 @@ const installedFonts = (() => {
const checks = [];
function check(name, ok, detail) { checks.push({ name, ok, detail }); }
check("A4 page size", /<w:pgSz[^>]*w:w="11906"[^>]*w:h="16838"/.test(doc), "210 mm x 297 mm");
const expectedTopMargin = profile === "letter" ? "1701" : profile === "command" ? "1134" : "2098";
check("Page margins", new RegExp(`<w:pgMar[^>]*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`);
const horizontal = profile === "horizontal-table";
check("A4 page size", horizontal ? /<w:pgSz[^>]*w:w="16838"[^>]*w:h="11906"[^>]*w:orient="landscape"/.test(doc) : /<w:pgSz[^>]*w:w="11906"[^>]*w:h="16838"/.test(doc), horizontal ? "297 mm x 210 mm landscape table page" : "210 mm x 297 mm");
const expectedTopMargin = profile === "letter" ? "1701" : "2098";
check("Page margins", new RegExp(`<w:pgMar[^>]*w:top="${expectedTopMargin}"[^>]*w:right="1474"[^>]*w:bottom="1984"[^>]*w:left="1588"`).test(doc), `top ${expectedTopMargin === "2098" ? 37 : 30} mm, bottom 35, left 28, right 26 mm`);
check("Document grid", /<w:docGrid[^>]*w:linePitch="560"/.test(doc), "28 pt implementation grid");
const bodyFontStandard = /w:eastAsia="(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312)"/.test(styles);
const bodyFontAvailable = bodyFontStandard || (!requireStandardFonts && /w:eastAsia="STFangsong"/.test(styles));
@@ -122,13 +123,16 @@ if (profile === "ordinary") {
check("Odd/even page positions", /w:jc w:val="right"/.test(footerOdd) && /w:jc w:val="left"/.test(footerEven), "odd right and even left");
check("Formal page-number distance", /w:footer="1134"/.test(doc), "page-number footer is 7 mm below the 225 mm type area");
}
if (horizontal) {
check("Horizontal table width", /<w:tblW w:w="12756" w:type="dxa"/.test(doc), "table uses the 225 mm landscape writing width");
}
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("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 agencyIndex = docParagraphs.findIndex((p) => /w:sz w:val="(?:56|48|44)"/.test(p) && /w:color w:val="FF0000"/.test(p));
// Keep this expected value independent from the generator's calibration
// constants. It is 35 mm in twentieths of a point, as required by 7.2.4.
const agencyOffsetFromTypeAreaTop = Math.round(35 * 56.692913);
@@ -140,7 +144,7 @@ if (profile === "formal") {
check("Digital red-head field frames", fixedHeaderFrames.every((value, index) => value === index * 560 || value === agencyOffsetFromTypeAreaTop), "left-corner fields use first-line grid positions without changing the agency mark anchor");
const docNoIndex = docParagraphs.findIndex((p) => /\d{4}[1-9]\d*/.test(textOf(p)));
const agencyToDocNo = agencyIndex >= 0 && docNoIndex > agencyIndex ? docParagraphs.slice(agencyIndex + 1, docNoIndex) : [];
const agencyFlowSpacer = agencyToDocNo.filter((p) => /w:before="1880"/.test(p));
const agencyFlowSpacer = agencyToDocNo.filter((p) => /w:before="[1-9]\d*"/.test(p) && /w:snapToGrid w:val="false"/.test(p));
const agencyGapLines = agencyToDocNo.slice(-2);
check("Agency mark to document number two blank lines", docNoIndex < 0 || (agencyFlowSpacer.length === 1 && agencyGapLines.length === 2 && agencyGapLines.every(isExplicitBlankGridLine)), "document number follows the fixed agency mark with two exact 28-point grid lines");
check("Two blank lines below red rule", hasExplicitTwoLineTitleGap, "title is preceded by two exact 28-point blank paragraphs below the red rule");
@@ -165,6 +169,7 @@ if (profile === "letter") {
if (profile === "command") {
const commandParagraphs = paragraphs(doc);
const commandAgencyIndex = commandParagraphs.findIndex((p) => /w:color w:val="FF0000"/.test(p));
check("Command agency position", commandAgencyIndex >= 0 && /w:before="1134"/.test(commandParagraphs[commandAgencyIndex]), "command mark starts 20 mm below the 37 mm type-area top");
const commandOrderIndex = commandParagraphs.findIndex((p, index) => index > commandAgencyIndex && /w:jc w:val="center"/.test(p) && /w:sz w:val="32"/.test(p) && !/w:color w:val="FF0000"/.test(p));
const agencyToOrder = commandAgencyIndex >= 0 && commandOrderIndex > commandAgencyIndex ? commandParagraphs.slice(commandAgencyIndex + 1, commandOrderIndex) : [];
check("Command agency-to-order spacing", commandOrderIndex < 0 || (agencyToOrder.length === 2 && agencyToOrder.every(isExplicitBlankGridLine)), "command agency mark is followed by two exact 28-point grid lines");