Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions docs/pdfs.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,48 @@
# 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.

## 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 {
break-before: page;
}
```

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
Expand Down
29 changes: 28 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void>) | 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];
Expand All @@ -74,6 +90,13 @@ export const handleArgs = (): (() => Promise<void>) | 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;
Expand All @@ -82,7 +105,11 @@ export const handleArgs = (): (() => Promise<void>) | 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();
};
};
124 changes: 124 additions & 0 deletions src/pdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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];
(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));

// 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.
const height = Math.ceil(document.documentElement.getBoundingClientRect().height);
for (const [media, text] of patched) media.mediaText = text;

done({ width: document.documentElement.scrollWidth, height });
})();
`;

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<void> => {
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<PageSize>(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();
}
};
Loading
Loading