692 lines
18 KiB
JavaScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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;
|
|
});
|