Spaces:
Running
Running
| import { spawn } from 'node:child_process'; | |
| import { setTimeout as delay } from 'node:timers/promises'; | |
| import { chromium } from 'playwright'; | |
| import { resolve } from 'node:path'; | |
| import { promises as fs } from 'node:fs'; | |
| import process from 'node:process'; | |
| async function run(command, args = [], options = {}) { | |
| return new Promise((resolvePromise, reject) => { | |
| const child = spawn(command, args, { stdio: 'inherit', shell: false, ...options }); | |
| child.on('error', reject); | |
| child.on('exit', (code) => { | |
| if (code === 0) resolvePromise(undefined); | |
| else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`)); | |
| }); | |
| }); | |
| } | |
| async function waitForServer(url, timeoutMs = 60000) { | |
| const start = Date.now(); | |
| while (Date.now() - start < timeoutMs) { | |
| try { | |
| const res = await fetch(url); | |
| if (res.ok) return; | |
| } catch { } | |
| await delay(500); | |
| } | |
| throw new Error(`Server did not start in time: ${url}`); | |
| } | |
| function parseArgs(argv) { | |
| const out = {}; | |
| for (const arg of argv.slice(2)) { | |
| if (!arg.startsWith('--')) continue; | |
| const [k, v] = arg.replace(/^--/, '').split('='); | |
| out[k] = v === undefined ? true : v; | |
| } | |
| return out; | |
| } | |
| function slugify(text) { | |
| return String(text || '') | |
| .normalize('NFKD') | |
| .replace(/\p{Diacritic}+/gu, '') | |
| .toLowerCase() | |
| .replace(/[^a-z0-9]+/g, '-') | |
| .replace(/^-+|-+$/g, '') | |
| .slice(0, 120) || 'article'; | |
| } | |
| function parseMargin(margin) { | |
| if (!margin) return { top: '12mm', right: '12mm', bottom: '16mm', left: '12mm' }; | |
| const parts = String(margin).split(',').map(s => s.trim()).filter(Boolean); | |
| if (parts.length === 1) { | |
| return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] }; | |
| } | |
| if (parts.length === 2) { | |
| return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] }; | |
| } | |
| if (parts.length === 3) { | |
| return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] }; | |
| } | |
| return { top: parts[0] || '12mm', right: parts[1] || '12mm', bottom: parts[2] || '16mm', left: parts[3] || '12mm' }; | |
| } | |
| function cssLengthToMm(val) { | |
| if (!val) return 0; | |
| const s = String(val).trim(); | |
| if (/mm$/i.test(s)) return parseFloat(s); | |
| if (/cm$/i.test(s)) return parseFloat(s) * 10; | |
| if (/in$/i.test(s)) return parseFloat(s) * 25.4; | |
| if (/px$/i.test(s)) return (parseFloat(s) / 96) * 25.4; // 96 CSS px per inch | |
| const num = parseFloat(s); | |
| return Number.isFinite(num) ? num : 0; // assume mm if unitless | |
| } | |
| function getFormatSizeMm(format) { | |
| const f = String(format || 'A4').toLowerCase(); | |
| switch (f) { | |
| case 'letter': return { w: 215.9, h: 279.4 }; | |
| case 'legal': return { w: 215.9, h: 355.6 }; | |
| case 'a3': return { w: 297, h: 420 }; | |
| case 'tabloid': return { w: 279.4, h: 431.8 }; | |
| case 'a4': | |
| default: return { w: 210, h: 297 }; | |
| } | |
| } | |
| async function waitForImages(page, timeoutMs = 15000) { | |
| await page.evaluate(async (timeout) => { | |
| const deadline = Date.now() + timeout; | |
| const imgs = Array.from(document.images || []); | |
| const unloaded = imgs.filter(img => !img.complete || (img.naturalWidth === 0)); | |
| await Promise.race([ | |
| Promise.all(unloaded.map(img => new Promise(res => { | |
| if (img.complete && img.naturalWidth !== 0) return res(undefined); | |
| img.addEventListener('load', () => res(undefined), { once: true }); | |
| img.addEventListener('error', () => res(undefined), { once: true }); | |
| }))), | |
| new Promise(res => setTimeout(res, Math.max(0, deadline - Date.now()))) | |
| ]); | |
| }, timeoutMs); | |
| } | |
| async function waitForPlotly(page, timeoutMs = 20000) { | |
| try { | |
| await page.evaluate(async (timeout) => { | |
| const start = Date.now(); | |
| const hasPlots = () => Array.from(document.querySelectorAll('.js-plotly-plot')).length > 0; | |
| // Wait until plots exist or timeout | |
| while (!hasPlots() && (Date.now() - start) < timeout) { | |
| await new Promise(r => setTimeout(r, 200)); | |
| } | |
| const deadline = start + timeout; | |
| // Then wait until each plot contains the main svg | |
| const allReady = () => Array.from(document.querySelectorAll('.js-plotly-plot')).every(el => el.querySelector('svg.main-svg')); | |
| while (!allReady() && Date.now() < deadline) { | |
| await new Promise(r => setTimeout(r, 200)); | |
| } | |
| console.log('Plotly ready or timeout'); | |
| }, timeoutMs); | |
| } catch (e) { | |
| console.warn('waitForPlotly timeout or error:', e.message); | |
| } | |
| } | |
| async function waitForD3(page, timeoutMs = 20000) { | |
| try { | |
| await page.evaluate(async (timeout) => { | |
| const start = Date.now(); | |
| const isReady = () => { | |
| // Prioritize hero banner if present (generic container) | |
| const hero = document.querySelector('.hero-banner'); | |
| if (hero) { | |
| return !!hero.querySelector('svg circle, svg path, svg rect, svg g'); | |
| } | |
| // Else require all D3 containers on page to have shapes | |
| const containers = [ | |
| ...Array.from(document.querySelectorAll('.d3-line')), | |
| ...Array.from(document.querySelectorAll('.d3-bar')) | |
| ]; | |
| if (!containers.length) return true; | |
| return containers.every(c => c.querySelector('svg circle, svg path, svg rect, svg g')); | |
| }; | |
| while (!isReady() && (Date.now() - start) < timeout) { | |
| await new Promise(r => setTimeout(r, 200)); | |
| } | |
| console.log('D3 ready or timeout'); | |
| }, timeoutMs); | |
| } catch (e) { | |
| console.warn('waitForD3 timeout or error:', e.message); | |
| } | |
| } | |
| async function waitForStableLayout(page, timeoutMs = 5000) { | |
| const start = Date.now(); | |
| let last = await page.evaluate(() => document.scrollingElement ? document.scrollingElement.scrollHeight : document.body.scrollHeight); | |
| let stableCount = 0; | |
| while ((Date.now() - start) < timeoutMs && stableCount < 3) { | |
| await page.waitForTimeout(250); | |
| const now = await page.evaluate(() => document.scrollingElement ? document.scrollingElement.scrollHeight : document.body.scrollHeight); | |
| if (now === last) stableCount += 1; else { stableCount = 0; last = now; } | |
| } | |
| } | |
| /** | |
| * Make all remaining SVGs, Mermaid diagrams, and iframes responsive for print. | |
| * Small icon SVGs are locked to their current pixel size to prevent breakage. | |
| */ | |
| async function makeMediaResponsive(page) { | |
| await page.evaluate(() => { | |
| function isSmallSvg(svg) { | |
| try { | |
| const vb = svg?.viewBox?.baseVal; | |
| if (vb && vb.width <= 50 && vb.height <= 50) return true; | |
| const r = svg.getBoundingClientRect?.(); | |
| if (r && r.width <= 50 && r.height <= 50) return true; | |
| } catch { } | |
| return false; | |
| } | |
| function lockSmallSvgSize(svg) { | |
| try { | |
| const r = svg.getBoundingClientRect?.(); | |
| if (r?.width) svg.style.setProperty('width', Math.round(r.width) + 'px', 'important'); | |
| if (r?.height) svg.style.setProperty('height', Math.round(r.height) + 'px', 'important'); | |
| svg.style.setProperty('max-width', 'none', 'important'); | |
| } catch { } | |
| } | |
| function fixSvg(svg) { | |
| if (!svg) return; | |
| if (isSmallSvg(svg)) { lockSmallSvgSize(svg); return; } | |
| if (!svg.getAttribute('viewBox')) { | |
| const rect = svg.getBoundingClientRect(); | |
| const w = rect.width || svg.clientWidth || parseFloat(svg.getAttribute('width')) || 0; | |
| const h = rect.height || svg.clientHeight || parseFloat(svg.getAttribute('height')) || 0; | |
| if (w > 0 && h > 0) svg.setAttribute('viewBox', `0 0 ${Math.round(w)} ${Math.round(h)}`); | |
| } | |
| try { svg.removeAttribute('width'); } catch { } | |
| try { svg.removeAttribute('height'); } catch { } | |
| svg.style.maxWidth = '100%'; | |
| svg.style.width = '100%'; | |
| svg.style.height = 'auto'; | |
| if (!svg.getAttribute('preserveAspectRatio')) svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); | |
| } | |
| document.querySelectorAll('svg').forEach(fixSvg); | |
| document.querySelectorAll('.mermaid, .mermaid svg').forEach(el => { | |
| if (el.tagName?.toLowerCase() === 'svg') fixSvg(el); | |
| else { el.style.display = 'block'; el.style.width = '100%'; el.style.maxWidth = '100%'; } | |
| }); | |
| document.querySelectorAll('iframe, embed, object').forEach(el => { | |
| el.style.width = '100%'; | |
| el.style.maxWidth = '100%'; | |
| try { el.removeAttribute('width'); } catch { } | |
| try { | |
| const doc = el.contentDocument; | |
| if (doc?.head) { | |
| const s = doc.createElement('style'); | |
| s.textContent = 'html,body{overflow-x:hidden} svg,canvas,img,video{max-width:100%!important;height:auto!important} svg[width]{width:100%!important}'; | |
| doc.head.appendChild(s); | |
| doc.querySelectorAll('svg').forEach(svg => { if (isSmallSvg(svg)) lockSmallSvgSize(svg); else fixSvg(svg); }); | |
| } | |
| } catch { } | |
| }); | |
| }); | |
| } | |
| /** | |
| * Screenshot every visible .html-embed and replace its content with a static | |
| * <img>. This freezes embeds at their current (web-quality) render, preventing | |
| * D3/Plotly re-render issues when the viewport changes for PDF generation. | |
| */ | |
| async function screenshotAndReplaceEmbeds(page) { | |
| const handles = await page.$$('.html-embed'); | |
| let replaced = 0; | |
| for (let i = 0; i < handles.length; i++) { | |
| const el = handles[i]; | |
| try { | |
| const visible = await el.evaluate(node => { | |
| const r = node.getBoundingClientRect(); | |
| return r.width > 10 && r.height > 10; | |
| }); | |
| if (!visible) continue; | |
| await el.scrollIntoViewIfNeeded(); | |
| await page.waitForTimeout(30); | |
| const buf = await el.screenshot({ type: 'png' }); | |
| const b64 = buf.toString('base64'); | |
| await el.evaluate((node, data) => { | |
| const r = node.getBoundingClientRect(); | |
| const img = document.createElement('img'); | |
| img.src = 'data:image/png;base64,' + data; | |
| img.style.cssText = 'width:100%;height:auto;display:block;max-width:100%;'; | |
| img.setAttribute('width', String(Math.round(r.width))); | |
| img.setAttribute('height', String(Math.round(r.height))); | |
| node.innerHTML = ''; | |
| node.appendChild(img); | |
| }, b64); | |
| replaced++; | |
| } catch (e) { | |
| // Leave the original embed in place if screenshotting fails | |
| } | |
| } | |
| return replaced; | |
| } | |
| async function main() { | |
| const cwd = process.cwd(); | |
| const port = Number(process.env.PREVIEW_PORT || 8080); | |
| const baseUrl = `http://127.0.0.1:${port}/`; | |
| const args = parseArgs(process.argv); | |
| // Default: light (do not rely on env vars implicitly) | |
| const theme = (args.theme === 'dark' || args.theme === 'light') ? args.theme : 'light'; | |
| const format = args.format || 'A4'; | |
| const margin = parseMargin(args.margin); | |
| const wait = (args.wait || 'full'); // 'networkidle' | 'images' | 'plotly' | 'full' | |
| const bookMode = !!args.book; | |
| // filename can be provided, else computed from DOM (button) or page title later | |
| let outFileBase = (args.filename && String(args.filename).replace(/\.pdf$/i, '')) || 'article'; | |
| // Build only if dist/ does not exist | |
| const distDir = resolve(cwd, 'dist'); | |
| let hasDist = false; | |
| try { | |
| const st = await fs.stat(distDir); | |
| hasDist = st && st.isDirectory(); | |
| } catch { } | |
| if (!hasDist) { | |
| console.log('> Building Astro site…'); | |
| await run('npm', ['run', 'build']); | |
| } else { | |
| console.log('> Skipping build (dist/ exists)…'); | |
| } | |
| console.log('> Starting Astro preview…'); | |
| // Start preview in its own process group so we can terminate all children reliably | |
| const preview = spawn('npm', ['run', 'preview'], { cwd, stdio: 'inherit', detached: true }); | |
| const previewExit = new Promise((resolvePreview) => { | |
| preview.on('close', (code, signal) => resolvePreview({ code, signal })); | |
| }); | |
| try { | |
| await waitForServer(baseUrl, 60000); | |
| console.log('> Server ready, generating PDF…'); | |
| const browser = await chromium.launch({ headless: true }); | |
| try { | |
| const context = await browser.newContext(); | |
| await context.addInitScript((desired) => { | |
| try { | |
| localStorage.setItem('theme', desired); | |
| // Apply theme immediately to avoid flashes | |
| if (document && document.documentElement) { | |
| document.documentElement.dataset.theme = desired; | |
| } | |
| } catch { } | |
| }, theme); | |
| const page = await context.newPage(); | |
| // Wider viewport so D3/Plotly embeds render at full web size before screenshotting | |
| const webViewportWidth = 1200; | |
| await page.setViewportSize({ width: webViewportWidth, height: 1400 }); | |
| await page.goto(baseUrl, { waitUntil: 'load', timeout: 60000 }); | |
| // Wait for CDN scripts (Plotly/D3) in parallel to halve the timeout cost | |
| await Promise.allSettled([ | |
| page.waitForFunction(() => !!window.Plotly, { timeout: 8000 }), | |
| page.waitForFunction(() => !!window.d3, { timeout: 8000 }) | |
| ]); | |
| // Prefer explicit filename from the download button if present | |
| if (!args.filename) { | |
| const fromBtn = await page.evaluate(() => { | |
| const btn = document.getElementById('download-pdf-btn'); | |
| const f = btn ? btn.getAttribute('data-pdf-filename') : null; | |
| return f || ''; | |
| }); | |
| if (fromBtn) { | |
| outFileBase = String(fromBtn).replace(/\.pdf$/i, ''); | |
| } else { | |
| // Fallback: compute slug from hero title or document.title | |
| const title = await page.evaluate(() => { | |
| const h1 = document.querySelector('h1.hero-title'); | |
| const t = h1 ? h1.textContent : document.title; | |
| return (t || '').replace(/\s+/g, ' ').trim(); | |
| }); | |
| outFileBase = slugify(title); | |
| } | |
| if (bookMode) { | |
| outFileBase += '-book'; | |
| } | |
| } | |
| // Wait for render readiness (images + D3 + Plotly in parallel) | |
| { | |
| const waits = []; | |
| if (wait === 'images' || wait === 'full') waits.push(waitForImages(page)); | |
| if (wait === 'd3' || wait === 'full') waits.push(waitForD3(page)); | |
| if (wait === 'plotly' || wait === 'full') waits.push(waitForPlotly(page)); | |
| if (waits.length) { | |
| console.log('⏳ Waiting for content readiness (parallel)…'); | |
| await Promise.all(waits); | |
| } | |
| if (wait === 'full') { | |
| console.log('⏳ Waiting for stable layout…'); | |
| await waitForStableLayout(page); | |
| } | |
| } | |
| // Generate OG thumbnail BEFORE any print modifications (1200x630, screen mode) | |
| try { | |
| const savedViewport = { width: webViewportWidth, height: 1400 }; | |
| await page.setViewportSize({ width: 1200, height: 630 }); | |
| await page.waitForTimeout(200); | |
| await page.evaluate(() => window.scrollTo(0, 0)); | |
| const cssHandle = await page.addStyleTag({ | |
| content: `.hero .points { mix-blend-mode: normal !important; }` | |
| }); | |
| const thumbPath = resolve(cwd, 'dist', 'thumb.auto.jpg'); | |
| await page.screenshot({ path: thumbPath, type: 'jpeg', quality: 85, fullPage: false }); | |
| const thumbPngPath = resolve(cwd, 'dist', 'thumb.auto.png'); | |
| await page.screenshot({ path: thumbPngPath, type: 'png', fullPage: false }); | |
| await fs.mkdir(resolve(cwd, 'public'), { recursive: true }); | |
| try { await fs.copyFile(thumbPath, resolve(cwd, 'public', 'thumb.auto.jpg')); } catch { } | |
| try { await fs.copyFile(thumbPngPath, resolve(cwd, 'public', 'thumb.auto.png')); } catch { } | |
| try { await cssHandle.evaluate((el) => el.remove()); } catch { } | |
| console.log(`✅ OG thumbnail generated: ${thumbPath}`); | |
| // Restore viewport | |
| await page.setViewportSize(savedViewport); | |
| await page.evaluate(() => window.scrollTo(0, 0)); | |
| await page.waitForTimeout(100); | |
| } catch (e) { | |
| console.warn('Unable to generate OG thumbnail:', e?.message || e); | |
| } | |
| // If --thumbnail-only, skip everything else | |
| if (args['thumbnail-only']) { | |
| console.log('🏁 Thumbnail-only mode, skipping PDF generation'); | |
| return; | |
| } | |
| // Screenshot all embeds and replace them with static <img> tags. | |
| // This freezes the render at 1200px, preventing D3/Plotly re-render | |
| // issues when the viewport shrinks to print width later. | |
| console.log('📸 Screenshotting embeds…'); | |
| const embedCount = await screenshotAndReplaceEmbeds(page); | |
| console.log(` ${embedCount} embed(s) replaced with screenshots`); | |
| // Make remaining SVGs/iframes responsive (Mermaid diagrams, icons, etc.) | |
| console.log('🔧 Fixing remaining SVGs for print…'); | |
| try { await makeMediaResponsive(page); } catch { } | |
| if (bookMode) { | |
| console.log('📂 Opening all accordions for book mode…'); | |
| await page.evaluate(() => { | |
| const accordions = document.querySelectorAll('details.accordion, details'); | |
| accordions.forEach((accordion) => { | |
| if (!accordion.hasAttribute('open')) { | |
| accordion.setAttribute('open', ''); | |
| const wrapper = accordion.querySelector('.accordion__content-wrapper'); | |
| if (wrapper) { | |
| wrapper.style.height = 'auto'; | |
| wrapper.style.overflow = 'visible'; | |
| } | |
| } | |
| }); | |
| }); | |
| await waitForStableLayout(page, 2000); | |
| } | |
| // Inject a print-friendly Table of Contents built from actual headings | |
| console.log('📑 Injecting Table of Contents for PDF…'); | |
| const tocCount = await page.evaluate(() => { | |
| const main = document.querySelector('main'); | |
| if (!main) return 0; | |
| const headings = Array.from(main.querySelectorAll('h2, h3')); | |
| if (headings.length === 0) return 0; | |
| const tocEl = document.createElement('nav'); | |
| tocEl.className = 'pdf-toc'; | |
| const title = document.createElement('h2'); | |
| title.className = 'pdf-toc__title'; | |
| title.textContent = 'Table of Contents'; | |
| tocEl.appendChild(title); | |
| const list = document.createElement('ol'); | |
| list.className = 'pdf-toc__list'; | |
| let sectionNum = 0; | |
| let subNum = 0; | |
| let currentSubList = null; | |
| for (const h of headings) { | |
| const text = h.textContent?.trim(); | |
| if (!text) continue; | |
| const id = h.getAttribute('id') || ''; | |
| if (h.tagName === 'H2') { | |
| sectionNum++; | |
| subNum = 0; | |
| const li = document.createElement('li'); | |
| li.className = 'pdf-toc__section'; | |
| const a = document.createElement('a'); | |
| a.className = 'pdf-toc__link'; | |
| if (id) a.href = '#' + id; | |
| const num = document.createElement('span'); | |
| num.className = 'pdf-toc__num'; | |
| num.textContent = String(sectionNum); | |
| const label = document.createElement('span'); | |
| label.className = 'pdf-toc__label'; | |
| label.textContent = text; | |
| a.appendChild(num); | |
| a.appendChild(label); | |
| li.appendChild(a); | |
| const subList = document.createElement('ol'); | |
| subList.className = 'pdf-toc__sublist'; | |
| li.appendChild(subList); | |
| currentSubList = subList; | |
| list.appendChild(li); | |
| } else if (h.tagName === 'H3' && currentSubList) { | |
| subNum++; | |
| const li = document.createElement('li'); | |
| li.className = 'pdf-toc__subsection'; | |
| const a = document.createElement('a'); | |
| a.className = 'pdf-toc__link'; | |
| if (id) a.href = '#' + id; | |
| const num = document.createElement('span'); | |
| num.className = 'pdf-toc__num'; | |
| num.textContent = sectionNum + '.' + subNum; | |
| const label = document.createElement('span'); | |
| label.className = 'pdf-toc__label'; | |
| label.textContent = text; | |
| a.appendChild(num); | |
| a.appendChild(label); | |
| li.appendChild(a); | |
| currentSubList.appendChild(li); | |
| } | |
| } | |
| tocEl.appendChild(list); | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| .pdf-toc { | |
| page-break-after: always; | |
| break-after: page; | |
| max-width: 600px; | |
| margin: 0 auto; | |
| padding: 40px 0 0; | |
| font-family: Source Sans Pro, ui-sans-serif, system-ui, sans-serif; | |
| } | |
| .pdf-toc__title { | |
| font-size: 28px; | |
| font-weight: 700; | |
| letter-spacing: -0.02em; | |
| color: #111827; | |
| margin: 0 0 32px; | |
| padding-bottom: 16px; | |
| border-bottom: 2px solid #111827; | |
| } | |
| .pdf-toc__list { | |
| list-style: none; | |
| padding: 0; | |
| margin: 0; | |
| counter-reset: none; | |
| } | |
| .pdf-toc__sublist { | |
| list-style: none; | |
| padding: 0; | |
| margin: 0; | |
| } | |
| .pdf-toc__section { | |
| margin: 0; | |
| padding: 0; | |
| } | |
| .pdf-toc__section > .pdf-toc__link { | |
| display: flex; | |
| align-items: baseline; | |
| gap: 12px; | |
| padding: 10px 0; | |
| text-decoration: none; | |
| border-bottom: 1px solid rgba(0,0,0,0.08); | |
| } | |
| .pdf-toc__section > .pdf-toc__link .pdf-toc__num { | |
| flex-shrink: 0; | |
| width: 24px; | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: #6b7280; | |
| } | |
| .pdf-toc__section > .pdf-toc__link .pdf-toc__label { | |
| font-size: 15px; | |
| font-weight: 600; | |
| color: #111827; | |
| line-height: 1.4; | |
| } | |
| .pdf-toc__subsection { | |
| margin: 0; | |
| padding: 0; | |
| } | |
| .pdf-toc__subsection > .pdf-toc__link { | |
| display: flex; | |
| align-items: baseline; | |
| gap: 10px; | |
| padding: 5px 0 5px 36px; | |
| text-decoration: none; | |
| } | |
| .pdf-toc__subsection > .pdf-toc__link .pdf-toc__num { | |
| flex-shrink: 0; | |
| width: 32px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| color: #9ca3af; | |
| } | |
| .pdf-toc__subsection > .pdf-toc__link .pdf-toc__label { | |
| font-size: 13px; | |
| font-weight: 400; | |
| color: #6b7280; | |
| line-height: 1.4; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| const meta = document.querySelector('header.meta'); | |
| if (meta && meta.nextElementSibling) { | |
| meta.parentNode.insertBefore(tocEl, meta.nextElementSibling); | |
| } else { | |
| const contentGrid = document.querySelector('.content-grid'); | |
| if (contentGrid) { | |
| contentGrid.parentNode.insertBefore(tocEl, contentGrid); | |
| } | |
| } | |
| return headings.length; | |
| }); | |
| console.log(` ${tocCount} headings indexed`); | |
| await page.emulateMedia({ media: 'print' }); | |
| // Set viewport to printable width for PDF generation | |
| const outPath = resolve(cwd, 'dist', `${outFileBase}.pdf`); | |
| try { | |
| const fmt2 = getFormatSizeMm(format); | |
| const mw2 = fmt2.w - cssLengthToMm(margin.left) - cssLengthToMm(margin.right); | |
| const printableWidthPx = Math.max(320, Math.round((mw2 / 25.4) * 96)); | |
| console.log(`📐 Setting viewport to printable width: ${printableWidthPx}px`); | |
| await page.setViewportSize({ width: printableWidthPx, height: 1400 }); | |
| await page.evaluate(() => window.scrollTo(0, 0)); | |
| await page.waitForTimeout(300); | |
| await waitForStableLayout(page, 2000); | |
| } catch { } | |
| // Inject styles for PDF | |
| let pdfCssHandle = null; | |
| try { | |
| if (bookMode) { | |
| console.log('📚 Applying book styles…'); | |
| const bookCssPath = resolve(cwd, 'src', 'styles', '_print-book.css'); | |
| const bookCss = await fs.readFile(bookCssPath, 'utf-8'); | |
| pdfCssHandle = await page.addStyleTag({ content: bookCss }); | |
| await page.waitForTimeout(500); | |
| } else { | |
| pdfCssHandle = await page.addStyleTag({ | |
| content: ` | |
| /* General container safety */ | |
| html, body { overflow-x: hidden !important; } | |
| /* Make all media responsive for print */ | |
| svg, canvas, img, video { max-width: 100% !important; height: auto !important; } | |
| .mermaid, .mermaid svg { display: block; width: 100% !important; max-width: 100% !important; height: auto !important; } | |
| svg[width] { width: 100% !important; } | |
| iframe, embed, object { width: 100% !important; max-width: 100% !important; height: auto; } | |
| /* Embeds are screenshots - just ensure they scale */ | |
| .html-embed, .html-embed__card { max-width: 100% !important; width: 100% !important; } | |
| .html-embed img { width: 100% !important; height: auto !important; display: block; } | |
| /* --- Cover page: hero + meta centered, then page break --- */ | |
| .hero { | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 50vh; | |
| padding-top: 15vh !important; | |
| } | |
| /* Banner: 80% width, centered */ | |
| .hero-banner { | |
| width: 80% !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| overflow: hidden; | |
| } | |
| .hero-banner > * { | |
| width: 100% !important; | |
| height: auto !important; | |
| max-width: 100% !important; | |
| display: block; | |
| } | |
| /* Force page break after meta (end of cover page) */ | |
| header.meta { | |
| page-break-after: always !important; | |
| break-after: page !important; | |
| } | |
| ` }); | |
| } | |
| } catch { } | |
| await page.pdf({ | |
| path: outPath, | |
| format, | |
| printBackground: true, | |
| displayHeaderFooter: false, | |
| preferCSSPageSize: false, | |
| margin: bookMode ? { | |
| top: '20mm', | |
| right: '20mm', | |
| bottom: '25mm', | |
| left: '25mm' | |
| } : margin | |
| }); | |
| try { if (pdfCssHandle) await pdfCssHandle.evaluate((el) => el.remove()); } catch { } | |
| console.log(`✅ PDF generated: ${outPath}`); | |
| // Copy into public only under the slugified name | |
| const publicSlugPath = resolve(cwd, 'public', `${outFileBase}.pdf`); | |
| try { | |
| await fs.mkdir(resolve(cwd, 'public'), { recursive: true }); | |
| await fs.copyFile(outPath, publicSlugPath); | |
| console.log(`✅ PDF copied to: ${publicSlugPath}`); | |
| } catch (e) { | |
| console.warn('Unable to copy PDF to public/:', e?.message || e); | |
| } | |
| } finally { | |
| await browser.close(); | |
| } | |
| } finally { | |
| // Try a clean shutdown of preview (entire process group first) | |
| try { | |
| if (process.platform !== 'win32') { | |
| try { process.kill(-preview.pid, 'SIGINT'); } catch { } | |
| } | |
| try { preview.kill('SIGINT'); } catch { } | |
| await Promise.race([previewExit, delay(3000)]); | |
| // Force kill if still alive | |
| // eslint-disable-next-line no-unsafe-optional-chaining | |
| if (!preview.killed) { | |
| try { | |
| if (process.platform !== 'win32') { | |
| try { process.kill(-preview.pid, 'SIGKILL'); } catch { } | |
| } | |
| try { preview.kill('SIGKILL'); } catch { } | |
| } catch { } | |
| await Promise.race([previewExit, delay(1000)]); | |
| } | |
| } catch { } | |
| } | |
| } | |
| main().catch((err) => { | |
| console.error(err); | |
| process.exit(1); | |
| }); | |