The blog
We built a deterministic layout engine. It is not live yet.
It renders the same bytes on any machine, and it is not in the product yet.
Render the same document on two machines and you get two different files.
We hit this building Pressentable, and it is not a bug you can patch. A browser decides layout at paint time, which means the thing deciding where your text goes is also the thing drawing it, and it decides again on every backend: screen, print, PDF export. Font metrics come through the operating system, CoreText on macOS, DirectWrite on Windows, HarfBuzz elsewhere. Sub-pixel rounding lands differently per device and per zoom. The pipeline accumulates scales, transforms and kerning offsets in doubles, and the order those operations round in is an implementation detail that varies by engine version and sometimes by CPU.
So we wrote a deterministic layout engine. It is called Grillage, 1276 lines of TypeScript including its tests, and its whole job is to take every layout decision away from the browser.
It is not in the product yet, on purpose.
Why a browser cannot render the same document twice
Search this and you land in browser internals explainers. They describe the rendering pipeline accurately, they name the sources of nondeterminism, and they stop there, because from inside a browser there is nothing else to say. The one place it gets treated as a problem is a Playwright issue titled "Rendering determinism", filed by people trying to test the thing.
The whole failure is one moved line break.
Break points depend on measured text widths. A sub-pixel difference in one word's width moves one break, one moved break rewrites every line after it in that paragraph, the block height changes, and overflow and page breaks land somewhere else. Tiny metric noise becomes a structural diff. If you have ever written code that touches money you know this shape already: floats are the enemy, and tiny roundings compound into numbers that are simply wrong. Layout has the same disease.
In a browser you own neither the inputs nor the algorithm, and the algorithm moves under you. Browsers have been adding smarter wrapping modes recently, text-wrap: balance and text-wrap: pretty, so what the browser does is not stable over time either.
You cannot hash any of that, so you cannot prove the document is the same document.
What a deterministic layout engine requires
Every decision below is the boring option.
No floats anywhere. Every length is an integer count of 1/1000 of a contour unit. The multiply-divide all geometry runs through is computed in BigInt and floored:
export function mulDiv(a: number, b: number, d: number): number {
const r = (BigInt(a) * BigInt(b)) / BigInt(d)
...
}
There is a test named "float in any numeric field rejected". Even line height is an integer, per-mille of font size, so 1300 means 1.3. The comment in the source says why: keeps floats out.
The engine parses its own fonts. Text measurement is layout. If the engine does not know exactly how wide 1 234 567 renders, the browser decides, and you are back where you started. So there is a TTF parser in the repo, 118 lines, and every codepoint's advance is scaled through the same exact integer math.
Line breaking is greedy, on purpose. Line breaking is the oldest problem in typesetting. Knuth and Plass published the reference algorithm in 1981 and TeX still runs it. Grillage does not. The whole breaker is one greedy first-fit function in a 44 line file, and the fit test is measureWidth(candidate) <= maxWidthFp. Integer against integer. No epsilon (as a first step).
To be fair about it: Knuth-Plass is also deterministic in isolation. The browser's sin is not its algorithm, it is unstable inputs plus an algorithm you do not control. Our answer is owning both, then picking the simple one first. Greedy is local, so editing a word only changes lines after it. Knuth-Plass is global, so one added word can re-break the paragraph above the edit. For documents you want to diff, local beats optimal. The clever option is not rejected, it is fenced behind a config option for later.
The grid is edges, not boxes. The trick here is not ours. Compute each cell as available / cols and round, and the rounding accumulates: 1000 units over 7 columns is 142.857 each, round down to 142 and after seven cells you have lost six units. The right margin drifts, and a two-column span stops equalling two cells plus a gutter. Browsers hide this by doing layout in float sub-pixels and letting the rasterizer round at paint time, which is exactly the machine-dependent step we are trying to remove.
So there are no cell widths. Only edges:
// Edge-based: start(i) = floor(i*avail/cols) + i*gutter. Exact at both ends, drift-free.
const colStart = (i: number) => left + mulDiv(i, availW, g.cols) + i * gx
Each edge derives independently from integers. A width is the difference of two edges. Nothing is summed, so error cannot accumulate: edge zero sits on the left margin and edge N on the right, always. Same family of trick as Bresenham lines and audio resampling (not ours => Bresenham). What is ours is running it in exact integer space so the result can be hashed.
One canonical form, one hash. The engine emits a Scene: a flat ordered display list where every token has collapsed to a literal, every coordinate is an integer, and nothing references anything outside itself. The engine serializes that canonically and hashes it with fnv1a64, a four line hash from 1991. Sixteen characters. 1 byte of drift -> diff spotted.
The painter is dumb on purpose. Once layout is decided, rendering is mechanical translation. The whole SVG painter is 27 lines, one loop, two cases. It holds no font metrics and no access to layout, and every glyph arrives with its position already computed. Number formatting is canonical by construction: every coordinate is the fixed-point integer divided by 1000, printed with exactly three decimals, always, which kills locale, scientific notation and trailing-zero wobble in one line. Asked for a font it does not know, it throws instead of guessing. The test name is the claim: "unknown fontId in scene throws (painter must not guess fonts)".
The invariant, from the README, is one line: same Config plus same ENGINE_VERSION equals same Scene, byte for byte.
How to test layout without a tolerance threshold
Testing visual output is the industry's unsolved chore. Browser layout is not assertable data, so teams fall back on screenshots and pixel diffing. Pixel diffs are flaky by nature, anti-aliasing and font rasterization and GPU differences, so every tool in the category ships tolerance thresholds. Playwright's screenshot comparison defaults its threshold to 0.2 and offers maxDiffPixels on top. The guidance across every guide in the category is the same: tune the band until the flakes stop.
A tolerance band is sanctioned drift. It is also where layout bugs live.
Compilers solved this decades ago with golden files: the expected output committed next to the input. That only works when the output is deterministic data, which browser layout is not and a Scene is.
Measured on the tree, 2026-09-05:
- 60 tests, 1301 assertions, 14 files, 0 failures, about 100 ms on our Mac. Your number will differ, that is the point of the rest of this section.
- Grep the engine for
epsilon,tolerance,thresholdortoBeCloseToand you get nothing. Not kept low. Zero occurrences. - Three goldens on disk. Each self-checks: the committed
scene.jsonmust already be canonical and must hash to the committedscene.hash, so a hand-edited golden fails on its own. The end-to-end ones re-run the pipeline and assert the Scene byte for byte. The comment on that assertion reads// THE drift gate. - The complete SVG of the social-card golden is 355 bytes. A document and its entire source, in less than a tweet.
- Three property tests, 50 seeded random configs each: layout is idempotent, the clip policy never emits an item outside its page, a fresh font parse changes nothing. The note on the generator is the honest bit, that it produces test inputs deterministically and never enters the engine, because the engine has no randomness to seed.
Then the part that makes it a gate rather than a promise. Goldens cannot be regenerated casually. Run the update script without the environment variable set and it prints refusing: set GOLDEN_UPDATE=1 to intentionally rewrite goldens and exits 1. A build step that says no. The rule sitting next to it in the README, verbatim:
Never "fix" a golden by regenerating it without reading the scene diff: a golden diff IS a layout behavior change and requires an ENGINE_VERSION bump.
Those goldens were generated on a Mac. CI re-renders them on GitLab's Linux runners, on oven/bun:1. That job used to be scoped to changes under grillage/ until we removed the scoping, because a gate scoped by path is how a gate quietly stops being one: the paths people actually edit drift out of the list, and a pipeline that runs nothing still reports success. So it runs on every pipeline now. The counter is at 309, and 83 of the last 100 were green. Each of those is a macOS against Linux byte check of the same document, on committed font fixtures, which is what makes the comparison mean anything.
Exact equality is easy when the output is small and the inputs are frozen. 355 bytes, committed TTFs, one page. 60 tests is not a large suite and we are not claiming exhaustiveness, only exactness. And this is not a rebuke to Percy or Chromatic or Playwright: they tolerate because they compare rasterized pixels produced by real browsers on real GPUs, a harder problem than comparing canonical JSON to a 355-byte string. The claim is narrow. Determinism was designed in from the start, so the tests could be exact from the start. Nobody retrofitted it.
The version-bump discipline has never fired. ENGINE_VERSION has been changed exactly once, when it was created. Three commits have ever touched the goldens directory and all three add a golden. No golden has ever been regenerated, no drift has ever shipped, no bug has ever hidden inside a tolerance band here. The stance came from the float work, not from a wound. It is a rule chosen before it was needed, still untested by the thing it exists to catch.
Why it is not in the product yet
The engine cannot paginate. layoutConfig returns a single-entry pages array. There is no page-break concept in it anywhere. One page, hardcoded.
Two block kinds, rect and text. Two scene item types. One painter, SVG, no PDF at all. Zero imports from the application's source: the app does not link it. Its only consumers today are the scripts that generate our marketing images, and its own CI job.
Meanwhile the export people use right now rasterizes the on-screen document with html-to-image and slices it with jsPDF. The opposite technique, and it ships.
So the sequencing is deliberate. Pagination is the exact problem the engine looks like the answer to, and it is the one thing the engine does not do. Routing export through it today would mean rebuilding inside it everything the app already has, tables, charts, images, brand blocks, plus a PDF painter that does not exist, and only then arriving at page breaking, still unsolved, now living in two places instead of one. That is a worse product this quarter and a worse engine next quarter.
We would rather ship the foundation when it is bulletproof than ship it early and spend a year patching it in front of customers. The determinism work is the part that has to be right before anything else is built on top: fixed-point math, canonical serialization, content hashing, goldens behind a version gate. That part is done and it is pinned by the gate. What comes next is pagination, a PDF painter with selectable text and embedded fonts, and the remaining block kinds. Then it goes under the product.
Three things have to be true before it does. Beta-2 has to be the active goal in the roadmap, which it is not yet: beta-1 is. The existing export path has to paginate first, so pagination is a solved problem being ported rather than an open one being discovered inside a new engine. And the requirement has to be vector PDF specifically, selectable text and small files and print-grade output. "The export looks wrong" is not the trigger. That is a bug in the export code.
The code is untouched and the gate keeps running on every pipeline, because an engine waiting its turn still must not rot.
Where this goes
Grillage becomes the document engine under Pressentable. That is what it was built for, and the design decisions only make sense read that way: a document defined by a config rather than assembled by hand, so it can be rebuilt, diffed, hashed and re-run, which is the same conviction we are building Pressentable on.
There is a second reason to want the schema at the bottom of the stack, and it was in the design doc before the first line of engine code: a config that fully determines geometry is what a model can target. Models are still bad at pixels and good at constrained structure. Placement is four small integers, the validator returns every error with a path, and the hash says whether anything moved. Zero AI features today. The interface is the part we are building first.
For now the reports go out on the path that ships, and it works. The client reports page has live ones. The engine goes in when the three conditions above are met, and not before.
The same rules on a live page
This is what the product renders now. The engine below is the next foundation under it, not the thing that made this one.
Where this fits
Typesetting is the second decision. The first is the shape of the report itself: what goes in it, in what order, and what the client is meant to do after reading it.
The guide it belongs to Client reports your clients actually read The five-part client report format that wins renewals, with a free CSV template and a report published in the open.Keep reading
From CSV to this in minutes
Upload the file. Fill the blocks: text, the KPI tiles, the chart, the table, your logo at the head of the page. Export a typeset PDF for the inbox, or publish a live link.
Founding members publish live links and print without our mark. See the founding offer.