Every PDF ReflowPDF exports is now produced by a layout engine written in Rust. The PHP engine that carried the product through its first year has been switched off. Your files, the editor and the round-trip re-editing are unchanged; the part that turns a document into a PDF is new.
Below is what we measured after the switch, taken on the production server, including the places where the numbers are worse than we would like.
Why replace an engine that worked
The PHP engine did work. It reached three limits at roughly the same time.
Speed and memory. Around 110 ms per page, and peak memory that grew with the document. A 250-page export needed more than a gigabyte of RAM, which put a ceiling on what we could allow people to export at all.
CSS coverage. It recognised 144 properties and implemented block, flex, grid and table layout. There was no text shaping, no vertical writing modes, no container queries, no masking. Each of those showed up in the editor as advice to avoid something.
Standards depth. Tagged PDF, PDF/UA and PDF/A are not a switch you add at the end of an export. The structure tree has to be built while the boxes are laid out, and the further that work goes, the more the implementation language matters.
Rewrites are usually a bad trade. This one bought all three of those at once, and one thing the PHP engine could never have given us: the same code runs on the server and inside your browser tab, so the page breaks you see while editing are computed by the code that will write the PDF.
What the engine is built from
Fourteen crates of our own, plus a small set of libraries that handle parsing and font plumbing. Those are worth naming, because “we wrote a CSS engine” is a claim readers should be able to size.
- HTML parsing is
html5ever; CSS tokenising iscssparser. Both come from Servo. - Fonts are read with
ttf-parserand subset withsubsetter. WOFF2 is decompressed bywuff. - Text is shaped by
rustybuzz, the Rust port of HarfBuzz. This is the largest single correctness gain in the port. The PHP engine had no shaper and summed glyph advances, so ligatures, kerning pairs, variable-font axes and right-to-left text either did not happen or happened wrongly.
Everything above that layer is ours: the cascade, box generation, block, inline, flex, grid and table layout, fragmentation, paged media, the PDF object model, tagging, PDF/UA and PDF/A.
Exports are rendered by a local daemon (flexpdf-rustd) that keeps the engine and the fonts warm between requests. No process boot, no font directory rescan per export, and no browser anywhere in the path.
Speed
Same documents, same machine, both engines run alternately in the same session.
| Document | Pages | PHP, median | Rust, median | PHP per page | Rust per page | Ratio |
|---|---|---|---|---|---|---|
| one paragraph | 1 | 0.060 s | 0.017 s | 60 ms | 17 ms | 3.6× |
| 24 KB of HTML | 9 | 1.040 s | 0.129 s | 116 ms | 14 ms | 8.0× |
| 72 KB of HTML | 25 | 3.059 s | 0.385 s | 122 ms | 15 ms | 7.9× |
| 144 KB of HTML | 50 | 6.187 s | 0.830 s | 124 ms | 17 ms | 7.5× |
| 288 KB of HTML | 99 | 12.947 s | 1.818 s | 131 ms | 18 ms | 7.1× |
| 720 KB of HTML | 246 | 38.280 s | 6.258 s | 156 ms | 25 ms | 6.1× |
The Pages column is single because the two engines agree on it: given the same page geometry, every document above paginates to the same count in both. Spread across the nine runs, low to high: PHP 1.034–1.048 s at 9 pages and 12.739–14.906 s at 99; Rust 0.128–0.131 s and 1.728–2.572 s.
Method. The same generated HTML (headings, paragraphs, bordered tables, lists) rendered by both engines, with the page geometry pinned identically on both sides: A4, 72 pt margins, Liberation Sans so both pick the same faces. Nine alternating runs per document, median reported with the range above. This is the live production server, not a lab: the one-minute load average moved between 1.6 and 5.4 on 16 cores during the run. Load moves the absolute numbers in both directions, which is why the two engines are interleaved rather than run in separate batches. The PHP side is a CLI process, so it pays interpreter startup and Composer autoload, measured at 5 ms; under php-fpm with opcache that part would be smaller. The Rust side is a full HTTP round trip to the local daemon.
Where the time is not going. The obvious way to inflate a comparison like this is to let a warm daemon compete against a cold process, so that most of the difference is startup being amortised. The one-paragraph row is there to bound that: a single-page document costs 60 ms in PHP and 17 ms in Rust, which puts the entire fixed difference at about 43 ms, or roughly a third of one PHP page. It is gone by page three. The ratio is at its lowest on the shortest document and rises with length, which is the opposite of what fixed-cost dominance looks like.
Per-page cost is not flat in either engine. PHP goes 60, 116, 122, 124, 131, 156 ms per page as the document grows; Rust goes 17, 14, 15, 17, 18, 25. Both curves bend upward past a hundred pages, and Rust’s bends harder: at 982 pages it takes 68 seconds, which is 69 ms per page, about four times its cost at 99 pages. That is a real limit and it is on our list.
The PHP engine has no figure at 982 pages, because it refuses the document: it caps a parse at 50,000 DOM nodes and this one has 85,206. That cap is the concrete form of the memory problem below, and it is the sort of ceiling that made the rewrite worth doing. The Rust engine has no equivalent limit.
Throughput. One client at a time, on the 25-page document, is 2.5 renders per second. Two clients give 5.0, four give 9.4, eight give 19.5, and sixteen give 24.3, which is roughly 600 pages per second in aggregate before the box stops scaling. The daemon runs one worker per core and caps concurrent renders at the core count, since layout is CPU-bound and single-threaded per document.
Memory is the bigger practical change. The PHP engine’s peak resident set scaled with the document: 10 MB for a single page, 70 MB at 9 pages, 190 at 25, 372 at 50, 734 at 99, and 1.8 GB at 246. The Rust daemon serves all of those from a flat resident set of about 185 MB that does not move, and it is still 188 MB after rendering a thousand pages. That is why the export size limits could go up rather than down.
Cold start. The daemon binds its port in 13 ms. The first render after start pays about 125 ms extra while the font set loads, and every render after that is warm. In production this happens once per deploy.
CSS: how much of the platform we actually render
Claiming “full CSS support” is free, so here is the measurement, and the method that produced it.
We run the Web Platform Tests, the reftest suite browsers are judged by. A reftest is a pair of files: the test, and a reference written with different and simpler primitives, which has to render identically if the engine is correct. We render both with our own engine, rasterize them at 288 dpi and compare pixel by pixel, honouring each test’s <meta name="fuzzy"> allowance.
The obvious objection is that we render both halves ourselves, so an engine that is wrong twice in the same way still passes. It applies equally to every browser, since this is how browsers run the same suite, and the answer is in how references are written. A grid-template-columns test has a reference built from absolutely positioned blocks; a flexbox test has one built from floats or fixed offsets. Agreement means two different code paths arrived at the same pixels. That is not proof, and it catches the large majority of real defects. On the dashboard below every test also shows your own browser rendering the test file next to our PDF, so you can check any single case against a real implementation instead of taking the pair on trust.
Tests that need JavaScript, user interaction, animation or the network are recorded as skipped, because a PDF engine has none of those.
The last full run:
24,092 reftests executed
98 of those are our own print-CSS tests, set aside
4,440 skipped 3,582 need JavaScript, 808 are tentative or draft,
30 need a browser flag, 20 other
────────
19,554 applicable Web Platform Tests
17,497 pass 89.48%
2,029 fail
28 anti-aliasing ties at 288 dpi, counted with the failuresTwo accounting choices there are worth stating. Pixel ties that differ only by anti-aliasing used to sit in a category of their own; they are counted with the failures now, so the run has two outcomes instead of three. And our own print-CSS corpus stays out of the headline, because we wrote those tests and they do not belong in a conformance figure. It covers what the web suite does not test at all (running headers and footers, footnotes, leaders, named strings, target-counter), it stands at 98 of 98, and it is reported on its own.
Counting every skipped test as a failure, which is the harshest reading available, gives 72.9%. Both figures are on the dashboard.
The number is live
The tables below are a snapshot. The current figure is at reflowpdf.com/conformance, rebuilt from the same gate that guards every commit. It carries the tests individually: source, your browser’s rendering, our PDF, the reference and the pixel diff, for 16,836 of them, with every failure listed. It moves, so it will not always agree with this post.
By area, the parts that carry real documents:
| CSS area | Pass | Fail | Rate |
|---|---|---|---|
| CSS 2.1 (flow, floats, positioning, borders) | 5,648 | 202 | 96.5% |
| Flexbox | 880 | 22 | 97.6% |
| Grid | 647 | 22 | 96.7% |
Fragmentation (css-break) | 903 | 80 | 91.9% |
| Sizing | 504 | 18 | 96.6% |
| Transforms | 695 | 13 | 98.2% |
| Custom properties (variables) | 180 | 1 | 99.4% |
| Text | 1,233 | 142 | 89.7% |
| Writing modes | 934 | 179 | 83.9% |
| Backgrounds and borders | 532 | 65 | 89.1% |
Paged media (@page, margin boxes) | 194 | 28 | 87.4% |
| Tables | 132 | 4 | 97.1% |
| Counter styles | 206 | 6 | 97.2% |
| Web fonts (WOFF2 decoding) | 293 | 5 | 98.3% |
And the weak areas, published for the same reason as the strong ones:
| CSS area | Pass | Fail | Rate | What is actually failing |
|---|---|---|---|---|
| Multi-column | 322 | 81 | 79.9% | Balancing and spanners in fragmented flow |
| Ruby | 66 | 49 | 57.4% | Overhang, ruby line box sizing |
| Inline SVG | 90 | 66 | 57.7% | <use> structure, nested styling, embedded content |
| Filter effects | 143 | 128 | 52.8% | Mostly backdrop-filter (38) and SVG filter graphs; the plain filter: functions largely pass |
Positioning (css-position) | 58 | 65 | 47.2% | Absolute boxes inside multi-column and vertical writing modes; stretch sizing of form controls |
| Media queries | 21 | 27 | 43.8% | calc() in queries, aspect-ratio features, invalid-query parsing |
Cascade (@scope, @layer) | 23 | 18 | 56.1% | @scope implicit scoping, revert-layer, conditional @import |
| MathML (both suites) | 53 | 96 | 35.6% | Not implemented |
Rows here are whole directories; the dashboard splits MathML into its two suites, where the rates read 24.0% (presentation-markup) and 59.2% (relations). MathML is not implemented at all, and the 53 passes are tests whose expected rendering an engine without MathML happens to match. Dropping the directory would move the headline from 89.48% to 89.89%, and we would rather publish the smaller number than curate the run. The same accidental-pass effect exists elsewhere in smaller amounts, and it is the reason the per-test dashboard exists: a single figure can be gamed by choosing directories, a list of 19,554 individual verdicts cannot.
There is nothing to compare this to
We would like to give you a competitor’s figure to hold this against, and there is not one. We could not find a per-specification conformance number published by Prince, PDFreactor or Antenna House; the public conformance marker for that class of tool is still Acid2, a test from 2005. Browser pass rates on wpt.fyi are not a fair comparison either, because most of that suite is the JavaScript tests we skip by construction.
So treat 89.48% as a number about us over time, and as one you can audit test by test. It does not place us against anyone.
What changed in a real document
The property registry went from 144 recognised CSS properties to 360. The ones you will notice:
- Typography that survives the export. Ligatures and kerning from the font’s own tables,
font-feature-settings, variable-font axes,letter-spacingandword-spacingthat stay correct across a font fallback, soft hyphens, right-to-left text. - Layout that used to be a warning. Floats and shapes, vertical writing modes, container queries,
@supports,@layer, CSS nesting,oklch()andlab()colours,color-mix(), and multi-column, which is new and the roughest of them at 80%. - Paged media done properly. Per-page size and orientation in one document,
@pagemargin boxes, repeating table headers, footnotes that land on the page that references them, leaders in a table of contents. - Images and effects. Gradients,
clip-path, masks, blend modes, 3D transforms with correct plane splitting.
Standards, and what a validator can and cannot tell you
Rewriting a renderer is exactly when tagged output quietly stops being valid, because the structure tree is the first thing to break when boxes move. So it is gated, and the gate ran again for this post.
- PDF/UA-1. All 51 templates in the gallery export tagged and validate clean in veraPDF 1.30.2, as does a ten-document set that covers every tag construct we have broken before.
- PDF/A-3. The archival sample validates under the
3b,3uand3aprofiles, and all 51 templates validate as PDF/A-3a. The levels are not nested in practice, so we check all three rather than assume thataimplies the rest. - Content stream syntax. An audit of graphics-state and marked-content pairing (the graphics-object state machine, ISO 32000-2 §8.2) across every template: zero violations.
- PDF/UA-2 (ISO 14289-2). The engine emits it and it validates, with a
%PDF-2.0header. That has enough detail of its own for a separate post.
The veraPDF profile for PDF/UA-1 contains 106 rules. The reference document below passes all 106, and it only exercises 94 of them. The other 12 cover constructs the engine never produces: encryption, optional-content groups, TrapNet and PrinterMark annotations, multimedia clip dictionaries, embedded CMaps, and the CharSet entry of a Type 1 font descriptor. A rule with nothing to check passes by default, so “106 of 106” would say less about the file than it looks like it says.
A worked example of the checker being wrong
This month the engine could emit PDF/UA-2, and veraPDF validated those files. It was also writing %PDF-1.7 in the header of every file it produced. PDF/UA-2 is ISO 14289-2, which is written against ISO 32000-2, and a file whose header says 1.7 and carries no /Version in its catalogue is a PDF 1.7 file. So the file claimed conformance with an edition of a standard that does not exist in the version it declared itself to be, and the validator said PASS, because the ua2 profile checks prohibitions and not the container version.
We found it by reading the first eight bytes of the file. It was fixed on 26 August, and the gate now reads those eight bytes on every run: it fails if a UA-2 file does not say %PDF-2.0, and it fails if a UA-1 file stops saying %PDF-1.7, since ISO 14289-1 is written against ISO 32000-1 and raising the version there would be wrong in the other direction.
That is the general shape of it. A validator tells you the file does not break the rules it can express. It cannot tell you whether the heading levels describe the document, whether the reading order matches the visual order, or whether the alt text says anything useful.
Some of that gap is machine-checkable even though veraPDF does not check it, so the gate also runs a structural audit of its own across the same 51 templates: does the /Alt text describe anything, does the tree read in page order, did decoration end up inside it, does what the document says about itself match what is in it. Eighteen families of question, and today it returns three findings. Two templates put repeated footer text (a date, a VAT number, a document reference) into the reading order as /P nodes, where it should be marked as an artifact. veraPDF passes both, because no rule in the profile forbids it. The count is held at a ratchet, so it cannot grow without someone noticing, and it is currently three rather than zero.
What is left after all of that is judgment: whether the structure describes the document as a person would read it. That belongs to us and to the author, and no tool decides it. We wrote about that gap in more detail in the Matterhorn Protocol post.
Where we are not there yet
Asking for PDF/UA-2 and PDF/A-3 in the same file is rejected rather than produced, because PDF/A-3 is built on PDF 1.7 and UA-2 requires PDF 2.0. The correct pair for PDF 2.0 is PDF/A-4 (ISO 19005-4), and we do not emit PDF/A-4 yet. Until we do, a PDF 2.0 file from us can be accessible or archival but not both. It is on the list below.
The same engine, inside your browser
The editor does not guess where pages break. The engine compiles to WebAssembly and the canvas paginates through it, so what you arrange on screen is what the PDF gets.
The module is 8.6 MB compiled and 2.9 MB over the wire with Brotli, fetched once per version and then cached.
That agreement is gated too. We keep a corpus of 1,000 documents in which the editor’s page map and the engine’s page map have to match exactly, and the gate is a ratchet against a frozen baseline: no document may regress, and improvements do not update the baseline automatically. The last full run agreed on 983 of 1,000. The remaining 17 are catalogued by class rather than written off. One of those classes is forced page breaks on out-of-flow boxes, where the engine honours a break that the canvas ignores because absolutely positioned boxes do not take part in pagination.
What we are building next
- 1A developer API. The render endpoint the editor already uses, documented, authenticated and public: HTML and CSS in, tagged PDF out, with the PDF/UA and PDF/A switches exposed.
- 2HTML and CSS in the editor. A code view for people who would rather type
grid-template-columns: 2fr 1frthan drag a handle. The documents are CSS documents underneath and the editor does not currently let you say so. - 3PDF/A-4, which closes the accessible-and-archival gap described above.
Check the claims yourself
Two files, both straight out of the engine:
- **PDF/UA-1 reference document** — every tagged construct once: headings, lists, definition lists, a table with headers, figures with alt text, links, footnotes, form fields, artifacts.
- **PDF/UA-2 reference document** — the same in the second edition of the standard (PDF 2.0, namespaces,
FENote, structural destinations).
Run them through veraPDF:
$ verapdf -f ua1 --format text pdf-ua-1-reference.pdf
PASS pdf-ua-1-reference.pdf ua1
$ verapdf -f ua2 --format text pdf-ua-2-reference.pdf
PASS pdf-ua-2-reference.pdf ua2The per-test conformance data is at reflowpdf.com/conformance. Or skip all of it and open the editor, with no sign-up: build something with a grid, a table that runs over a page break and a footnote, and export it.