SKILLS/md2img/scripts/render.js

459 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 with emoji support
const cjkFonts = "'Noto Sans SC', 'Noto Sans CJK SC', 'Source Han Sans SC', 'WenQuanYi Micro Hei', 'PingFang SC', 'Microsoft YaHei', sans-serif";
const emojiFonts = "'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', 'Noto Emoji', 'Android Emoji', 'EmojiOne Color', 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}, ${emojiFonts};
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', ${emojiFonts}, 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);
});