#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync } from "node:child_process";
const PAGE_W = 11906;
const PAGE_H = 16838;
const MARGIN = { top: 2098, bottom: 1984, left: 1588, right: 1474 };
const CONTENT_W = PAGE_W - MARGIN.left - MARGIN.right;
const FIRST_LINE_INDENT = 640;
const ONE_CHAR_INDENT = FIRST_LINE_INDENT / 2;
// First-page red-head frames use the type-area top as their vertical anchor.
// GB/T 9704-2012 7.2.1-7.2.4 then become direct coordinates: the first
// header line is y=0 and the agency mark begins 35 mm below it. Keeping the
// paragraph spacing at zero prevents optional left-corner fields from moving
// the centred mark or changing the preprinted-paper reserve.
const MM_TO_DXA = 56.692913;
const HEADER_FIELD_BEFORE = 0;
const AGENCY_FRAME_BEFORE = 0;
const AGENCY_FLOW_BEFORE = 1880;
const HEADER_FIELD_Y = 0;
const AGENCY_FRAME_Y = Math.round(35 * MM_TO_DXA);
function installedFontFamilies() {
try {
return new Set(
execFileSync("fc-list", ["-f", "%{family}\n"], { encoding: "utf8" })
.split(/\r?\n/)
.flatMap((line) => line.split(","))
.map((name) => name.trim())
.filter(Boolean),
);
} catch {
const families = new Set();
const aliases = {
simfang: "FangSong",
simfangb: "FangSong_GB2312",
"方正小标宋简体": "方正小标宋简体",
"方正小标宋_GBK": "方正小标宋_GBK",
"fzxiaobiaosong-b05s": "FZXiaoBiaoSong-B05S",
"fzxiaobiaosong-b13s": "FZXiaoBiaoSong-B13S",
fangsong: "FangSong",
fangsong_gb2312: "FangSong_GB2312",
};
const roots = [
process.env.WINDIR ? path.join(process.env.WINDIR, "Fonts") : "",
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "Microsoft", "Windows", "Fonts") : "",
path.join(os.homedir(), "Library", "Fonts"),
"/Library/Fonts",
"/usr/share/fonts",
"/usr/local/share/fonts",
].filter(Boolean);
const walk = (dir) => {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.(?:ttf|ttc|otf)$/i.test(entry.name)) {
const stem = path.basename(entry.name, path.extname(entry.name));
families.add(stem);
const alias = aliases[stem.toLowerCase()];
if (alias) families.add(alias);
}
}
};
roots.forEach(walk);
return families;
}
}
const INSTALLED_FONTS = installedFontFamilies();
function preferredFont(candidates, fallback) {
return candidates.find((font) => INSTALLED_FONTS.has(font)) ?? fallback ?? candidates[0];
}
const FONT = {
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|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.
- 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.
- formal and letter document numbers are checked for full-year〔〕sequence号 syntax; dates are checked for YYYY年M月D日 without zero padding.
- --org and --doc-no are printed as supplied after boundary validation. 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, pipe tables, and repeatable Markdown attachment pages via --attachment-file. The colophon is anchored to the bottom of the final page's type area.
- No npm dependencies; requires zip in PATH.`);
}
function parseArgs(argv) {
const args = {};
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;
const name = key.slice(2);
if (flags.has(name)) {
args[name] = true;
continue;
}
const value = argv[i + 1];
if (value === undefined || value.startsWith("--")) {
throw new Error(`--${name} requires a value`);
}
if (repeatable.has(name)) {
args[name] = [...(args[name] ?? []), value];
} else {
args[name] = value;
}
i += 1;
}
return args;
}
function esc(value) {
return String(value ?? "")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function attr(value) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function fontAttrs(opts = {}) {
const preset = opts.fontPreset ? FONT[opts.fontPreset] : null;
const eastAsia = opts.eastAsia ?? opts.font ?? preset?.eastAsia ?? FONT.body.eastAsia;
const ascii = opts.ascii ?? preset?.ascii ?? FONT.body.ascii;
const hAnsi = opts.hAnsi ?? ascii;
const cs = opts.cs ?? preset?.cs ?? ascii;
return `w:ascii="${attr(ascii)}" w:hAnsi="${attr(hAnsi)}" w:eastAsia="${attr(eastAsia)}" w:cs="${attr(cs)}" w:hint="eastAsia"`;
}
function run(text, opts = {}) {
const size = opts.size ?? "32";
const bold = opts.bold ? "" : "";
const color = opts.color ?? "000000";
const textXml = String(text ?? "").split("\n").map((line, index) => `${index ? "" : ""}${esc(line)}`).join("");
return `${bold}${textXml}`;
}
function paragraph(text, opts = {}) {
const alignValue = opts.align === undefined ? "both" : opts.align;
const align = alignValue ? `` : "";
const indAttrs = [];
if (opts.indent !== false) indAttrs.push(`w:firstLine="${opts.firstLine ?? FIRST_LINE_INDENT}"`);
if (opts.left) indAttrs.push(`w:left="${opts.left}"`);
if (opts.right) indAttrs.push(`w:right="${opts.right}"`);
const indent = indAttrs.length ? `` : "";
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 frame = opts.frame ? `` : "";
const pageBreak = opts.pageBreakBefore ? "" : "";
const snapToGrid = opts.snapToGrid === false ? "" : "";
const pPr = `${style}${keepNext}${pageBreak}${frame}${align}${indent}${outline}${snapToGrid}`;
return `${pPr}${run(text, opts)}`;
}
function titleParagraph(text, before = "0") {
return paragraph(text, {
align: "center",
indent: false,
fontPreset: "title",
size: "44",
before,
after: "560",
style: "GongwenTitle",
});
}
function floatingHeaderFieldParagraph(text, fontPreset, index) {
return paragraph(text, {
align: "left",
indent: false,
fontPreset,
size: "32",
before: HEADER_FIELD_BEFORE,
frame: { hAnchor: "margin", vAnchor: "margin", xAlign: "left", y: HEADER_FIELD_Y + index * 560, w: CONTENT_W, h: 560 },
});
}
function floatingAgencyMarkParagraph(text, lineCount = 1, size = "56") {
return paragraph(text, {
align: "center",
indent: false,
fontPreset: "title",
size,
color: "FF0000",
before: AGENCY_FRAME_BEFORE,
frame: { hAnchor: "margin", vAnchor: "margin", xAlign: "center", y: AGENCY_FRAME_Y, w: CONTENT_W, h: 800 * Math.max(1, lineCount) },
});
}
function agencyMarkFlowSpacer(extraDxa = 0) {
return paragraph("\u00a0", {
align: "left",
indent: false,
fontPreset: "body",
size: "2",
line: "560",
snapToGrid: false,
before: AGENCY_FLOW_BEFORE + extraDxa,
after: "0",
});
}
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",
});
}
function documentNumberParagraph(text, upward = false) {
return paragraph(text, {
align: upward ? "left" : "center",
indent: false,
fontPreset: "body",
size: "32",
after: "227",
left: upward ? FIRST_LINE_INDENT : undefined,
});
}
function upwardHeaderParagraph(docNo, signer) {
const tabPosition = CONTENT_W - ONE_CHAR_INDENT;
const signers = String(signer).split(/[、,,;;\s]+/).map((name) => name.trim()).filter(Boolean);
if (signers.length > 2) throw new Error("upward --signer supports up to two names; use the authority's joint-signature template for more signers");
const signerRuns = signers.map((name, index) => `${index ? run(" ", { fontPreset: "h2", size: "32" }) : ""}${run(name, { fontPreset: "h2", size: "32" })}`).join("");
return `${run(docNo, { fontPreset: "body", size: "32" })}${run("签发人:", { fontPreset: "body", size: "32" })}${signerRuns}`;
}
function subtitleParagraph(text) {
return paragraph(text, {
align: "center",
indent: false,
fontPreset: "subtitle",
size: "32",
after: "280",
});
}
function mainSendParagraph(text) {
return paragraph(text, {
align: "left",
indent: false,
fontPreset: "body",
size: "32",
});
}
function normalizeMainSend(text) {
const value = String(text ?? "").trim();
return value && /[::]$/.test(value) ? value : `${value}:`;
}
function normalizeDocumentNumber(text, label = "--doc-no") {
const value = String(text ?? "").trim();
if (!value) return value;
if (!/^[^〔〕\[\]【】]+〔\d{4}〕[1-9]\d*号$/.test(value) || /第/.test(value)) {
throw new Error(`${label} must use 年份全称、六角括号、非虚位顺序号且以“号”结尾,例如“示例发〔2026〕1号”`);
}
return value;
}
function normalizeChineseDate(text, label = "--date", allowPrintSuffix = false) {
const raw = String(text ?? "").trim();
const value = allowPrintSuffix ? raw.replace(/印发$/, "") : raw;
const match = value.match(/^(\d{4})年((?:[1-9]|1[0-2]))月((?:[1-9]|[12]\d|3[01]))日$/);
if (!match) throw new Error(`${label} must use YYYY年M月D日 and must not zero-pad month/day`);
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
throw new Error(`${label} is not a valid calendar date`);
}
return value;
}
function normalizeAttachmentNote(text) {
const value = String(text ?? "").trim().replace(/[。;;]+$/, "");
if (!value) throw new Error("--attachment-note cannot be empty");
const names = value.replace(/^附件[::]\s*/, "").trim();
if (!names) throw new Error("--attachment-note must contain an attachment name after 附件:");
return `附件:${names}`;
}
function normalizedAttachmentName(text) {
return String(text ?? "")
.trim()
.replace(/^附件[::]\s*/, "")
.replace(/^附件\s*[一二三四五六七八九十百\d]+\s*[::]\s*/, "")
.replace(/^\d+\s*[..、]\s*/, "")
.replace(/[。;;]+$/, "")
.trim();
}
function attachmentNamesFromNote(text) {
return normalizeAttachmentNote(text)
.replace(/^附件:/, "")
.split(/[;;]/)
.map(normalizedAttachmentName)
.filter(Boolean);
}
function attachmentTitleFromFile(filePath) {
const blocks = parseMarkdown(fs.readFileSync(filePath, "utf8"));
const heading = blocks.find((block) => block.type === "heading");
return normalizedAttachmentName(heading?.text ?? path.basename(filePath, path.extname(filePath)));
}
function validateAttachmentConsistency(note, files) {
if (!files.length) return;
if (!note) throw new Error("--attachment-file requires --attachment-note so attachment order and titles can be checked");
const noted = attachmentNamesFromNote(note);
const actual = files.map(attachmentTitleFromFile);
if (noted.length !== actual.length || noted.some((name, index) => name !== actual[index])) {
throw new Error(`--attachment-note must match attachment titles and order: note=[${noted.join("、")}] files=[${actual.join("、")}]`);
}
}
function h1(text) {
return paragraph(text, {
align: "left",
fontPreset: "h1",
size: "32",
keepNext: true,
before: "160",
after: "0",
style: "Heading1",
outlineLevel: 0,
});
}
function h2(text) {
return paragraph(text, {
align: "left",
fontPreset: "h2",
size: "32",
keepNext: true,
before: "80",
after: "0",
style: "Heading2",
outlineLevel: 1,
});
}
function h3(text) {
return paragraph(text, {
align: "left",
fontPreset: "body",
size: "32",
keepNext: true,
before: "40",
after: "0",
style: "Heading3",
outlineLevel: 2,
});
}
function h4(text) {
return paragraph(text, {
align: "left",
fontPreset: "body",
size: "32",
keepNext: true,
before: "0",
after: "0",
style: "Heading4",
outlineLevel: 3,
});
}
function right(text) {
return paragraph(text, { align: "right", indent: false, right: FIRST_LINE_INDENT });
}
function rightWithIndent(text, rightIndent) {
return paragraph(text, { align: "right", indent: false, right: String(rightIndent) });
}
function redRule(thickness = "5") {
const height = thickness === "thick" ? "0.35mm" : thickness === "thin" ? "0.25mm" : "0.5mm";
return ``;
}
function mmToDxa(mm) {
return Math.round(Number(mm) * 1440 / 25.4);
}
function redDoubleRule({ upperMm = "0.35", lowerMm = "0.25", widthMm = "170" } = {}) {
const width = mmToDxa(widthMm);
const row = (heightMm) => ` `;
const spacer = ``;
return `${row(upperMm)}${spacer}${row(lowerMm)}`;
}
function minutesPersonParagraph(label, people, before = "0") {
// The label starts two characters into the type area; wrapped names align
// after the four-character label “出席:/请假:/列席:”.
return `${run(`${label}:`, { fontPreset: "h1", size: "32" })}${run(people, { fontPreset: "body", size: "32" })}`;
}
function headerFieldParagraph(text, fontPreset = "h1", opts = {}) {
return paragraph(text, { align: opts.align ?? "left", indent: false, fontPreset, size: "32", before: opts.before ?? "0", after: opts.after ?? "0", left: opts.left, right: opts.right });
}
function letterHeaderRow(leftText, docNo) {
const cell = (text, align, fontPreset) => `${paragraph(text, { align, indent: false, fontPreset, size: "32", before: "280", after: "0" })}`;
return `${cell(leftText, "left", "body")}${cell(docNo, "right", "body")}`;
}
function attachmentNote(text) {
return paragraph(normalizeAttachmentNote(text), { align: "left", fontPreset: "body", size: "32", before: "560", firstLine: "-960", left: "1600" });
}
function estimatedTextWidthDxa(text) {
return [...String(text ?? "")].reduce((sum, char) => sum + (/^[\x00-\x7f]$/.test(char) ? 160 : 320), 0);
}
function signatureParagraphs(args) {
if (args["seal-mode"] === "signed") {
if (!args.signer || !args["signer-title"]) throw new Error("signed seal mode requires --signer and --signer-title");
const lines = [rightWithIndent(`${args["signer-title"]} ${args.signer}`, FIRST_LINE_INDENT * 2)];
if (args.date) lines.push(...blankGridLines(1), rightWithIndent(args.date, FIRST_LINE_INDENT * 2));
return lines;
}
if (args["seal-mode"] === "seal") {
if (!args.sender && !args.date) return [];
const lines = [];
if (args.sender) lines.push(rightWithIndent(args.sender, FIRST_LINE_INDENT * 2));
if (args.date) lines.push(rightWithIndent(args.date, FIRST_LINE_INDENT * 2));
return lines;
}
if (!args.sender && !args.date) return [];
if (!args.sender || !args.date) return [right(args.sender || args.date)];
const senderWidth = estimatedTextWidthDxa(args.sender);
const dateWidth = estimatedTextWidthDxa(args.date);
let senderRight = FIRST_LINE_INDENT;
let dateRight = Math.max(0, senderWidth - dateWidth);
if (dateWidth > senderWidth) {
dateRight = FIRST_LINE_INDENT;
senderRight = dateWidth - senderWidth + FIRST_LINE_INDENT;
}
return [
paragraph(args.sender, { align: "right", indent: false, right: String(senderRight) }),
paragraph(args.date, { align: "right", indent: false, right: String(dateRight) }),
];
}
function colophonTextRow(text, align = "left") {
return `${paragraph(text, { align, firstLine: "-960", left: "1280", right: "320", fontPreset: "body", size: "28" })}`;
}
function colophonRuleRow(thickness) {
const heightMm = thickness === "thick" ? "0.35" : "0.25";
return `${redRule(thickness)}`;
}
function colophon(args) {
if (!args["cc"] && !args["print-org"] && !args["print-date"]) return [];
const body = [colophonRuleRow("thick")];
if (args.cc) body.push(colophonTextRow(`抄送:${args.cc.replace(/[。..]+$/, "")}。`));
if (args.cc) body.push(colophonRuleRow("thin"));
if (args["print-org"] || args["print-date"]) {
const left = args["print-org"] ?? "";
const rightValue = args["print-date"] ? `${args["print-date"].replace(/印发$/, "")}印发` : "";
const printRow = `${run(left, { fontPreset: "body", size: "28" })}${run(rightValue, { fontPreset: "body", size: "28" })}`;
body.push(`${printRow}`);
}
body.push(colophonRuleRow("thick"));
const tablePr = ``;
return [`${tablePr}${body.join("")}`];
}
function emptyLine() {
return ``;
}
function blankGridLine() {
return paragraph("\u00a0", {
align: "left",
indent: false,
fontPreset: "body",
size: "2",
line: "560",
lineRule: "exact",
snapToGrid: false,
before: "0",
after: "0",
});
}
function blankGridLines(count = 2) {
return Array.from({ length: count }, () => blankGridLine());
}
function tableXml(headers, rows, width = CONTENT_W) {
const colCount = headers.length;
const colWidth = Math.floor(width / colCount);
const grid = Array.from({ length: colCount }, () => ``).join("");
const tr = (cells, header = false) => {
const trPr = header ? "" : "";
return `${trPr}${cells.map((cell) => tableCell(cell, colWidth, header)).join("")}`;
};
return `${grid}${tr(headers, true)}${rows.map((row) => tr(row)).join("")}`;
}
function tableCell(text, width, header) {
const shade = header ? '' : "";
const props = `${shade}`;
const pPr = '';
const r = run(
text,
header
? { fontPreset: "h1", size: "32" }
: { size: "32" },
);
return `${props}${pPr}${r}`;
}
function stripFrontmatter(md) {
return md.replace(/^---\n[\s\S]*?\n---\n?/, "");
}
function parseMarkdown(md) {
const lines = stripFrontmatter(md).split(/\r?\n/);
const blocks = [];
let para = [];
let i = 0;
const flush = () => {
const text = para.join("").trim();
if (text) blocks.push({ type: "paragraph", text });
para = [];
};
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
flush();
i += 1;
continue;
}
if (/^\|.+\|$/.test(trimmed)) {
flush();
const tableLines = [];
while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) {
tableLines.push(lines[i].trim());
i += 1;
}
const rows = tableLines
.filter((l) => !/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(l))
.map((l) => l.replace(/^\||\|$/g, "").split("|").map((c) => c.trim()));
if (rows.length > 0) blocks.push({ type: "table", headers: rows[0], rows: rows.slice(1) });
continue;
}
const heading = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
flush();
blocks.push({ type: "heading", level: heading[1].length, text: heading[2].trim() });
i += 1;
continue;
}
const bullet = trimmed.match(/^[-*]\s+(.+)$/);
if (bullet) {
flush();
blocks.push({ type: "paragraph", text: bullet[1].trim() });
i += 1;
continue;
}
const ordered = trimmed.match(/^(\d+[..、])\s*(.+)$/);
if (ordered) {
flush();
blocks.push({ type: "paragraph", text: `${ordered[1]} ${ordered[2].trim()}` });
i += 1;
continue;
}
para.push(trimmed);
i += 1;
}
flush();
return blocks;
}
function normalizeSignatureText(text) {
return String(text ?? "").replace(/\s+/g, "").replace(/[::]$/, "");
}
function isMatchingParagraph(block, text) {
return (
block?.type === "paragraph" &&
normalizeSignatureText(block.text) === normalizeSignatureText(text)
);
}
function isChineseDateParagraph(block) {
if (block?.type !== "paragraph") return false;
try {
normalizeChineseDate(normalizeSignatureText(block.text), "source date");
return true;
} catch {
return false;
}
}
function validateDateParagraphs(blocks) {
for (const block of blocks) {
if (block?.type === "paragraph" && /^\d{4}年\d{1,2}月\d{1,2}日$/.test(normalizeSignatureText(block.text))) {
normalizeChineseDate(normalizeSignatureText(block.text), "source date");
}
}
}
function stripTrailingMatchingSignature(blocks, args) {
if (args.date && (isMatchingParagraph(blocks.at(-1), args.date) || isChineseDateParagraph(blocks.at(-1)))) {
blocks.pop();
}
if (args.sender && isMatchingParagraph(blocks.at(-1), args.sender)) {
blocks.pop();
}
}
function isFirstLayerHeadingText(text) {
return /^[一二三四五六七八九十百]+、/.test(String(text ?? "").trim());
}
function isSecondLayerHeadingText(text) {
return /^([一二三四五六七八九十百]+)/.test(String(text ?? "").trim());
}
function isThirdLayerHeadingText(text) {
return /^\d+[..]/.test(String(text ?? "").trim());
}
function isFourthLayerHeadingText(text) {
return /^(\d+)/.test(String(text ?? "").trim());
}
function looksLikeMainSend(text) {
const compact = String(text ?? "").replace(/\s+/g, "");
if (!/[::]$/.test(compact) || compact.length > 60) return false;
if (/[。!?;;]/.test(compact)) return false;
return /(各|贵|省|市|县|区|镇|乡|机关|单位|公司|部门|委员会|办公室|厅|局|处|中心|领导|集团|党委|党总支|党支部)/.test(compact.slice(0, -1));
}
function renderHeading(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);
if (block.level <= 2) return h1(block.text);
if (block.level === 3) return h2(block.text);
if (block.level === 4) return h3(block.text);
return h4(block.text);
}
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 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(detachedDocNo ? `${detachedDocNo} 附件${index}` : `附件${index}`, { align: "left", indent: false, fontPreset: "h1", size: "32", pageBreakBefore: true, keepNext: true }),
emptyLine(),
titleParagraph(attachmentTitle),
];
for (const block of attachmentBlocks) {
if (block.type === "heading") body.push(renderHeading(block));
else if (block.type === "table") body.push(tableXml(block.headers, block.rows), emptyLine());
else body.push(renderParagraph(block));
}
return body.join("");
}
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" || format === "horizontal-table") return "standard";
return "center";
}
function buildDocument(blocks, args) {
let title = args.title;
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;
// 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;
}
if (title && sourceBlocks[0]?.type === "heading" && sourceBlocks[0].text.trim() === title.trim()) {
sourceBlocks.shift();
}
validateDateParagraphs(sourceBlocks);
stripTrailingMatchingSignature(sourceBlocks, args);
let mainSend = args.to;
if (mainSend && isMatchingParagraph(sourceBlocks[0], mainSend)) sourceBlocks.shift();
if (!mainSend && sourceBlocks[0]?.type === "paragraph" && looksLikeMainSend(sourceBlocks[0].text)) {
mainSend = sourceBlocks.shift().text;
}
if (mainSend) mainSend = normalizeMainSend(mainSend);
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"] && !/^\d{1,6}$/.test(String(args["copy-no"]))) throw new Error("--copy-no must contain one to six Arabic digits");
let headerFieldIndex = 0;
if (args["copy-no"]) body.push(floatingHeaderFieldParagraph(String(args["copy-no"]).padStart(6, "0"), "body", headerFieldIndex++));
else if (args.secret) body.push(floatingHeaderFieldParagraph("", "body", headerFieldIndex++));
if (args.secret) body.push(floatingHeaderFieldParagraph(args.secret, "h1", headerFieldIndex++));
if (args.urgent) body.push(floatingHeaderFieldParagraph(args.urgent, "h1", headerFieldIndex++));
if (letterhead === "digital" && args.org) {
// 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.
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 keeps joint agency names in order with the
// host first. When “文件” is present it sits to the right of the
// stacked names and is vertically centred against that name block.
const host = String(args.org).trim();
const jointNames = jointOrganizations.length && host.endsWith("文件")
? [host.slice(0, -2), ...jointOrganizations]
: agencyNames;
const middleIndex = Math.floor((jointNames.length - 1) / 2);
const jointText = jointOrganizations.length && host.endsWith("文件")
? jointNames.map((name, index) => index === middleIndex ? `${name} 文件` : name).join("\n")
: agencyNames.join(" ");
const agencyMarkSize = agencyNames.length > 1 ? (jointText.length > 16 ? "44" : "48") : "56";
body.push(floatingAgencyMarkParagraph(jointText, jointOrganizations.length ? jointNames.length : 1, agencyMarkSize));
body.push(agencyMarkFlowSpacer(jointOrganizations.length ? Math.max(0, jointNames.length - 1) * 560 : 0));
if (args["doc-no"]) body.push(...blankGridLines(2));
}
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");
// The physical red-head paper already contains the agency mark and
// separator. Its reserve is a paper-coordinate boundary, so optional
// copy/secret/urgency fields must not shorten it.
body.push(letterheadReserveParagraph(reserve));
}
if (args["doc-no"]) args["doc-no"] = normalizeDocumentNumber(args["doc-no"]);
if (upward && args["doc-no"] && args.signer) body.push(upwardHeaderParagraph(args["doc-no"], args.signer));
else if (args["doc-no"]) body.push(documentNumberParagraph(args["doc-no"], upward));
if (letterhead === "digital" || args["preprinted-rule"] === "true") body.push(redRule());
titleGapLines = 2;
} else if (format === "letter") {
if (!args.org) throw new Error("letter format requires --org");
if (args["copy-no"] && !/^\d{1,6}$/.test(String(args["copy-no"]))) throw new Error("--copy-no must contain one to six Arabic digits");
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "0", after: "227" }));
body.push(redDoubleRule({ upperMm: "0.35", lowerMm: "0.25" }));
if (args["doc-no"]) args["doc-no"] = normalizeDocumentNumber(args["doc-no"]);
const letterFields = [];
if (args["copy-no"]) letterFields.push({ text: String(args["copy-no"]).padStart(6, "0"), fontPreset: "body" });
else if (args.secret) letterFields.push({ text: "", fontPreset: "body" });
if (args.secret) letterFields.push({ text: args.secret, fontPreset: "h1" });
if (args.urgent) letterFields.push({ text: args.urgent, fontPreset: "h1" });
if (letterFields.length || args["doc-no"]) {
const first = letterFields[0] ?? { text: "", fontPreset: "body" };
body.push(letterHeaderRow(first.text, args["doc-no"] ?? ""));
for (const field of letterFields.slice(1)) {
body.push(headerFieldParagraph(field.text, field.fontPreset));
}
}
titleGapLines = 2;
} else if (format === "command") {
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 令号");
// 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" }));
body.push(...blankGridLines(2));
}
} else if (format === "minutes") {
if (!args.org) throw new Error("minutes format requires --org (for XXXXX纪要)");
if (!/纪要$/.test(String(args.org).trim())) throw new Error("minutes --org must end with“纪要”");
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "1984", after: "560" }));
}
if (args.date) args.date = normalizeChineseDate(args.date);
if (args["print-date"]) args["print-date"] = normalizeChineseDate(args["print-date"], "--print-date", true);
if (title) {
if (titleGapLines) body.push(...blankGridLines(titleGapLines));
body.push(titleParagraph(title));
}
if (args.subtitle) body.push(subtitleParagraph(args.subtitle));
if (mainSend) body.push(mainSendParagraph(mainSend));
for (const block of sourceBlocks) {
if (block.type === "heading") {
body.push(renderHeading(block));
continue;
}
if (block.type === "table") {
body.push(tableXml(block.headers, block.rows, horizontalTable ? horizontalContentWidth : CONTENT_W));
body.push(emptyLine());
continue;
}
body.push(renderParagraph(block));
}
if (args["attachment-note"]) {
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));
}
if (args.note) body.push(paragraph(`(${args.note.replace(/^(|)$/g, "")})`, { align: "left", fontPreset: "body", size: "32" }));
const attachmentFiles = Array.isArray(args["attachment-file"]) ? args["attachment-file"] : args["attachment-file"] ? [args["attachment-file"]] : [];
const absoluteAttachmentFiles = attachmentFiles.map((filePath) => {
const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) throw new Error(`attachment file not found: ${filePath}`);
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, args["attachment-detached"] ? args["doc-no"] : ""));
});
if (format !== "letter") body.push(...colophon(args));
const letterFooter = format === "letter" ? '' : "";
const footerRefs = letterFooter || (pageNumberMode === "standard"
? ''
: pageNumberMode === "center"
? ''
: "");
const footerDistance = format === "letter" || pageNumberMode === "standard" ? "1134" : "1588";
const pageSize = horizontalTable
? ``
: ``;
body.push(`${footerRefs}${pageSize}`);
return `${body.join("")}`;
}
function stylesXml() {
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") {
const alt = altName ? `` : "";
return `${alt}`;
}
function fontTableXml() {
const fonts = [
fontDef("方正小标宋简体", "Songti SC"),
fontDef("方正小标宋_GBK", "Songti SC"),
fontDef("FZXiaoBiaoSong-B05S", "方正小标宋简体"),
fontDef("FZXiaoBiaoSong-B13S", "方正小标宋_GBK"),
fontDef("华文中宋", "STZhongsong"),
fontDef("STZhongsong", "华文中宋"),
fontDef("仿宋", "STFangsong"),
fontDef("仿宋_GB2312", "STFangsong"),
fontDef("FangSong", "仿宋"),
fontDef("FangSong_GB2312", "仿宋_GB2312"),
fontDef("STFangsong", "仿宋"),
fontDef("黑体", "Heiti SC", "swiss"),
fontDef("SimHei", "黑体", "swiss"),
fontDef("Heiti SC", "黑体", "swiss"),
fontDef("楷体", "Kaiti SC"),
fontDef("楷体_GB2312", "Kaiti SC"),
fontDef("KaiTi", "楷体"),
fontDef("KaiTi_GB2312", "楷体_GB2312"),
fontDef("Kaiti SC", "楷体"),
fontDef("宋体", "Songti SC"),
fontDef("SimSun", "宋体"),
fontDef("Songti SC", "宋体"),
fontDef("Times New Roman", "", "roman", "00"),
].join("");
return `${fonts}`;
}
function footerXml(align = "center") {
const pageRunProps = ``;
const sideIndent = align === "right"
? ''
: align === "left"
? ''
: "";
return `${sideIndent}${pageRunProps}— ${pageRunProps}1${pageRunProps} —`;
}
function letterFooterXml() {
return `${redDoubleRule({ upperMm: "0.25", lowerMm: "0.35" })}`;
}
function settingsXml(pageNumberMode) {
const evenOdd = pageNumberMode === "standard" ? "" : "";
return `${evenOdd}`;
}
function documentRelsXml(pageNumberMode, format) {
const footerRels = format === "letter"
? ''
: pageNumberMode === "standard"
? ''
: pageNumberMode === "center"
? ''
: "";
return `${footerRels}`;
}
function writeDocx(output, documentXml, pageNumberMode, format) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gongwen-docx-"));
fs.mkdirSync(path.join(dir, "_rels"), { recursive: true });
fs.mkdirSync(path.join(dir, "word"), { recursive: true });
fs.mkdirSync(path.join(dir, "word/_rels"), { recursive: true });
const footerTypes = format === "letter"
? ''
: pageNumberMode === "standard"
? ''
: pageNumberMode === "center"
? ''
: "";
fs.writeFileSync(path.join(dir, "[Content_Types].xml"), `${footerTypes}`);
fs.writeFileSync(path.join(dir, "_rels/.rels"), ``);
fs.writeFileSync(path.join(dir, "word/_rels/document.xml.rels"), documentRelsXml(pageNumberMode, format));
fs.writeFileSync(path.join(dir, "word/document.xml"), documentXml);
fs.writeFileSync(path.join(dir, "word/styles.xml"), stylesXml());
fs.writeFileSync(path.join(dir, "word/fontTable.xml"), fontTableXml());
fs.writeFileSync(path.join(dir, "word/settings.xml"), settingsXml(pageNumberMode));
if (format === "letter") {
fs.writeFileSync(path.join(dir, "word/footerLetter.xml"), letterFooterXml());
} else if (pageNumberMode === "standard") {
fs.writeFileSync(path.join(dir, "word/footerOdd.xml"), footerXml("right"));
fs.writeFileSync(path.join(dir, "word/footerEven.xml"), footerXml("left"));
} else if (pageNumberMode === "center") {
fs.writeFileSync(path.join(dir, "word/footerCenter.xml"), footerXml("center"));
}
fs.rmSync(output, { force: true });
execFileSync("zip", ["-qr", output, "."], { cwd: dir });
fs.rmSync(dir, { recursive: true, force: true });
}
let args;
try {
args = parseArgs(process.argv);
} catch (error) {
console.error(`GENERATOR ERROR: ${error.message}`);
process.exit(2);
}
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");
try {
const blocks = parseMarkdown(md);
const documentXml = buildDocument(blocks, args);
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)}`);
} catch (error) {
console.error(`GENERATOR ERROR: ${error.message}`);
process.exit(2);
}