Files
sso-portal/scripts/render-markdown-pdf.mjs
2026-07-14 15:20:54 +08:00

206 lines
6.8 KiB
JavaScript

import { readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
const [mode, inputPath, workDir, outputPath] = process.argv.slice(2)
if (!['prepare', 'render'].includes(mode) || !inputPath || !workDir) {
console.error('Usage: render-markdown-pdf.mjs <prepare|render> <input.md> <work-dir> [output.html]')
process.exit(1)
}
const markdown = await readFile(inputPath, 'utf8')
function escapeHtml(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function inline(text) {
const tokens = []
let value = escapeHtml(text)
value = value.replace(/`([^`]+)`/g, (_, code) => {
tokens.push(`<code>${code}</code>`)
return `\u0000${tokens.length - 1}\u0000`
})
value = value.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
value = value.replace(/\[(.+?)\]\((https?:[^)]+)\)/g, '<a href="$2">$1</a>')
return value.replace(/\u0000(\d+)\u0000/g, (_, index) => tokens[Number(index)])
}
function isTableDivider(line) {
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line)
}
function cells(line) {
return line
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => inline(cell.trim()))
}
function renderMarkdown(source, diagrams) {
const diagramPattern = /```mermaid\n[\s\S]*?\n```/g
let index = 0
const prepared = source.replace(diagramPattern, () => `@@MERMAID_${index++}@@`)
const lines = prepared.replace(/\r\n/g, '\n').split('\n')
const output = []
for (let i = 0; i < lines.length;) {
const line = lines[i]
if (!line.trim()) {
i += 1
continue
}
const diagram = line.match(/^@@MERMAID_(\d+)@@$/)
if (diagram) {
const svg = diagrams[Number(diagram[1])] || ''
output.push(`<div class="mermaid-diagram">${svg}</div>`)
i += 1
continue
}
const fence = line.match(/^```([^`]*)$/)
if (fence) {
const language = fence[1].trim()
const code = []
i += 1
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
code.push(lines[i])
i += 1
}
if (i < lines.length) i += 1
output.push(`<pre><code class="language-${escapeHtml(language)}">${escapeHtml(code.join('\n'))}</code></pre>`)
continue
}
const heading = line.match(/^(#{1,6})\s+(.+)$/)
if (heading) {
const level = heading[1].length
output.push(`<h${level}>${inline(heading[2])}</h${level}>`)
i += 1
continue
}
if (line.startsWith('> ')) {
const quote = []
while (i < lines.length && lines[i].startsWith('> ')) {
quote.push(lines[i].slice(2))
i += 1
}
output.push(`<blockquote>${inline(quote.join('<br>'))}</blockquote>`)
continue
}
if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1])) {
const header = cells(line)
const rows = []
i += 2
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
rows.push(cells(lines[i]))
i += 1
}
output.push(`<table><thead><tr>${header.map((cell) => `<th>${cell}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table>`)
continue
}
const unordered = line.match(/^[-*]\s+(.*)$/)
const ordered = line.match(/^\d+\.\s+(.*)$/)
if (unordered || ordered) {
const tag = ordered ? 'ol' : 'ul'
const items = []
while (i < lines.length) {
const match = tag === 'ol'
? lines[i].match(/^\d+\.\s+(.*)$/)
: lines[i].match(/^[-*]\s+(.*)$/)
if (!match) break
const task = match[1].match(/^\[([ xX])\]\s+(.*)$/)
const content = task
? `<span class="checkbox">${task[1].trim() ? '☑' : '☐'}</span> ${inline(task[2])}`
: inline(match[1])
items.push(`<li>${content}</li>`)
i += 1
}
output.push(`<${tag}>${items.join('')}</${tag}>`)
continue
}
const paragraph = [line.trim()]
i += 1
while (
i < lines.length &&
lines[i].trim() &&
!/^(#{1,6})\s+/.test(lines[i]) &&
!/^```/.test(lines[i]) &&
!/^@@MERMAID_\d+@@$/.test(lines[i]) &&
!/^[-*]\s+/.test(lines[i]) &&
!/^\d+\.\s+/.test(lines[i]) &&
!lines[i].startsWith('> ') &&
!(lines[i].includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]))
) {
paragraph.push(lines[i].trim())
i += 1
}
output.push(`<p>${inline(paragraph.join(' '))}</p>`)
}
return output.join('\n')
}
if (mode === 'prepare') {
const diagrams = [...markdown.matchAll(/```mermaid\n([\s\S]*?)\n```/g)].map((match) => match[1])
await Promise.all(diagrams.map((diagram, index) => writeFile(path.join(workDir, `diagram-${index}.mmd`), diagram)))
process.exit(0)
}
if (!outputPath) {
console.error('An HTML output path is required for render mode.')
process.exit(1)
}
const svgFiles = (await readdir(workDir))
.filter((file) => /^diagram-\d+\.svg$/.test(file))
.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]))
const diagrams = await Promise.all(svgFiles.map((file) => readFile(path.join(workDir, file), 'utf8')))
const body = renderMarkdown(markdown, diagrams)
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Markdown PDF</title>
<style>
@page { size: A4; margin: 18mm 15mm; }
* { box-sizing: border-box; }
body { color: #1f2937; font: 10.5pt/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; }
h1 { margin: 0 0 8px; color: #0f4c81; font-size: 24pt; }
h2 { margin-top: 26px; border-bottom: 1px solid #dbe4ef; padding-bottom: 5px; color: #123b62; font-size: 16pt; }
h3 { margin-top: 18px; color: #174f84; font-size: 12pt; }
p, li, table, .mermaid-diagram { break-inside: avoid; }
table { width: 100%; margin: 12px 0; border-collapse: collapse; font-size: 9.5pt; }
th { background: #eaf3fb; color: #123b62; font-weight: 600; }
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; text-align: left; vertical-align: top; }
code { border-radius: 3px; background: #f1f5f9; padding: 1px 4px; color: #a33d10; font-family: "SFMono-Regular", Consolas, monospace; }
pre { overflow-wrap: anywhere; border: 1px solid #dbe4ef; border-radius: 4px; background: #f8fafc; padding: 10px; white-space: pre-wrap; font-size: 8.8pt; }
pre code { background: transparent; padding: 0; color: #1f2937; }
blockquote { margin: 10px 0; border-left: 3px solid #67a6d8; padding-left: 12px; color: #475569; }
.mermaid-diagram { margin: 14px 0; text-align: center; }
.mermaid-diagram svg { max-width: 100%; height: auto; }
.checkbox { color: #0f4c81; }
a { color: #0f4c81; }
</style>
</head>
<body>${body}</body>
</html>`
await writeFile(outputPath, html)