A print-quality document system in one HTML file. Real A4 geometry, backgrounds that survive the print dialog, vector text, controlled page breaks, and portrait and landscape pages in the same PDF.
No pagination polyfill, no headless-browser screenshot library, no build chain beyond Python and the Chrome you already have.
Print a designed HTML page from a browser and you get something that is not the design. The usual failures, in the order people hit them:
- The colour disappears. The print dialog's "Background graphics" checkbox is off by default in some flows, and every background you drew goes white. White text on a dark panel becomes white text on white paper.
- The paper is the wrong size. A scripted build without the right flag silently produces US Letter, so nothing lines up and the margins are wrong.
- The pages break in the wrong places. Headings strand at the foot of a page, tables lose their header row halfway through, a panel splits across a break with an open edge.
- The text is a picture. Anything built on
html2canvasrasterises the page. It cannot be searched, selected, copied or reflowed, and it prints soft. - Chrome stamps its own header on your cover. The document title and the full
local
file:///Users/…path, printed across the artwork. No CSS suppresses it. - The file is four times bigger than it should be, and preflight rejects the fonts.
Every one of those has a fix that ships in the browser. This repository is the fixes, assembled, with a template that exercises them and the measurements that show they work.
template.src.html— a thirteen-page A4 landscape deck with a portrait appendix, carrying every archetype: cover, notice, contents, section divider, summary with stat tiles, at-a-glance, a chart page, a financials page with chart plus table, a process page, a team grid, an appendix, a back cover and a style sheet page. Design tokens, a twelve-column grid, a component library and hand-authored inline SVG charts.build.py— inlines the fonts as base64, expands the logo slots to inline SVG, and renders the canonical PDF with headless Chrome.docs/print-to-pdf.md— the technical reference: the CSS boilerplate, the break-control rules, the fourteen footguns with a fix for each, and the evidence.docs/charts.md— the chart conventions, and a validated categorical palette.docs/validate_palette.py— the validator, so the palette claim is reproducible and so is any replacement you make.
git clone https://github.com/Proxuma/paged-deck.git
cd paged-deck
python3 build.py template.src.htmlThat writes template.html, a single self-contained file with no external
requests at all, and template.pdf, the canonical render. Nothing to install:
Python 3.9+ and a Chrome, Chromium or Edge on the machine.
HTML template.html (152 kB)
PDF template.pdf (166 kB)
Chrome is found automatically. Point at a different one if you need to:
export PAGED_DECK_CHROME='/Applications/Chromium.app/Contents/MacOS/Chromium'
python3 build.py template.src.htmlThen make it yours:
- Replace
assets/sample-logo.svgwith your own mark. It is referenced from the HTML as<!--LOGO:sample-logo.svg-->and inlined at build time, so a single CSS rule can recolour it for dark and light grounds. - Change the tokens at the top of
template.src.html: a dark ink, a deeper ink for headings, a secondary dark field, one accent, one light surface. The rest of the sheet follows. - Leave the chart series alone, or replace them and re-run
python3 docs/validate_palette.py. - Delete the archetypes you do not need and duplicate the ones you do.
Verify what came out:
pdfinfo template.pdf | grep "Page size" # A4, and per-page for mixed orientation
pdffonts template.pdf # CID TrueType only, nothing you did not ship
pdfimages -list template.pdf # zero rows: the charts are still vectorEach was established by rendering and measuring, not by reading documentation.
All three are reproducible from this repository. The full evidence, with commands,
is in docs/print-to-pdf.md.
*, *::before, *::after {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}Rendered twice with backgrounds explicitly disabled — which is what Chrome does when the user leaves "Background graphics" unticked — with and without those two lines as the only difference. Without them the cover collapsed to a blank white page: the dark art field, the accent edge and the coloured panel all gone, taking the white logo and the white headline with them. Table headers lost their rule, zebra rows went white, panels lost their fill. With them, the render is identical to a full-colour one.
This is the single most important declaration in the file. It is also the one
most often written without the -webkit- form, which older Chromium and Safari
still need.
Rendered with headers enabled against a document whose CSS says
@page { margin: 0 }, and with the renderer's own margins also set to zero:
injected header present on: 13 of 13 pages
The document title and the full local file:// path print straight across the
cover artwork, and a page counter across the bottom. The CSS page box is not
shifted; the furniture is simply overprinted. @page { margin: 0 } changes
nothing, and no other declaration helps.
That is why the canonical PDF is built from the command line, where the flag exists:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless --disable-gpu --no-pdf-header-footer \
--print-to-pdf=out.pdf "file:///absolute/path/template.html"The in-page "Export to PDF" button is kept as a convenience for whoever opens the HTML, and it says so on screen. It is not the master copy.
Same template, same markup, only the @font-face sources changed:
| fonts in the PDF | file size | |
|---|---|---|
| variable WOFF2 | 37 Type 3 objects | 651,549 bytes |
| static instances | 5 CID TrueType subsets | 169,668 bytes |
74% smaller. Type 3 is not a correctness failure — the glyph procedures are pure vector path fills, no raster anywhere, and text extraction still works — but it is rejected by several PDF/X and print-house preflight profiles and renders inconsistently in older Acrobat.
How to get static instances from Google Fonts. The css2 API serves a
variable file when you ask for a weight range, and a static instance when you
ask for a single weight. Take the @font-face block whose unicode-range begins
U+0000-00FF — that is the Latin subset:
UA='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36'
curl -sS -A "$UA" 'https://fonts.googleapis.com/css2?family=Archivo:wght@600'
# ... /* latin */ @font-face { font-weight: 600; src: url(...woff2) ... }
curl -sS -A "$UA" 'https://fonts.googleapis.com/css2?family=Archivo:[email protected]'
# ... @font-face { font-weight: 400 600; ... } <- variable, Type 3 in the PDFDownload that .woff2, one per weight you actually use, drop it in
assets/fonts/, and build.py inlines it. The user agent matters: without a
modern one, css2 serves TTF instead of WOFF2.
One corollary worth stating: every glyph in the document must come from a font
you embedded. A single element styled in a system monospace pulls that system
font into the PDF as a subset, which makes the output machine-dependent and may
not be redistributable. pdffonts should list exactly what you shipped and
nothing more.
The sheet model: one element, one printed page.
@page { size: A4 landscape; margin: 0; }
.page {
position: relative; overflow: hidden;
width: 297mm; height: 210mm;
break-after: page; break-inside: avoid;
counter-increment: pageno;
}
.page:last-child { break-after: auto; }
.page--portrait { page: portrait; width: 210mm; height: 297mm; }margin: 0 is what lets colour reach the paper edge. It also means the native
@page margin boxes have nowhere to live, so running headers and folios are
drawn into the sheet instead. In exchange, page breaks become structural: a fixed
height cannot drift, so the document you see on screen is the document that
prints.
For anything long and unstyled — schedules, definitions, an index — the template
also documents the other model, a named page with real margins where native
@page margin boxes and counter(page) " / " counter(pages) work with no
JavaScript at all. Both models can live in one file.
Charts are hand-authored inline SVG, because a <canvas> chart is a bitmap by the
time it reaches the PDF. The conventions are in docs/charts.md:
no gridlines, one baseline, selective direct labels with the full numbers in an
adjacent table, legend above, source line below, one axis and never a dual axis,
a fixed series order, and a fifth series that becomes "Other" rather than
inventing a hue.
Google Chrome 151.0.7922.75 (PDF producer Skia/PDF m150), macOS 26.5 arm64.
Nothing here is Chrome-version-sensitive in the parts that matter, but the
measured numbers above come from that build.
The repository ships two typefaces, both under the SIL Open Font License 1.1, which permits redistribution provided the licence text travels with the font. It does:
| Font | Files | Licence |
|---|---|---|
| Archivo 2.001, static instances, weights 400/500/600 | assets/fonts/archivo/ |
assets/fonts/archivo/OFL.txt |
| Source Serif 4 4.004, static italics, weights 400/500 | assets/fonts/source-serif-4/ |
assets/fonts/source-serif-4/OFL.txt |
Both are Latin subsets from the Google Fonts css2 API, and both report
fsType 0 (installable embedding), which is what makes base64-inlining them into
a distributed document legitimate. The template aliases them as IM Sans and
IM Serif so you can swap the files without touching the CSS.
Everything else in this repository is MIT. See LICENSE.
build.py fonts + logos inlined, then the canonical PDF
template.src.html the template. edit this one
assets/
sample-logo.svg placeholder wordmark. replace it
fonts/
archivo/ 3 static weights + OFL.txt
source-serif-4/ 2 static italics + OFL.txt
docs/
print-to-pdf.md the technical reference and the evidence
charts.md chart conventions and the palette
validate_palette.py the palette validator
img/ the screenshots above
template.html and template.pdf are build output and are not committed.

