From 18a8235a1abfde9e065f4bcfdde65a8fb96095ba Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Thu, 17 Sep 2026 09:17:35 +0200 Subject: [PATCH 1/5] feat(#302): initial export implementation --- src/cli.ts | 29 +++++++- src/pdf.ts | 95 ++++++++++++++++++++++++ src/utils/webdriver.ts | 160 +++++++++++++++++++++++++++++++++++++++++ viv | 4 ++ 4 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 src/pdf.ts create mode 100644 src/utils/webdriver.ts diff --git a/src/cli.ts b/src/cli.ts index 8276f99a..17bf0547 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { existsSync } from 'fs'; import open from 'open'; import { resolve as presolve } from 'path'; import { address, config } from './config.js'; +import { exportPdf } from './pdf.js'; import { pathToURL, preferredPath } from './utils/path.js'; // exported for unit test @@ -58,10 +59,25 @@ export const completeStartup = () => { } }; +const exportTargets = async (targets: string[], output: string | undefined) => { + // a single --output only makes sense for a single target + const out = targets.length === 1 ? output : undefined; + for (const target of targets) { + const { path } = getPathAndLine(target); + if (!path || !existsSync(path)) { + console.log(`File not found: ${target}`); + continue; + } + await exportPdf(path, out); + } +}; + export const handleArgs = (): (() => Promise) | undefined => { const args = process.argv.slice(2); const positionals: string[] = []; let parseOptions = true; + let pdf = false; + let output: string | undefined; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -74,6 +90,13 @@ export const handleArgs = (): (() => Promise) | undefined => { case '--version': console.log(`vivify-server ${process.env.VERSION ?? 'dev'}`); return; + case '--pdf': + pdf = true; + break; + case '-o': + case '--output': + output = args[++i]; + break; case '--': parseOptions = false; break; @@ -82,7 +105,11 @@ export const handleArgs = (): (() => Promise) | undefined => { } } return async () => { - await Promise.all(positionals.map((target) => openTarget(target))); + if (pdf) { + await exportTargets(positionals, output); + } else { + await Promise.all(positionals.map((target) => openTarget(target))); + } completeStartup(); }; }; diff --git a/src/pdf.ts b/src/pdf.ts new file mode 100644 index 00000000..84f04bdb --- /dev/null +++ b/src/pdf.ts @@ -0,0 +1,95 @@ +import { writeFileSync } from 'fs'; +import { basename, extname, resolve } from 'path'; + +import { address } from './config.js'; +import { pathToURL, preferredPath } from './utils/path.js'; +import { BrowserSession, findDriver } from './utils/webdriver.js'; + +const CM_PER_PX = 2.54 / 96; +const PRINT_WIDTH_CM = 21; // A4 width; content is centered within this +const BOTTOM_MARGIN_PX = 40; + +// Runs inside the page: hide what `@media print` hides, wait for images, fonts +// and mermaid diagrams, then report the document's scroll size. +const MEASURE_SCRIPT = ` +const done = arguments[arguments.length - 1]; +const style = document.createElement('style'); +style.textContent = '#top-nav,#front-matter-button{display:none!important}'; +document.head.appendChild(style); +(async () => { + if (document.readyState !== 'complete') { + await new Promise((r) => window.addEventListener('load', r, { once: true })); + } + await Promise.all([...document.images].map((img) => + img.complete ? null : new Promise((r) => { img.onload = img.onerror = r; }))); + if (document.fonts && document.fonts.ready) await document.fonts.ready; + for (let i = 0; i < 100; i++) { + if (!document.querySelectorAll('.mermaid:not([data-processed="true"])').length) break; + await new Promise((r) => setTimeout(r, 100)); + } + await new Promise((r) => setTimeout(r, 150)); + done({ + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }); +})(); +`; + +interface PageSize { + width: number; + height: number; +} + +// Firefox/Chrome print PDFs are uncompressed, so the page count is readable +// straight from the bytes. +const pageCount = (pdf: Buffer): number => { + const text = pdf.toString('latin1'); + const count = text.match(/\/Count\s+(\d+)/); + if (count) return parseInt(count[1], 10); + return (text.match(/\/Type\s*\/Page[^s]/g) ?? []).length || 1; +}; + +// Render a file to a single tall "infinite scroll" PDF via a headless browser. +export const exportPdf = async (filePath: string, outPath?: string): Promise => { + const driver = findDriver(); + if (!driver) { + console.log( + 'PDF export needs a WebDriver on your PATH.\n' + + 'Install geckodriver (for Firefox) or chromedriver (for Chrome), then try again.', + ); + return; + } + + const resolved = resolve(filePath); + const url = `${address}${pathToURL(preferredPath(resolved))}`; + const output = outPath ?? `${basename(resolved, extname(resolved))}.pdf`; + + console.log(`Exporting ${basename(resolved)} via ${driver.bin} …`); + const session = await BrowserSession.launch(driver); + try { + await session.setViewportWidth(Math.round(PRINT_WIDTH_CM / CM_PER_PX)); + await session.navigate(url); + const size = await session.evaluateAsync(MEASURE_SCRIPT); + + // Print onto one tall page. The browser's print layout can be a hair + // taller than the measured scroll height, which would spill a blank + // second page, so we verify the page count and grow the page if needed. + let extraPx = BOTTOM_MARGIN_PX; + let pdf = await session.print({ + widthCm: PRINT_WIDTH_CM, + heightCm: (size.height + extraPx) * CM_PER_PX, + }); + for (let attempt = 0; attempt < 5 && pageCount(pdf) > 1; attempt++) { + extraPx += 64; + pdf = await session.print({ + widthCm: PRINT_WIDTH_CM, + heightCm: (size.height + extraPx) * CM_PER_PX, + }); + } + + writeFileSync(output, pdf); + console.log(`Wrote ${output}`); + } finally { + await session.close(); + } +}; diff --git a/src/utils/webdriver.ts b/src/utils/webdriver.ts new file mode 100644 index 00000000..a70d8609 --- /dev/null +++ b/src/utils/webdriver.ts @@ -0,0 +1,160 @@ +import { ChildProcess, spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { createServer } from 'net'; +import { delimiter, join } from 'path'; + +export type DriverKind = 'firefox' | 'chrome'; + +export interface Driver { + kind: DriverKind; + path: string; + bin: string; +} + +// WebDriver binaries we know how to drive, in order of preference +const KNOWN_DRIVERS: { kind: DriverKind; bin: string }[] = [ + { kind: 'firefox', bin: 'geckodriver' }, + { kind: 'chrome', bin: 'chromedriver' }, +]; + +const findExecutable = (name: string): string | undefined => { + for (const dir of (process.env.PATH ?? '').split(delimiter)) { + if (!dir) continue; + const candidate = join(dir, name); + if (existsSync(candidate)) return candidate; + } + return undefined; +}; + +// Detect an available WebDriver on the user's PATH, or undefined if none. +export const findDriver = (): Driver | undefined => { + for (const { kind, bin } of KNOWN_DRIVERS) { + const path = findExecutable(bin); + if (path) return { kind, path, bin }; + } + return undefined; +}; + +const freePort = (): Promise => + new Promise((resolve, reject) => { + const srv = createServer(); + srv.on('error', reject); + srv.listen(0, () => { + const addr = srv.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + srv.close(() => resolve(port)); + }); + }); + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export interface PrintOptions { + widthCm: number; + heightCm: number; +} + +// Minimal WebDriver (W3C) client that speaks just enough of the protocol to +// load a page, measure it, and print it. Uses Node's built-in fetch and +// child_process only — no npm dependencies. +export class BrowserSession { + private constructor( + private readonly process: ChildProcess, + private readonly base: string, + private readonly sessionId: string, + ) {} + + static async launch(driver: Driver): Promise { + const port = await freePort(); + const args = driver.kind === 'firefox' ? ['--port', String(port)] : [`--port=${port}`]; + const proc = spawn(driver.path, args, { stdio: 'ignore' }); + const base = `http://127.0.0.1:${port}`; + + try { + await BrowserSession.waitUntilReady(base); + const sessionId = await BrowserSession.createSession(base, driver.kind); + return new BrowserSession(proc, base, sessionId); + } catch (error) { + proc.kill(); + throw error; + } + } + + private static async waitUntilReady(base: string): Promise { + for (let i = 0; i < 100; i++) { + try { + const res = await fetch(`${base}/status`); + const json = (await res.json()) as { value?: { ready?: boolean } }; + if (json.value?.ready) return; + } catch { + // driver process not accepting connections yet + } + await sleep(100); + } + throw new Error('WebDriver did not become ready'); + } + + private static async createSession(base: string, kind: DriverKind): Promise { + const browserOptions = + kind === 'firefox' + ? { 'moz:firefoxOptions': { args: ['-headless'] } } + : { 'goog:chromeOptions': { args: ['--headless=new', '--disable-gpu'] } }; + const res = await fetch(`${base}/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + capabilities: { alwaysMatch: { ...browserOptions, timeouts: { script: 60000 } } }, + }), + }); + const json = (await res.json()) as { value?: { sessionId?: string; message?: string } }; + const sessionId = json.value?.sessionId; + if (!sessionId) { + throw new Error(`Could not start browser: ${json.value?.message ?? 'unknown error'}`); + } + return sessionId; + } + + private async send(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${this.base}/session/${this.sessionId}${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const json = (await res.json()) as { value: T }; + if (!res.ok) { + throw new Error(`WebDriver ${method} ${path} failed: ${JSON.stringify(json)}`); + } + return json.value; + } + + async setViewportWidth(widthPx: number): Promise { + await this.send('POST', '/window/rect', { width: widthPx, height: 2000 }); + } + + async navigate(url: string): Promise { + await this.send('POST', '/url', { url }); + } + + async evaluateAsync(script: string): Promise { + return this.send('POST', '/execute/async', { script, args: [] }); + } + + async print({ widthCm, heightCm }: PrintOptions): Promise { + const base64 = await this.send('POST', '/print', { + page: { width: widthCm, height: heightCm }, + margin: { top: 0, bottom: 0, left: 0, right: 0 }, + background: true, + scale: 1, + shrinkToFit: false, + }); + return Buffer.from(base64, 'base64'); + } + + async close(): Promise { + try { + await this.send('DELETE', ''); + } catch { + // best effort; we kill the driver process regardless + } + this.process.kill(); + } +} diff --git a/viv b/viv index e17bf3ce..f7268b67 100755 --- a/viv +++ b/viv @@ -14,6 +14,10 @@ arguments: suffix with :n to scroll to the content at line n in the source file options: + --pdf export the target(s) to a single-page "infinite scroll" + PDF instead of opening a browser. Requires a WebDriver + (geckodriver or chromedriver) on your PATH + -o, --output FILE output path for --pdf (only with a single target) --help show this help message and exit --version show version information and exit EOF From e216eb4354b2776bc530cd0ccb6aca41c1e1270e Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Thu, 17 Sep 2026 09:35:48 +0200 Subject: [PATCH 2/5] fix(#302): short docs --- src/pdf.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pdf.ts b/src/pdf.ts index 84f04bdb..cece6d61 100644 --- a/src/pdf.ts +++ b/src/pdf.ts @@ -28,9 +28,11 @@ document.head.appendChild(style); await new Promise((r) => setTimeout(r, 100)); } await new Promise((r) => setTimeout(r, 150)); + // getBoundingClientRect().height is the true content height; scrollHeight is + // clamped up to the viewport, which over-pads short documents. done({ width: document.documentElement.scrollWidth, - height: document.documentElement.scrollHeight, + height: Math.ceil(document.documentElement.getBoundingClientRect().height), }); })(); `; From 78fcea68867414c37e35d80e12020dd6c06185bf Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Thu, 17 Sep 2026 09:35:55 +0200 Subject: [PATCH 3/5] fix(#302): executability --- src/utils/webdriver.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/webdriver.ts b/src/utils/webdriver.ts index a70d8609..dfe90000 100644 --- a/src/utils/webdriver.ts +++ b/src/utils/webdriver.ts @@ -1,5 +1,5 @@ import { ChildProcess, spawn } from 'child_process'; -import { existsSync } from 'fs'; +import { accessSync, constants } from 'fs'; import { createServer } from 'net'; import { delimiter, join } from 'path'; @@ -21,7 +21,12 @@ const findExecutable = (name: string): string | undefined => { for (const dir of (process.env.PATH ?? '').split(delimiter)) { if (!dir) continue; const candidate = join(dir, name); - if (existsSync(candidate)) return candidate; + try { + accessSync(candidate, constants.X_OK); + return candidate; + } catch { + // not in this directory, or not executable + } } return undefined; }; From 5658353d2dde3191df4b4651340d4c2ce57aaf27 Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Thu, 17 Sep 2026 10:07:43 +0200 Subject: [PATCH 4/5] feat(#302): consider all print media queries for height computation --- src/pdf.ts | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/pdf.ts b/src/pdf.ts index cece6d61..432b4a19 100644 --- a/src/pdf.ts +++ b/src/pdf.ts @@ -13,9 +13,6 @@ const BOTTOM_MARGIN_PX = 40; // and mermaid diagrams, then report the document's scroll size. const MEASURE_SCRIPT = ` const done = arguments[arguments.length - 1]; -const style = document.createElement('style'); -style.textContent = '#top-nav,#front-matter-button{display:none!important}'; -document.head.appendChild(style); (async () => { if (document.readyState !== 'complete') { await new Promise((r) => window.addEventListener('load', r, { once: true })); @@ -28,12 +25,42 @@ document.head.appendChild(style); await new Promise((r) => setTimeout(r, 100)); } await new Promise((r) => setTimeout(r, 150)); + + // Measure the actual print layout rather than hard-coding what print hides. + // We temporarily activate every '@media print' rule (and switch off + // screen-only ones) so both Vivify's built-in and any user print CSS take + // effect, then restore the stylesheets so the real print stays pristine. + // (Width-based media features still resolve against the window, which we + // size to the print width.) + const forPrint = (query) => { + const q = query.trim().toLowerCase(); + if (q.includes('print')) return 'all'; + if (q.includes('screen')) return 'not all'; + return query; + }; + const patched = []; + const activatePrint = (rules) => { + for (const rule of rules) { + if (rule.media && rule.media.mediaText) { + patched.push([rule.media, rule.media.mediaText]); + rule.media.mediaText = rule.media.mediaText.split(',').map(forPrint).join(', '); + } + if (rule.cssRules) activatePrint(rule.cssRules); + if (rule.styleSheet) { + try { activatePrint(rule.styleSheet.cssRules); } catch (e) { /* cross-origin */ } + } + } + }; + for (const sheet of document.styleSheets) { + try { activatePrint(sheet.cssRules); } catch (e) { /* cross-origin */ } + } + // getBoundingClientRect().height is the true content height; scrollHeight is // clamped up to the viewport, which over-pads short documents. - done({ - width: document.documentElement.scrollWidth, - height: Math.ceil(document.documentElement.getBoundingClientRect().height), - }); + const height = Math.ceil(document.documentElement.getBoundingClientRect().height); + for (const [media, text] of patched) media.mediaText = text; + + done({ width: document.documentElement.scrollWidth, height }); })(); `; From 7064fb9cb12713d407af07c42121f6f1fa44b333 Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Thu, 17 Sep 2026 10:18:42 +0200 Subject: [PATCH 5/5] docs(#302): document new export capability --- docs/pdfs.md | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/docs/pdfs.md b/docs/pdfs.md index 63e807a1..e3cff08b 100644 --- a/docs/pdfs.md +++ b/docs/pdfs.md @@ -1,8 +1,27 @@ # Making PDFs with Vivify You can leverage Vivify's rendering capabilities and your browser to create -nice PDFs from Markdown. To do this, simply **use your browser's printing -function and save the resulting PDF**. +nice PDFs from Markdown. + +## "Infinite scroll" PDFs + +Markdown documents and Vivify renders are fundamentally made for screens where +there are no page breaks and you can scroll continuously forever. PDFs on the +other hand are fundamentally made for print and multi-page layouts where you +have to think about where and when pages break. Nevertheless, they are still +the only document type you can cleanly wrap into a single file that renders +basically anywhere and for whoever you share it with. + +To get all the benefits of the PDF file type without sacrificing the continuous +scroll layout, Vivify offers exporting PDFs with a single page that is tall +enough to accommodate your whole file. You can export such an "infinite scroll" +PDF with `viv --pdf`. Note that this requires having a WebDriver (e.g. +`geckodriver` for Firefox or `chromedriver` for Chromium) installed. + +## Paged PDFs + +To export normal multi-page PDFs with normal paper sizes, simply **use your +browser's printing function and save the resulting PDF**. Note that many browsers will add some headers and footers by default, which you will probably want to turn off in the printing options to get a clean PDF. @@ -10,10 +29,10 @@ will probably want to turn off in the printing options to get a clean PDF. ## Customization You can customize how the PDF will look further by using [custom -styles](./customization.md). One particularly useful function you may not know -about are the `break-before`/`break-after` CSS properties with the `page` -value. These are invisible in HTML, but control where page breaks are inserted. -You could, e.g. add +styles](./customization.md). One particularly useful function for paged PDFs +you may not know about are the `break-before`/`break-after` CSS properties with +the `page` value. These are invisible in HTML, but control where page breaks +are inserted. You could, e.g. add ```css h2 { @@ -21,7 +40,9 @@ h2 { } ``` -to your custom style sheet to **automatically break the page before each `h2` heading**. +to your custom style sheet to **automatically break the page before each `h2` +heading**. Note that you should avoid this if your goal is to produce the +single-page, infinite scroll PDFs with `viv --pdf`. Another useful feature are `print` media queries. You can use these to add styling that will only be applied to the PDFs but not to the HTML. Vivify uses