import fs from "fs/promises"; import os from "os"; import path from "path"; import { execFile } from "child_process"; import { promisify } from "util"; const execFileAsync = promisify(execFile); const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", ".."); const docsDir = path.join(rootDir, "docs"); const chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const currentDate = "2026-04-03"; const documents = [ { source: path.join(docsDir, "system-operation-manual.md"), output: path.join(docsDir, "system-operation-manual.pdf"), docCode: "UNIS-CRM-UM-2026", subtitle: "正式交付版", audience: "业务用户 / 管理人员", }, { source: path.join(docsDir, "system-construction.md"), output: path.join(docsDir, "system-construction.pdf"), docCode: "UNIS-CRM-BUILD-2026", subtitle: "正式交付版", audience: "项目组 / 运维 / 交付人员", }, ]; function escapeHtml(value) { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function inlineMarkdownToHtml(text, baseDir) { let html = escapeHtml(text); html = html.replace(/`([^`]+)`/g, (_, code) => `${escapeHtml(code)}`); html = html.replace(/\*\*([^*]+)\*\*/g, "$1"); html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => { const resolved = resolveHref(href, baseDir); return `${escapeHtml(label)}`; }); return html; } function resolveHref(href, baseDir) { if (/^(https?:|file:|data:|#)/i.test(href)) { return href; } const absolutePath = path.resolve(baseDir, href); return `file://${absolutePath}`; } function slugify(text, fallbackIndex) { const normalized = text .toLowerCase() .replace(/[`~!@#$%^&*()+=[\]{};:'"\\|,.<>/?,。;:()【】《》?、\s]+/g, "-") .replace(/^-+|-+$/g, ""); return normalized || `section-${fallbackIndex}`; } function extractTitle(markdown) { const match = markdown.match(/^#\s+(.+)$/m); return match ? match[1].trim() : "文档"; } function parseBlocks(markdown, baseDir) { const lines = markdown.replace(/\r\n/g, "\n").split("\n"); const blocks = []; let index = 0; while (index < lines.length) { const line = lines[index]; const trimmed = line.trim(); if (!trimmed) { index += 1; continue; } const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { blocks.push({ type: "heading", level: headingMatch[1].length, text: headingMatch[2].trim(), }); index += 1; continue; } const codeMatch = trimmed.match(/^```(\w+)?$/); if (codeMatch) { const language = codeMatch[1] || ""; index += 1; const content = []; while (index < lines.length && !lines[index].trim().startsWith("```")) { content.push(lines[index]); index += 1; } if (index < lines.length) { index += 1; } blocks.push({ type: "code", language, content: content.join("\n"), }); continue; } const imageMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)$/); if (imageMatch) { blocks.push({ type: "image", alt: imageMatch[1].trim(), src: resolveHref(imageMatch[2].trim(), baseDir), }); index += 1; continue; } if ( trimmed.includes("|") && index + 1 < lines.length && /^\|?[\s:-]+(\|[\s:-]+)+\|?$/.test(lines[index + 1].trim()) ) { const tableLines = [trimmed]; index += 2; while (index < lines.length && lines[index].trim().includes("|")) { tableLines.push(lines[index].trim()); index += 1; } blocks.push({ type: "table", rows: tableLines.map((row) => row .replace(/^\|/, "") .replace(/\|$/, "") .split("|") .map((cell) => cell.trim()), ), }); continue; } const listMatch = trimmed.match(/^([-*]|\d+\.)\s+(.+)$/); if (listMatch) { const ordered = /\d+\./.test(listMatch[1]); const items = []; while (index < lines.length) { const itemMatch = lines[index].trim().match(/^([-*]|\d+\.)\s+(.+)$/); if (!itemMatch) { break; } items.push(itemMatch[2].trim()); index += 1; } blocks.push({ type: "list", ordered, items, }); continue; } const paragraphLines = [trimmed]; index += 1; while (index < lines.length) { const next = lines[index].trim(); if ( !next || /^#{1,6}\s+/.test(next) || /^```/.test(next) || /^!\[/.test(next) || /^([-*]|\d+\.)\s+/.test(next) || ( next.includes("|") && index + 1 < lines.length && /^\|?[\s:-]+(\|[\s:-]+)+\|?$/.test(lines[index + 1].trim()) ) ) { break; } paragraphLines.push(next); index += 1; } blocks.push({ type: "paragraph", text: paragraphLines.join(" "), }); } return blocks; } function buildContentAndToc(blocks, baseDir) { const toc = []; const html = []; let headingIndex = 0; let skippedTitle = false; for (const block of blocks) { if (block.type === "heading") { headingIndex += 1; if (block.level === 1 && !skippedTitle) { skippedTitle = true; continue; } const id = slugify(block.text, headingIndex); if (block.level <= 3) { toc.push({ level: block.level, text: block.text, id }); } html.push(`${escapeHtml(block.text)}`); continue; } if (block.type === "paragraph") { html.push(`

${inlineMarkdownToHtml(block.text, baseDir)}

`); continue; } if (block.type === "list") { const tag = block.ordered ? "ol" : "ul"; const items = block.items .map((item) => `
  • ${inlineMarkdownToHtml(item, baseDir)}
  • `) .join(""); html.push(`<${tag}>${items}`); continue; } if (block.type === "image") { html.push( `
    ${escapeHtml(block.alt ||
    ${escapeHtml(block.alt || "")}
    `, ); continue; } if (block.type === "code") { if (block.language === "mermaid") { html.push( `
    架构示意
    ${escapeHtml(block.content)}
    `, ); } else { html.push( `
    ${escapeHtml(block.content)}
    `, ); } continue; } if (block.type === "table") { const [headerRow, ...bodyRows] = block.rows; const thead = `${headerRow.map((cell) => `${inlineMarkdownToHtml(cell, baseDir)}`).join("")}`; const tbody = `${bodyRows.map((row) => `${row.map((cell) => `${inlineMarkdownToHtml(cell, baseDir)}`).join("")}`).join("")}`; html.push(`${thead}${tbody}
    `); } } return { html: html.join("\n"), toc, }; } function renderToc(toc) { const items = toc .filter((item) => item.level >= 2 && item.level <= 3) .map((item) => { const cls = item.level === 3 ? "toc-item toc-sub" : "toc-item"; return `
  • ${escapeHtml(item.text)}
  • `; }) .join(""); return `

    目录

    `; } function buildCover(title, options) { return `
    UNIS CRM

    紫光汇智客户关系管理平台

    ${escapeHtml(title)}

    ${escapeHtml(options.subtitle)}

    文档编号${escapeHtml(options.docCode)}
    适用对象${escapeHtml(options.audience)}
    版本状态正式交付版
    编制日期${currentDate}
    `; } function buildHtml(title, bodyContent, tocHtml, options) { return ` ${escapeHtml(title)}
    ${buildCover(title, options)} ${tocHtml}
    UNIS CRM ${escapeHtml(options.docCode)}
    ${escapeHtml(title)} ${currentDate}
    说明:本 PDF 为正式交付版排版,内容依据仓库中的 Markdown 文档自动生成。
    ${bodyContent}
    `; } async function renderDocument(config) { const markdown = await fs.readFile(config.source, "utf8"); const title = extractTitle(markdown); const blocks = parseBlocks(markdown, path.dirname(config.source)); const { html, toc } = buildContentAndToc(blocks, path.dirname(config.source)); const fullHtml = buildHtml(title, html, renderToc(toc), config); const tempHtml = path.join(os.tmpdir(), `${path.basename(config.output, ".pdf")}-${Date.now()}.html`); await fs.writeFile(tempHtml, fullHtml, "utf8"); try { await execFileAsync( chromePath, [ "--headless=new", "--disable-gpu", "--allow-file-access-from-files", "--no-pdf-header-footer", `--print-to-pdf=${config.output}`, "--print-to-pdf-no-header", `file://${tempHtml}`, ], { cwd: rootDir, maxBuffer: 10 * 1024 * 1024, }, ); } finally { await fs.rm(tempHtml, { force: true }); } } async function main() { const requested = process.argv[2]; const targetDocuments = requested ? documents.filter((documentConfig) => path.basename(documentConfig.output) === requested || path.basename(documentConfig.source) === requested) : documents; if (requested && targetDocuments.length === 0) { throw new Error(`未找到需要导出的文档:${requested}`); } for (const documentConfig of targetDocuments) { console.log(`Rendering ${path.basename(documentConfig.output)} ...`); await renderDocument(documentConfig); } } main().catch((error) => { console.error(error); process.exitCode = 1; });