Someone sent me a PDF exported from an internal report page and asked me to check one SKU in it. Ctrl+F found nothing. Dragging across the table produced no selection. At 400% the glyph edges were jagged, so the whole page was a bitmap — and the person who exported it had no idea, because on their side it was just a button labelled Export PDF.
PDF generation is not my lane. I work on codec and model loading on the client side, so everything below comes from measuring artifacts, not from reading anyone's source.
I hand-wrote seven HTML samples shaped like a cross-border warehouse report. The brand in them, Tidebox, does not exist; the SKUs, warehouses and amounts are all made up, and none of it corresponds to a real company or a real report. Then I exported each sample three ways: Playwright calling page.pdf(), which is the browser print path; the HTML-to-PDF tool on ImgIng (https://imging.ai/); and a control I built myself — screenshot the full page at 2x and drop that single PNG into a PDF of the same size. That third one matches what html2canvas plus jsPDF produces in shape. It is not a stand-in for any product, it only exists to put a number on what "export the page as an image" costs.
About where the second path runs, since the rest of my measurements depend on it: importing, directory reading, resource mapping, instant preview, and the lossless minification applied after the PDF comes back all happen locally in the browser, with zero upstream traffic during that stretch. Only after you click convert does it take a self-contained HTML snapshot — scripts stripped, every resource inlined — and submit it with a single POST to the same-origin imging.cn endpoint, where server-side Chromium / Skia returns the real PDF. This is the one document capability there that goes through a server. I wrapped fetch, XHR and sendBeacon to keep a ledger: zero non-GET requests before the click, exactly one POST after it, 1,604,339 bytes of request body for the illustrated sample, zero <script> tags inside that body, and one domain touched the whole session.
Counting what survives
The blunt number first. Same 46-row report: the laid-out PDF is 156,370 bytes with 2,996 extractable characters; my screenshot export is 995,232 bytes with zero. Here is the whole measurement, which is short enough to paste:
import fitz, os, sys
for label, path in zip(("laid-out", "screenshot"), sys.argv[1:]):
doc = fitz.open(path)
chars = sum(len(page.get_text("text")) for page in doc)
print(label, f"{os.path.getsize(path):,} bytes", f"{chars:,} chars")
It prints laid-out 156,370 bytes 2,996 chars and screenshot 995,232 bytes 0 chars.
6.4x the bytes for zero selectable text is the part people notice. The parts they notice later cost more. The SVG chart in the illustrated sample survives as 819 vector drawing objects — I rendered that region at 900 dpi and the axis labels still have clean edges. In the screenshot export the vector object count is 0. The four link annotations in the body (three external, one in-page anchor) come through intact with identical URIs on both laid-out paths, and come through as 0 on the screenshot path. A link in a PDF is a separate object bound to a rectangle; photographing blue text does not create one.
The bitmap was the one thing that did not degrade. The 960x600 PNG has the same sha256 in the source file and in the laid-out artifact, with a maximum per-pixel difference of 0. It does grow from 817,313 to 1,432,687 bytes once embedded, but that is Chromium rewrapping the PNG and both laid-out paths grow identically.
Paper size is a print-pipeline decision
The other half of the complaint — tiny type, wrong page count — has nothing to do with screenshots.
With no print CSS at all, the 46-row report prints to 2 pages at 612x792pt. That is Letter, not A4; I had assumed A4 for years and this build of Chromium defaults to Letter. To fit an 1180px-wide table onto an 816px-wide sheet it shrinks the page, and 13px table text lands at 6.74pt. The path that reads the full rendered width and height instead emits one custom 1080x1536pt page, and the same text lands at 9.75pt. That gap is scaling, not rendering quality — I could not measure any difference in glyph sharpness between the two.
Once the page declares @page, the two paths converge. I added @page, repeating headers and no in-row breaks to a longer table, and both exports came out at 7 A4 pages with the header repeated 7 times, matching item by item on page count, page size, type size and extractable characters. @page inside @media print { } is picked up just as well as a bare one — my two samples differing only in that detail produced artifacts 30 bytes apart.
One thing I had prepared for did not happen: table rows cut in half across a page boundary. I tagged every row twice and checked whether a tag ever landed on two pages. Across seven samples and every path the count was 0. This build pushes a row whole to the next page and repeats thead on its own, so that thead { display: table-header-group } line everyone copies is redundant.
The one place data actually disappears
A table inside height: 520px; overflow: auto exported 8 of its 46 rows. Both paths, same 8 rows, no warning in either UI. Component libraries put tables in scroll containers by default, so this is the common case in admin dashboards, and no exporter saves you from it. Release the container before exporting and let the table reach its real height.
Still unsolved on my side: Chinese line breaks differ between local printing and server-side rendering, because the renderer does not have my machine's CJK fonts and substitutes its own metrics. Four characters in one lead paragraph moved to the next line. The Latin face was inlined in the page, so that part matches character for character. Inlining the CJK font should fix it; two attempts did not work and I left it there.

Top comments (0)