A while back I wrote a post here about building a CSS layout engine from scratch, because every PDF editor treats the document as a picture with text boxes stuck on top of it.
Nobody argued with me about it. What I got was email, from people asking whether it would handle their invoices.
That post is still up and parts of it are no longer true. Some of it wasn't careful to begin with. So.
Just go run it
reflowpdf.com/playground. HTML and CSS on the left, paginated PDF on the right, download button. No account, nothing uploaded, no render server. It's the engine compiled to WebAssembly, running in your tab.
If you want to break it fast: nested tables with break-inside: avoid, a @page margin box, footnotes. That's usually where things go wrong.
The numbers, caveat first
I run the Web Platform Tests against the engine. The reftests browsers get judged by. I didn't write any of them, which is the only reason they're worth quoting.
Dataset 20260828-0537-12e0bd8:
90.22% of the tests the engine can be judged on match their reference. 17,641 of 19,554. 73.52% if you count all 23,994 and hold every test I can't run against me. 1,885 failures, each one published with the diff.
The run repeats. This post and that page will disagree within days. Believe the page.
Both rates are up there because either one alone is a lie by omission. Roughly four and a half thousand tests I don't run at all, mostly because they need JavaScript and a PDF renderer has no script engine. You can call that a fair exclusion or a limitation I'm burying in a denominator. I went back and forth on it for a while and gave up, so both numbers are printed instead of the flattering one.
Three things in the old post that are wrong
It said the engine is primarily PHP. It's Rust now, and there's a longer writeup of that move with the benchmark method if you want it.
There was a whole section I was pleased with about keeping PHP, Python and TypeScript producing byte-identical layout output. Deterministic operation order, one shared font cache, lookup tables instead of native math, rounding at fixed boundaries. Real work, on a problem I had invented myself by having three implementations of one thing. One codebase and the entire category stops existing.
[[ONE REAL INCIDENT — what actually pushed you over. two sentences, boring is fine]]
The side effect I didn't plan for is the playground at the top of this post. PHP can't run in a browser tab. Rust compiles to a native binary and to WASM, and the pages come out the same, so what you typed into up there is the production engine rather than a demo built to look like one. Beforehand I'd have called that a nice-to-have.
It said "around 2,900 tests". I still have my suite and it still matters, but it's the wrong number to quote at people. Every test in it exists because something broke in a document somebody sent me. That measures how long I've been doing this, not how correct the engine is. Same problem with the 98 paged-media reftests I wrote for running headers, footnotes, leaders and cross-references. All passing. All kept out of the headline number.
It said the HTML gets embedded in the PDF's metadata. It goes in as a proper embedded file, encrypted. XMP is XML, it has escaping rules, and pushing a whole HTML document through it falls over at exactly the sizes where you'd notice. The behaviour is as described though: export, reopen, get your structure back, nothing stored on my server. Nobody has told me yet why this is a bad idea and I remain slightly suspicious about that.
veraPDF said PASS and the file was lying
The engine emits PDF/UA-2, the 2024 edition of the accessibility standard. I shipped it, ran the validator, got a PASS, moved on.
The file was PDF 1.7 claiming to be UA-2.
UA-2 is written on top of PDF 2.0, and not as a stylistic preference. A file whose header says %PDF-1.7 with no /Version in the catalogue is a 1.7 file whatever its metadata claims. veraPDF passed it because the profile checks prohibitions and nothing in it had an opinion about container version. No rule, so no complaint, and I read the silence as approval.
$ head -c 8 pdf-ua-2-reference.pdf
%PDF-2.0
Eight bytes.
The thing that bothers me more is next to it. The ua1 profile has 106 rules. My reference file passes all 106 and actually exercises 94. The other twelve cover things the engine never emits: encryption, optional content, TrapNet, a Type 1 CharSet. A rule with nothing to check passes by default. "106 of 106" would have been completely true and I'd have put it on a landing page.
So I added PAC, the free checker from the PDF/UA Foundation, as a second opinion, and it found things veraPDF had waved through. Bare /Artifact BMC all over the place, 21 of 28 regions on one page, which is legal and tells a screen reader nothing about what actually repeats. Figures missing /BBox, but only out of the SVG path, the raster path was fine, which is exactly why I'd never noticed. Footnotes with nothing linking to them, which isn't an ISO rule at all, just a warning, and I fixed it because it annoyed me. That last one turned out later to be required by UA-2 in the opposite direction, which was luck rather than planning.
Two implementations agreeing is worth something. One green screen isn't.
Where it's bad
Same run:
| Suite | Rate |
|---|---|
| Shadow | 3% |
| Filter effects | 53% |
| Ruby annotations | 57% |
Of 489 standard CSS properties measured against Chrome, 140 match everywhere I tried, 141 differ in at least one example, 45 aren't implemented, and 163 have no example that moves Chrome in the first place.
There are 119 diagnosed root causes sitting behind 345 of the failures, 20 of them still open. Positioning is the one I keep coming back to. It isn't an obscure corner of anything.
If you have a PDF that comes out wrong, send it to me. That's the single most useful thing anyone gives me and it isn't close.
Two files if you want to point a validator at something today, both straight out of the production engine:
- PDF/UA-2 reference, PDF 2.0 container
- PDF/UA-1 reference, PDF 1.7, for comparison
verapdf -f ua2 --format text pdf-ua-2-reference.pdf takes about ten seconds.