← Back to blog

We ran 15 real invoice templates through our PDF engine and published the score

3 rendered correctly, 8 degraded, 4 came out broken. Twenty-four hours of fixes later: 11 correct, 4 degraded, 0 broken. What we expected to fail didn't, what failed was silent, and the best fix was an assertion.

Every HTML-to-PDF engine claims it handles real-world templates. The claim is cheap because nobody checks it: the demos are written by the people who wrote the engine, and an engine author's HTML has a suspicious way of only using the parts that work.

So we collected 15 production templates from GitHub, originally to answer an unrelated feasibility question, and rendered them cold. Sparksuite's famous nested-table invoice. Anvil's invoice template. Two stock Crater designs written for dompdf. Two InvoicePlane templates written for mPDF, custom pagination hacks included. AdminLTE 2's printable invoice page with the entire admin shell around it. Two more Bootstrap 3 invoices, a Bootstrap 2 Stripe invoice page from 2012, a nested-table email receipt in XHTML, two ERPNext print formats from the wkhtmltopdf era, and two Invoice Ninja designs, one of them modern CSS grid on A5 landscape. None of them were written with our engine in mind. That was the point.

We graded each render against Chrome's print output of the same file, three buckets: USABLE (you could send it to a client), DEGRADED (everything present and legible, visible layout divergence), BROKEN (content loss or unreadable output).

First run: 3 USABLE, 8 DEGRADED, 4 BROKEN.

This post is about what those numbers taught us, and how they became 11, 4, and 0. The first corpus commit and the last fix merged 24 hours apart; the specifics come from the commits, not from memory.

The reversal: what we expected to fail didn't

Going in, we knew what the wall would be: floats. Our HTML input path is a documented subset, and floats were deliberately out of it. Text wrapping around a float needs per-line available width, and our line breaker works with a single width per paragraph. Every float in a template produced a warning naming the property and suggesting a remedy. We expected the corpus to confirm that floats were the big gap.

Floats turned out to be the frequency leader and nowhere near the severity leader. Nine of fifteen templates used them, and every single one degraded gracefully: float columns stacked vertically, content intact, reading order sensible, warning emitted. Annoying, clearly visible, honestly reported.

All four BROKEN grades came from something else entirely, and none of them produced a single warning.

The catastrophe was one line ignoring colspan

Our automatic table layout inferred the column count from the first row's cell count. The first row of a real invoice is very often a full-width banner: one cell, colspan spanning everything. Logo on the left, invoice number on the right.

One cell in the first row means a one-column table. Every later row's cells got crammed into a sliver, and text in a sliver wraps at every character. The result is a column of text rendered one letter per line, vertically, like a ransom note. Four different templates, four different authors, same shape, same silent shredding.

That inversion is worth sitting with. The feature we had scoped out warned loudly and failed gently. The feature we had scoped in failed catastrophically and said nothing, because our warnings contract answered exactly one question: "what did you ask for that we don't support?" It had no vocabulary at all for "what did we support and get wrong?"

We fixed the table layout (count columns from the widest row's colspan sum, size them by min and max content the way a browser does, floor specified widths at min-content). And we added a second warning channel for the question the first one couldn't ask: warnings prefixed render defect:, emitted when the engine knowingly produces output that differs from what the document requested. A table column forced below its minimum content width now says so, in the same stream as everything else.

While we were in there: one template's stylesheet produced 6,905 warning lines, because a framework CSS file mentions unsupported properties a few thousand times. Identical warnings now collapse to one line with a count. Six thousand lines of the same fact is not a signal, it is a denial-of-service attack on the one warning that matters.

Floats, measured before built

With the broken class gone, floats were next. Before building anything we measured what the templates actually did with them, and the answer changed the project: of the eight affected templates, the number that wrap text around a float is zero. Every one uses runs of floated siblings as columns. Bootstrap's col-* grid is float runs. The left-address, right-address invoice header is a float pair. Nobody floats an image into a paragraph in an invoice.

So instead of a multi-week line-breaker rewrite, floats became a mapper transformation: a run of consecutive floated siblings becomes a flex row. Left floats keep markup order, right floats reverse (CSS stacks successive float: right elements right to left, which is exactly the kind of rule you pin with its own test before you forget it). clear ends a run. The one genuinely unsupported case, a non-floated sibling that a browser would wrap alongside the float, renders below it and warns. On the corpus, that residual warning fired on two templates out of fifteen.

The bug class nobody had named

Here is where the campaign earned its keep. Fixing the corpus kept surfacing bugs that looked unrelated: a giant phantom gap under a flex row here, a mis-centered running header there, blank space trailing an image. Six fixes in, the pattern was undeniable, because they were all the same bug:

Measurement and layout are two code paths computing the same quantity, and nothing forced them to agree.

The engine measures a node's height to decide page fit and container size, then lays it out for real. Every time those two disagreed, the symptom was phantom space: the container reserved what measurement predicted, layout produced less, and the difference rendered as inexplicable blank space. Six confirmed instances by the end:

  1. Table column count (the colspan bug above).
  2. Table intrinsic width had no Table arm at all, so a shrink-to-fit box holding a table measured as its widest single cell.
  3. Row measurement resolved percent widths against the child's own width instead of the container's. width: 27% became 27% of 27%, text measured at a quarter of its real width, one word per line, and rows measured up to 4x taller than they rendered.
  4. Measurement always used the greedy line breaker. Layout honors optimal (Knuth-Plass) breaking, which will accept a slightly overfull line that greedy wraps. At boundary widths, measurement counted one more line than layout drew.
  5. Absolutely positioned children were counted into their parent's flow height. Layout takes them out of flow. Every auto-height ancestor of an absolutely positioned element reserved phantom space equal to that element's height.
  6. An image with no explicit height measured at container width times aspect ratio, while layout drew it at intrinsic size. A small logo styled width: 100%; max-width: 300px reserved a container-sized block and rendered at a few points.

Each of the first three was found by a person staring at wrong output. That is a terrible detection mechanism for a bug class, so we built the invariant instead: under a test flag, any auto-height container whose measured children height exceeds what the children actually occupy emits a violation, and a test renders our whole fixture corpus with the flag on and fails on any emission.

We wrote the gate before fixing instance three, so the fix could be verified by the mechanism instead of by eyeball. The gate caught it. Then, on its very first run over fixtures we considered clean, it found instances four and five, which nobody knew existed. The greedy-versus-Knuth-Plass one is my favorite: our own letterhead fixture had been rendering its running header slightly mis-centered the whole time, because measurement thought the header was two lines and layout drew one. A twelve-point error, invisible unless you were looking, sitting in the fixture we looked at most.

Instance six got the stronger treatment. Instead of keeping two code paths and asserting agreement, image sizing is now one function that both measurement and layout call. Agreement by construction beats agreement by assertion, when you can get it.

The garble that wasn't a font bug

One template had the corpus's only silent text corruption: a receipt whose footer read "Thk ft" where the source said "Thank you for your payment." It looked exactly like a shaping or subsetting bug. We went in expecting fonts.

It was geometry. The template is A5 landscape with a footer positioned position: absolute; bottom: 0, plus margin-top: 1rem. We anchored the border box at bottom: 0 and applied the margin afterward, which pushed the footer 12 points past the bottom edge of the page. The only ink that survived on the page was the tops of the tall letters. Look at the string: T, h, k, f, t. "Thk ft" is "Thank you for your payment." with everything but the ascenders clipped off.

Per CSS, offsets on absolutely positioned elements place the margin edge, not the border box. That is a conformance bug that affected any bottom- or right-anchored element with margins, and it hid behind a symptom pointing at the completely wrong subsystem.

The same template audit turned up quieter ones. Table rows with display: none rendered anyway, because the row-collection code path skipped the visibility check every other element gets, so rows that templates hide for JavaScript to reveal (we don't run JavaScript, deliberately) all showed. A second <thead> placed after <tbody>, which one template used for its totals block, was hoisted to the top of the table as a repeating header; per HTML only the first thead is the header. And <tfoot> rendered wherever it sat in the markup, when browsers universally put it at the bottom.

The score, and the four we're not hiding

Final corpus, same 15 templates, same Chrome references: 11 USABLE, 4 DEGRADED, 0 BROKEN.

The email-template receipt went from six sparse pages with two entirely blank ones to a single faithful page. The mPDF invoices went from a blank first page and a cut-off right edge to clean single-pagers. The Bootstrap 3 invoice that printed its totals above its line items now prints them below, where its author and everyone else expected them.

The four still degraded, and why:

  • One uses position: running(), a paged-media feature we don't support yet; the element that should move into the margin renders in flow. Warned by name.
  • One is AdminLTE's invoice inside the full admin shell, and a remnant of the shell's sidebar survives printing. Not diagnosed yet; it is an open item, not a mystery we are pretending is a feature.
  • Two are responsive grids that only activate above a 768px viewport. Our rendering viewport is the page content box, about 487 points, so their media queries honestly evaluate false and the columns stack. What min-width should mean inside a print renderer is a semantics question we would rather answer deliberately than patch around.

Every specific bug the corpus found now lives in the public repo as a minimal failing-first test, twenty-nine of them at last count, running in CI on every commit. The corpus itself stays private (seven of the fifteen templates carry licenses that make redistribution inside an MIT repo a bad idea), and it runs as a release gate: render everything, diff against the committed baselines, and anything that moved is either an intended improvement recorded in the release notes or a stop-the-release regression. The public artifact is the number.

What we'd tell anyone building a layout engine

Your own fixtures cannot find this class of bug, structurally. They were written by someone who knows the engine, and that knowledge leaks into the HTML no matter how honest you are. Every catastrophic failure in this campaign was invisible to our fixtures and obvious in the first hour of running other people's markup.

And when the same bug shape shows up three times, stop fixing instances and build the invariant. The measure/layout gate took an afternoon. It found two unknown bugs before dinner, and the next divergence in that family will announce itself in CI instead of in a customer's invoice.


Postscript, 2026-09-05: the campaign continued past this post. The four remaining DEGRADED grades resolved to four unrelated causes — two of which were not what we wrote above (the "768px viewport" diagnosis was wrong for both templates it named). The corpus now measures 14 USABLE, 1 DEGRADED, 0 BROKEN as of 0.20.0. The follow-up, including what our own postmortem got wrong, is here.