458 lines
15 KiB
JavaScript
458 lines
15 KiB
JavaScript
/**
|
||
* md2img - Markdown + Mermaid + LaTeX => PNG
|
||
*
|
||
* Usage:
|
||
* node render.js <input.md> [output.png] [--paper=a4|letter] [--dark|--light]
|
||
*
|
||
* Rendering strategy:
|
||
* - LaTeX (KaTeX): pre-rendered in Node.js → inline HTML
|
||
* - Mermaid: rendered in browser via mermaid.js CDN
|
||
* - Code highlighting: highlight.js via CDN
|
||
*
|
||
* Browser: auto-detects puppeteer-core (local Edge/Chrome) or puppeteer (bundled Chromium)
|
||
* Environment variable CHROME_PATH can override browser executable path
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { marked } = require('marked');
|
||
const katex = require('katex');
|
||
|
||
// CLI args
|
||
const args = process.argv.slice(2);
|
||
let inputPath = null;
|
||
let outputPath = null;
|
||
let paper = 'a4';
|
||
let forcedTheme = null;
|
||
|
||
for (const arg of args) {
|
||
if (arg.startsWith('--paper=')) { paper = arg.split('=')[1]; }
|
||
else if (arg === '--dark') { forcedTheme = 'dark'; }
|
||
else if (arg === '--light') { forcedTheme = 'light'; }
|
||
else if (!inputPath) { inputPath = arg; }
|
||
else if (!outputPath) { outputPath = arg; }
|
||
}
|
||
|
||
if (!inputPath) {
|
||
console.error('Usage: node render.js <input.md> [output.png] [--paper=a4|letter] [--dark|--light]');
|
||
process.exit(1);
|
||
}
|
||
|
||
inputPath = path.resolve(inputPath);
|
||
if (!outputPath) { outputPath = inputPath.replace(/\.md$/i, '.png'); }
|
||
outputPath = path.resolve(outputPath);
|
||
|
||
const markdown = fs.readFileSync(inputPath, 'utf-8');
|
||
|
||
// Page dimensions
|
||
const PAGE_WIDTH = paper === 'letter' ? 816 : 794;
|
||
const MARGIN = 48;
|
||
|
||
// Helpers
|
||
function escapeHtml(str) {
|
||
return str
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
// LaTeX rendering helper
|
||
function renderMath(text, displayMode) {
|
||
try {
|
||
return katex.renderToString(text, {
|
||
displayMode,
|
||
throwOnError: false,
|
||
strict: false, // Allow more LaTeX commands
|
||
});
|
||
} catch (e) {
|
||
return displayMode
|
||
? `<div class="math-block math-error">${escapeHtml(text)}</div>`
|
||
: `<span class="math-error">${escapeHtml(text)}</span>`;
|
||
}
|
||
}
|
||
|
||
// marked extension for inline math $...$
|
||
const mathExtension = {
|
||
name: 'math',
|
||
level: 'inline',
|
||
start(src) {
|
||
const idx = src.search(/(?<!\$)\$(?!\$)/);
|
||
return idx === -1 ? undefined : idx;
|
||
},
|
||
tokenizer(src) {
|
||
// Match $...$ but not $$ or currency patterns like $5.00
|
||
const match = src.match(/^(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/);
|
||
if (match) {
|
||
const text = match[1].trim();
|
||
// Skip currency patterns (only digits with optional decimal)
|
||
if (/^\d+[\.,]?\d*$/.test(text)) {
|
||
return;
|
||
}
|
||
return { type: 'math', raw: match[0], text, displayMode: false };
|
||
}
|
||
},
|
||
renderer(token) {
|
||
return renderMath(token.text, token.displayMode);
|
||
},
|
||
};
|
||
|
||
// marked extension for block math $$...$$
|
||
const blockMathExtension = {
|
||
name: 'blockMath',
|
||
level: 'block',
|
||
start(src) {
|
||
const idx = src.indexOf('$$');
|
||
return idx === -1 ? undefined : idx;
|
||
},
|
||
tokenizer(src) {
|
||
const match = src.match(/^\$\$([\s\S]+?)\$\$\n?/);
|
||
if (match) {
|
||
return { type: 'blockMath', raw: match[0], text: match[1].trim() };
|
||
}
|
||
},
|
||
renderer(token) {
|
||
return `<div class="math-block">${renderMath(token.text, true)}</div>`;
|
||
},
|
||
};
|
||
|
||
// marked extension for \[...\] block math
|
||
const bracketMathExtension = {
|
||
name: 'bracketMath',
|
||
level: 'block',
|
||
start(src) {
|
||
const idx = src.indexOf('\\[');
|
||
return idx === -1 ? undefined : idx;
|
||
},
|
||
tokenizer(src) {
|
||
const match = src.match(/^\\\[([\s\S]+?)\\\]\n?/);
|
||
if (match) {
|
||
return { type: 'bracketMath', raw: match[0], text: match[1].trim() };
|
||
}
|
||
},
|
||
renderer(token) {
|
||
return `<div class="math-block">${renderMath(token.text, true)}</div>`;
|
||
},
|
||
};
|
||
|
||
// marked extension for \begin{env}...\end{env} environments
|
||
const envMathExtension = {
|
||
name: 'envMath',
|
||
level: 'block',
|
||
start(src) {
|
||
const idx = src.indexOf('\\begin{');
|
||
return idx === -1 ? undefined : idx;
|
||
},
|
||
tokenizer(src) {
|
||
// Supported math environments
|
||
const envs = [
|
||
'equation', 'equation\\*',
|
||
'align', 'align\\*', 'alignat', 'alignat\\*',
|
||
'gather', 'gather\\*', 'multline', 'multline\\*',
|
||
'aligned', 'gathered', 'split', 'alignedat', 'multlined',
|
||
'matrix', 'pmatrix', 'bmatrix', 'Bmatrix', 'vmatrix', 'Vmatrix',
|
||
'smallmatrix', 'smallmatrix\\*', 'psmallmatrix', 'bsmallmatrix', 'Bsmallmatrix', 'vsmallmatrix', 'Vsmallmatrix',
|
||
'cases', 'rcases', 'numcases', 'subnumcases',
|
||
'array', 'CD',
|
||
'subarray', 'subsplit',
|
||
];
|
||
const envPattern = envs.map(e => e.replace('\\*', '\\*')).join('|');
|
||
const regex = new RegExp(`^\\\\begin\\{(${envPattern})\\}([\\s\\S]*?)\\\\end\\{\\1\\}`);
|
||
const match = src.match(regex);
|
||
if (match) {
|
||
return { type: 'envMath', raw: match[0], env: match[1], text: match[2].trim() };
|
||
}
|
||
},
|
||
renderer(token) {
|
||
return `<div class="math-block">${renderMath(token.text, true)}</div>`;
|
||
},
|
||
};
|
||
|
||
// LaTeX rendering function (post-processing for non-marked syntax)
|
||
function renderLatex(text) {
|
||
// Clean up extra <br> tags around math-block divs (from marked's breaks: true)
|
||
text = text.replace(/<br\s*\/?>\s*<div class="math-block">/gi, '<div class="math-block">');
|
||
text = text.replace(/<\/div>\s*<br\s*\/?>/gi, '</div>');
|
||
return text;
|
||
}
|
||
|
||
// Configure marked with math extensions
|
||
marked.use({
|
||
extensions: [
|
||
blockMathExtension,
|
||
bracketMathExtension,
|
||
envMathExtension,
|
||
mathExtension,
|
||
],
|
||
renderer: {
|
||
// Handle code blocks
|
||
code(code, infostring, escaped) {
|
||
const lang = (infostring || '').trim();
|
||
if (lang === 'mermaid') {
|
||
return `<div class="mermaid">${code}</div>`;
|
||
}
|
||
const langClass = lang ? ` language-${escapeHtml(lang)}` : '';
|
||
return `<pre><code class="hljs${langClass}">${escapeHtml(code)}</code></pre>`;
|
||
},
|
||
// Handle inline code
|
||
codespan(text) {
|
||
return `<code class="inline-code">${escapeHtml(text)}</code>`;
|
||
},
|
||
// Handle tables for better styling
|
||
table(header, body) {
|
||
return `<div class="table-wrapper"><table><thead>${header}</thead><tbody>${body}</tbody></table></div>`;
|
||
},
|
||
},
|
||
});
|
||
marked.setOptions({ breaks: true, gfm: true });
|
||
|
||
// renderer already defined in extension
|
||
|
||
// Build HTML
|
||
function buildHtml(md) {
|
||
let html = marked.parse(md);
|
||
html = renderLatex(html);
|
||
return html;
|
||
}
|
||
|
||
// CSS template
|
||
function buildCss(theme) {
|
||
const isDark = theme === 'dark';
|
||
// CJK font stack: Linux优先使用Noto Sans CJK,macOS用PingFang,Windows用Microsoft YaHei
|
||
const cjkFonts = "'Noto Sans SC', 'Noto Sans CJK SC', 'Source Han Sans SC', 'WenQuanYi Micro Hei', 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
||
|
||
return `
|
||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&display=swap');
|
||
|
||
:root { color-scheme: ${isDark ? 'dark' : 'light'}; }
|
||
${isDark ? `
|
||
:root { --bg: #1e1e1e; --text: #d4d4d4; --code-bg: #2d2d2d; --border: #404040; }
|
||
pre, code { background: var(--code-bg); color: var(--text); }
|
||
a { color: #79c0ff; }
|
||
.math-block { background: #2d2d2d !important; }
|
||
tr:nth-child(even) { background: rgba(255,255,255,0.03); }
|
||
blockquote { background: rgba(255,255,255,0.04); }
|
||
` : `
|
||
:root { --bg: #ffffff; --text: #24292e; --code-bg: #f6f8fa; --border: #d0d7de; }
|
||
a { color: #0550ae; }
|
||
.math-block { background: #f6f8fa !important; }
|
||
tr:nth-child(even) { background: rgba(0,0,0,0.03); }
|
||
blockquote { background: rgba(0,0,0,0.04); }
|
||
`}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
width: ${PAGE_WIDTH}px;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', ${cjkFonts};
|
||
font-size: 14px;
|
||
line-height: 1.7;
|
||
padding: ${MARGIN}px;
|
||
word-break: break-word;
|
||
overflow-wrap: break-word;
|
||
}
|
||
h1, h2, h3, h4, h5, h6 { margin: 1.2em 0 0.5em; font-weight: 600; line-height: 1.3; }
|
||
h1 { font-size: 1.8em; border-bottom: 2px solid var(--border); padding-bottom: 0.3em; }
|
||
h2 { font-size: 1.4em; border-bottom: 1px solid var(--border); padding-bottom: 0.2em; }
|
||
h3 { font-size: 1.15em; }
|
||
p { margin: 0.6em 0; }
|
||
ul, ol { margin: 0.6em 0; padding-left: 1.8em; }
|
||
li { margin: 0.2em 0; }
|
||
li > p { margin: 0.3em 0; }
|
||
pre { margin: 0.8em 0; padding: 12px 16px; border-radius: 6px; overflow-x: auto; font-size: 13px; background: var(--code-bg); border: 1px solid var(--border); }
|
||
code { font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', 'Noto Sans Mono CJK SC', monospace; font-size: 0.9em; }
|
||
p code, li code { padding: 2px 6px; border-radius: 4px; background: var(--code-bg); border: 1px solid var(--border); }
|
||
table { border-collapse: collapse; margin: 0.8em 0; width: 100%; }
|
||
th, td { border: 1px solid var(--border); padding: 6px 12px; text-align: left; }
|
||
th { background: var(--code-bg); font-weight: 600; }
|
||
.table-wrapper { overflow-x: auto; margin: 0.8em 0; }
|
||
.table-wrapper table { margin: 0; }
|
||
.inline-code { font-family: inherit; background: none; padding: 0; border: none; }
|
||
blockquote { margin: 0.8em 0; padding: 8px 16px; border-left: 4px solid var(--border); }
|
||
hr { border: none; border-top: 1px solid var(--border); margin: 1em 0; }
|
||
a { text-decoration: none; }
|
||
a:hover { text-decoration: underline; }
|
||
img { max-width: 100%; height: auto; }
|
||
.mermaid { margin: 1em 0; text-align: center; }
|
||
.mermaid svg { max-width: 100%; height: auto; }
|
||
.mermaid-error { color: #f85149; padding: 12px; border: 1px dashed #f85149; border-radius: 6px; margin: 0.5em 0; text-align: left; }
|
||
.mermaid-error pre { color: #f85149; white-space: pre-wrap; font-size: 12px; }
|
||
.math-block { display: block; text-align: center; margin: 1em 0; padding: 12px; border-radius: 6px; overflow-wrap: break-word; word-break: break-word; max-width: 100%; overflow-x: visible; }
|
||
.math-error { color: #f85149; font-family: monospace; }
|
||
/* KaTeX CJK support */
|
||
.katex { font-family: 'KaTeX_Main', 'Noto Sans SC', 'Noto Sans CJK SC', sans-serif; }
|
||
.katex .mord, .katex .mop, .katex .mbin, .katex .mrel, .katex .mopen, .katex .mclose, .katex .mpunct, .katex .minner { font-family: inherit; }
|
||
`;
|
||
}
|
||
|
||
// Browser detection
|
||
const osPlatform = process.platform;
|
||
const osArch = process.arch;
|
||
|
||
function findBrowserPath() {
|
||
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
|
||
return process.env.CHROME_PATH;
|
||
}
|
||
const candidates = {
|
||
win32: [
|
||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||
],
|
||
darwin: [
|
||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||
],
|
||
linux: [
|
||
'/usr/bin/google-chrome',
|
||
'/usr/bin/chromium-browser',
|
||
'/usr/bin/chromium',
|
||
'/snap/bin/chromium',
|
||
],
|
||
};
|
||
for (const p of (candidates[osPlatform] || [])) {
|
||
if (fs.existsSync(p)) return p;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function getBrowser() {
|
||
let browserModule, usingCore = false;
|
||
try {
|
||
browserModule = require('puppeteer-core');
|
||
usingCore = true;
|
||
} catch {
|
||
try {
|
||
browserModule = require('puppeteer');
|
||
} catch {
|
||
console.error('Error: Neither puppeteer-core nor puppeteer is installed.');
|
||
console.error('Run: cd scripts && npm install puppeteer-core');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
const browserPath = findBrowserPath();
|
||
if (usingCore) {
|
||
if (!browserPath) {
|
||
console.error('Error: puppeteer-core requires a local Chrome/Edge/Chromium.');
|
||
console.error('Set CHROME_PATH env var or install Chrome/Edge/Chromium.');
|
||
process.exit(1);
|
||
}
|
||
console.log(`Using: puppeteer-core`);
|
||
console.log(`Browser: ${browserPath}`);
|
||
return browserModule.launch({
|
||
headless: 'new',
|
||
executablePath: browserPath,
|
||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||
});
|
||
} else {
|
||
console.log('Using: puppeteer (bundled Chromium)');
|
||
const args = ['--no-sandbox', '--disable-setuid-sandbox'];
|
||
if (osPlatform === 'linux') {
|
||
args.push('--disable-dev-shm-usage', '--disable-gpu', '--no-zygote');
|
||
}
|
||
return browserModule.launch({ headless: 'new', args });
|
||
}
|
||
}
|
||
|
||
// Main
|
||
async function main() {
|
||
console.log(`Input: ${inputPath}`);
|
||
console.log(`Output: ${outputPath}`);
|
||
console.log(`Paper: ${paper.toUpperCase()}`);
|
||
console.log(`Theme: ${forcedTheme || 'auto'}`);
|
||
console.log(`OS: ${osPlatform} (${osArch})`);
|
||
|
||
const bodyHtml = buildHtml(markdown);
|
||
const theme = forcedTheme || 'light';
|
||
const mermaidTheme = theme;
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="zh">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=${PAGE_WIDTH}">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/${theme === 'dark' ? 'github-dark' : 'github'}.min.css">
|
||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/highlight.min.js"></script>
|
||
<style>${buildCss(theme)}</style>
|
||
</head>
|
||
<body>
|
||
${bodyHtml}
|
||
<script>
|
||
mermaid.initialize({
|
||
startOnLoad: true,
|
||
theme: '${mermaidTheme}',
|
||
securityLevel: 'loose',
|
||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", "Noto Sans SC", "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Micro Hei", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||
errorNodes: {
|
||
onError: function(error, node) {
|
||
node.classList.add('mermaid-error');
|
||
node.innerHTML = '<pre>' + error.message + '</pre>';
|
||
}
|
||
}
|
||
});
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Highlight code blocks with error handling
|
||
document.querySelectorAll('pre code:not(.hljs)').forEach(function(block) {
|
||
hljs.highlightElement(block);
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
|
||
const browser = await getBrowser();
|
||
|
||
try {
|
||
const page = await browser.newPage();
|
||
await page.setViewport({ width: PAGE_WIDTH, height: 800, deviceScaleFactor: 2 });
|
||
|
||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||
|
||
// Wait for mermaid to finish rendering
|
||
await page.waitForFunction(() => {
|
||
const mermaidDivs = document.querySelectorAll('.mermaid');
|
||
if (mermaidDivs.length === 0) return true;
|
||
return Array.from(mermaidDivs).every(div => div.getAttribute('data-processed') === 'true' || div.querySelector('svg'));
|
||
}, { timeout: 30000 });
|
||
|
||
// Extra wait for font rendering
|
||
await sleep(1500);
|
||
|
||
const bodyHeight = await page.evaluate(() => document.body.scrollHeight);
|
||
|
||
await page.setViewport({
|
||
width: PAGE_WIDTH,
|
||
height: Math.max(bodyHeight, 200),
|
||
deviceScaleFactor: 2,
|
||
});
|
||
|
||
await page.screenshot({
|
||
path: outputPath,
|
||
fullPage: true,
|
||
type: 'png',
|
||
});
|
||
|
||
console.log(`Done: ${outputPath}`);
|
||
const stats = fs.statSync(outputPath);
|
||
console.log(`Size: ${(stats.size / 1024).toFixed(1)} KB`);
|
||
console.log(`Dimensions: ${PAGE_WIDTH} x ${bodyHeight} px (2x scale)`);
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error('Error:', err.message || err);
|
||
process.exit(1);
|
||
});
|