#!/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] [--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] 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"]); const repeatable = new Set(["attachment-file"]); 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; } if (repeatable.has(name)) { args[name] = [...(args[name] ?? []), argv[i + 1]]; } else { args[name] = argv[i + 1]; } 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"; return `${bold}${esc(text)}`; } 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) { return paragraph(text, { align: "center", indent: false, fontPreset: "title", size: "56", color: "FF0000", before: AGENCY_FRAME_BEFORE, frame: { hAnchor: "margin", vAnchor: "margin", xAlign: "center", y: AGENCY_FRAME_Y, w: CONTENT_W, h: 800 }, }); } function agencyMarkFlowSpacer() { return paragraph("\u00a0", { align: "left", indent: false, fontPreset: "body", size: "2", line: "560", snapToGrid: false, before: AGENCY_FLOW_BEFORE, 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) { const colCount = headers.length; const colWidth = Math.floor(CONTENT_W / 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) { 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 }), 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") 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 = resolvePageNumberMode(args, format); const body = []; let titleGapLines = 0; const pageMarginTop = format === "letter" ? 1701 : format === "command" ? 1134 : 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. body.push(floatingAgencyMarkParagraph(args.org)); body.push(agencyMarkFlowSpacer()); 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 令号"); body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "0", 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)); 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"])); } 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); absoluteAttachmentFiles.forEach((absolutePath, index) => { const attachmentBlocks = parseMarkdown(fs.readFileSync(absolutePath, "utf8")); body.push(attachmentPage(attachmentBlocks, index + 1, absolutePath)); }); 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" ? '' : ""; const footerRefs = letterFooter || (pageNumberMode === "standard" ? '' : pageNumberMode === "center" ? '' : ""); const footerDistance = format === "letter" || pageNumberMode === "standard" ? "1134" : "1588"; body.push(`${footerRefs}`); 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 }); } const args = parseArgs(process.argv); 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"].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); }