SKILLS/md2img/scripts/render.js

458 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: Linux优先使用Noto Sans CJKmacOS用PingFangWindows用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);
});