/** * md2img - Markdown + Mermaid + LaTeX => PNG * * Usage: * node render.js [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 [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, '''); } 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 ? `
${escapeHtml(text)}
` : `${escapeHtml(text)}`; } } // marked extension for inline math $...$ const mathExtension = { name: 'math', level: 'inline', start(src) { const idx = src.search(/(?${renderMath(token.text, true)}`; }, }; // 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 `
${renderMath(token.text, true)}
`; }, }; // 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 `
${renderMath(token.text, true)}
`; }, }; // LaTeX rendering function (post-processing for non-marked syntax) function renderLatex(text) { // Clean up extra
tags around math-block divs (from marked's breaks: true) text = text.replace(/\s*
/gi, '
'); text = text.replace(/<\/div>\s*/gi, '
'); 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 `
${code}
`; } const langClass = lang ? ` language-${escapeHtml(lang)}` : ''; return `
${escapeHtml(code)}
`; }, // Handle inline code codespan(text) { return `${escapeHtml(text)}`; }, // Handle tables for better styling table(header, body) { return `
${header}${body}
`; }, }, }); 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.cn/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 = ` ${bodyHtml} `; 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); });