diff --git a/design-refs/mockup/build.sh b/design-refs/mockup/build.sh index 3c4def9..171c556 100755 --- a/design-refs/mockup/build.sh +++ b/design-refs/mockup/build.sh @@ -8,6 +8,13 @@ cd "$(dirname "$0")" PDF_SKILL_DIR="${PDF_SKILL_DIR:-$HOME/.zcode/cli/plugins/cache/zcode-plugins-official/pdf/0.1.7/skills/pdf}" export PDF_SKILL_DIR +# 本地渲染器(固定 headless-shell 规避系统 Chrome 占用;TMPDIR 重定向规避 /tmp 磁盘配额) +HTML2POSTER="$PWD/tools/html2poster.js" +H2P_CHROMIUM="${H2P_CHROMIUM:-$HOME/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell}" +export HTML2POSTER H2P_CHROMIUM +mkdir -p "$HOME/.cache/h2p-tmp" +export TMPDIR="$HOME/.cache/h2p-tmp" + STEMS=("$@") if [ ${#STEMS[@]} -eq 0 ]; then STEMS=(course-shelf components) @@ -45,7 +52,7 @@ out_dir = sys.argv[1] pages = sorted(glob.glob(f"{out_dir}/page-*.html"), key=lambda p: int(re.search(r"(\d+)", os.path.basename(p)).group(1))) for p in pages: out = p.replace(".html", ".pdf") - subprocess.run(["node", f"{os.environ['PDF_SKILL_DIR']}/scripts/html2poster.js", p, "--output", out, "--width", "1280px"], check=True, capture_output=True) + subprocess.run(["node", os.environ['HTML2POSTER'], p, "--output", out, "--width", "1280px"], check=True, capture_output=True) print(f"[{out_dir}] rendered {len(pages)} pages") EOF diff --git a/design-refs/mockup/components.html b/design-refs/mockup/components.html index c33add9..c0a8e5b 100644 --- a/design-refs/mockup/components.html +++ b/design-refs/mockup/components.html @@ -661,7 +661,7 @@
<CourseGraph />

课程路线图 · 线条是构件

-
线条构件:直段 / 转角 / 步进 / 分流 / 并线 / 并行 / 站点,横平竖直。并行线逐条不合并;单线圆点、多线扁点。CourseCard 由路线串联成网。
+
线条构件:直段 / 转角 / 步进 / 分流 / 并线 / 并行 / 站点,横平竖直。并行线逐条不合并;单线圆点、多线胶囊。CourseCard 由路线串联成网。
@@ -697,8 +697,8 @@
dot · 单线圆点(半嵌卡边)
- -
flat · 多线扁点(半嵌卡边)
+ +
flat · 多线胶囊(半嵌卡边)
@@ -747,11 +747,11 @@ - +
→ 下一程:③ 高级教程
-
分流 ×1 · 三线并行 ×1 · 圆点 ×10 · 扁点 ×1
+
分流 ×1 · 三线并行 ×1 · 圆点 ×10 · 胶囊 ×1
@@ -761,7 +761,7 @@ pathSegment[]直段/转角/步进序列 forksFork[]分流岔口(分出车道) mergesMerge[]直角并线点 - stationsNode[]单线圆点 / 多线扁点,半嵌卡边 + stationsNode[]单线圆点 / 多线胶囊,半嵌卡边 status无 prop不在图上编码
@@ -771,7 +771,7 @@
2背景级线条:鼠尾草绿 #A2B49B · 1.4px · 划隙 4:2;站点 #758C72 半嵌卡边。
3横平竖直:走向只有水平与垂直,转折用小圆角 R8。
4并行不合并:多线并行逐条渲染,间距 4px,不合成一条。
-
5站点随线变形:单线 → 圆点 r2.8;多线入同一卡口 → 扁点 rx8×ry4。
+
5站点随线变形:单线 → 圆点;多线 → 沿线束方向的胶囊(6×14,端头半圆)。
6顺序为示例占位,填 courses.ts 自动更新。
diff --git a/design-refs/mockup/components.pdf b/design-refs/mockup/components.pdf index 8023068..e4c6cd1 100644 Binary files a/design-refs/mockup/components.pdf and b/design-refs/mockup/components.pdf differ diff --git a/design-refs/mockup/tools/html2poster.js b/design-refs/mockup/tools/html2poster.js new file mode 100644 index 0000000..7bf9ec8 --- /dev/null +++ b/design-refs/mockup/tools/html2poster.js @@ -0,0 +1,294 @@ +#!/usr/bin/env node +/** + * html2poster.js — Single-page poster/long-image HTML → PDF converter + * + * Purpose: Convert a fixed-width, dynamic-height HTML poster into a single-page + * vector PDF with zero margins. This script is PURPOSE-BUILT for posters and + * infographics — it does NOT handle multi-page documents, A4 pagination, or + * document-style margins. For those, use html2pdf-next.js. + * + * Usage: + * node html2poster.js poster.html + * node html2poster.js poster.html --output out.pdf + * node html2poster.js poster.html --width 720px + * node html2poster.js poster.html --width 720px --max-height 8000 + * + * What it does (in order): + * 1. Load HTML in Playwright + * 2. Force overflow:hidden on .poster/.page containers (clip decorative overflow) + * 3. Inject @page { margin: 0 } (override any existing margin) + * 4. Ensure html/body have margin:0, padding:0, matching background + * 5. Measure .poster scrollHeight (actual content height) + * 6. Generate single-page PDF with exact dimensions + * + * What it does NOT do: + * - No pagination / page breaks + * - No A4 fallback + * - No margin injection (always zero) + * - No cover adaptation + * - No pdf-lib post-processing + * - No continuous-canvas detection + * - No vertical overflow expansion (posters WANT overflow:hidden) + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync, spawnSync } = require('child_process'); + +// ── Playwright resolution ── + +function loadPlaywright() { + for (const name of ['playwright', 'playwright-core']) { + try { return require(name); } catch (_) {} + } + + // Node never searches npm's global dir, and when node itself was installed + // into a private prefix, playwright is usually installed right next to it. + // Resolve both by hand instead of reporting "not installed". + const Module = require('module'); + const roots = new Set(); + if (process.env.PLAYWRIGHT_PATH) roots.add(process.env.PLAYWRIGHT_PATH); + if (process.env.NODE_PATH) { + process.env.NODE_PATH.split(path.delimiter).filter(Boolean).forEach(p => roots.add(p)); + } + // /bin/node -> /node_modules + roots.add(path.join(path.dirname(process.execPath), '..', 'node_modules')); + try { + const g = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); + if (g) roots.add(g); + } catch (_) {} + + for (const base of roots) { + for (const name of ['playwright', 'playwright-core']) { + const dir = path.join(base, name); + if (!fs.existsSync(path.join(dir, 'package.json'))) continue; + // Require by absolute path — createRequire() would still walk the + // node_modules chain and miss roots not named node_modules. + try { return Module.createRequire(__filename)(dir); } catch (_) {} + } + } + return null; +} + +// ── Chromium resolution (shared logic with html2pdf-next.js) ── + +function resolveChromium(chromiumObj) { + let exe; + try { exe = chromiumObj.executablePath(); } catch (_) { exe = null; } + if (exe && fs.existsSync(exe)) return { status: 'ok', executablePath: exe }; + + const candidates = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome', + ]; + if (process.env.PLAYWRIGHT_CHROMIUM_PATH) candidates.unshift(process.env.PLAYWRIGHT_CHROMIUM_PATH); + + for (const c of candidates) { + if (fs.existsSync(c)) return { status: 'fallback', executablePath: c }; + } + return { status: 'missing', executablePath: exe || '' }; +} + +// ── CLI parsing ── + +function parseArgs(argv) { + const tokens = argv.slice(2); + let input = null, output = null, width = '720px', maxHeight = 16000; + + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]; + if (t === '--output' || t === '-o') output = tokens[++i]; + else if (t === '--width') width = tokens[++i]; + else if (t === '--max-height') maxHeight = parseInt(tokens[++i], 10); + else if (t === '--help' || t === '-h') { + console.log(` +Usage: node html2poster.js [options] + +Options: + --output, -o Output PDF path (default: input with .pdf extension) + --width Poster width (default: 720px) + --max-height Maximum allowed height in px (default: 16000, safety limit) + -h, --help Show this help +`); + process.exit(0); + } + else if (!input) input = t; + else if (!output) output = t; + } + + if (!input) { + console.error('Error: No input HTML file specified.'); + process.exit(1); + } + + if (!output) { + output = input.replace(/\.html?$/i, '.pdf'); + if (output === input) output = input + '.pdf'; + } + + return { input, output, width, maxHeight }; +} + +// ── Main ── + +async function main() { + const { input, output, width, maxHeight } = parseArgs(process.argv); + const absIn = path.resolve(input); + const absOut = path.resolve(output); + + if (!fs.existsSync(absIn)) { + console.error(`Error: File not found: ${absIn}`); + process.exit(1); + } + + console.log(`\n🖼 html2poster — Single-page poster PDF generator`); + console.log(` Input: ${absIn}`); + console.log(` Output: ${absOut}`); + console.log(` Width: ${width}`); + + // Load Playwright + const playwright = loadPlaywright(); + if (!playwright) { + console.error('Error: playwright or playwright-core not installed.'); + console.error(` Searched: require paths, PLAYWRIGHT_PATH, NODE_PATH,`); + console.error(` ${path.join(path.dirname(process.execPath), '..', 'node_modules')}, npm root -g`); + process.exit(1); + } + + const { chromium } = playwright; + const bInfo = resolveChromium(chromium); + + if (bInfo.status === 'missing') { + console.error('Error: No Chromium found. Run: npx playwright install chromium'); + process.exit(1); + } + if (bInfo.status === 'fallback') { + console.log(` ⚠ Using fallback Chromium: ${bInfo.executablePath}`); + } + + // Launch browser + // 本地补丁:H2P_CHROMIUM 强制指定浏览器可执行文件,避免与用户正在使用的系统 Chrome 冲突; + // TMPDIR 由 build.sh 重定向到非配额磁盘(/tmp 配额超限会导致渲染子进程崩溃)。 + const launchOpts = { headless: true }; + if (process.env.H2P_CHROMIUM && fs.existsSync(process.env.H2P_CHROMIUM)) { + launchOpts.executablePath = process.env.H2P_CHROMIUM; + } else if (bInfo.status === 'fallback') { + launchOpts.executablePath = bInfo.executablePath; + } + + const browser = await chromium.launch(launchOpts); + + try { + // Use a wide viewport so content doesn't wrap unexpectedly + const widthPx = parseInt(width, 10) || 720; + const page = await browser.newPage({ viewport: { width: widthPx, height: 1200 } }); + + await page.goto('file://' + absIn, { waitUntil: 'networkidle' }); + console.log(`\n ✓ HTML loaded`); + + // ── Step 1: Force overflow:hidden on page containers ── + // Decorative elements with negative offsets or width>100% inflate scrollWidth, + // causing Playwright to shrink content to fit. overflow:hidden clips them. + const overflowFixed = await page.evaluate(() => { + const selectors = ['.poster', '.page', '#poster', '#page']; + let fixed = 0; + for (const sel of selectors) { + const el = document.querySelector(sel); + if (!el) continue; + const computed = getComputedStyle(el); + if (computed.overflow !== 'hidden') { + el.style.overflow = 'hidden'; + fixed++; + } + } + return fixed; + }); + if (overflowFixed > 0) { + console.log(` ✓ Added overflow:hidden to ${overflowFixed} container(s)`); + } + + // ── Step 2: Inject @page { margin: 0 } — override any existing @page rule ── + await page.evaluate(() => { + const s = document.createElement('style'); + // Use !important-equivalent: place at end so it wins cascade + s.textContent = `@page { margin: 0 !important; size: auto; }`; + document.head.appendChild(s); + }); + + // ── Step 3: Ensure html/body have zero margin/padding ── + const bgSync = await page.evaluate(() => { + const html = document.documentElement; + const body = document.body; + html.style.margin = '0'; + html.style.padding = '0'; + body.style.margin = '0'; + body.style.padding = '0'; + + // Sync body background with poster background to avoid color gaps + const poster = document.querySelector('.poster') || document.querySelector('.page'); + if (poster) { + const posterBg = getComputedStyle(poster).backgroundColor; + if (posterBg && posterBg !== 'rgba(0, 0, 0, 0)' && posterBg !== 'transparent') { + body.style.backgroundColor = posterBg; + html.style.backgroundColor = posterBg; + return posterBg; + } + } + return null; + }); + if (bgSync) { + console.log(` ✓ Synced body background: ${bgSync}`); + } + + // ── Step 4: Measure actual content height ── + const measurement = await page.evaluate(() => { + const poster = document.querySelector('.poster') || document.querySelector('.page') || document.body; + return { + scrollHeight: poster.scrollHeight, + scrollWidth: poster.scrollWidth, + offsetWidth: poster.offsetWidth, + selector: poster.className ? '.' + poster.className.split(' ')[0] : poster.tagName, + }; + }); + + console.log(` ✓ Measured: ${measurement.selector} = ${measurement.scrollWidth}×${measurement.scrollHeight}px`); + + if (measurement.scrollWidth > widthPx + 2) { + console.log(` ⚠ WARNING: scrollWidth (${measurement.scrollWidth}px) > width (${widthPx}px)`); + console.log(` Decorative elements may still overflow. Check for position:absolute elements with negative offsets.`); + } + + let contentHeight = measurement.scrollHeight; + if (contentHeight > maxHeight) { + console.log(` ⚠ Content height ${contentHeight}px exceeds max ${maxHeight}px, clamping.`); + contentHeight = maxHeight; + } + if (contentHeight < 100) { + console.log(` ⚠ Content height ${contentHeight}px seems too small, using 960px fallback.`); + contentHeight = 960; + } + + // ── Step 5: Generate PDF ── + console.log(`\n 📄 Generating PDF: ${width} × ${contentHeight}px`); + await page.pdf({ + path: absOut, + width: width, + height: contentHeight + 'px', + printBackground: true, + margin: { top: '0', right: '0', bottom: '0', left: '0' }, + }); + + console.log(`\n ✅ Done: ${absOut}`); + console.log(` Size: ${(fs.statSync(absOut).size / 1024).toFixed(1)} KB`); + + } finally { + await browser.close(); + } +} + +main().catch(err => { + console.error(`\n✗ Fatal: ${err.message}`); + process.exit(1); +}); +