#!/usr/bin/env node
/**
* Export PDF Book with Paged.js
*
* Generates a professional book-quality PDF using Playwright + Paged.js polyfill.
* Paged.js implements W3C CSS Paged Media specs that browsers don't support natively:
* - @page :left / :right (alternating margins for binding)
* - Running headers via string-set
* - Footnotes via float: footnote
* - Named pages (@page chapter)
* - target-counter() for ToC page numbers
* - bookmark-level for PDF outline
*
* Usage:
* npm run export:pdf:book
* npm run export:pdf:book -- --theme=light --format=A4
*
* Options:
* --theme=light|dark Color theme (default: light)
* --format=A4|Letter Page format (default: A4)
* --filename=xxx Output filename (default:
-book)
* --wait=full Wait strategy (default: full)
* --debug Keep browser open for inspection
*/
import { spawn } from 'node:child_process';
import { setTimeout as delay } from 'node:timers/promises';
import { chromium } from 'playwright';
import { resolve, dirname } from 'node:path';
import { promises as fs } from 'node:fs';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
const __dirname = dirname(fileURLToPath(import.meta.url));
// ============================================================================
// Utilities
// ============================================================================
async function run(command, args = [], options = {}) {
return new Promise((ok, fail) => {
const child = spawn(command, args, { stdio: 'inherit', shell: false, ...options });
child.on('error', fail);
child.on('exit', (code) => {
if (code === 0) ok(); else fail(new Error(`${command} ${args.join(' ')} exited ${code}`));
});
});
}
async function waitForServer(url, timeoutMs = 60_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try { const r = await fetch(url); if (r.ok) return; } catch {}
await delay(500);
}
throw new Error(`Server did not start within ${timeoutMs}ms: ${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';
}
// ============================================================================
// Content-readiness helpers (ported from export-pdf.mjs)
// ============================================================================
async function waitForImages(page, timeoutMs = 15_000) {
await page.evaluate(async (timeout) => {
const deadline = Date.now() + timeout;
const imgs = Array.from(document.images || []);
const pending = imgs.filter(i => !i.complete || i.naturalWidth === 0);
await Promise.race([
Promise.all(pending.map(i => new Promise(r => {
if (i.complete && i.naturalWidth !== 0) return r();
i.addEventListener('load', r, { once: true });
i.addEventListener('error', r, { once: true });
}))),
new Promise(r => setTimeout(r, Math.max(0, deadline - Date.now())))
]);
}, timeoutMs);
}
async function waitForD3(page, timeoutMs = 20_000) {
try {
await page.evaluate(async (timeout) => {
const start = Date.now();
const ready = () => {
const hero = document.querySelector('.hero-banner');
if (hero) return !!hero.querySelector('svg circle, svg path, svg rect, svg g');
const containers = [
...document.querySelectorAll('.d3-line'),
...document.querySelectorAll('.d3-bar'),
...document.querySelectorAll('[class^="d3-"]'),
];
return !containers.length || containers.every(c => c.querySelector('svg circle, svg path, svg rect, svg g'));
};
while (!ready() && Date.now() - start < timeout) await new Promise(r => setTimeout(r, 200));
}, timeoutMs);
} catch {}
}
async function waitForPlotly(page, timeoutMs = 20_000) {
try {
await page.evaluate(async (timeout) => {
const start = Date.now();
const has = () => document.querySelectorAll('.js-plotly-plot').length > 0;
while (!has() && Date.now() - start < timeout) await new Promise(r => setTimeout(r, 200));
const ok = () => Array.from(document.querySelectorAll('.js-plotly-plot')).every(e => e.querySelector('svg.main-svg'));
while (!ok() && Date.now() - start < timeout) await new Promise(r => setTimeout(r, 200));
}, timeoutMs);
} catch {}
}
async function waitForHtmlEmbeds(page, timeoutMs = 15_000) {
await page.evaluate(async (timeout) => {
const start = Date.now();
const isReady = (embed) => {
try {
const has = embed.querySelector('svg, canvas, div[id^="frag-"]');
if (!has) return false;
for (const svg of embed.querySelectorAll('svg')) {
if (!svg.querySelector('path, circle, rect, line, polygon, g')) return false;
}
return true;
} catch { return false; }
};
while (Date.now() - start < timeout) {
const embeds = Array.from(document.querySelectorAll('.html-embed__card'));
if (!embeds.length || embeds.every(isReady)) break;
await new Promise(r => setTimeout(r, 300));
}
}, timeoutMs);
}
async function waitForStableLayout(page, timeoutMs = 5_000) {
const start = Date.now();
let last = await page.evaluate(() =>
(document.scrollingElement || document.body).scrollHeight
);
let stable = 0;
while (Date.now() - start < timeoutMs && stable < 3) {
await page.waitForTimeout(250);
const now = await page.evaluate(() =>
(document.scrollingElement || document.body).scrollHeight
);
if (now === last) stable++; else { stable = 0; last = now; }
}
}
// ============================================================================
// SVG viewBox injection (ensures SVGs scale properly after Paged.js reflows)
// ============================================================================
async function injectSvgViewBoxes(page) {
return page.evaluate(() => {
let fixed = 0, skipped = 0, errors = 0;
const sel = [
'.html-embed__card svg', '[class^="d3-"] svg', '[class*=" d3-"] svg',
].join(', ');
document.querySelectorAll(sel).forEach(svg => {
try {
if (svg.getAttribute('viewBox')) { skipped++; return; }
const r = svg.getBoundingClientRect();
const w = r.width || svg.clientWidth || parseFloat(svg.getAttribute('width')) || 0;
const h = r.height || svg.clientHeight || parseFloat(svg.getAttribute('height')) || 0;
if (w > 0 && h > 0) {
svg.setAttribute('viewBox', `0 0 ${Math.round(w)} ${Math.round(h)}`);
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.removeAttribute('width');
svg.removeAttribute('height');
svg.style.width = '100%';
svg.style.height = 'auto';
svg.style.maxWidth = '100%';
fixed++;
} else { skipped++; }
} catch { errors++; }
});
return { fixed, skipped, errors };
});
}
// ============================================================================
// Open all accordions so their content is visible in the book
// ============================================================================
async function openAllAccordions(page) {
const count = await page.evaluate(() => {
let opened = 0;
document.querySelectorAll('details.accordion, details').forEach(d => {
if (!d.hasAttribute('open')) {
d.setAttribute('open', '');
const w = d.querySelector('.accordion__content-wrapper');
if (w) { w.style.height = 'auto'; w.style.overflow = 'visible'; }
opened++;
}
});
return opened;
});
if (count > 0) await waitForStableLayout(page, 2_000);
return count;
}
// ============================================================================
// Main
// ============================================================================
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);
const theme = (args.theme === 'dark' || args.theme === 'light') ? args.theme : 'light';
const format = args.format || 'A4';
const wait = args.wait || 'full';
const debug = !!args.debug;
let outFileBase = (args.filename && String(args.filename).replace(/\.pdf$/i, '')) || '';
// -- Build if needed -------------------------------------------------------
const distDir = resolve(cwd, 'dist');
let hasDist = false;
try { const st = await fs.stat(distDir); hasDist = st?.isDirectory(); } catch {}
if (!hasDist) {
console.log('π¦ Building Astro siteβ¦');
await run('npm', ['run', 'build']);
} else {
console.log('β dist/ exists, skipping build');
}
// -- Start preview server --------------------------------------------------
console.log('π Starting preview serverβ¦');
const preview = spawn('npm', ['run', 'preview'], { cwd, stdio: 'inherit', detached: true });
const previewExit = new Promise(r => preview.on('close', (code, signal) => r({ code, signal })));
try {
await waitForServer(baseUrl, 60_000);
console.log('β Server ready');
// -- Launch browser ------------------------------------------------------
const browser = await chromium.launch({ headless: !debug });
try {
const context = await browser.newContext();
await context.addInitScript((desired) => {
try {
localStorage.setItem('theme', desired);
if (document?.documentElement) document.documentElement.dataset.theme = desired;
} catch {}
}, theme);
const page = await context.newPage();
await page.setViewportSize({ width: 1200, height: 1600 });
// -- Load page and wait for content ------------------------------------
console.log('π Loading pageβ¦');
await page.goto(baseUrl, { waitUntil: 'load', timeout: 60_000 });
try { await page.waitForFunction(() => !!window.d3, { timeout: 8_000 }); } catch {}
try { await page.waitForFunction(() => !!window.Plotly, { timeout: 5_000 }); } catch {}
// Derive filename from page if not provided
if (!outFileBase) {
const fromBtn = await page.evaluate(() => {
const btn = document.getElementById('download-pdf-btn');
return btn?.getAttribute('data-pdf-filename') || '';
});
if (fromBtn) {
outFileBase = String(fromBtn).replace(/\.pdf$/i, '') + '-book';
} else {
const title = await page.evaluate(() => {
const h1 = document.querySelector('h1.hero-title');
return (h1?.textContent || document.title || '').replace(/\s+/g, ' ').trim();
});
outFileBase = slugify(title) + '-book';
}
}
if (wait === 'images' || wait === 'full') {
console.log('β³ Waiting for imagesβ¦');
await waitForImages(page);
}
if (wait === 'd3' || wait === 'full') {
console.log('β³ Waiting for D3β¦');
await waitForD3(page);
}
if (wait === 'plotly' || wait === 'full') {
console.log('β³ Waiting for Plotlyβ¦');
await waitForPlotly(page);
}
if (wait === 'full') {
console.log('β³ Waiting for HTML embedsβ¦');
await waitForHtmlEmbeds(page);
await waitForStableLayout(page);
}
// -- Prepare content for book ------------------------------------------
console.log('π Opening accordionsβ¦');
const accordionCount = await openAllAccordions(page);
console.log(` ${accordionCount} accordion(s) opened`);
console.log('π§ Fixing SVG viewBox attributesβ¦');
const vb = await injectSvgViewBoxes(page);
console.log(` Fixed: ${vb.fixed}, Skipped: ${vb.skipped}, Errors: ${vb.errors}`);
// Hide web-only UI elements before Paged.js processes the DOM
await page.evaluate(() => {
const hide = [
'#theme-toggle', '.table-of-contents', '.toc-mobile-toggle',
'.toc-mobile-backdrop', '.toc-mobile-sidebar', '.right-aside',
'nav', '.code-lang-chip', '.meta-container-cell--pdf', 'button',
'.interactive-only',
];
hide.forEach(sel => {
document.querySelectorAll(sel).forEach(el => { el.style.display = 'none'; });
});
// Force single column
const grid = document.querySelector('.content-grid');
if (grid) grid.style.gridTemplateColumns = '1fr';
const main = document.querySelector('main');
if (main) { main.style.maxWidth = 'none'; main.style.padding = '0'; }
});
// -- Prepare book CSS ---------------------------------------------------
console.log('π Preparing book stylesβ¦');
const bookCssPath = resolve(__dirname, '..', 'src', 'styles', '_print-book.css');
const bookCss = await fs.readFile(bookCssPath, 'utf-8');
// Strip @media print wrapper β Paged.js works in screen mode
const unwrappedCss = bookCss
.replace(/^@media\s+print\s*\{/, '')
.replace(/\}\s*$/, '');
// Neutralize external stylesheet references that cause CORS/XHR errors
// in Paged.js. Inline their content and remove @import rules.
console.log('π Neutralizing external stylesheets for Paged.jsβ¦');
const neutralized = await page.evaluate(async () => {
let count = 0;
// 1. Replace with inline