feat: 使用js 实现渲染
This commit is contained in:
+138
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: md2img
|
||||
description: >
|
||||
This skill should be used when the user wants to render a Markdown document
|
||||
(containing Mermaid diagrams, LaTeX math formulas, or rich formatting) into a PNG image.
|
||||
---
|
||||
|
||||
# md2img — Markdown → PNG
|
||||
|
||||
Render Markdown documents into high-quality PNG images with full support for Mermaid diagrams, LaTeX math formulas, code syntax highlighting, and GFM tables.
|
||||
|
||||
## Features
|
||||
|
||||
- **Mermaid diagrams**: flowchart, sequence, class, state, gantt, pie, etc. (rendered in-browser via mermaid.js)
|
||||
- **LaTeX math**: inline `$...$` and block `$$...$$` / `\[...\]` (via KaTeX)
|
||||
- **GFM syntax**: tables, task lists, strikethrough, code blocks
|
||||
- **Code highlighting**: via highlight.js CSS
|
||||
- **Dark/Light theme**: auto-detects system preference, or override with `--dark`/`--light`
|
||||
- **Paper sizes**: A4 (default) or Letter, 2x DPI for crisp output
|
||||
- **Cross-platform**: Windows (Chrome/Edge) and Linux (Chrome/Chromium)
|
||||
|
||||
## Usage Workflow
|
||||
|
||||
1. Write the Markdown content to a temporary `.md` file
|
||||
2. Run the render script
|
||||
3. Present the generated PNG to the user
|
||||
|
||||
### Command
|
||||
|
||||
```bash
|
||||
node <skill_dir>/scripts/render.js <input.md> [output.png] [--paper=a4|letter] [--dark|--light]
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `input.md` | (required) | Input Markdown file path |
|
||||
| `output.png` | `<input>.png` | Output PNG file path |
|
||||
| `--paper` | `a4` | Paper size: `a4` (794px) or `letter` (816px) |
|
||||
| `--dark` | auto | Force dark theme |
|
||||
| `--light` | auto | Force light theme |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Basic usage
|
||||
node render.js readme.md
|
||||
|
||||
# Specify output path and paper size
|
||||
node render.js slides.md output.png --paper=letter
|
||||
|
||||
# Force dark theme (for dark background images)
|
||||
node render.js notes.md card.png --dark
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
### Windows (current machine)
|
||||
|
||||
Already configured. Uses local Edge/Chrome via `puppeteer-core`.
|
||||
|
||||
### Linux (headless server)
|
||||
|
||||
On Linux servers without a GUI browser, install Chromium:
|
||||
|
||||
```bash
|
||||
# Option 1: Install puppeteer (auto-downloads Chromium)
|
||||
cd ~/.workbuddy/skills/md2img/scripts
|
||||
npm install puppeteer
|
||||
npx puppeteer browsers install chrome
|
||||
|
||||
# Option 2: Install system Chromium
|
||||
# Ubuntu/Debian:
|
||||
sudo apt install -y chromium-browser
|
||||
# CentOS/RHEL:
|
||||
sudo yum install -y chromium
|
||||
# Alpine:
|
||||
apk add chromium
|
||||
|
||||
# Option 3: Set CHROME_PATH env variable
|
||||
export CHROME_PATH=/usr/bin/chromium-browser
|
||||
```
|
||||
|
||||
**Linux headless prerequisites:**
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian — required shared libraries
|
||||
sudo apt install -y libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
||||
libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2 \
|
||||
libpango-1.0-0 libcairo2 libxshmfence1
|
||||
|
||||
# Alpine
|
||||
apk add nss atk cups-libs libdrm libxkbcommon libxcomposite libxdamage \
|
||||
libxrandr mesa-gbm alsa-lib pango cairo
|
||||
```
|
||||
|
||||
### Docker deployment
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-slim
|
||||
|
||||
# Install Chromium dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
chromium \
|
||||
fonts-noto-cjk \
|
||||
--no-install-recommends && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set CHROME_PATH so puppeteer-core finds it
|
||||
ENV CHROME_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
|
||||
WORKDIR /app
|
||||
COPY scripts/package.json scripts/render.js ./
|
||||
RUN npm install --production
|
||||
|
||||
ENTRYPOINT ["node", "render.js"]
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
- **Markdown parsing**: `marked` v12 with custom renderer for mermaid code blocks
|
||||
- **LaTeX rendering**: `KaTeX` server-side pre-render (no browser-side JS needed)
|
||||
- **Mermaid rendering**: `mermaid.js` browser-side rendering via CDN (mermaid requires DOM API, cannot render in Node.js)
|
||||
- **Browser automation**: `puppeteer-core` (Windows, uses local Edge/Chrome) or `puppeteer` (Linux, bundled Chromium), with auto-detection
|
||||
- **Output**: 2x DPI PNG (A4: 1588px wide, Letter: 1632px wide)
|
||||
- **Font stack**: System fonts + CJK fallback (PingFang SC, Microsoft YaHei, Noto Sans SC)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| "No Chrome/Edge/Chromium found" | Install Chrome/Chromium or run `npm install puppeteer` |
|
||||
| Chinese characters missing on Linux | Install CJK fonts: `apt install fonts-noto-cjk` |
|
||||
| `/dev/shm` errors on Linux | Use `--disable-dev-shm-usage` (already included) |
|
||||
| Puppeteer download timeout in China | Set mirror: `PUPPETEER_DOWNLOAD_BASE_URL=https://cdn.npmmirror.com/binaries/chrome-for-testing npx puppeteer browsers install chrome` |
|
||||
| Screenshot blank/white | Ensure `waitUntil: 'networkidle0'` completes; check font availability |
|
||||
Generated
+2129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "md2img",
|
||||
"version": "1.1.0",
|
||||
"description": "Render Markdown with Mermaid & LaTeX to PNG (cross-platform)",
|
||||
"scripts": {
|
||||
"render": "node render.js",
|
||||
"setup:linux": "npm install && npx puppeteer browsers install chrome",
|
||||
"setup:fonts": "bash scripts/setup-linux-fonts.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"katex": "^0.16.9",
|
||||
"marked": "^12.0.0",
|
||||
"mermaid": "^10.9.0",
|
||||
"puppeteer-core": "^22.15.0"
|
||||
},
|
||||
"optionalDependencies": {},
|
||||
"_linuxSetup": "On Linux headless servers without Chrome/Chromium, run: npm install puppeteer && npx puppeteer browsers install chrome"
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user