feat: 使用js 实现渲染

This commit is contained in:
ViperEkura 2026-04-20 16:31:50 +08:00
parent 55c175d7a7
commit d9687d9f60
7 changed files with 2751 additions and 427 deletions

13
.gitignore vendored
View File

@ -1,9 +1,14 @@
# Ignore everything by default
*
# Keep all directories
!*/
# Whitelist specific files and directories
!.gitignore
!SKILL.md
!*.py
# Keep specific file types
!*.md
!*.py
!*.js
!*.json
# Keep root .gitignore
!.gitignore

View File

@ -1,47 +0,0 @@
---
name: markdown-converter
description: A simple tool to convert Markdown to PNG images using markdown library + BeautifulSoup + Pillow.
metadata: {"clawdbot":{"emoji":"🖼️","os":["linux","darwin","win32"]}}
---
# Markdown to PNG Converter
A simple tool to convert Markdown documents to PNG images using markdown library, BeautifulSoup, and Pillow.
## Features
- **Full GFM support**: Tables, code blocks, task lists, and more via markdown library
- **BeautifulSoup parsing**: Robust HTML parsing
- **Pure Pillow rendering**: No browser required
- **CJK support**: Uses system fonts for Chinese character rendering
## Installation
```bash
pip install Pillow markdown beautifulsoup4
```
## Dependencies
- **Pillow**: Image processing and drawing
- **markdown**: Python Markdown parser with GFM extensions
- **beautifulsoup4**: HTML parsing
## Usage
```bash
# Convert Markdown to PNG
python scripts/md_convert.py input.md output.png
```
## Supported Platforms
- **Windows**: Uses system CJK fonts
- **macOS**: Uses system CJK fonts
- **Linux**: Uses system CJK fonts
## Notes
- Uses markdown library for robust Markdown parsing
- BeautifulSoup handles complex HTML structures
- Chinese fonts are supported via system fonts

View File

@ -1,376 +0,0 @@
"""Markdown 转 PNG 图片工具(使用 markdown 库 + BeautifulSoup + Pillow
Dependencies:
- pip install Pillow markdown beautifulsoup4
特点
- 使用 markdown 库解析支持完整 GFM
- 使用 BeautifulSoup 解析 HTML
- Pillow 渲染无需浏览器
"""
import sys
import os
import re
from pathlib import Path
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("Error: Pillow not installed. Run: pip install Pillow", file=sys.stderr)
sys.exit(1)
try:
import markdown
except ImportError:
print("Error: markdown not installed. Run: pip install markdown", file=sys.stderr)
sys.exit(1)
try:
from bs4 import BeautifulSoup
except ImportError:
print("Error: beautifulsoup4 not installed. Run: pip install beautifulsoup4", file=sys.stderr)
sys.exit(1)
# ============================================================
# 字体查找
# ============================================================
def _find_font():
"""Find a suitable TrueType font across platforms."""
candidates = []
if sys.platform == "win32":
pf = os.environ.get("WINDIR", r"C:\Windows")
candidates = [
os.path.join(pf, "Fonts", "msyh.ttc"),
os.path.join(pf, "Fonts", "msyhbd.ttc"),
os.path.join(pf, "Fonts", "simhei.ttf"),
os.path.join(pf, "Fonts", "simsun.ttc"),
]
elif sys.platform == "darwin":
candidates = [
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
else:
candidates = [
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/opentype/noto/NotoSansSC-Regular.otf",
]
for p in candidates:
if os.path.exists(p):
return p
return None
# ============================================================
# Markdown 转 PNG
# ============================================================
def markdown_to_png(md_text, img_path):
"""将 Markdown 转换为 PNG 图片"""
font_path = _find_font()
# 加载字体
try:
if font_path:
font_h1 = ImageFont.truetype(font_path, 24)
font_h2 = ImageFont.truetype(font_path, 20)
font_h3 = ImageFont.truetype(font_path, 18)
font_body = ImageFont.truetype(font_path, 15)
font_code = ImageFont.truetype(font_path, 13)
font_small = ImageFont.truetype(font_path, 12)
else:
font_h1 = font_h2 = font_h3 = font_body = font_code = font_small = ImageFont.load_default()
except Exception:
font_h1 = font_h2 = font_h3 = font_body = font_code = font_small = ImageFont.load_default()
W = 800
PAD = 40
CONTENT_W = W - PAD * 2
# 使用 markdown 库转换为 HTML再用 BeautifulSoup 解析
md = markdown.Markdown(extensions=['tables', 'fenced_code', 'codehilite', 'nl2br'])
html = md.convert(md_text)
soup = BeautifulSoup(html, 'html.parser')
# 获取 body 或根元素
root = soup.body if soup.body else soup
# 创建临时 draw 对象用于测量
temp_img = Image.new('RGB', (W, 100))
draw = ImageDraw.Draw(temp_img)
def measure_text(text, font):
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
def wrap_text(text, font, max_width):
lines = []
for paragraph in text.split('\n'):
if not paragraph.strip():
lines.append('')
continue
current = ''
for ch in paragraph:
test = current + ch
if measure_text(test, font) > max_width and current:
lines.append(current)
current = ch
else:
current = test
if current:
lines.append(current)
return lines
def get_text(elem):
"""获取元素的文本内容"""
return elem.get_text()
# 预计算高度
y = PAD
line_height = 26
def calc_height(elem):
nonlocal y
tag = elem.name
if tag in ('h1', 'h2', 'h3'):
text = get_text(elem)
font = {'h1': font_h1, 'h2': font_h2, 'h3': font_h3}[tag]
lines = wrap_text(text, font, CONTENT_W)
h = {'h1': 40, 'h2': 36, 'h3': 32}[tag]
y += len(lines) * h + (15 if tag == 'h1' else 12 if tag == 'h2' else 10)
elif tag == 'p':
text = get_text(elem)
lines = wrap_text(text, font_body, CONTENT_W - 20)
y += len(lines) * line_height + 8
elif tag == 'pre' or (tag == 'div' and 'codehilite' in elem.get('class', [])):
text = get_text(elem)
lines = text.split('\n') if text else ['']
y += len(lines) * 20 + 20
elif tag == 'blockquote':
text = get_text(elem)
lines = wrap_text(text, font_body, CONTENT_W - 30)
y += len(lines) * line_height + 10 + 15 # 边框高度 + 间距
elif tag == 'ul':
for li in elem.find_all('li', recursive=False):
text = get_text(li)
lines = wrap_text(text, font_body, CONTENT_W - 20)
y += len(lines) * line_height + 4
y += 8
elif tag == 'ol':
for li in elem.find_all('li', recursive=False):
text = get_text(li)
lines = wrap_text(text, font_body, CONTENT_W - 20)
y += len(lines) * line_height + 4
y += 8
elif tag == 'hr':
y += 30
elif tag == 'table':
for row in elem.find_all('tr'):
y += 36
y += 20
for elem in root.children:
if hasattr(elem, 'name') and elem.name:
calc_height(elem)
y += PAD + 40
TOTAL_H = max(400, y)
# 创建图片
img = Image.new('RGB', (W, TOTAL_H), '#1a1a2e')
draw = ImageDraw.Draw(img)
# 渐变背景
for row in range(TOTAL_H):
ratio = row / TOTAL_H
r = int(26 + (255 - 26) * ratio * 0.1)
g = int(26 + (255 - 26) * ratio * 0.1)
b = int(46 + (255 - 46) * ratio * 0.1)
draw.line([(0, row), (W, row)], fill=(r, g, b))
# 白色内容区域
content_top = 60
draw.rectangle(
[0, content_top, W - 1, TOTAL_H - 1],
fill='#ffffff',
)
# 顶部渐变
for row in range(content_top):
ratio = row / content_top
r = int(42 + (61 - 42) * ratio)
g = int(98 + (133 - 98) * ratio)
b = int(239 + (255 - 239) * ratio)
draw.line([(0, row), (W, row)], fill=(r, g, b))
# 渲染内容
x = PAD
cy = content_top + PAD
def render_elem(elem):
nonlocal cy, x
tag = elem.name
if tag == 'h1':
text = get_text(elem)
lines = wrap_text(text, font_h1, CONTENT_W)
for line in lines:
draw.text((x, cy), line, fill='#1a1a2e', font=font_h1)
cy += 40
cy += 15
elif tag == 'h2':
text = get_text(elem)
lines = wrap_text(text, font_h2, CONTENT_W)
for line in lines:
draw.text((x, cy), line, fill='#1a1a2e', font=font_h2)
cy += 36
cy += 12
elif tag == 'h3':
text = get_text(elem)
lines = wrap_text(text, font_h3, CONTENT_W)
for line in lines:
draw.text((x, cy), line, fill='#1a1a2e', font=font_h3)
cy += 32
cy += 10
elif tag == 'p':
text = get_text(elem)
lines = wrap_text(text, font_body, CONTENT_W - 20)
for line in lines:
draw.text((x, cy), line, fill='#374151', font=font_body)
cy += line_height
cy += 8
elif tag == 'strong' or tag == 'b':
text = get_text(elem)
lines = wrap_text(text, font_body, CONTENT_W - 20)
for line in lines:
draw.text((x, cy), line, fill='#1a1a2e', font=font_body)
cy += line_height
elif tag == 'em' or tag == 'i':
text = get_text(elem)
lines = wrap_text(text, font_body, CONTENT_W - 20)
for line in lines:
draw.text((x, cy), line, fill='#666666', font=font_body)
cy += line_height
elif tag == 'code' and not elem.find_all(recursive=False):
# 行内代码
text = get_text(elem)
draw.text((x, cy), text, fill='#333333', font=font_code)
x += measure_text(text, font_code)
elif tag == 'pre' or (tag == 'div' and 'codehilite' in elem.get('class', [])):
# 代码高亮块
text = get_text(elem)
lines = text.split('\n') if text else ['']
code_h = len(lines) * 20 + 20
draw.rounded_rectangle(
[x, cy, x + CONTENT_W, cy + code_h],
radius=8,
fill='#f4f4f4',
outline='#e0e0e0',
width=1,
)
for i, line in enumerate(lines):
max_chars = int((CONTENT_W - 24) / 7)
display_line = line[:max_chars] if max_chars > 0 else line[:80]
draw.text((x + 12, cy + 10 + i * 20), display_line, fill='#333333', font=font_code)
cy += code_h + 15
elif tag == 'blockquote':
text = get_text(elem)
lines = wrap_text(text, font_body, CONTENT_W - 30)
quote_h = len(lines) * line_height
draw.rectangle([x, cy, x + 3, cy + quote_h], fill='#0066cc')
# 引用文本
for line in lines:
draw.text((x + 15, cy), line, fill='#666666', font=font_body)
cy += line_height
cy += 10
elif tag == 'ul':
for li in elem.find_all('li', recursive=False):
text = get_text(li)
draw.text((x, cy), '', fill='#0066cc', font=font_body)
draw.text((x + 16, cy), text.strip(), fill='#374151', font=font_body)
cy += line_height
cy += 8
elif tag == 'ol':
for i, li in enumerate(elem.find_all('li', recursive=False), 1):
text = get_text(li)
draw.text((x, cy), f'{i}.', fill='#0066cc', font=font_body)
draw.text((x + 20, cy), text.strip(), fill='#374151', font=font_body)
cy += line_height
cy += 8
elif tag == 'hr':
draw.line([(x, cy), (x + CONTENT_W, cy)], fill='#e0e0e0', width=1)
cy += 30
elif tag == 'table':
for row in elem.find_all('tr'):
cells = row.find_all(['td', 'th'])
if cells:
cell_x = x
cell_w = CONTENT_W // len(cells)
for cell in cells:
cell_text = get_text(cell).strip()[:15]
draw.rectangle([cell_x, cy, cell_x + cell_w, cy + 32], outline='#ddd')
draw.text((cell_x + 8, cy + 6), cell_text, fill='#333', font=font_small)
cell_x += cell_w
cy += 36
elif tag == 'br':
cy += line_height
for elem in root.children:
if hasattr(elem, 'name') and elem.name:
render_elem(elem)
Path(img_path).parent.mkdir(parents=True, exist_ok=True)
img.save(img_path, 'PNG')
print(f'Converted: Markdown -> {img_path}')
def main():
if len(sys.argv) < 3:
print("Usage: python md_convert.py <input.md> <output.png>", file=sys.stderr)
sys.exit(1)
input_path = sys.argv[1]
output_path = sys.argv[2]
if not os.path.exists(input_path):
print(f"Error: Input file not found: {input_path}", file=sys.stderr)
sys.exit(1)
md_text = Path(input_path).read_text(encoding="utf-8")
markdown_to_png(md_text, output_path)
if __name__ == "__main__":
main()

138
md2img/SKILL.md Normal file
View File

@ -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 |

2129
md2img/scripts/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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"
}

457
md2img/scripts/render.js Normal file
View File

@ -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, '&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);
});