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

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)}`);