DEV Community

hao jia
hao jia

Posted on

Your PDF export tool isn't the problem, your print CSS is

An ops colleague sent back a PDF we had exported from one of our internal report pages. The complaint was that the text was too small to read. The page itself looks fine in the browser: four metric cards, then a 46-row fulfillment table. Someone had hit Ctrl+P, saved it, and sent it out. What came back was three pages of shrunken type.

My first guess was that the browser's print path was just bad at this, so we tried a different HTML-to-PDF tool. That one produced a single very tall page with comfortably sized text, which the ops team liked until they tried to print it, at which point the printer scaled it down and we were back where we started.

Two attempts, two wrong results, and my assumption was that this was a tooling problem. It wasn't.

Two defaults, both defensible

I rebuilt the whole thing as a controlled comparison. The sample is mine, generated from a script: a fictional cross-border fulfillment weekly report for a brand called Tidebox that does not exist. Every SKU, warehouse, channel and amount in it is made up. Layout-wise it mirrors what our real report pages look like — 1440px rendered width, the table itself 1180px wide, a dark thead row on top.

Path one is the browser's own print export. Its default paper follows the printer and the OS, and on this machine that came out as Letter, 612×792pt. Not A4, which is what I had assumed for years. 612pt of width cannot hold a 1180px table, so Chromium shrinks the whole page to fit and then slices it by paper height. Result: 3 pages, table type dropping from 13px to 6.74pt, 431,114 bytes.

Path two was ImgIng (https://imging.ai/ ), which by default does not impose a paper size at all — it generates one custom-sized PDF page matching the page's full rendered width and height. Same file, 1 page at 1080×2379pt, type at 9.75pt, 204,648 bytes. The bigger type is purely the absence of a shrink step, not better rendering; I compared the same table region at 300 dpi and could not measure a difference in glyph quality.

One thing worth stating plainly, because our compliance process cares: import, directory reading, resource mapping and live preview all happen locally in the browser, and so does the lossless minification applied after the PDF comes back — nothing leaves the machine during any of that. Only after you click convert does it POST one self-contained HTML snapshot, scripts stripped and resources inlined, to a same-origin endpoint on the site's own domain (imging.cn, since I ran the test on the Chinese site), where server-side Chromium and Skia return the actual PDF bytes. That is the one document capability in this product that touches a server. I wrapped fetch and XHR on the page to log every call: zero non-GET requests before the click, exactly one POST after it.

The fork in the road is the print CSS

Neither default is wrong. They are both answering a question my HTML never answered: how big is the paper, and should this be paginated at all.

So I answered it. Three declarations: @page for size, orientation and margins; display:table-header-group on thead so the header repeats; break-inside:avoid on tr so multi-line rows are not split mid-row. Exported the same long table through both paths again.

Both produced 7 pages of 595×842pt A4, the header repeated on all 7, table type at 8.25pt, and 99.32% text-extraction parity — identical item by item. At that point the choice of tool stops mattering.

Same long table with no print CSS: browser print on the left, full-rendered-size output on the right

Two things that contradict advice I had read. First, @page does not have to be written bare at the top level of a stylesheet — I built a variant with it inside @media print{} and the two outputs differed by 30 bytes, with page count, page size, font size and text parity all identical. Second, the thead line is redundant. Remove it and the header still repeats on every page, because that is already the UA stylesheet default. I keep it so the intent stays visible in the code, not because it does the work.

While I was at it I also checked the claim that tables get cut in half across page breaks. I tagged every row with two unique markers and treated a marker appearing on two pages as a split. Across seven samples and every export path, the count was zero. This build of Chromium pushes the whole row to the next page when space runs out.

The expensive one

Everything above is about ugly output. This one is about missing data.

Our report tables almost always live inside a fixed-height scroll area — height:520px; overflow:auto, which is the default shape for most table components. Export a page like that and you only get the slice that was visible inside the container.

46 rows inside an overflow:auto container: both export paths return only 8

My 46-row table came out with 8 rows. Both paths, identically. Text extraction parity dropped to 13.40%, and neither UI said anything — progress completed normally, the quality report read normally.

I only caught it because of the row markers, and counting them is now the last step of my export checklist:

import fitz, re, sys

text = "".join(page.get_text() for page in fitz.open(sys.argv[1]))
rows = sorted(set(re.findall(r"R\d{2}", text)))
print(len(rows), rows[:3], rows[-1])
Enter fullscreen mode Exit fullscreen mode

On the scroll-container export that prints 8 ['R01', 'R02', 'R03'] R08. On the version where the container was released to its natural height, 46 [...] R46. If your rows do not carry an id, any per-row string that is unique works the same way.

The fix is to let that container expand to its natural height before export — we now ship it as part of the shared print stylesheet alongside the three declarations above, and older pages are being patched one at a time. What I have not found is a way to be warned about it up front. Neither path flags a scrollable region with hidden content, so for now it is stylesheet coverage plus counting rows afterwards.

Top comments (0)