` element with no break inside it, so the printer decides where it divides.
The third is the biggest and the most structural: **a block that spans a page boundary has no break
inside it in the DOM at all.** Our page breaks are widgets BETWEEN top-level blocks.
## The bug that was hiding under all of it
Chasing CA_Courts led to this, and it had been costing every document in the corpus for days.
The trail: the harness said CA_Courts's paragraphs had **the same line count in both** — 30 lines
against 30 — but were 540px in the browser and 460px computed. Same lines, different height, so it
had to be line height. Reading the paragraph's computed style in the browser gave
`line-height: normal`. The paragraph had no `line-height` at all.
Only 109 of 415 paragraphs had one, and those 109 were exactly the ones with an explicit
`w:spacing w:line` — the raw _fallback_ branch. So `lineHeightTwips` was returning undefined
throughout, which it only does when it has no measurer.
The call was `docFromBlocks(blocks, registry, context)`. The measurer and the content width were
**optional positional parameters**, an earlier edit adding them had silently not applied, and
TypeScript had nothing to complain about. So no paragraph ever received a computed line height, the
browser used `normal` everywhere, and tables were sized against a default 9360-twip column instead
of the section's real one.
`docFromBlocks` now takes an options object with both fields **required**, which immediately caught
three more call sites doing the same thing. An optional parameter that must always be passed is not
optional, it is a trap.
| | before | after |
| -------------------------- | ------- | ---------- |
| Oregon_MSA drifting blocks | 674/843 | **13/843** |
| Oregon_MSA mean error | -4.6px | **-0.0px** |
| CA_Courts drifting blocks | 291/350 | 145/350 |
| CA_Courts `screen=model` | 69% | **93%** |
| NY_SERDA `screen=pdf` | 56% | 62% |
| VA_SLA mean error | +5.9px | -0.2px |
**Per-block measurement is now accurate to a pixel or two on every document.** What remains is an
accumulation problem: sheets still overflow by up to a few hundred pixels even where every block
in them is measured correctly, which means the page-FILL disagrees with the browser about how much
fits rather than about how tall anything is. That is a different bug and the next one to chase.
## What the last table error is
Two more table fixes came out of the row-by-row view:
- **The stylesheet drew a 1px cell border whatever the document said**, and with
`border-collapse: collapse` that adds its width to every row. The bridge now emits the document's
own border width, read from `w:tblBorders` and falling back to the **table style's** — most tables
carry no borders of their own and take them from a style, so reading only the table reports no
border for almost every table in the corpus.
- **The harness was reporting rounding as disagreement.** Both sides round to whole pixels from
sub-pixel values, so a true 42.5 reads as 42 on one side and 43 on the other; over twenty-five
rows that looks like a 25px error that is not there. Rows within a pixel and a half now count as
agreement.
With that noise gone, every remaining table disagreement has the same shape: **a single cell wraps
to one more line in the browser than in the model**, worth 17-18px each. Not spacing, not borders,
not margins -- line breaking, in a narrow measure where a word sits on the boundary.
That is the same root cause as the in-paragraph break fragment, one level down. **Line breaking
agreement is now the only thing between tdoc and R1**, and it is worth saying that it is one
problem rather than several: the widths agree to a sixteenth of a pixel, so it is about where the
breaker decides, not what it measures.
## The extra line, traced to the twip
VA_SLA's revision-history table reported every row a line too tall. The trail, each step made
possible by the previous one:
1. `table-check` ranked cells by **paragraph stack**, not cell height — a cell renders at its row's
height, so the old ranking filled with cells whose own content agreed exactly.
2. That named the cell: `r0c1 "Version"`, DOM 42px against 25px computed. One word, wrapping.
3. Reading the browser gave the numbers: content width **793 twips**, and "Version" measures **794**.
It fits in the model by one twip and wraps in the browser by one.
4. The `| ` carried a **0.5px border on each side**, and the model charged the border to the row's
HEIGHT but never to the cell's WIDTH. With `border-collapse` a cell gives up half a border on
each side, so its text has a border's width less room than the grid says.
Fixed, along with two others found on the way: the `` used **percentages**, whose rounding
over a 561px table is a twentieth of a pixel — enough on its own to wrap a word sitting on the
boundary, so columns are now absolute inches computed exactly as `layOutTable` computes them.
**And one fix rejected on the evidence.** The declared border is 10 twips, two thirds of a pixel,
which Chromium snaps to half a pixel per side — consuming 15. Charging the snapped width should
therefore be more accurate, and the harness measured it **worse** on every table-heavy document.
`snapToPixel` is kept and unused, with the reason recorded, because the snapping is real and the
next attempt will need it.
Table disagreement across the corpus fell from 472px to 332px. **VA_SLA's `screen=pdf` did not
move**, which is itself a finding: its page assignment is not sensitive to these table details, so
whatever holds it at 84% is somewhere else.
### Where the error is, in order
1. **Table row heights.** The largest cause by some distance, and now the only offender the render
harness still reports: every remaining overflow is a table rendering 10-20% taller than it was
measured. VA_SLA fills only 57% of page 1
before a table will not fit, and the first divergence in three documents is at a table. Cell
widths are approximated from `w:tblGrid` rather than resolved through `w:tblW` and
`w:tblCellMar`, and **table styles are not in the cascade at all**, so
cell paragraphs inherit document-default spacing that a table style usually suppresses.
2. **Shape-only font pairings.** A document naming Garamond now renders in Liberation Serif
everywhere, so the browser and the PDF agree — but the advance widths are not Garamond's, so
Word would break those lines elsewhere. That is the accepted trade, and
`isMetricCompatible` says which pairings carry it. MITP_KF_2026 is 2,187 Garamond runs measured against
DejaVu Serif, and comes out at 35 pages against 14. Correctly flagged, correctly excluded from
any conclusion, and not fixable without licensing the real font.
3. **Tab alignment.** Only default stops are honoured, so a line laid out against a
custom stop measures short.
4. **`w:type="continuous"` sections** start a new page here and should not, which overstates the
count on documents that change column layout mid-page.
5. **Headers and footers** do not reduce the body box when they are taller than their margin.
## What does not exist yet
> **Superseded, and kept as the record of the order this was built in.** Written when the layout
> layer could only measure. Items 1-5 are done: tables split between rows, `w:tabs`, the browser
> `Measurer`, list numbers computed AND drawn, and headers, footers and page-number fields. What is
> still true is item 6, `keepNext` (deliberately, see above), and item 7, incremental relayout --
> `paginateDocument` is ~50ms on a 56-page agreement and is deferred off the keystroke rather than
> made incremental. Read [plan.md](plan.md#what-is-left-in-order) for what is open now.
`tdoc/layout` cannot paginate. The scorecard therefore reports the reference side only: what the
PDF says, how it compares to Word, and whether every block could be located. Those must work
before a comparison means anything, and they can be wrong on their own.
Done: the `Measurer` and its headless font-metrics implementation, greedy line breaking, page fill,
widow and orphan control, keep-lines and explicit breaks, and tables split between rows.
Not done, in the order the baseline says they matter:
1. Table heights, per the list above. The one worth doing next.
2. `w:tabs`, explicit tab stops.
3. A **browser** `Measurer`, so the editor paginates with the same rules. The headless one is the
authority; the browser must agree with it rather than the other way round.
4. Computing list numbers — needed to DRAW a numbered clause, not to lay one out, because the
marker sits in the hanging indent and `w:ind` already accounts for its width.
5. Headers, footers and page numbers.
6. `keepNext`, which is parsed and carried but not yet enforced.
7. Incremental relayout from the first dirty block, or typing on page 3 of a 56-page agreement
will be unusable.
Deferred within the phase, both fairly: **footnotes** (circular, since adding one shrinks the body
box that positions its anchor) and **anchored objects** (inline-only first).
## Measure what is DRAWN, not what is stored
`w:caps` does not change the stored text. The document holds "Contractor shall defend" and the
renderer turns it into capitals, which in a browser is `text-transform: uppercase`. A measurer that
takes the string straight out of the XML therefore measures a string nobody ever sees, and capitals
are materially wider than mixed case: Oregon's indemnity clause broke into 14 model lines against 18
in the browser, and every page below it moved.
`TextStyle` now carries `caps` and `smallCaps`, and `width()` measures the rendered form. Small caps
are measured per character, because the two cases are drawn at two different sizes and none of the
Liberation faces carry real `smcp` glyphs, so the browser synthesises them as scaled capitals.
The general rule this is an instance of: **any run property that changes what the reader sees must
reach the measurer, not only the stylesheet.** `w:spacing` (letter spacing) is the one still
outstanding, and it is currently consistent by accident, since neither side applies it.
## Both halves must make the same choice about space at a page top
The paginator drops `w:spacing w:before` at the top of a page. That is a defensible choice and it is
not the point. The point is that the BROWSER was still drawing it, so every page began with a
padding the model had not charged for and the whole page below it shifted down. NY_SERDA overflowed
22 of its 55 sheets on this alone, with not one block mismeasured: the drift check found only 15
blocks out of 590 off by more than two pixels.
The stylesheet now suppresses it too, on the block after a break and on the first block of the
document. `screen=libre` did not move, so the choice costs nothing against the reference; it is the
INCONSISTENCY that cost 22 sheets.
### Overflow cascades, so read the first one
An overflowing sheet clamps its filler at zero, because a filler cannot be negative. Everything
below it then sits too low by the shortfall, and every later break reports overflow too. Oregon's
twelve overflowing sheets trace to two or three genuinely over-full pages; the rest are the wake.
So the overflow COUNT is not a count of bugs. Find the first overflowing sheet, fix that, and
measure again before reading anything into the others.
## A word is not a run
`terminated, unless` is ONE word followed by a space and another word. The
only break opportunity is the space. But splitting each run on whitespace independently makes every
run boundary a break opportunity, including the one inside "terminated,", so the model packed a line
the browser cannot and the paragraph rendered a line taller than it was measured.
A run boundary falls inside a word constantly: a change of font, a defined term in bold, a tracked
insertion, a spell-check artefact from whatever wrote the document. `pieces()` now merges adjacent
non-whitespace text into one piece whatever runs it came from, and a piece carries its `fragments`
so the line still knows which part is bold.
### How to read a line-width comparison
The probe reports `lineWidths`, so a line-count disagreement can be traced to the line the two
breakers parted on. Two things to know before reading one:
- **A matching line is ~65 twips narrower in the model.** That is the trailing space: Chromium HANGS
it, per CSS Text, so it appears in the client rect and does not affect wrapping. The model drops
it. Both are right, and a uniform delta of about one space means the line AGREES.
- **The break label is not a line.** A break inside a paragraph is a child of it, so a range over
the block picks up "page 29 of 43" as a line of the contract. The probe skips text inside
`.page-break`; anything else reading the DOM must too.
## Contextual spacing has to be suppressed in BOTH halves
`w:contextualSpacing` suppresses space against a neighbour of the same style, which is how
consecutive list items sit tight while the list keeps its space above and below. The suppression
depends on the NEIGHBOURS, and a function resolving one paragraph cannot see them.
So the paginator did the neighbour comparison itself, and the bridge -- which builds one paragraph
at a time -- rendered the unsuppressed spacing. The paginator then packed a page to a height the
browser would never draw. Oregon put 985px of content on an 888px page with EVERY BLOCK MEASURING
EXACTLY, because a dozen suppressed gaps came back at eight pixels each.
`contextualSpace()` in `~/model` is now the single rule, and both halves call it. Inside a table
cell neither applies it, because the cell height sums its paragraphs' spacing unsuppressed: again,
the two halves agreeing matters more than which rule they agree on.
That took every document in the corpus to 100% `screen=model`.
## The stylesheet is a measurement, whether or not it means to be
Three separate bugs in this project have been a CSS rule that changed how wide the text is while
the model knew nothing about it. They are hard to find because the model is right, the measurement
is right, the harness says the paragraph is a line too tall, and nothing in either breaker is wrong:
- a **floated** style label, in the flow, shrinking the first line of every styled paragraph;
- `font-weight: 600` on header rows, decoration in the demo's own stylesheet. Bold
"Communication" is 101px where regular is 94.5, so in a 95.7px cell the browser's
`overflow-wrap: break-word` split it as "Communicatio" + "n" and the row came out a line taller
than the model measured. Word gets header bold from the table style's `firstRow` conditional
format, which the model resolves and the bridge writes per run;
- a blanket `padding-left` on list paragraphs, dead only because nothing set the attribute.
The tell is the probe's `width` and `inner` fields: what the browser actually broke against. If
those agree and the line count does not, look for something that changes the TEXT rather than the
box -- weight, transform, letter spacing, a synthesised face.
`overflow-wrap: break-word` deserves its own note. It comes from prosemirror-view's own stylesheet,
and it means a word the browser thinks is too wide is SPLIT rather than left to overflow. That
turns a sub-pixel width disagreement into a whole extra line, so it makes narrow table cells the
most sensitive place in the document to a measurement error.
## The aligner must not go backwards inside a page either
The reference side of every comparison is built by matching block text against page text, walking
both in document order. The cursor only ever moves forward through PAGES -- but within a page it
was free to match anywhere, so a repeated string matched its FIRST occurrence.
MITP's "Cambridge, MA 02142" appears in two address blocks. The second was reported a page early,
which showed up as a failure in the R1 column with nothing wrong in the product. Blocks are in
document order, so their matches must be too: the search now starts at the line the previous block
matched on. At that line, not after it, because a table row's cells are one line of extracted text
and two blocks legitimately share it.
Worth internalising: **a disagreement in a comparison is a claim about two things, and the harness
is one of them.** Of the last four R1 failures in this corpus, three were the measuring apparatus.
## Where it stands, and what the residue is
All nine corpus documents are at 100% `screen=model` and 100% `screen=pdf`. The browser draws the
pages the paginator computed, and the PDF matches the screen it was printed from, block for block.
What remains is one sheet overflowing by 9 pixels (NY_SERDA 50, remeasured 2026-09-03; it was five
sheets across two documents when this was written). It is less than a line height, no block on it
is mismeasured, and it changes no page assignment. It is the accumulated fractional difference between measuring a page in twips and
rendering it in device pixels, forty lines at a time. Removing it means giving the paginator the
browser's own measurements rather than closing a gap in the model.
## Other page sizes, and the one case that does not work
Every contract in the corpus is US Letter with uniform sections, so the whole scorecard was one
page geometry until `pnpm variants` existed. It rewrites `w:sectPr` on real documents and saves
real `.docx` files, so the variants exercise sectPr -> model -> layout -> render rather than a test
hook.
**Uniform geometry holds everywhere tested.** A4, Legal, A5 with half-inch margins, half-inch
margins on Letter, inch-and-a-half margins, and full-document landscape: all 100% `screen=model`
and 100% `screen=pdf`, with the page counts moving as they should (Oregon 43 -> 41 A4, 32 Legal, 63
A5, 36 narrow, 56 wide).
All-landscape was then run across the WHOLE corpus rather than the three documents the size sweep
used, because "it works" deserved more than an extrapolation from a sample that had already
produced one false pass. All nine: 100% and 100%, page counts rising as a shorter page requires --
CA_Courts 25 -> 29, MITP 36 -> 44, NY_SERDA 55 -> 60, Oregon 43 -> 47, VA_SLA 15 -> 22.
Note what this does and does not cover. CA_Courts has seven sections, VA_SLA three, NY_SERDA two,
and all of them pass: MULTI-SECTION documents are fine. What is not supported is sections that
DISAGREE, which is the next heading.
Landscape only holds because the sweep found `.page { max-width: 8.5in }`. The editor writes the
sheet's real width inline and an inline `width` DOES NOT beat a `max-width`, so every landscape
sheet was clamped back to portrait: the paginator measured a 9in column and the browser drew 6.5in.
Oregon in landscape scored 11%. Nothing in the corpus could have caught it, which is the argument
for the tool.
### MIXED page setups within one document are NOT supported
The paginator is section-aware and always has been: `paginateDocument` walks sections and fills each
against its own `contentBox`. The RENDERER is not. There is one `.page` element for the whole
document, sized from `sections[0]`, and `sizeSheets` takes a single `PageSetup`. So a document whose
sections differ is computed correctly and drawn wrong.
Measured, on the largest section of each document:
| variant | screen=model | screen=pdf | pages |
| ------------------------------ | ------------ | ---------- | ----- |
| CA_Courts, section 5 Legal | 54% | 41% | 22/29 |
| NY_SERDA, section 1 Legal | 26% | 22% | 43/74 |
| CA_Courts, section 5 landscape | 100% | 100% | 27/27 |
| NY_SERDA, section 1 landscape | 100% | 100% | 59/59 |
**The landscape rows are a FALSE PASS and should not be read as support.** Rotating a Letter page
makes the column wider AND the page shorter. The renderer draws the section too narrow, so its text
comes out taller than computed -- and the sheet it is drawn on is 11in where the paginator filled to
8.5in, so there is enough slack to absorb the error. Two mistakes cancelling. Change the page SIZE
instead, where only the height moves and no slack appears, and the same code scores 26%.
Supporting this properly is not a small fix, and the reason is worth stating: a single continuous
editable flow CANNOT change its column width mid-document. The options are a per-section container
inside the same `contentEditable` -- selection still crosses them, so the Google-Docs feel survives
-- with `sheetLayout` taught to work per section and `sheetAt` given per-section origins; or
restricting mixed documents to sections that differ only in HEIGHT, which needs `sheetLayout` to
take an array of page heights rather than one, and nothing else.
## Page assignment is only half of R1
`screen=pdf` compares WHICH BLOCK IS ON WHICH PAGE. Every break in our print is forced, so that
number cannot move for geometric reasons at all -- and it read 100% while every printed page after
the first had NO TOP MARGIN. Oregon's text began at 53.6pt on page one and at 13.1pt on every page
after it, on a document with 0.75in margins.
The cause is a CSS rule that is easy to read past: a block's top padding goes on its FIRST fragment
and its bottom padding on its LAST. The sheet is one element that the printer fragments into pages,
so its padding could only ever produce a margin on page one.
Three things about the fix are worth keeping:
- **Giving the PRINTER the document's margins is the obvious answer and is wrong.** It shrinks the
printable area to exactly one page of content, so the sub-pixel excess that the full sheet
absorbed spills into pages of its own. Oregon printed 56 pages instead of 43. The margin has to
come from inside the flow.
- **`break-before`, not `break-after`.** With `break-after` the whole break widget sits on the
OUTGOING page, so nothing is at the top of the new one. With `break-before` the widget is the
first thing on the new page and its padding IS that page's top margin.
- **A page can begin without a widget.** One that starts in the middle of a table has a `.row-break`
above it instead, and that row is then the only thing that can supply the margin. Missing this
left MITP's and VA_SLA's table pages with their text hard against the top of the sheet.
`pnpm render-check` now measures where the text actually SITS, via `pdftotext -bbox`, and reports
any page whose text falls outside the document's margins. It is a gate, like the other two.
### A page break inside a table, and why it is not a splice
A table crossing a page cannot be split into two tables: the document has one table, and splitting
it in the view means the view no longer holds what the model holds. So the break is SPACE INSIDE THE
TABLE -- padding on the row the paginator divided at -- and that is the part that is straightforwardly
correct. It is what creates the gap in both media and it is what the paginator measured.
Everything else is compensation for the fact that a cell paints its own padding, and each piece of
it is answering a specific defect rather than a general worry:
- **The cell's fill covers the gap.** Painting over the spacer is right and CLIPPING it is not:
`background-clip: content-box` also takes the fill off the cell's ordinary padding, so the row
came out as a thin strip around its text.
- **The cell's own top padding must survive.** The break adds the sheet to `padding-top`, and
replacing the property outright dropped the cell's margin, so the row resuming on the next page
had its text against the border. `--cell-pad-top` carries it.
- **The side borders run the full border box** and stripe the gap, so the band overhangs the cell
by enough to cover them.
- **The band and the table's cut edges are different widths.** The band is the PAGE and reaches the
paper; the edges are the TABLE. On one element the table's edge ran the width of the paper.
- **Reaching the paper is a measurement, not a guess.** `--table-indent` and `--table-trail` are how
far the table sits from each edge of the text column, so the band lands exactly on the paper
rather than approximately.
**Why not draw the boundary at page level instead**, as sheet furniture positioned at the sheet
offsets `sizeSheets` already knows? It is the obvious cleaner answer and it does not survive print:
an absolutely positioned band is placed against the flow's origin, and Chromium's pagination does
not carry those offsets across fragments -- the same reason the last page's footer needs a second,
print-only offset, and the reason positioning every footer that way made Chromium emit 84 pages for
a 43-page document. Per-cell painting is anchored to content, and content is the one thing
fragmentation keeps in the right place.
The known limit is a cell VERTICALLY MERGED across the break. That one genuinely cannot be drawn
without splitting the table, and nothing in the corpus does it.
## A tab stop positions what FOLLOWS it
`w:tabs` stops carry an alignment and every one of them was treated as LEFT: advance the cursor to
the stop. That is right for a left stop and wrong for the other three, because a right, centre or
decimal stop positions the TEXT AFTER the tab rather than the cursor.
The visible cost was a table of contents. `Purpose1` with a right stop at the margin moved the
cursor to the margin, and the page number after it had nowhere to go but the next line -- so every
entry took two lines. LibreOffice puts them on one, the browser puts them on one, and we were the
odd one out on the document with the corpus's largest mean block error.
Implementing it means looking AHEAD from the tab to the next tab or the end of the line, measuring
what lies between, and starting it a width (or half a width, or the width before the decimal point)
to the left of the stop. Never behind the cursor: a segment wider than the room left of the stop
starts where the cursor is, which is what Word does rather than overlapping what is already there.
VA_SLA mean block error -4.1px -> -1.3px
Both renderers do it, and they have to do it identically: the breaker decides where the line ends
and the PDF writer decides where the glyphs go, and if they disagreed the line would break in one
place and draw in another.
### What it exposed, and what is deliberately NOT fixed yet
VA_SLA's agreement with LibreOffice fell from 84% to 17%, and the fall is the fix working. Two
errors had been cancelling: the TOC took twice the lines it should, which pushed the document to
fifteen pages, which is what Word says. Correct the lines and it comes out at fourteen -- and the
real reason Word has fifteen shows up.
**`w:br w:type="page"` is an explicit page break, and it is now honoured.** Thirteen of them across
five of the nine corpus documents. It took three goes, and what the first two got wrong is the
useful part:
| | before | honoured |
| ----------------------- | ------ | -------- |
| VA_SLA `screen=libre` | 17% | **100%** |
| NY_SERDA `screen=libre` | 46% | **80%** |
| MITP `screen=libre` | 68% | **81%** |
The page COUNTS are what to read rather than those percentages. VA_SLA goes from fourteen pages to
fifteen and Oregon from forty-three to forty-four, and both are what LibreOffice says. Oregon's
percentage FELL, from 58% to 45%, and that is the same fix working twice over: it was a page long
from block 46 onwards and a page short at the end, and the two cancelled. Getting the length right
is what exposed the early error, which is still there and is now the thing to chase.
Three things had to be right at once, and each was wrong on its own first:
1. **A forced break does not exempt the rest of the paragraph from the page height.** The first
attempt branched inside the greedy path and came out a page short on CA_Courts, with
`screen=model` still reading 100% -- the drawing faithfully agreed with a paginator that was
wrong, and the printer spilled onto a page nobody had drawn. The forced case gets its own walk,
which applies both constraints in order: out of room first, then the break.
2. **A break with nothing but empty lines after it belongs to the NEXT block.** A paragraph that is
ONLY a page break leaves its own mark behind on the outgoing page, as Word does. Opening the new
page with the blank line instead costs a line on every page below it, and on Oregon that put one
block on the wrong side of a boundary -- visible as an R1 failure, not as a page count.
3. **A split block is on two pages at once**, so "which page is it on" has two right answers. See
below; this one was the harness, not the paginator.
### The harness half: a block that is on two pages
Two of the three "regressions" above were the harness comparing different questions. A paragraph
whose first line is EMPTY and whose break falls after it -- an `ATTACHMENT 1` heading announced by a
blank line, which is how a contract opens an exhibit -- has its box on one page and its only text on
the next. The screen side reported the box, and a reference built by finding text in a PDF can only
report the text. Both renderings were correct and identical; the comparison was wrong. Two changes,
each defensible alone:
- the probe reports the sheet a block's **text** is on, because text is all a PDF can see;
- `comparePagination` accepts any page **within a split block's span**, taking the span from
whichever side records its splits -- a PDF-derived pagination has none.
Watch the second one: written as a fallback to the block's own page it silently forgives every
block the reference placed EARLIER, and agreement climbs to 100% on documents that plainly
disagree. It read as a spectacular win for about ninety seconds.
**`w:keepNext` is parsed and not enforced**, and that was measured too: pulling the trailing chain
onto the new page moved Oregon from 56% to 59% against LibreOffice and its overflowing sheets from
two to SIX, because the chain plus the block that caused the break do not always fit the page they
land on. Three points of a proxy is not worth four sheets that do not fit.
## The measurer uses the reference's own fonts
`pnpm fonts` extracts the 27 faces out of the reference container into `corpus/fonts/`. Not from
this machine and not from a download: whatever LibreOffice laid out with is what tdoc measures
with, by construction. Otherwise the comparison measures the difference between two font sets
rather than between two paginators.
The substitution table in `src/layout/measure.ts` mirrors the ones Debian's fontconfig makes —
Arial to Liberation Sans, Calibri to Carlito, Cambria to Caladea — because those are what the
reference used. A pairing that is not metric-compatible is worse than no substitution: it silently
produces plausible pages of the wrong length.
## Headers and footers
Drawn as FURNITURE, never as document nodes. A header lives in the page's margin and the body box is
unchanged by it, which is precisely why it cannot be part of the flow ProseMirror is editing: it is
painted into the margin bands the page-break decorations already own.
That forced the break widget to stop being one box with padding. Padding cannot contain anything, so
a break is now three real boxes:
tail the rest of the outgoing page plus its bottom margin -- the FOOTER
gap the space between sheets, screen only
head the incoming page's top margin -- the HEADER, and the box that carries `break-before`
**Fields are evaluated, not read.** `PAGE` and `NUMPAGES` are stored with whatever Word last
computed, and those caches are stale on arrival: NY_SERDA's footer says "34" on every page and
Oregon's says "28". Both `w:fldSimple` and the `w:fldChar`/`w:instrText` form are handled, and an
instruction we do not evaluate keeps its cached result, which is what a reader of the .docx saw.
`w:titlePg`, `w:evenAndOddHeaders` and section inheritance all work: four of the nine corpus
documents have a distinct first page, three carry a full first/default/even set, and CA_Courts has
twelve parts across seven sections.
### Two things that look equivalent to putting furniture in the flow, and are not
Page one's header and the last page's footer have no break to live in. As zero-height widget
decorations at the start and end of the document they cost `screen=model` seven points across the
corpus, and the one at the end made Chromium print an extra page for its overflow. Neither showed up
as a header in the wrong place; both showed up as pagination bugs. They are now siblings of the
editable root, positioned against the SHEET, and cannot perturb a layout they are not part of.
The two heights they need are different on screen and on paper: screen counts the gap between
sheets, paper has none, so they differ by a gap per page.
### Known defect: the first page's footer, in print
The footer of the page BEFORE THE FIRST BREAK paints at the top of page two. Every later page's
footer is correct, on every document: CA_Courts shows `rev. Dec.2023` at the top of page 2 and
`rev 5-04-15` correctly at the bottom of it; NY_SERDA shows `1` then `2`. Page one is the only page
whose top margin comes from the sheet's own padding rather than from a `head` band, which is the
difference to chase.
Three arrangements have been measured and this is the best of them:
- tail keeps its real height in print: each page becomes exactly full, the sub-pixel excess the
sheet absorbs spills, and Oregon prints 46 pages instead of 43 (`screen=pdf` 3%);
- every footer positioned against the sheet: Chromium generates pages to contain the offsets, and
Oregon prints 84;
- **tail collapsed, footer offset from it**: page counts exact, `screen=pdf` 100%, one footer wrong.
# Plan and status (docs/plan.md)
# Plan and status
**This is the one status file.** Phases, the road to a first real consumer, and the defects that
are measured and open. Two lists of the same thing drift, so anything that looks like a second one
should be folded in here instead.
The PHASES are ordered by **uncertainty**, not by dependency: find out early whether the expensive
requirement is achievable, so the phases that could kill the project come before the ones that are
merely long. The WAVES below them are ordered by what blocks a consumer. They are two orderings of
overlapping work, which is why they are in one file.
Anything marked DONE has a test.
---
## Where this actually is
Measured, `pnpm render-check` over the nine corpus contracts (2026-09-03):
```
document pages ovfl screen=model screen=pdf screen=server glyphs screen=libre
CA_Courts_Agreement 26/26 0 100% 100% 100% 94% 13%
Employment_Agreement 2/2 0 100% 100% 100% 99% 100%
MITP_KF_2026 37/37 0 100% 100% 100% 31% 76%
NDA_Template 2/2 0 100% 100% 100% 100% 100%
NY_SERDA_Agreement 56/56 1 100% 100% 100% 93% 84%
OECS_SOW_2026 2/2 0 100% 100% 100% 27% 100%
Oregon_MSA 44/44 0 100% 100% 100% 93% 45%
SOW_Template 2/2 0 100% 100% 100% 56% 100%
VA_SLA 15/15 0 100% 100% 100% 37% 100%
```
Phases 0-3 are done. **R1 holds on all nine documents**, on both PDF paths -- the one printed from
Chromium and the one drawn from the model with no browser anywhere. Over the twenty generated
page-setup variants it holds on nineteen; `zvar-VA_SLA-mixed-size` is at 75% and is the known
mixed-section defect, and `zvar-VA_SLA-narrow` at 98% is one block.
`glyphs` is a ratchet against `tools/pagination/glyph-baseline.json`, and a collision check (two
words drawn over each other on one baseline) gates at zero. **`pnpm render-check` reports `12 of 29`
failing when the variants are present**, which is not a regression and not R1: three corpus
documents and nine variants trip the geometry gates -- text outside the margins, a sheet that
overflows, a collision -- all of them in the defects register at the bottom of this file, all of
them open since Phase 3. Run `pnpm render-check CA_Courts` (or any name) to look at one.
Phase 4 (the CRDT) is built to its exit criterion in-process: two editors and an HTTP agent
converge and the server writes the file with no browser attached (`test/room-server.test.ts`).
What is not here is the Durable Object itself, which is Pact's to host. Phase 5 (suggestions and
comments) is built through the two Pact asks 9g and 9h: typing can produce a suggestion, and the
editor can show the review view. Open in Phase 5: tracked joins, formatting changes, and R1
asserted in the review view by the harness.
Phase 6, packaging, is untouched, and is now the thing between this and a consumer that does not
reach past the exports map. **[What is left, in order](#what-is-left-in-order)** is at the bottom.
`glyphs` and `screen=libre` are not gates. See [pagination.md](pagination.md#r1-today) for how to
read them and for the defects list, which is also summarised at the bottom of this file.
---
## Phase 0 — Substrate ✅ DONE
Lossless XML, the OPC container, and a fidelity harness over the real corpus.
- [x] `tdoc/xml`: parse and serialize, byte-identical on unmodified input
- [x] `tdoc/opc`: zip read/write, untouched parts copied as compressed bytes
- [x] `test/xml-fidelity.test.ts`, `test/opc-fidelity.test.ts` over all nine corpus documents
- [x] `tools/fidelity.ts` scorecard
**Why first.** Everything else is built on the claim "we never lose anything". That claim is
either true from the first commit or it is retrofitted later, badly.
---
## Phase 1 — Recognition ✅ DONE
Teach the model to _see_ the document. Read-only, so fidelity cannot regress.
- [x] `tdoc/model`: body, paragraph, run, table, row, cell, with content controls transparent
- [x] Text extraction as SEGMENTS, excluding field codes, tracked deletions and `mc:Fallback`,
and skipping properties subtrees (a `w:tab` under `w:pPr/w:tabs` is a tab STOP, not a tab)
- [x] **Sections**: a `w:sectPr` inside a paragraph ENDS a section (the properties come after
the content they describe); page size, margins, orientation, columns, title page,
header/footer references, and the content box each section fills
- [x] `styles.xml`: the full cascade — docDefaults, the default style, the paragraph's `w:basedOn`
chain, the numbering level, the run's character style chain, direct formatting — with
**toggle-property XOR** (ECMA-376 §17.7.3) and cycle-safe chains
- [x] Theme fonts: `w:asciiTheme="minorHAnsi"` resolved through `theme1.xml`, and the theme as the
last-resort fallback when `docDefaults` names no font at all, which one corpus contract does
- [x] `numbering.xml`: abstract definitions, concrete `w:num`, `w:lvlOverride`, and the level's
contribution to the cascade
- [x] `MODELLED`, the coverage set, exported as data so the workbench can show it honestly
- [ ] **Computing the displayed number** — the `1.1/1.2/1.3` a reader sees is stored nowhere and
must be counted over the body, honouring `w:start`, `w:lvlRestart` and overrides. Moved to
Phase 3, which is the first thing that actually has to draw it.
- [x] Headers and footers as first-class bodies (`src/model/furniture.ts`), with `w:titlePg` and
`w:evenAndOddHeaders`, and `PAGE`/`NUMPAGES`/`SECTIONPAGES` fields evaluated per page in the
model rather than in either renderer
- [ ] Footnote parts as first-class bodies
**Exit criterion: MET.** Every run in all nine contracts resolves to a plausible size and >99% to
a font family, asserted in `test/styles.test.ts`. The workbench renders from the resolved cascade,
so a heading is Arial 20pt because its style chain says so rather than because anything guessed.
---
## Phase 2 — Round-trip under mutation (R3)
The first phase that can lose data. Text and paragraph structure are done; creating content that
did not exist is not.
- [x] Dirty tracking and regeneration of a mutated node
- [x] `Paragraph.setText` / `Run.setText`: a diff that touches only the runs the change reached,
so formatting on either side survives
- [x] Refusal rather than approximation when a change would have to alter an atom
- [x] Split and join paragraphs, with Word's own rules for the paragraph mark: a split heading is
two headings; a join keeps the SURVIVING paragraph's properties
- [x] Span-preserving clone, so a copied `w:pPr` is byte-identical rather than merely equivalent
- [x] Fidelity assertion after mutation, over the whole corpus: append to EVERY writable
paragraph and assert the character count moved by exactly the number of accepted edits;
split and rejoin every paragraph and assert no drift
- [x] **Table mutation** (`src/model/table-write.ts`): create, insert and delete rows and columns,
merge, split, borders as four presets, cell shading, header rows, row heights. Two rules hold
it: THE GRID IS THE WIDTH, so every operation preserves the total and `w:tblW` stays true
without being rewritten; and A ROW IS NOT ITS COLUMNS, so `columnAt` walks the spans and is
the only way a column is addressed
- [x] **Headers and footers written** (`src/model/furniture-write.ts`): the part, its content-type
declaration and the `w:sectPr` reference, which are useless apart, plus the two switches
(`w:titlePg`, `w:evenAndOddHeaders`) without which a first or even header is never drawn
- [x] **Pictures are insertable** (`src/model/image-write.ts`): `insertImage` writes the media
part, the extension's content type and a relationship FROM THE PART THE PARAGRAPH LIVES IN;
`replaceImageBytes` swaps a bitmap keeping the drawing; `resizeImage` writes both copies of
the extent, since Word draws the smaller when they disagree
- [x] **Pictures** (`src/model/image.ts`, `src/pdf/images.ts`): read from both encodings, one
character in the traversal, charged in the layout, drawn in the DOM, embedded in the
browser-free PDF (JPEG and PNG, alpha included; other formats refused by name and counted)
- [x] **Lists written** (`src/model/numbering-write.ts`): `applyList`, `clearList`,
`setListLevel`, creating `word/numbering.xml` where there is none. ONE `w:num` is reused
across a run of paragraphs, because an instance per paragraph numbers 1, 1, 1. Writing one
found that `Numbering` was read once at open, so a definition written afterwards drew no
marker at all; it rebuilds on the part's version now, as `Comments` already did
- [ ] A builder for content that did not exist: new paragraphs, new runs
- [ ] Delete an atom, and split inside a `w:hyperlink` / `w:ins` / content control
- [x] Page mutation: set page size, orientation and margins per section, inserting `w:pgSz`
and `w:pgMar` in **schema order** (`insertInSchemaOrder`), since Word repairs a wrong order
silently into a different document
- [x] Formatting mutation: run and paragraph properties, in `src/model/format.ts`
**Exit criterion.** Edit every corpus document in five scripted ways, re-import the export, and
assert the model is identical to the model of the edit applied to the original.
---
## Phase 3 — Pagination (R1) ✅ DONE
**100% on `screen=model`, `screen=pdf` and `screen=server` for all nine contracts and all twenty
page-setup variants.** See [pagination.md](pagination.md) for the table, how to read the two
non-gate columns, and the open defects.
- [x] Its inputs: the style cascade and per-section page geometry
- [x] **The comparison harness, built first** — `src/layout` and `tools/`, see
[pagination.md](pagination.md)
- [x] The reference environment: `tools/pagination/container/`, 501 MB, explicit font list.
**Matched Word\'s own `` exactly on all four documents that carry a real measurement**
- [x] `Measurer`, and a headless implementation reading TrueType metrics directly — `head`,
`hhea`, `OS/2`, `cmap`, `hmtx` — using the fonts extracted from the reference container, so
the two cannot disagree about metrics
- [x] Greedy first-fit line breaking, matching Word rather than being better than it
- [x] Page fill: space-before dropped at a page top, widow and orphan control (ON by default, as
Word does), keep-lines, explicit breaks, contextual spacing between same-style neighbours
- [x] Tables split between rows, with header rows repeating
- [x] **Sheet rendering**: page breaks drawn as widget decorations inside the single flow, each
padded so every sheet is exactly one page. Arithmetic in `src/layout/sheets.ts`, pure and
unit-tested, because a browser is a terrible place to find a one-line off-by-one
- [x] **The render harness** (`pnpm render-check`): headless Chromium reads the RENDERED page
assignment out of the DOM and compares it to the paginator AND to the PDF. Found three bugs
in its first hour that visual inspection had missed. See [pagination.md](pagination.md)
- [x] **The table harness** (`pnpm table-check`): every table row by row and column by column,
because a total cannot say whether the cause is width or height
- [x] Real cell margins (`w:tblCellMar`, `w:tcMar`), emitted into the DOM as padding so the
two cannot drift; `w:trHeight` as floor and override; inside borders charged per row;
continued `w:vMerge` cells no longer measured on every row they cover; the fabricated
240-twip minimum row height removed
- [x] **Fonts normalised on import** to an approved set, rewriting `w:rFonts`, the theme,
`w:fontTable` and `w:sym`, so the browser, the PDF and the exported .docx cannot disagree.
MITP_KF_2026 went from 18% to 61% agreement
- [x] **Table styles in the cascade**: `w:tblStyle` plus `w:tblStylePr` conditional formatting,
applied weakest-first (bands, then first/last row and column, then corners), with both
the named and the legacy hexadecimal forms of `w:tblLook`
- [x] `w:tblW` and `w:tblInd`: an `auto` table is as wide as its grid, which is Word's own
measurement rather than a set of proportions
- [x] Nested tables inside a cell counted toward its height
ignored, and table styles absent from the cascade
- [x] `w:tabs` explicit tab stops, accumulated across the cascade rather than overridden,
with `w:val="clear"` removing one. **VA_SLA went from 20% to 70% agreement**
- [x] A cell-level table diagnostic, with the paragraph stacks side by side
- [x] Tab ALIGNMENT: right, centre and decimal stops shift by the width of the segment that
follows them, decimal by the part before the point
- [x] A browser `Measurer`: `demo/fonts.ts` builds a `FontLibrary` from the same font files the
harness uses, so the editor paginates by the same rules and the two cannot disagree
- [x] Headers, footers and page numbers, in the bands of each break and in the PDF
- [x] **Explicit page breaks** (`w:br w:type="page"`), with a break that has only empty lines after
it belonging to the NEXT block, as Word does
- [x] **`w:trHeight` both ways**: `atLeast` as a row minimum, `exact` CLIPPED, which needs a box
inside the cell because a CSS height on a row is only ever a minimum
- [x] **Computing list numbers** (`src/model/markers.ts`), honouring `w:start`, `w:lvlRestart` and
`w:lvlOverride`, counting through table cells, and translating Word's private-use bullet
codepoints. Drawn in the browser and in the browser-free PDF, from the same function
- [x] **Every marker drawn, at Word's geometry.** `markerTextStart` is the one rule: the marker sits
at the first-line indent and the text starts at the first tab stop past its end, where the
left indent itself is a stop -- so there is no separate hanging case. It found that the
previous version drew hanging markers ON TOP of their text with every gate at 100%, because
the gates measure agreement and not appearance. Three renderings were needed before Chromium
printed the marker where it drew it on screen; `document.css` says which two failed and how
- [x] **The PDF renderer draws from the paginator's own layout.** It laid every block out a second
time, without `marker`, and drew charged origins with lines broken at uncharged widths: six
points off on the first word of every numbered paragraph, glyph agreement 93%→53% on
CA_Courts. From `pagination.laid` now. NY_SERDA's glyph agreement is 90%, twelve points
above what it was before markers existed
- [ ] `keepNext`, parsed and deliberately not enforced: pulling the trailing chain onto the new page
moved Oregon from 56% to 59% against LibreOffice and its overflowing sheets from two to six
- [ ] Incremental relayout. Pagination is off the keystroke path already (Wave 1); this is the
algorithmic half, and it may never be needed
- [x] **One layout pass for refresh and settle.** A refresh paginates once, builds the DOM from
that layout, and carries the pagination on its own transaction so the plugin places the breaks
from it and no settle follows; the mount does the same. 102ms → 60ms on a 56-page agreement
- [x] **Tabs at their real width.** Each tab's advance is recorded by the line breaker and written
by the bridge; before that every tab was an arrow glyph plus 22px of padding from a workbench
rule that shared its class name, and agreed with the paginator by luck
- [x] **Print HTML and the PDF produced from tdoc's own pagination** (`pnpm render-check`).
Each break carries `break-after: page`, so the printer draws the pages the paginator
chose rather than repaginating. **This is the R1 metric**; `render-check` against
LibreOffice is a Word-fidelity proxy
- [x] **Line breaking that matches the browser exactly.** Was the last R1 gap; `screen=model` is
the invariant that pins it and it is at 100%
- [x] CA_Courts, the last outlier: zero overflowing sheets, 25/25 pages, 100% on all three gates
- [x] **A PDF drawn from the model with no browser** (`src/pdf`, `pnpm server-pdf`), gated as
`screen=server`. It has already found three cases where the BROWSER was wrong and the model
right — cell shading, `w:trHeight`, and a set of invented greys
- [x] **Break markers INSIDE a block that spans a boundary.** `fillPages` records a `Split`;
a paragraph gets a widget at the character its next line starts at, a table gets
`break-before: page` on the row. **Page counts now match on eight of nine documents**;
Oregon 40%->100%, MITP 36-sheets-vs-13-pages -> 36/36, NY_SERDA 32%->84%
- [x] An overlong word breaks at the margin, as Word and browsers do
**Exit criterion: MET.** For every corpus document, PDF page N contains the same block sequence as
screen page N. Measured continuously by `pnpm render-check` rather than checked once.
**What Phase 3 did NOT finish**, carried into the defects register below: text printing outside the
document margins on three documents, three overflowing sheets, glyph placement in the browser-free
PDF, and mixed page setups inside one document.
---
## Phase 4 — The CRDT (R2) ← BUILT, awaiting a host
The shared state is MODEL-SIDE, not ProseMirror-side: a Y.Array of blocks, each a Y.Map with a
stable `pid`, a `kind`, and either a Y.Text of the paragraph's text or, once a block carries a
tracked change, its XML markup. ProseMirror is a lossy projection of the model and shares nothing.
Everything the XML holds that the projection cannot express stays in the model, so what the server
writes is what the screen drew -- the layering question that R1 turns on. See
[architecture.md](architecture.md).
- [x] Yjs mirror of the model (`src/crdt/document.ts`): `seed`, `writeText`, `splitBlock`,
`joinBlock`, `writeMarkup`, `applyTo`. About 20 bytes per keystroke; a push is ~1ms
- [x] Editor binding (`src/crdt/binding.ts`), by element identity rather than `y-prosemirror`,
because the CRDT is not of the ProseMirror document. Local and agent origins never echo
- [x] A room (`src/crdt/room.ts`): `DocumentRoom.open({ docx, stored })` seeds or joins without
being told which, `flush()` returns the bytes and the state to store, `dirty` says whether
to. An evicted room rebuilds from storage and does not re-apply what it already wrote
- [x] Server-side export, no browser involved (`test/room-server.test.ts`)
- [x] HTTP-agent writes into the live room (`setBlockText`, `splitBlock`, `joinBlock`, AGENT
origin), carried to an open editor
- [ ] The Durable Object itself: hosting, auth, and the store the room flushes to. Pact's, since
the room seeds from whatever ground truth the app keeps -- which is the `.docx`, by Pact's
own decision
**Exit criterion: MET in-process.** Two editors and one HTTP agent editing concurrently converge,
and the server writes the file with no browser attached.
---
## Phase 5 — Suggestions and comments (R4) ← IN PROGRESS
- [x] **Tracked changes read from the model** (`src/model/tracked.ts`), with per-operation author
and date, and ranges into the REVIEW text -- the only string in which a deletion has a
position at all. The view is a PARAMETER of the one traversal
(`TextOptions.includeDeleted`), never a second walk beside it
- [x] **Accept and reject, per change and in bulk, server-side.** To a fixpoint, because deciding a
change un-nests the ones inside it
- [x] **Suggesting mode** (`src/model/suggest.ts`): the same diff `setText` computes, emitting
`w:ins`/`w:del` instead of rewriting a run. This is the operation no consumer can intercept --
by the time a host sees the result, the runs have been rewritten and what was replaced is gone.
A REPLACEMENT IS WIDENED TO WORD BOUNDARIES, which is where a suggestion deliberately
differs from a write. `setText` splices minimally so it touches the fewest runs; a
suggestion has a second job, which is that somebody has to READ it. Minimally, "100"
becoming "200" is one character, and the review then says the fee is `1`/`2` followed by
"00". Word records whole words for the same reason. Capped, so a long unbroken token does
not turn a one-character correction into a deletion of the whole thing, and applied only to
a replacement -- widening a pure insertion strikes through text that is not changing
- [ ] Paragraph MARK changes (``): reported by `paragraphMarkChange`,
refused rather than decided. Deciding one splits or joins a paragraph
- [x] **Comments in the model** (`src/model/comments.ts`), anchored to ranges, with reply threads.
Corpus-asserted throughout: 41 real comments in CA_Courts, 7 in MITP, real threads, real
authors, and a real absence of dates
- [x] **Writing comments** (`src/model/comment-write.ts`): add, reply, resolve, delete, with
`comments.xml` and `commentsExtended.xml` created where they do not exist -- part, content
type and relationship together, since a part in the zip that is not declared is a package
Word REFUSES rather than one it opens with the part ignored. Every test saves and re-opens
before asserting, because a comment in the tree and not in the package is the failure this
area invites
- [ ] `w:rPrChange` / `w:pPrChange`, since a review panel renders a formatting summary
- [x] **Refusal as a model mode**: `Authoring` and `writeUnder`. The MODE belongs to the writing
session and the CHANGES belong to the document, which is why they are stored in different
places. A suggest-only agent key is otherwise enforced only by a 403 at the HTTP handler --
right, and also the only guard: one code path that forgets the check writes final text into a
contract
- [x] **Both markup views paginate.** `paginateDocument` and `layOutBlock` take `markup`, deleted
text is measured when it is `all` and not when it is `final`, through ONE traversal both
times. A deletion draws struck through, which costs no width -- the extra TEXT is what moves
the pages. `test/markup-view.test.ts` asserts the two answers, and that an untracked document
gives the same answer in both
- [ ] `render-check` asserting R1 in BOTH views, which is the harness half of the same thing
- [x] **Undo of a suggestion is a rejection**, not an inverse edit. `suggestText` reports the
changes it made and `SuggestionHistory` rejects them; replaying the inverse through the
suggest path proposed the deleted text back in, turning a deletion into a replacement
nobody made
- [x] **An edit that moves text and changes nothing is refused.** In the review view struck-through
text is drawn and not stored, so deleting it moves the projection alone -- which used to
apply silently, and a dropped keystroke reads as a broken editor where a refusal is
recoverable
- [x] **The clipboard keeps a variable a variable**: a `parseDOM` rule for the control mark, and
`applyPastedMarks` writing what arrived through the model. The rule alone would look right
and vanish at the next rebuild, because the reconciler compares text. `insertion`,
`deletion` and `runStyle` are deliberately not parsed
- [x] **Typing can produce a suggestion** (Pact ask 9g). `Authoring` is a `MountOptions` option and
a `setAuthoring` toggle, threaded through `applyToModel` into the reconciler, which writes
through `writeUnder`. Typing into your own insertion extends it, in either direction, as
Word does -- the alternative was every second keystroke refused as "inside another tracked
change". Enter is a tracked paragraph mark; a join is REFUSED in suggesting mode until the
renderers can draw one
- [x] **The editor shows the review view** (Pact ask 9h). `markup` is a `MountOptions` option
and a `setMarkup` toggle; it reaches the pagination plugin and the bridge, which projects
`insertion` and `deletion` marks by ancestry when it is `all` and builds from the traversal
WITH deleted text. `textOf`, the selection-to-model offset, and the caret guard all know
that struck-through text is drawn and is not in the model, so typing beside a deletion
writes the model and not the deletion. Switching views repaginates, which the R1xR4
decision says is correct: two page counts, one screen equal to one PDF each
- [x] **A suggestion rebuilds the projection and places the caret.** The projection is not the
document after a suggest write: the transaction applied the plain edit, the model wrapped it
in `w:ins` or kept the text under `w:del`. The mount rebuilds from the model (coalesced
through a microtask) and puts the caret where the WRITER says it wrote, because restoring it
by position lands it past text that is no longer in the model -- after which every keypress
addresses characters that are not there, reconciles to nothing and refuses nothing. Which
SIDE of the strikethrough the caret goes on is decided by the keypress, not the model:
backspace grows a deletion leftwards under a caret that stays in front of it, forward-delete
eats rightwards and the caret stays behind. The signal is the selection BEFORE the
transaction, since both keys leave it in the same place afterwards
- [x] **`strict` or `touching` edges** on `controlAt` and `deletionAt`. A one-character span has no
strict interior, which is the right answer for the caret guard and the wrong one for a host
asking which span the caret is in
- [x] **The splice is anchored where the edit was made** (`editPoints`, `commonAffixes(before,
after, at)`). Typing a character that matches the text after the caret used to leave the
TYPED character untracked and record the one already there, which broke a suggestion in half
around an ordinary character. Both readings produce the same string, so no rule in the text
settles it -- anchoring as early as possible fixes this case and records `0 net 3` for
appending ` net 30` to `Fee: 100`. The transaction's own step positions settle it
- [x] **Deletions coalesce, as insertions already did.** Each keystroke is its own transaction, so
backspacing over three characters left three `w:del` and a reviewer saw three suggestions --
in a panel a host can group, and in WORD's review pane, which it cannot. A splice whose
doomed runs TOUCH an adjacent same-author `w:del` moves them into it instead of minting a
sibling, and deleting the character between two of your own closes the gap. Strict adjacency:
a deletion behind a bookmark or a field is not something to move runs across
- [x] **The browser-free PDF renders the view it is asked for** (`RenderOptions.markup`). The third
place the view has to reach, and the one that decides what a counterparty receives: a host
mounted in the review view rendering without it exports a final PDF of an all-view screen.
Insertions are underlined in the LAYOUT, not in the editor's stylesheet, so both renderers
take the mark from one rule -- a rule they do not share is a rule that makes the printed page
disagree with the screen it was printed from. Author COLOUR is the exception and is stated as
one: it lives in `--tdoc-insertion` / `--tdoc-deletion`, which the PDF cannot see
- [x] **Suggestions in a live room.** A suggestion IS in the document -- `w:ins`/`w:del` around the
runs -- but its FINAL text is indistinguishable from the same edit made directly, and the
shared state carried only text. A peer would have called `setText` and got an untracked
rewrite where the author has a tracked change: two documents agreeing about every character
and disagreeing about what is a suggestion. A block with tracked changes now shares its
MARKUP instead, and keeps sharing it for the life of the room -- see the note below on why it
cannot drop back
**Exit criterion.** An agent-authored suggestion and comment made over HTTP against a live room
render in the editor attributed to the agent, and open correctly in Word. R1 holds with the
suggestion shown AND hidden, at two different page counts.
---
## The workbench
Not a phase; it grows alongside them. `pnpm demo` opens a real contract, renders it from the
model, lets you edit any paragraph, and shows which parts of the package moved and which elements
the model understands.
It is deliberately NOT a document editor and must not drift into pretending to be one. It is **one
ProseMirror host over the whole document and it paginates**, with a formatting bar, headers and
footers, and three panes side by side: the editor, the PDF printed from it, and the PDF drawn from
the model. There is **no CRDT and no review view** in it, and while that is true the workbench
should make it obvious rather than paper over it.
**That is now a gap rather than a boundary.** Suggesting mode and the markup view are mount options
the workbench does not set, so the only eyes on them are headless tests and `proj_pact`. Every
appearance bug this project has shipped got in with a gate at 100% and nobody looking, and neither
appearance gate runs over a document with a suggestion in it, because no harness makes one. Two
toggles and a session author, and it would. It is item 2 in
[what is left](#what-is-left-in-order).
**A cross-block selection typed over is still refused** on one corpus document —
`pnpm edit-check` reports `join failed: not-siblings` on OECS, one refusal in fourteen edits across
nine documents. That is the model's reach rather than the demo's shape: a cross-block edit joins the
head of one paragraph to the tail of another, drops the ones between, and carries the surviving
paragraph mark's own formatting (`w:pPr/w:rPr`), any `w:ins`/`w:del` wrapper and the list numbering
with it. Phase 2's builder is where that lands.
---
## Phase 6 — Publish
- [ ] Build to `dist` with subpath exports preserved
- [ ] Peer dependencies declared optional so a Worker importing `tdoc/docx` pulls no ProseMirror
- [ ] Version, changelog, and a consumer smoke test from another repo
---
## The road to a consumer
The phases above are ordered by uncertainty. This is ordered by **what blocks a consumer**, and the
first consumer is `proj_pact`, which already runs on tdoc behind a flag. Its asks are inventoried in
`proj_sb-meta/planning/pact/tdoc-upstream.md` (the library asks) and `tdoc-formatting-parity.md`
(the toolbar, item by item against the OOXML). This section is the agreed ORDER, folded in here so
there is one list.
### Three constraints that decide the order
Not preferences. Each makes a particular sequence cheaper than the obvious one.
**There are three renderers, and every visible item costs three times.** The model feeds
`src/editor/bridge.ts` (the DOM plus the document stylesheet), Chromium's print of that same DOM,
and `src/pdf/render.ts`. Anything a reader can see has to land in all three or the harness reports a
regression, and anything that changes height has to be charged in `src/layout` as well. So
model-and-write items (run properties, shading, tab stops, table mutation) are genuinely small, and
see-it-on-the-page items (list markers, images, paragraph borders, struck-through deletions) are
each three edits plus a measurement. Batch the second kind.
**Glyph placement in the browser-free PDF is mid-work in exactly those files** — `lines.ts`,
`blocks.ts`, `pdf/render.ts`, `tools/render-check.ts`. So the first wave is deliberately chosen from
items that do not touch them. List markers wait, and are better off for it: a marker sits in the
hanging indent, which is a HORIZONTAL position, and the open glyph disagreement is dominated by
horizontal drift (median dx -0.7 to -2.8pt on the weak documents, median dy about zero). A marker
drawn now could not be told apart from it.
**Phase 4 freezes the schema, so content controls come before the CRDT.** The `y-prosemirror`
binding pins the ProseMirror schema and the reconciler. A content control needs a node with
`atom: true` and a selection guard, which is a schema change; making it after the binding exists
means migrating a live document format. Pact built its variables on top of tdoc in about 400 lines
without touching this repo — it works, and it is a fork that Phases 4 and 5 will both break.
### The waves
| wave | what | days | state |
| ---- | -------------------------------------------- | ---- | ------------------------------------------------------------ |
| 0 | the doc truth pass | 0.5 | **done 2026-09-01** |
| 1 | stop the consumer forking us | 1-2 | **done** — the four consumer seams, `tdoc/document.css` |
| 2 | content controls in the model | 3-5 | **done** — a mark, not an atom node |
| 3 | list markers drawn, and the defects register | ~5 | **markers done**; the defects register is the open half |
| 4 | the writing side of formatting | 3-5 | **open** — the largest remaining block of consumer work |
| 5 | the builder, then tables and section breaks | ~5 | **tables done**; the builder and section breaks are the rest |
| 6 | images | 3-5 | **done**, all four layers |
| 7 | the CRDT (Phase 4) | — | **done in-process**; the DO is the consumer's |
| 8 | tracked changes and comments (Phase 5) | — | **done through Pact asks 9g/9h**; three items left |
| 9 | packaging (Phase 6) | — | **open, and now the nearest blocker** |
### Wave 1 — stop the consumer forking us ✅ DONE
Every item was something a consumer maintained a copy of, or reached into internals to get.
Measured, `pnpm keystroke-check`, 30 characters at a typist's cadence:
```
document chars wall long tasks blocked longest settle
MITP_KF_2026 30 919ms 0 0ms 0ms 119ms
NY_SERDA_Agreement 30 1080ms 0 0ms 0ms 138ms
Oregon_MSA 30 1136ms 0 0ms 0ms 134ms
```
against a baseline of **2226ms wall and three long tasks, the longest 77ms**, on NY_SERDA. The
harness is kept, so the exit criterion is measured continuously rather than checked once.
**The first version of that harness reported a 70x improvement that did not exist**, and the trap
is worth carrying: timing the call that inserts a character measures nothing, because ProseMirror
reads DOM mutations on its own schedule and `execCommand` returns long before the work it causes.
It reported 0.7ms with repagination deferred AND with it synchronous. Long tasks and wall clock are
what a typist feels.
1. **`plugins?: Plugin[]` in `MountOptions`**, appended after tdoc's own. A consumer's decorations
and selection guards do not belong in tdoc. The only way in today is
`view.updateState(view.state.reconfigure(...))` after mount, redone on every remount.
2. **Export the document stylesheet.** The rules `src/editor`'s class names and custom properties
consume move out of `demo/ui.css` into `src/editor/document.css` as a subpath export, with the
consumer supplying five colour variables and nothing else. The strut-free `.page`, spacing as
padding and never margin, `font-kerning: none`, the three-band break, the row-break bands, the
cell clip box and the whole `@media print` block are ONE CONTRACT with `src/editor`, and shipping
half of it is what forced the consumer to copy the file.
**This is the most urgent item in the wave, and it is no longer a prediction.** The `w:trHeight`
work on 2026-09-01 added a `clip` attribute to `table_cell` and a `.cell-clip` rule to
`demo/ui.css`. A consumer holding a copy of that stylesheet does not have the rule, so an exact
row height does not clip there and its browser now disagrees with its paginator. The fork broke
the same day the feature landed.
Two rules stay behind as workbench-only: the `data-style` margin label and the `--fit` transform.
**Keep the label's comment wherever it lands** — the warning outlives the rule, because a float
is in the flow and it shrank the first line of every styled paragraph, so the browser wrapped a
word earlier than the paginator did.
3. **`WordDocument.blank()`.** Building a minimal OPC package belongs in `src/opc`, not in every
consumer. Pact fetches a `/blank.docx` template today and announces when it is missing.
4. **A subtree that can serialize itself** — `Paragraph.toXml()` and a document-scoped equivalent —
so a caller does not have to have kept the source string it parsed from. `serializeElement`
requires it, and the footgun surfaces as
`Cannot read properties of undefined (reading 'slice')`.
5. **Pagination off the keystroke path.** 51 ms median per character on NY_SERDA (55 pages, 590
blocks) becomes about 4 ms with no algorithmic change. Three things to get right, and only the
first is obvious:
- `compute()` reads the model, and `applyToModel` mutates the model before `state.apply`.
Deferring means the plugin must **map the previous `DecorationSet` through the transaction**
rather than keeping it, or the break widgets drift down the document as you type. That mapping
is the actual work.
- The recompute comes back as a meta-tagged transaction with `addToHistory: false` and no doc
change, so the reconciler sees nothing to do.
- **`sizeSheets` runs from `requestAnimationFrame` on every view update** and measures every
break in the document. Throttling `compute` alone still re-measures 55 pages per keystroke.
Both, or neither.
Exit: median keystroke under 5 ms on NY_SERDA, breaks settled within ~200 ms of the last one,
`screen=model` unchanged at rest. **Incremental relayout stays on the plan** with its own exit
criterion — under 16 ms per keystroke on NY_SERDA, so a keystroke fits in a frame — and is very
likely to stop mattering once the scheduling is right.
### Wave 2 — content controls in the model ✅ DONE
`w:sdt` is a container the model sees THROUGH. For a contract-assembly consumer it is the product: a
variable is every `w:sdt` sharing a `w:alias`. Four operations — `create` (wrap a range or insert at
the caret) with alias and tag, `list` with alias, tag and text, `replaceContent` accepting rich
content, and `unwrap` — plus three things learned the hard way downstream:
- **A control counts only with BOTH a `w:tag` and a non-empty `w:alias`.** Google Docs exports wrap
every comment anchor in an `sdt` with a `goog_rdk_N` tag and no alias, and a corpus contract has
seven of them. Matching on tag alone reports those as seven fillable variables. This belongs in a
corpus assertion, not a comment — the fixture already exists.
- **Refuse rather than approximate**, which is the house posture already. A control whose content is
another control, a block-level control holding paragraphs or a table, or one inside a tracked
insertion, has a correct rewrite that is not an obvious one.
- **The caret must be evictable from inside one.** A node with `atom: true` plus a selection guard.
Downstream this replaced ~500 lines of caret guarding and deleted ~300 lines of reconciliation.
Word's own `contentLocked` is not the answer: it makes the control refuse `replaceContent` too,
and reports success on a `lockMode` patch that changes nothing.
**The trap to name out loud**, because it is the one CLAUDE.md warns about: this must not become a
second traversal. `textSegments` keeps seeing through `w:sdt` exactly as it does now. A content
control is an **addressable range over that one traversal**, plus a projection node in the schema.
Anything that walks `w:sdt` separately to find its text will disagree with the traversal that
renders it, and that has already cost this project a day three times.
**Shading need not be in the file.** Consumers mark variables with `w:shd` and treat the shade as a
signature so pasted text gets re-wrapped. If tdoc draws the decoration from the model, that whole
mechanism goes away — which is only available to a library that owns the model.
### Wave 3 — list markers, and the defects register
After glyph placement settles, for the reason above.
- **List markers, drawn.** The single most visible gap: NY_SERDA carries 502 `w:numPr`, CA_Courts
302, Oregon 42, and a numbered clause renders today as correctly indented text with no number.
Compute the displayed number over the body honouring `w:start`, `w:lvlRestart` and `w:lvlOverride`
(Phase 1's deferred item), then draw from the level's `lvlText` with the level's own run
properties, in `bridge.ts` AND in `src/pdf/render.ts`.
**Bullets carry a measured trap.** The glyph is usually in the level's own `w:rFonts`, typically
Symbol or Wingdings. An unmanaged bullet font was measured rendering at a 12.22pt line box against
11.5pt — 0.72pt per item, which over ~25 items on one page pushed an item onto the next. So the
marker's font is normalised like everything else and its line box is measured, not assumed.
- **The defects register below**, which is nobody's feature request and is what "real use" means.
### Wave 4 — the writing side of formatting
Mechanical, low risk, mostly `src/model/format.ts` where `R_PR_ORDER` already lists the insertion
points. One wave rather than three: clear-formatting needs the others, and a half-done `RunFormat`
means a consumer ships a toolbar with dead buttons.
- **Eight run properties**: `w:highlight` (a NAMED colour, and a different element from `w:shd`),
`w:vertAlign` (three states, so `null` must mean baseline rather than leave-alone), `w:dstrike` (a
toggle, so the same XOR treatment as `w:b`), run-level `w:spacing`, `w:position`, underline style
and colour. **Name the run-level `w:spacing` field so it cannot be confused with paragraph
`w:spacing`** — that is the same trap `w:tab` set for tab stops, and this codebase has paid for it
once.
- **Decide what `null` means in `RunFormat`, once**: remove the element, against `false` meaning
`w:val="0"`. For a toggle those are two different documents, and turning underline off must write
`w:val="none"` rather than removing `w:u`.
- **Paragraph shading, borders, writable tab stops, and `resetDirectFormatting`**, after which
clear-formatting becomes expressible. Two choices worth carrying from downstream: a property the
UI never offered is not cleared, and font family and size are deliberately not cleared, because on
an imported contract they are the document's own and dropping them leaves the text in the fallback.
- **Paragraph borders are a measurement**, not just a write: a border changes the height a paragraph
occupies, the way cell borders are already charged. Do that row while the layout is open.
### Wave 5 — the builder, then tables and section breaks
- **Finish Phase 2's builder**: new paragraphs, runs and table rows; deleting an atom; splitting
inside a `w:hyperlink`, a `w:ins` or a content control. That last is cheaper after Wave 2, which
is where "what is half a content control" gets decided. **Clearing `edit-check`'s one refusal**
belongs here.
- **Table mutation**: insert and delete rows and columns, merge and split cells, border presets,
cell shading. **Inserting a column means `w:tblGrid` and every row together** — the grid is what
the layout reads for width, so out of step gives a table that measures at one width and draws at
another. `pnpm table-check` already catches exactly that, so it is the test for this item.
- **Section break and hyperlink insertion.** ⚠ **Section-break insertion lets a user create a
document tdoc cannot paginate**: mixed page setups inside one document are unsupported, and the
generated variant that exercises it reports `screen=pdf` at 72% with 15 screen pages against 17
printed. So either mixed-section pagination lands first, or the insert operation is refused until
it does. Do not ship the write without one of the two.
- Page break needs nothing: `pageBreakBefore` on `ParagraphFormat` is already the right primitive,
and better than an inserted `` sitting at a fixed offset.
### Wave 6 — images
Greenfield, so it collides with nothing and can move earlier the moment a real contract needs it.
VA_SLA's two seals are preserved on export, invisible on screen, and contribute no measured height —
a pagination error, not only a cosmetic one.
- `w:drawing` and `w:pict` read and rendered inline, `wp:extent` deciding the space taken.
- Insert a picture, and **replace the bitmap a drawing holds without touching anything else**. That
second one is done server-side on the package downstream today, with a whole save/hold/rewrite/
reopen flow that exists only to bridge two writers over one file. As a model operation it
evaporates.
### Waves 7 and 8 — the CRDT, then tracked changes
Phases 4 and 5 above hold the checklists. Two things to assume from the start of Phase 4, both
learned downstream on SuperDoc:
- **Nothing in Durable Object instance fields** under hibernation. Every handler rebuilds the Y.Doc
from storage; awareness is deliberately not persisted.
- **Seed-or-join from R2 on cold open.** Rooms with an explicit create/join lifecycle and no
fallback forced a four-state retry machine driven by exception codes plus a `sessionStorage`
reseed marker. That is called the single largest piece of accidental complexity the old editor
imposed, and it is entirely avoidable. Build the fallback in.
Exit beyond convergence: **the agent's write lands in an open editor with no page reload and no
save.**
For Phase 5, the reason it cannot be done by a consumer is worth keeping in front of whoever starts
it: **to show a deletion, the projection has to contain text the model's traversal excludes**, and
there is exactly one traversal. Suggesting mode also means `retextSegments` — the diff at the heart
of the model — has to emit `w:ins`/`w:del` instead of rewriting a run, which no host can intercept.
Beyond the Phase 5 checklist: `w:rPrChange`/`w:pPrChange` (a review panel already renders a
formatting summary), `w:moveFrom`/`w:moveTo` as a decision rather than an omission, comment reply
threads, and **refusal as a model mode** — a mode in which a non-tracked mutation is refused makes a
suggest-only agent policy structural instead of remembered at every HTTP handler.
### The R1xR4 decision, made
**Which view is the PDF, once a suggestion exists? The screen, always.** Decided 2026-09-01 and
written into [requirements.md](requirements.md#r1-browser-pagination-matches-pdf-pagination).
Suggestions in the document show in the PDF and paginate with it, so **the markup view is an input
to pagination and toggling it repaginates.** That is correct, not a defect: it keeps "the screen is
the PDF" with no asterisk, which is what the promise is worth. There is no second renderer for a
clean copy -- a final PDF is the same pipeline with markup off.
What Wave 8 has to build to honour it: deleted text under `w:del` reachable and MEASURED when
markup is on, excluded when it is off, **through the same traversal both times**.
`TextOptions.includeDeleted` is the seam and it already exists. A review walk built beside the
normal one is the failure this codebase has already paid for three times, and it would produce a
plausible document that is quietly wrong.
And `pnpm render-check` grows a dimension: R1 is asserted in both views, and a document with a
suggestion is EXPECTED to have two different page counts.
### Gelasio, whenever a reference regeneration is already happening
`SHAPE_ONLY` maps `georgia` to Liberation Serif. Georgia was measured ~9% wider, differing on 94 of
95 ASCII advance widths, and it is not academic: a real client contract set entirely in Georgia had
its first line of prose end on a different word in the editor than in the PDF, moving every
signature block. Gelasio (SIL OFL, a purpose-built Georgia metric clone) measured **zero difference
across all 95 widths**.
It changes R1 either way not at all, since both sides use whatever we ship. It changes how much an
imported Georgia contract REFLOWS relative to what its author saw. And it is **not a two-line
change**: `APPROVED_FONTS`' own comment says the reference container's Dockerfile must agree with
it, so it means the font file, the Dockerfile, a rebuild of the 501 MB image, `pnpm fonts`,
`pnpm reference`, and a re-baselined R1 table. Slot it beside a regeneration that pays that cost
anyway.
Two cautions come with it. Gelasio has Georgia's advance widths but **not** its vertical metrics
(hhea 917/-219 against 928/-342, and Gelasio sets `USE_TYPO_METRICS` where Georgia clears it, ~12%
more line box). And **never add a `local()` optimisation to the `@font-face` rules**: supplying font
files unconditionally was measured OVERRIDING fonts the machine already had, taking Oregon_MSA from
44 pages to 60 and VA_SLA from 15 to 16. Metric-compatible means the advance widths, not the
vertical metrics that decide how many lines fit on a page.
---
## What is left, in order
Everything above is either a phase (ordered by uncertainty) or a wave (ordered by what blocks a
consumer). This is the merge of the two as of 2026-09-03: what is actually left, in the order it
should be done, with the reason the order is that way. It is the list to read first.
**1. Packaging (Phase 6, wave 9).** The nearest blocker and the smallest item. `proj_pact` reached
past the exports map for `src/pdf` for a day because the map did not name it, which is the failure
mode: a subpath that works in a monorepo and breaks the moment the package is installed. Build to
`dist` with subpaths preserved, declare the peers optional so a Worker importing `tdoc/docx` pulls
no ProseMirror, and smoke-test an install from another repo. Do it before the API grows again, not
after.
**2. Give the workbench a review view and a suggest toggle.** Ask 9g and 9h are tested headlessly
and used by Pact; nothing in this repo LOOKS at them. Every appearance bug this project has shipped
got in exactly that way -- a gate at 100% and nobody looking -- and the two gates added for it
(glyphs, collisions) do not run over a document with a suggestion in it, because no harness makes
one. Cheap: the workbench already mounts the editor, so it is two toggles and a session author.
**3. The three Phase 5 leftovers**, in this order because the first unlocks the third:
- **A tracked paragraph-mark deletion**: the representation of a JOIN as a suggestion. It is why
`reconcile` refuses a join in suggesting mode and why `paragraphMarkChange` is reported but not
decidable. Word keeps both paragraphs and draws them joined in the final view, so this is a
layout item as much as a model one.
- **`w:rPrChange` / `w:pPrChange`**, which a review panel needs to say "this was made bold".
- **`render-check` asserting R1 in BOTH views**, with a document that has a suggestion in it
expected to have two different page counts. The harness half of the R1xR4 decision.
**4. The geometry defects** -- wave 3's open half, the register below. R1 as a page-assignment
claim holds; these are the gates that see whether the page LOOKS right, and three corpus documents
plus nine variants fail them. In dependency order: Oregon's p2 margin fault (which is probably also
"Oregon is a page long from block 46"), the CA_Courts left-margin fault on 21 pages, the sheets
that overflow, the collisions on the narrow variants, and mixed-section pagination -- which is also
the gate on wave 5's section-break insertion.
**5. Wave 4, the rest of the writing side of formatting.** Lists are done. What is left is the
eight run properties (highlight, vertical alignment, run-level spacing, position, underline style
and colour), paragraph shading and borders, writable tab stops, and `resetDirectFormatting` --
after which clear-formatting becomes expressible.
**5b. Wave 4, as it was written.** The largest remaining block of consumer work and the
one a product notices first, because a half-done `RunFormat` is a toolbar with dead buttons.
Mechanical, low risk, mostly `src/model/format.ts`.
**6. Wave 5's remainder: the builder and section breaks.** Table mutation is done. What is left is
creating paragraphs and runs where none existed, splitting inside a hyperlink or a content control,
and section-break insertion -- which waits on item 4, because a user who inserts one can otherwise
make a document tdoc cannot paginate.
**7. What images still do not do.** Two things, named rather than assumed. An ANCHORED picture is
drawn inline, so text does not wrap around it. And GIF, BMP and EMF are not embedded in the
browser-free PDF -- `RenderResult.images.skipped` counts them, and VA_SLA's seal is a GIF, so it
takes up its space and prints blank there.
**8. Glyph placement in the browser-free PDF.** Ratcheted so it cannot get worse; four documents sit
at 27-56%. This rose in importance when Pact started rendering the reviewer's PDF with `tdoc/pdf`
in a Worker: that PDF is what a counterparty receives, and R1 compares page assignment, which is
blind to a word 2pt to the left. [server-pdf.md](server-pdf.md) has the analysis.
**9. Font subsetting**, whenever a 2-4 MB PDF starts to matter, and **Gelasio**, whenever a
reference regeneration is already being paid for.
**Not ours.** The Durable Object that hosts a room is the consumer's: `DocumentRoom.open()` is
transport-free on purpose. So is the review UI.
---
## Open defects
Measured and open. These are not feature requests; they are the difference between "the gates pass"
and "a person can use it on a real contract". Wave 3 is where they get worked.
Remeasured 2026-09-03, with the twenty variants present.
| what | where | evidence |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| **Text printed outside the document margins** | CA_Courts on 21 pages (p1 left by 36pt), NY_SERDA p2 top by 34.9pt, Oregon p2 top by 45.9pt — and every Oregon variant, at the same p2 | `render-check`, "the geometry half of R1" |
| **Oregon is a page long from block 46** | its p2 margin fault is at the same place, so probably one bug with the row above | `screen=libre` 45%, drift +0.57 |
| **Sheets overflow** | one on the corpus (NY_SERDA 50, by 9px, from four in August); the narrow and wide variants make it much worse — Oregon-wide overflows eight sheets, one by 36px | `render-check` ovfl column |
| **Words drawn on top of each other** | zero on the corpus, so the gate holds there; four narrow variants collide, VA_SLA-narrow on 13 pages. A repeated table header drawn over body text is the shape | `render-check`, the collision gate |
| **Glyph placement in the browser-free PDF** | OECS 27%, MITP 31%, VA_SLA 37%, SOW 56%; mostly horizontal, and ratcheted so it cannot get worse | `glyphs` column, [server-pdf.md](server-pdf.md) |
| **Mixed page setups inside one document** | `zvar-VA_SLA-mixed-size`: `screen=pdf` 75%, 15 screen pages against 17 printed. The one R1 failure anywhere | `pnpm variants` |
| **Font subsetting** | whole faces are embedded, so a PDF is 2-4 MB | [server-pdf.md](server-pdf.md) |
| **One cross-block edit refused** | OECS, `join failed: not-siblings` | `pnpm edit-check` |
| **A join cannot be suggested** | refused in suggesting mode: a tracked join is a deleted paragraph mark, which no renderer draws yet | `test/editor.test.ts` |
| **`w:keepNext` not enforced** | deliberate: enforcing moved Oregon 56%→59% against LibreOffice and its overflowing sheets 2→6 | `paginate.ts` |
Also not started, and each a decision rather than an omission: footnotes (circular — adding one
shrinks the body box that positions its anchor), anchored objects (inline-only first), and dot
leaders, highlight and paragraph borders/shading in the browser-free renderer.
---
## What would make this stop
Worth writing down while it is still cheap to abandon.
- Phase 2 cannot hold fidelity under mutation on real Word output. This is the genuine unknown.
- Phase 3 pagination cannot be made fast enough to type against on a 56-page agreement, and
incremental relayout does not rescue it.
- The volume of OOXML needed for "no content loss" on real contracts turns out to be far past
what Phase 1's exit criterion suggests.
# What tdoc has to do (docs/requirements.md)
# What tdoc has to do
Four requirements. They are not a wishlist; they are the reason this exists rather than a
SuperDoc integration, and each one is here because a specific thing was tried and did not hold.
Written 2026-08-31, from the SuperDoc scoping conversation. The requirements themselves have not
changed since; one decision was added to R1 on 2026-09-01, marked below.
**Where each stands, 2026-09-03.** [plan.md](plan.md) is the status doc and
[what is left, in order](plan.md#what-is-left-in-order) is the short version.
| | acceptance | today |
| --- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| R1 | PDF page N holds the same blocks as screen page N | **met** on all nine corpus contracts and on nineteen of twenty page-setup variants, on both PDF paths. The twentieth is mixed sections. The geometry gates -- text outside the margins, sheets that overflow -- still fail on the three long contracts |
| R2 | two browsers and an HTTP agent converge; the server writes the file with nobody's browser attached | **met in-process** (`test/room-server.test.ts`). Hosting it in a Durable Object is the consumer's half |
| R3 | untouched round-trip byte-identical; an edit moves only what it touched | **met** over the corpus, continuously (`pnpm fidelity`, the mutation sweeps). Content tdoc cannot yet CREATE is Phase 2's open half |
| R4 | an agent's suggestion and comment, made over HTTP, render attributed to the agent and open in Word | **met for text**: suggest mode, comments, accept/reject, the review view, attribution per operation. A tracked JOIN and formatting changes (`w:rPrChange`) are not expressible yet |
| R5 | it feels like one document, not a stack of pages | **met**: one ProseMirror host, selection across paragraphs, tables and pages, pagination off the keystroke |
---
## R1. Browser pagination matches PDF pagination
The page breaks a person sees while editing are the page breaks in the exported PDF. Not
approximately, not usually: the same breaks, because the same decision produced both.
**Why it is not free.** The obvious architecture puts two layout engines in the pipeline: one
paginates the screen, another re-paginates the file for the PDF. They then have to be made to
agree, and the only lever is shared inputs (identical font files, identical margins). That is what
`proj_pact` does today, and it is real work that mostly succeeds. But justification, hyphenation,
widow and orphan control, keep-with-next, and table row splitting are still two implementations,
and when they diverge the divergence _cascades_: a renderer that repaginates from page 1 moves
every page after the disagreement. Pages move under a signature block.
**The shape that satisfies it.** One layout engine decides page assignment, once. The PDF renderer
never repaginates; it draws pages that were already sliced. The residual failure mode becomes a
line wrapping differently _within_ a page, which cannot cascade, because page N's contents were
fixed before the renderer saw them.
**How it is guaranteed: the document is normalised to an approved font set on import.** tdoc does
not try to render whatever font a document names. It renders a fixed set and REWRITES THE DOCUMENT
to name them, so the browser, the reference PDF and the exported `.docx` all see the same font and
none of them is left substituting on its own. Substituting at measurement time cannot give that:
it only changes what tdoc thinks, and leaves every renderer downstream free to guess differently.
The cost is that a document naming Garamond comes back naming Liberation Serif. Fonts are
formatting rather than content, R3 is about content, and the alternative is pages that move under a
signature block. `src/model/fontpolicy.ts` holds the set and the pairings, and distinguishes the
metric-compatible ones (which keep Word's line breaks) from the shape-only ones (which do not).
**Explicitly not required: agreeing with Microsoft Word.** This is the single most important
scoping decision in the project. Word compatibility is what makes a layout engine a multi-year
effort; self-consistency is not. tdoc is the authority on its own pagination.
**Suggestions do not qualify this** (decided 2026-09-01, and it sits on the R1xR4 seam). Word
paginates "All Markup" differently from "No Markup", because struck-through deleted text occupies
space -- so a document with a tracked change has two legitimate lengths, and something has to say
which one the PDF is. **The screen is, always.** If suggestions are in the document and shown on
screen, the PDF shows them and paginates with them.
The consequence, which is the whole reason it needed deciding: **the markup view is an INPUT to
pagination, so toggling it repaginates the document.** That is correct rather than a defect, and it
is a better trade than qualifying the promise -- "the screen is the PDF" survives with no asterisk,
which is the thing the product is sold on.
It also means there is no second renderer for a clean copy. A final PDF for a counterparty is the
same pipeline with markup off: one layout engine, one decision, exported from the view you are
looking at.
**The trap this must not become is a second traversal.** Showing a deletion means the projection
contains text `visibleText` excludes, and this codebase has exactly one traversal on purpose -- two
over the same tree will disagree, and it has cost a day three times. So the view is a PARAMETER of
the one traversal (`TextOptions.includeDeleted` is the existing seam), never a review walk built
beside it. Deleted text is measured when markup is on and not measured when it is off, by the same
code path both times.
**Acceptance.** For every corpus document: paginate in the browser, render the PDF, and assert
that page N of the PDF contains the same block sequence as page N on screen. Equality of page
_assignment_, not of pixels. Once R4 lands, that assertion runs in BOTH markup views, and a
document with a suggestion in it is expected to have two different page counts.
---
## R2. One Yjs document, written by browsers and by agents
Collaborators in a browser and an external agent over HTTP write to the **same** Y.Doc. An agent's
suggestion appears in an open editor in real time, and an editor's change is visible to an agent,
without a relay, a lease, or a reconciliation pass.
**Why it is the requirement that forces the project.** A document's CRDT schema has to be readable
and writable by every writer. SuperDoc's room state was dumped live on 2026-08-31 and its
top-level keys are `meta · package · operations · checkpoints · capabilities · shards · content`:
an operations log with checkpoints, sharded across several rooms per document, obfuscated, and
under a licence that forbids reconstructing it. There is no room-joining agent API (`@superdoc/sdk`
shells out to a CLI and operates on files; it cannot run in a Worker). The Durable Object cannot
apply an edit to that state either.
So this is not a feature that was expensive. It was unavailable at any price, and everything else
followed from it. **Owning the document model is the whole point of tdoc.**
**What owning it buys, concretely:**
- Agents write into the Y.Doc through the DO. No relay, no writer lease, no attribution spike.
- The DO can export the `.docx` itself, because the serializer is plain TypeScript that runs in a
Worker. That removes "only a browser can write the file", and with it the writer lease and the
browser-debounced autosave.
- Seed (R2 → DO on cold open) and flush (DO → R2 when the room goes cold) become buildable,
because the DO can finally produce bytes.
**Acceptance.** Two browser clients and one HTTP agent, concurrently editing one document, all
converge. The agent's write lands in an open editor without a page reload and without a save.
---
## R3. Round-trip to Word without content loss
Import a Word document, edit it, export it, and open it in Word: nothing a person wrote is gone.
**Layout shift is acceptable.** Line breaks may move. Page count may differ from Word's. That is
an explicit trade, and it is what makes this requirement affordable: byte-level layout fidelity to
Word is a different and much larger project (see R1).
**Content is not.** Text, tables, numbering, images, headers and footers, footnotes, comments,
tracked changes, hyperlinks, bookmarks, content controls, and section properties all survive. So
does everything tdoc does not model, which is the harder half, and see
[fidelity-by-default.md](decisions/fidelity-by-default.md) for how.
**Acceptance.** `tools/fidelity.ts` over the corpus. Byte-identical for an untouched round-trip;
after an edit, every part unchanged except the ones the edit touched, and a text-extraction diff
that accounts for every character.
---
## R4. Tracked changes and comments, through the same API a person uses
Suggested edits and comments are first-class in the model, and an agent can make them over HTTP
with correct attribution: an agent's suggestion reads as the agent's, never as the signed-in
user's.
**Why attribution is called out.** It is the property that makes a suggestion reviewable. In the
relay design that SuperDoc's shape forces, a browser client applies the agent's edit and stamps
its own user on it, so the change reads as "Travis suggested this". That destroys the feature. In
a model tdoc owns, the author is a per-operation attribute and the problem does not arise.
**Also required: comments a person can see.** `proj_pact` can write comments an agent leaves into
`comments.xml`, and Word renders them, but Pact's own UI cannot — because the comments were
appended to the file behind the editor's back rather than living in the model.
**Acceptance.** An agent-authored suggestion and comment, made over HTTP against a live room,
render in the editor attributed to the agent, survive export, and open correctly in Word.
---
## R5. It has to feel like Google Docs
One continuous editing surface. Selection crosses paragraphs, tables and pages. ⌘A selects the
document. Backspace at the start of a paragraph merges it into the one above. Nothing about the
document's internal structure is visible as a seam.
**Why this is a requirement and not a nicety.** It is the difference between a word processor and
a block editor (Notion, Editor.js), and it is decided by one implementation detail: a browser
scopes a text selection to a single `contentEditable` host, so a document rendered as one editable
element per paragraph cannot be selected across. Google Docs, Word and SuperDoc all have
block-structured MODELS; none of them have block-structured editing surfaces.
This constrains pagination directly, which is why it is written down here rather than assumed:
**page breaks must be decorations inside the single editable flow, never separate page
containers.** A page container is another editable boundary, and selection would then stop at every
page break for exactly the same reason it stopped at every paragraph.
**Acceptance.** Select from the middle of page 1 to the middle of page 3 and type over it.
---
## Non-requirements
Named so they stay out.
- **Matching Word's pagination.** See R1. If a customer needs Word's page count, that is a
LibreOffice conversion at export time, not a constraint on the editor.
- **Full OOXML coverage.** tdoc understands what it needs to and preserves the rest verbatim.
Coverage grows against fixtures, never speculatively.
- **`.doc`, RTF, ODT.** Out of scope.
- **Being a general-purpose rich text editor.** The target is contracts: long, heavily numbered,
tabular, footnoted, signed. Scope decisions break that way.
# Rendering the PDF without a browser (docs/server-pdf.md)
# Rendering the PDF without a browser
The renderer that draws pages straight from the model, with no browser and no office suite. It is
what `POST /api/pdf` on the site runs, in a Cloudflare Worker, and what `proj_pact` renders a
reviewer's PDF with. The other path -- Chromium printing the editor -- satisfies R1 by
construction, one renderer printed; this one has to EARN the same agreement, which is why it is
measured against the printed PDF on every run.
To remove it completely: delete `src/pdf/`, delete `tools/server-pdf.ts`, and drop two lines from
`package.json`. Nothing else on the branch touches an existing file, and nothing outside `src/pdf/`
imports it.
## The view is an input, not a filter
`RenderOptions.markup` -- `'final'` (the default) or `'all'` -- and it must match the editor's. A
document holding one suggestion has two legitimate page counts, so a host mounted in the review
view that renders here without saying so exports a PDF of a different document: missing the
struck-through text, and free to disagree about how many pages there are. That is R1 failing rather
than a cosmetic difference.
The marks come from the LAYOUT's styles -- a deletion struck through, an insertion underlined --
not from a rule of this renderer's, so the browser and this agree about them by construction. The
one thing that does not carry is author COLOUR: the editor takes it from `--tdoc-insertion` and
`--tdoc-deletion` in the host's stylesheet, which a Worker cannot see, so a suggestion prints in
the document's own ink. Geometry matches; the ink does not. Say so to a host rather than let them
find it in a printed contract.
## What it does and does not decide
**It never decides where a page breaks.** It takes `paginateDocument`'s answer as given and works out
only where on the page each line sits. So any disagreement it can produce with the browser is a
POSITIONING difference, never a pagination one -- which is what makes the result below meaningful
rather than circular.
## Measured
`pnpm server-pdf`, against the PDF Chromium printed from the editor:
| document | pages | server=browser | drawn |
| ------------------- | ----- | -------------- | ----- |
| CA_Courts_Agreement | 25/25 | 100% | 105ms |
| MITP_KF_2026 | 36/36 | 100% | 33ms |
| NY_SERDA_Agreement | 55/55 | 100% | 152ms |
| Oregon_MSA | 43/43 | 100% | 115ms |
| VA_SLA | 15/15 | 100% | 29ms |
All nine documents agree, page counts exact, at 2-150ms against roughly 2000ms for a Chromium
print. `lost` counts blocks findable in the printed PDF and not in ours -- the honest denominator,
because the aligner EXCLUDES what it cannot find and a renderer that drew nothing would otherwise
score 100%.
## Drawn now
- **Headers and footers**, on every page, with **fields evaluated per page**. A field stores a
cached result and the cache is stale on arrival -- NY_SERDA's footer says "34" on all fifty-five
of its pages -- so the number has to be computed, not read.
- **The table grid**, both directions, at the document's own widths AND its own colour. `auto`
resolves to black, which is what Word draws.
- **Cell shading** from `w:shd`, the cell's own winning over the table's. `auto` means NONE, which
is what almost every cell in the corpus says: a renderer treating it as a colour would shade
every table in every document.
- **Run colour, underline and strike**, so a hyperlink comes out blue and underlined.
Two of those corrected the EDITOR rather than the renderer. It was painting a grey behind header
rows and a grey grid whatever the document said -- decoration, like the `font-weight` removed
earlier, and it made the three panes impossible to compare. The document's colour is now used in
both, and the invented shading is gone.
### The field walk lives in the model
`~/model/fields` is shared. The editor draws furniture as HTML and the PDF writer draws it as text,
and a field walk implemented twice is a field walk that disagrees with itself -- which it already
did: the editor drew the value at the `w:fldChar` separator and the PDF needed it on the result run,
because the separator usually carries no text of its own.
## What it does not draw yet
Found by rasterising a page and putting it beside the browser's, which is the only way to see any of
this: page agreement is blind to all of it.
- **Highlight, and paragraph borders and shading.**
- **Font subsetting.** Whole faces are embedded, so a contract is 2-4MB where it should be a few
hundred KB. Nothing is wrong with the output; it is just fat.
- **17 characters across the corpus** have no WinAnsi code -- private-use bullets out of Symbol and
Wingdings, and one black circle. They draw as `?`. A CID font would take them.
## What it exposed in the MODEL
The finding that matters most is not about the renderer.
A table-of-contents line reads `SLA Review and Concurrences ....... 10` in the browser, on one line.
We wrap it, putting the page number on a line of its own. The cause is in the LINE BREAKER, not
here: `w:tabs` stops are all treated as LEFT stops, so a right-aligned stop at the right margin
advances the cursor to the margin and the text after it overflows and wraps.
That is a pre-existing gap -- it is why VA_SLA carries the corpus's largest mean block error at
-4.1px -- and `screen=model` never showed it, because that measures which PAGE a block lands on and
the difference is a line within a block.
**A renderer that draws exactly what the model says is a much harsher test of the model than a
comparison of page assignments.** That is an argument for this path independent of deployment.
## Seeing all three at once
The demo's comparison selector has a **three-way** mode: the editor, the PDF we render, and the PDF
Chromium prints from that editor, side by side. The sidebar goes away -- at three panes it is the
difference between a readable page and three columns of hyphens -- and the sheet scales to its
column, which is what `--fit` is for.
The two PDFs should be indistinguishable. Side by side they either are or obviously are not, which
is a different and much blunter instrument than a page-agreement percentage: everything in the "what
it does not draw yet" list above is invisible to the percentage and unmissable in the panes.
`POST /__serverpdf` loads the renderer through Vite's `ssrLoadModule` rather than importing it,
because this config file is evaluated by Vite's own loader, which does not know the `~` alias --
whereas the dev server resolves exactly as the app does.
## Glyph positions: measured, and not yet good enough
Every column in the scorecard compares PAGE ASSIGNMENT -- which block lands on which page. None of
them looks at where the text sits, and "the two PDFs are indistinguishable" is a claim about exactly
that. Comparing word bounding boxes out of `pdftotext -bbox`, ours against the printed PDF's:
It is a column now -- `glyphs` in `pnpm render-check` -- and the detail underneath names each
document's median offset in both axes and its worst word.
| document | glyphs within 1pt (2026-09-03) | at the line-height fix | before it |
| -------------------- | ------------------------------ | ---------------------- | --------- |
| NDA_Template | 100% | 100% | 100% |
| Employment_Agreement | 99% | 99% | 99% |
| CA_Courts_Agreement | 94% | 93% | 92% |
| NY_SERDA_Agreement | 93% | 79% | 79% |
| Oregon_MSA | 93% | 92% | 92% |
| SOW_Template | 56% | not measured | - |
| VA_SLA | 37% | 25% | 25% |
| MITP_KF_2026 | 31% | 31% | 5% |
| OECS_SOW_2026 | 27% | 33% | 5% |
**One of them fell before the ratchet existed.** OECS was 33% in the row above and is 27% now, and
the baseline was checked in at the lower number, so the ratchet is holding a value that is worse
than a measurement this file recorded. It is six points on the weakest document in the set and it
is written down here rather than quietly baselined. VA_SLA moved the other way, 25% to 37%, and
NY_SERDA 79% to 93%.
**These numbers are now a ratchet, not a report.** Each is checked in at
`tools/pagination/glyph-baseline.json`; a run more than three points below its baseline fails
`pnpm render-check`, and one above it asks for `--update-baseline`. The ratchet exists because
CA_Courts fell 93% to 53% and stayed shipped for a commit: a renderer drawing every first word 6pt
off moves no lines, so every page-assignment gate stayed at 100%.
**Why this rose in importance.** It was a fidelity curiosity while the browser's print was the
delivered PDF. `proj_pact` now renders the reviewer's PDF with `tdoc/pdf` in a Worker -- measured
at 256ms and 270 KB against LibreOffice's ~4.3s warm -- so this renderer's output is what a
counterparty receives, and R1 compares page assignment, which is blind to a word 2pt to the left.
**Horizontal is close to exact.** Vertical is exact for prose -- Oregon is 94% within a point -- and
wrong wherever there are tables. Oregon is the document with fewest; OECS is almost entirely table.
### The drift ACCUMULATES, which says what kind of bug it is
Words at the top of OECS's first page agreed within a point, the prose halfway down was seven out,
and the table below it forty-six. A PDF drawn from the model places from the top of the page, so a
block measured short moves everything below it -- and on screen the same error hides, because
`sizeSheets` re-anchors every sheet against the DOM.
Two causes found, and one was a rule implemented twice:
- **A paragraph has ONE line height, not one per line.** CSS puts it on the block and every line box
inherits it, so a paragraph whose first run is large draws ALL its lines at that height. The
measurer took the tallest span on each line, so lines after a big one measured short: OECS's
heading was 34px against the browser's 69. `paragraphLineHeight` is the single rule now and the
bridge calls it rather than keeping its own copy.
- **The baseline** was a fraction of the line height; it is placed by CSS half-leading now.
A third cause, found by eye rather than by measurement: **cell text sat ON the rule above it**,
with none of the daylight the browser leaves. The row height the layout reports already includes the
border, so a cell's content starts a border AND a margin below the row's top, not a margin. VA_SLA
went 25% -> 37% on that alone.
### `w:trHeight`: the model was right and the browser ignored it
OECS declares `w:trHeight` of 645 and 825 twips on rows the browser drew at 25px. The layout had
honoured it from the start, so the model said 44px and 56px and was CORRECT -- Word honours it too.
The bridge never emitted it.
The first attempt to send it through fixed those rows and BROKE R1 on CA_Courts: its rows grew, the
content overflowed, and the printer produced 26 pages against the 25 on screen. **The two rules
need opposite mechanisms, and that is the whole lesson.** `hRule="atLeast"` is a minimum, which is
what a CSS height on a row already means. `hRule="exact"` CLIPS -- and a CSS height on a row or a
cell is only ever a minimum, so emitting an exact height as a plain height makes those rows GROW,
which is the reverse of what it asks for. Clipping needs a box inside the cell that can actually be
overflowed, which is what the `clip` attribute on `table_cell` and the `.cell-clip` rule are.
The corpus splits cleanly: CA_Courts uses `exact` on thirteen rows, OECS uses `atLeast` on eight,
and no other document uses either. Once exact rows clipped, CA_Courts' table 21 went from 422px
against the model's 339 to **341 against 339**, and the corpus-wide table height disagreement fell
from 332px to about 205px.
`table-check` measures all of this directly, and every one of these is a model-versus-browser
disagreement rather than a renderer one -- so fixing them improves the editor as well as the PDF.
**This is the measurement that decides whether the browser can be dropped**, and it is not passing
yet. Page assignment is; a reader looking at a page would see the difference in a table.
|