unis_crm/docs/scripts/render-formal-pdfs.mjs

692 lines
18 KiB
JavaScript

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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function inlineMarkdownToHtml(text, baseDir) {
let html = escapeHtml(text);
html = html.replace(/`([^`]+)`/g, (_, code) => `<code>${escapeHtml(code)}</code>`);
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
const resolved = resolveHref(href, baseDir);
return `<a href="${resolved}">${escapeHtml(label)}</a>`;
});
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(`<h${block.level} id="${id}">${escapeHtml(block.text)}</h${block.level}>`);
continue;
}
if (block.type === "paragraph") {
html.push(`<p>${inlineMarkdownToHtml(block.text, baseDir)}</p>`);
continue;
}
if (block.type === "list") {
const tag = block.ordered ? "ol" : "ul";
const items = block.items
.map((item) => `<li>${inlineMarkdownToHtml(item, baseDir)}</li>`)
.join("");
html.push(`<${tag}>${items}</${tag}>`);
continue;
}
if (block.type === "image") {
html.push(
`<figure><img src="${block.src}" alt="${escapeHtml(block.alt || "截图")}" /><figcaption>${escapeHtml(block.alt || "")}</figcaption></figure>`,
);
continue;
}
if (block.type === "code") {
if (block.language === "mermaid") {
html.push(
`<div class="mermaid-box"><div class="mermaid-title">架构示意</div><pre>${escapeHtml(block.content)}</pre></div>`,
);
} else {
html.push(
`<pre><code>${escapeHtml(block.content)}</code></pre>`,
);
}
continue;
}
if (block.type === "table") {
const [headerRow, ...bodyRows] = block.rows;
const thead = `<thead><tr>${headerRow.map((cell) => `<th>${inlineMarkdownToHtml(cell, baseDir)}</th>`).join("")}</tr></thead>`;
const tbody = `<tbody>${bodyRows.map((row) => `<tr>${row.map((cell) => `<td>${inlineMarkdownToHtml(cell, baseDir)}</td>`).join("")}</tr>`).join("")}</tbody>`;
html.push(`<table>${thead}${tbody}</table>`);
}
}
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 `<li class="${cls}"><a href="#${item.id}">${escapeHtml(item.text)}</a></li>`;
})
.join("");
return `<section class="toc-page page-break"><h2>目录</h2><ul class="toc-list">${items}</ul></section>`;
}
function buildCover(title, options) {
return `
<section class="cover-page">
<div class="cover-inner">
<div class="cover-mark">UNIS CRM</div>
<div class="cover-accent"></div>
<p class="cover-kicker">紫光汇智客户关系管理平台</p>
<h1 class="cover-title">${escapeHtml(title)}</h1>
<p class="cover-subtitle">${escapeHtml(options.subtitle)}</p>
<div class="cover-meta">
<div class="meta-item"><span>文档编号</span><strong>${escapeHtml(options.docCode)}</strong></div>
<div class="meta-item"><span>适用对象</span><strong>${escapeHtml(options.audience)}</strong></div>
<div class="meta-item"><span>版本状态</span><strong>正式交付版</strong></div>
<div class="meta-item"><span>编制日期</span><strong>${currentDate}</strong></div>
</div>
</div>
</section>
`;
}
function buildHtml(title, bodyContent, tocHtml, options) {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
<style>
@page {
size: A4;
margin: 16mm 16mm 16mm 16mm;
}
:root {
--brand: #5b21b6;
--brand-soft: #ede9fe;
--text: #0f172a;
--muted: #475569;
--line: #dbe2ea;
--page-width: 178mm;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
color: var(--text);
background: #fff;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 12px;
line-height: 1.72;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
a {
color: inherit;
text-decoration: none;
}
.document {
width: 100%;
max-width: var(--page-width);
margin: 0 auto;
padding: 0;
}
.cover-page {
min-height: 255mm;
display: flex;
align-items: center;
page-break-after: always;
position: relative;
overflow: hidden;
}
.cover-page::before {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 18%, rgba(109, 40, 217, 0.13), transparent 30%),
radial-gradient(circle at 82% 82%, rgba(14, 165, 233, 0.1), transparent 30%),
linear-gradient(180deg, #ffffff 0%, #faf7ff 100%);
border: 1px solid #ede9fe;
border-radius: 20px;
}
.cover-inner {
position: relative;
z-index: 1;
width: 100%;
padding: 24mm 18mm;
}
.cover-mark {
font-size: 11px;
letter-spacing: 0.22em;
color: var(--brand);
font-weight: 700;
margin-bottom: 14mm;
}
.cover-accent {
width: 52mm;
height: 3mm;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), #7c3aed, #38bdf8);
margin-bottom: 8mm;
}
.cover-kicker {
margin: 0 0 4mm;
color: #6b7280;
letter-spacing: 0.06em;
font-size: 12px;
}
.cover-title {
margin: 0;
font-size: 28px;
line-height: 1.3;
color: #111827;
}
.cover-subtitle {
margin: 6mm 0 14mm;
font-size: 14px;
color: #5b21b6;
font-weight: 600;
}
.cover-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8mm 10mm;
margin-top: 16mm;
max-width: 130mm;
}
.meta-item {
padding: 5mm 5.5mm;
border-radius: 12px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid #e9d5ff;
box-shadow: 0 10px 30px rgba(91, 33, 182, 0.06);
}
.meta-item span {
display: block;
font-size: 10px;
color: #6b7280;
margin-bottom: 2mm;
}
.meta-item strong {
display: block;
font-size: 12px;
color: #111827;
}
.toc-page {
min-height: 240mm;
}
.page-break {
page-break-before: always;
}
h1, h2, h3, h4 {
color: #111827;
page-break-after: avoid;
}
h2 {
font-size: 18px;
margin: 0 0 10px;
padding-left: 8px;
border-left: 4px solid var(--brand);
}
h3 {
font-size: 14px;
margin: 16px 0 8px;
color: #1f2937;
}
h4 {
font-size: 12px;
margin: 14px 0 6px;
}
p {
margin: 8px 0;
text-align: justify;
}
ul, ol {
margin: 8px 0 10px 18px;
padding: 0;
}
li {
margin: 3px 0;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
background: #f3f4f6;
border-radius: 4px;
padding: 1px 4px;
font-size: 11px;
}
pre {
margin: 10px 0 14px;
padding: 12px 14px;
border-radius: 10px;
background: #0f172a;
color: #e5e7eb;
white-space: pre-wrap;
word-break: break-word;
page-break-inside: avoid;
}
pre code {
background: transparent;
padding: 0;
color: inherit;
}
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0 14px;
page-break-inside: avoid;
}
th, td {
border: 1px solid #d7dee8;
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
th {
background: #f5f3ff;
color: #4c1d95;
font-weight: 700;
}
figure {
margin: 12px 0 18px;
page-break-inside: avoid;
}
img {
display: block;
width: 100%;
max-width: 170mm;
margin: 0 auto;
border: 1px solid #e5e7eb;
border-radius: 10px;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
figcaption {
text-align: center;
margin-top: 6px;
font-size: 10px;
color: #6b7280;
}
.content {
page-break-before: always;
padding-top: 0;
}
.content-head {
margin: 0 0 14px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #fafafa 100%);
page-break-inside: avoid;
}
.content-head-top,
.content-head-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.content-head-top {
margin-bottom: 8px;
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.content-head-bottom {
font-size: 11px;
color: #475569;
border-top: 1px solid #e5e7eb;
padding-top: 8px;
}
.toc-list {
list-style: none;
margin: 14px 0 0;
padding: 0;
border: 1px solid #e5e7eb;
border-radius: 14px;
overflow: hidden;
}
.toc-item {
margin: 0;
padding: 10px 14px;
border-bottom: 1px solid #eef2f7;
background: #fff;
}
.toc-item:last-child {
border-bottom: 0;
}
.toc-item a {
display: block;
color: #1f2937;
}
.toc-sub {
padding-left: 26px;
background: #fafafa;
color: #475569;
}
.mermaid-box {
margin: 12px 0 16px;
border: 1px solid #ddd6fe;
border-radius: 12px;
background: #faf7ff;
overflow: hidden;
page-break-inside: avoid;
}
.mermaid-title {
padding: 8px 12px;
background: #ede9fe;
color: #5b21b6;
font-weight: 700;
font-size: 11px;
letter-spacing: 0.04em;
}
.mermaid-box pre {
margin: 0;
border-radius: 0;
background: transparent;
color: #312e81;
}
.doc-note {
margin: 0 0 12px;
padding: 9px 12px;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 11px;
}
</style>
</head>
<body>
<main class="document">
${buildCover(title, options)}
${tocHtml}
<section class="content">
<div class="content-head">
<div class="content-head-top">
<span>UNIS CRM</span>
<span>${escapeHtml(options.docCode)}</span>
</div>
<div class="content-head-bottom">
<span>${escapeHtml(title)}</span>
<span>${currentDate}</span>
</div>
</div>
<div class="doc-note">说明:本 PDF 为正式交付版排版,内容依据仓库中的 Markdown 文档自动生成。</div>
${bodyContent}
</section>
</main>
</body>
</html>`;
}
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;
});