# tdoc > Open, edit, paginate and write `.docx` — in a browser and in a Cloudflare Worker — over a document model you own. The page breaks on screen are the page breaks in the exported PDF, because one layout decided both. TypeScript, no Node built-ins below the editor layer, no headless browser and no office suite anywhere. Measured continuously against nine real Word contracts. ## What surprises people - **Fonts are a runtime choice, not a build-time one.** `loadFontSet` produces the policy, the measurer, the `@font-face` CSS and the PDF faces from ONE declaration. Self-host `.ttf`: woff2, CFF `.otf` and variable fonts are refused by name, with the reason. - **A picture is one character.** It occupies a text position (U+FFFC), so offsets count it and typing over one is refused, the way a tab is. - **Undo of a suggestion is a REJECTION, not an inverse edit.** Replaying the inverse re-enters the suggest path and proposes the deleted text back in. `applyToModel` reports the change it made so a host can reject it; `mountEditor` binds ⌘Z to that. - **The markup view is an input to PAGINATION.** A document with a suggestion in it has two legitimate page counts, and switching views repaginates. That is the design, not a defect. - **An edit the model cannot express is REFUSED, with a reason.** Nothing is silently dropped, and `onRefused` firing is a contract a host can rely on. - **Relationship ids are per-part.** `rId1` of `document.xml` and `rId1` of `header1.xml` are different files; resolving against the wrong part returns the wrong one rather than failing. - **Fidelity is the default.** A node nobody touched serializes as its original bytes, so an element tdoc has never heard of still round-trips. Never rebuild a tree you only meant to read. ## Entry points Each is a subpath export, depending only on the ones below it. ### `tdoc/xml` 36 exports: `ancestors`, `appendChild`, `attr`, `childElements`, `childrenNamed`, `cloneElement`, `cloneNode`, `cloneShell`, `createElement`, `createText`, `decodeEntities`, `descendants`, `detachFromSource`, `ensureChild`, `escapeText`, `firstChild`, `getAttr`, `insertAt`, `insertBefore`, `insertInSchemaOrder`, `invalidate`, `isElement`, `nextNumericId`, `owningRun`, `parseXml`, `removeAttr`, `removeChild`, `replaceChild`, `serializeElement`, `serializeXml`, `setAttr`, `setText`, `textContent`, `touch`, `walk`, `XmlParseError` ### `tdoc/opc` 14 exports: `CONTENT_TYPES`, `crc32`, `deflate`, `DOCUMENT_PART`, `inflate`, `makeEntry`, `OpcPackage`, `PACKAGE_RELS`, `readZip`, `Relationship`, `relsPathFor`, `writeZip`, `ZipEntry`, `ZipError` ### `tdoc/model` 194 exports: `acceptChange`, `addComment`, `applyBorders`, `applyList`, `APPROVED_FONTS`, `assemble`, `Authoring`, `baseFamily`, `Block`, `blockControlsIn`, `blocksIn`, `BorderPreset`, `bulletGlyph`, `Cell`, `CellAddress`, `cellFill`, `CellMargins`, `changeKind`, `ChangeKind`, `charactersOf`, `classify`, `clearList`, `columnAt`, `columnsIn`, `Comment`, `COMMENT_MARKERS`, `CommentAnchor`, `commentAnchorsIn`, `CommentResult`, `Comments`, `commonAffixes`, `conditionalTypes`, `ContentControl`, `contextualSpace`, `ControlLevel`, `controlName`, `ControlResult`, `controlsIn`, `ControlSpec`, `createControl`, `createTable`, `decideAll`, `DecideResult`, `DEFAULT_CELL_MARGINS`, `DEFAULT_LOOK`, `DEFAULT_POLICY`, `DEFAULT_TABLE_WIDTH`, `deleteColumn`, `deleteComment`, `deleteRow`, `deleteTable`, `directParagraphs`, `DocImage`, `documentImages`, `EffectiveFonts`, `effectiveParagraph`, `EffectiveParagraph`, `effectiveParagraphMark`, `effectiveRun`, `EffectiveRun`, `EMU_PER_INCH`, `EMU_PER_TWIP`, `ensureFurniture`, `evaluateField`, `fieldOverrides`, `FieldOverrides`, `FieldValues`, `FontClass`, `FontPolicy`, `formatCounter`, `formatParagraph`, `formatRange`, `formatRun`, `Furniture`, `furnitureFor`, `FurnitureKind`, `furnitureKindFor`, `FurnitureParts`, `FurnitureResult`, `FurnitureSide`, `HeaderFooterRef`, `imageBytes`, `imageDimensions`, `imagesFor`, `imagesIn`, `imageSize`, `ImageStore`, `insertColumn`, `insertImage`, `InsertOptions`, `InsertResult`, `insertRow`, `isImageElement`, `isMetricCompatible`, `isModelled`, `isParagraph`, `isTable`, `isToggleProperty`, `joinParagraphs`, `JoinResult`, `leadTextOf`, `LETTER`, `ListKind`, `ListMarker`, `listMarkers`, `mapFamily`, `markerFont`, `MarkupView`, `mergeCells`, `METRIC_COMPATIBLE`, `MODELLED`, `NewComment`, `Numbering`, `NumberingLevel`, `OBJECT_REPLACEMENT`, `P_ORDER`, `P_PR_ORDER`, `PAGE_SIZES`, `PageMargins`, `PageSetup`, `PageSize`, `pageSizeNamed`, `Paragraph`, `ParagraphFormat`, `paragraphMarkChange`, `R_PR_ORDER`, `readPageSetup`, `readTableLook`, `readTheme`, `rejectChange`, `removeFurniture`, `replaceImageBytes`, `replyToComment`, `resizeImage`, `resolveComment`, `ResolveContext`, `resolveParagraph`, `resolveRun`, `resolveThemeFont`, `RetextResult`, `retextSegments`, `Row`, `Run`, `RunFormat`, `runsIn`, `SECT_PR_ORDER`, `Section`, `sectionBreakAfter`, `sectionsOf`, `setCellMargins`, `setCellShading`, `setControlText`, `setEvenAndOddHeaders`, `setHeaderRow`, `setListLevel`, `setPageMargins`, `setPageSize`, `setRowHeight`, `setTitlePage`, `shadingOf`, `SHAPE_ONLY`, `SpacingNeighbour`, `splitCell`, `splitParagraph`, `SplitResult`, `splitRunAt`, `StyleDef`, `StyleSheet`, `StyleType`, `Suggestion`, `SuggestResult`, `suggestText`, `Table`, `tableAt`, `TableContext`, `TableLook`, `tableParagraphLevels`, `TableResult`, `tableRunLevels`, `TabStop`, `TextOptions`, `TextSegment`, `textSegments`, `Theme`, `tidyRun`, `TOGGLES`, `TrackedChange`, `trackedChangesIn`, `trackParagraphMark`, `unwrapControl`, `visibleText`, `WordDocument`, `writeCharacters`, `writeUnder` ### `tdoc/docx` 4 exports: `blankDocx`, `BlankOptions`, `FontNormalisation`, `normaliseFonts` ### `tdoc/layout` 56 exports: `alignBlocksToPages`, `Alignment`, `AlignmentOptions`, `BlockId`, `breakIntoLines`, `BreakOptions`, `comparePagination`, `Comparison`, `DEFAULT_TEXT_STYLE`, `Disagreement`, `DocumentPagination`, `drawnRuns`, `FaceSpec`, `FillOptions`, `fillPages`, `firstStyleOf`, `Font`, `FontLibrary`, `FontMetrics`, `FontParseError`, `FontProblem`, `FontSetSpec`, `LaidBlock`, `layOutBlock`, `LayoutOptions`, `layOutParagraph`, `layOutTable`, `Line`, `LoadedFontSet`, `loadFontSet`, `LoadOptions`, `markerStyle`, `markerTextStart`, `Measurer`, `NO_METRIC_CLONE`, `normalise`, `Page`, `paginateDocument`, `PaginateOptions`, `Pagination`, `paginationFrom`, `paragraphLineHeight`, `policyFor`, `probeFor`, `sheetAt`, `SheetInput`, `sheetLayout`, `SheetLayout`, `SMALL_CAPS_RATIO`, `snapToPixel`, `spansOf`, `Split`, `standardFaces`, `StyledSpan`, `SUBSTITUTIONS`, `TextStyle` ### `tdoc/pdf` 11 exports: `EmbeddedImage`, `embedImage`, `encodeWinAnsi`, `FaceSource`, `formatOf`, `ImageRefusal`, `PdfWriter`, `RenderOptions`, `renderPdf`, `RenderResult`, `winAnsi` ### `tdoc/crdt` 22 exports: `ApplyReport`, `applyTo`, `bindRoom`, `BlockKind`, `BLOCKS`, `DocumentRoom`, `isSeeded`, `joinBlock`, `LOCAL`, `META`, `Pid`, `readBlocks`, `Room`, `RoomOpenOptions`, `RoomOptions`, `RoomState`, `seed`, `seedPid`, `SharedBlock`, `splice`, `splitBlock`, `writeText` ### `tdoc/editor` 48 exports: `AppliedTransaction`, `applyPageStyle`, `applyParagraphFormat`, `applyPastedMarks`, `applyPendingAfterInsert`, `applyRunFormat`, `applyToModel`, `CaretTarget`, `clearPendingFormat`, `controlAt`, `controlAtSelection`, `controlGuard`, `controlGuardKey`, `ControlSpan`, `controlSpans`, `deletionAt`, `docFromBlocks`, `editorPlugins`, `editPoints`, `FormatState`, `installFontCss`, `modelOffset`, `MountedEditor`, `mountEditor`, `MountOptions`, `nodeOffset`, `PaginationDecorations`, `paginationKey`, `PaginationOptions`, `paginationPlugin`, `ParagraphSnapshot`, `pendingFormat`, `readFormatState`, `reconcile`, `ReconcileResult`, `Registry`, `schema`, `SelectedParagraph`, `selectedParagraphs`, `SHEET_GAP`, `sizeSheets`, `snapshot`, `SuggestionHistory`, `tableAtSelection`, `textOf`, `Tri`, `unwrapControlAtSelection`, `wrapSelectionAsControl` `tdoc/document.css` ships with the editor and a host MUST include it: the classes it styles are what make the browser agree with the paginator. ## Documentation - [Architecture](https://github.com/saltbark/tdoc/blob/main/docs/architecture.md): Layers, bottom up. Each one is a subpath export, depends only on the ones below it, and is - [Audit: the editor stack, 2026-09-02](https://github.com/saltbark/tdoc/blob/main/docs/audit-2026-09-02.md): Scope: everything landed between `c4fd6d1` and `82b3d8f` -- the consumer seams, content controls, - [The harnesses](https://github.com/saltbark/tdoc/blob/main/docs/harnesses.md): Everything in tdoc is measured against nine real Word contracts rather than fixtures. This is the - [OOXML notes](https://github.com/saltbark/tdoc/blob/main/docs/ooxml.md): Working notes on ECMA-376 Part 1 (WordprocessingML, §17), kept as the model learns each area. - [Pagination, and how it is measured](https://github.com/saltbark/tdoc/blob/main/docs/pagination.md): R1 says the page breaks on screen and the page breaks in the PDF are the same. This is how that - [Plan and status](https://github.com/saltbark/tdoc/blob/main/docs/plan.md): **This is the one status file.** Phases, the road to a first real consumer, and the defects that - [What tdoc has to do](https://github.com/saltbark/tdoc/blob/main/docs/requirements.md): Four requirements. They are not a wishlist; they are the reason this exists rather than a - [Rendering the PDF without a browser](https://github.com/saltbark/tdoc/blob/main/docs/server-pdf.md): The renderer that draws pages straight from the model, with no browser and no office suite. It is - [README](https://github.com/saltbark/tdoc/blob/main/README.md): what it is, how to run it, and the site. - [CLAUDE.md](https://github.com/saltbark/tdoc/blob/main/CLAUDE.md): the rules an agent working IN this repo follows. - [/llms-full.txt](/llms-full.txt): every document above, in full, in one file. ## Status 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. The ordered list of what is left is in [plan.md](https://github.com/saltbark/tdoc/blob/main/docs/plan.md#what-is-left-in-order). --- # Architecture (docs/architecture.md) # Architecture Layers, bottom up. Each one is a subpath export, depends only on the ones below it, and is testable without the ones above. ``` tdoc/editor ProseMirror over the model browser tdoc/crdt Yjs over the model anywhere tdoc/pdf pages -> a PDF file anywhere tdoc/layout pagination: blocks -> pages browser + headless tdoc/model typed document model anywhere (in progress) tdoc/opc parts, content types, relationships anywhere (done) tdoc/xml lossless XML tree anywhere (done) tdoc/document.css the stylesheet the editor's classes require. Not optional. ``` **"Anywhere" means a Cloudflare Worker.** No Node built-ins, no subprocesses, no DOM below the layout layer. This is load-bearing for R2: the Durable Object has to be able to export a `.docx` itself, and the reason `proj_pact` cannot is that its exporter is browser code. Zip inflate and deflate go through `DecompressionStream`/`CompressionStream`, which Workers, browsers and Node 18+ all have. ## tdoc/xml A lossless XML tree. Serializing an unmodified parse returns the original bytes. See [decisions/fidelity-by-default.md](decisions/fidelity-by-default.md) for why this is the foundation rather than a detail. Not a general XML library and not a validator. It is deliberately permissive about namespaces: prefixes are kept as written and never rewritten, because Word cares about prefixes it emitted and we have nothing to gain by normalising them. ## tdoc/opc The zip container and the OPC conventions on top of it: `[Content_Types].xml`, `_rels`, relationship graphs, part naming. **Untouched parts are copied as compressed bytes**, never inflated and re-deflated. Fonts, images and media go through verbatim, which is both a fidelity property and a cost one: rewriting a 700 KB contract is one deflate, not twenty-five. ## tdoc/model The document as a tree of typed handles over the XML tree, and the layer the CRDT mirrors. This is where R2 and R4 live. Authorship is a per-operation attribute on a tracked change, not a mount-time configuration, which is the difference between an agent's suggestion being reviewable and reading as the signed-in user's. ## tdoc/docx Import and export. Import is mostly _recognition_ rather than conversion, because the tree is already there. Export is a walk that prefers retained source. ## tdoc/layout Blocks to pages. Owns R1. The measurement of text is behind an interface with two implementations: the browser measures with `Range.getClientRects`, and the headless side measures from font metrics directly. Page assignment is computed once and handed to both the screen and the PDF, so the two cannot disagree about what is on page N. ## Which way round it goes, because it is easy to read backwards Two questions get asked about this stack and the intuitive answer to both is wrong. **"The CRDT is ProseMirror-side, isn't it?"** No. `tdoc/crdt` imports `tdoc/model`, `tdoc/xml` and Yjs, and NOTHING ELSE -- there is no ProseMirror anywhere below `tdoc/editor`. That is what lets a Durable Object run a room in a Worker without an editor in the bundle, and it is the reason the server can write the file with nobody's browser attached. **"There's a 1:1 map between the ProseMirror doc and the OOXML, isn't there?"** No, and this is the one that matters. **ProseMirror is a lossy PROJECTION, not a mirror.** A paragraph in the projection carries five things: `pid`, `style`, `align`, `listLevel`, `css`. The same paragraph in the file carries its `w:pPr` -- `w:widowControl`, `w:ind`, the paragraph mark's own `w:rPr` -- plus everything it inherits through the style chain, the numbering level, `docDefaults` and the theme. And `css` is the RESOLVED answer, not the source: nothing can invert `margin-left: -0.5in` back into "`w:ind w:left=-720` on this paragraph" as against "inherited from the style it is based on". `runStyle` says so in the schema: view only, never written back. That is why `reconcile` handles exactly three shapes -- split, join, retext -- and refuses the rest. The projection cannot express most edits, so it is not allowed to attempt them. **One thing the reconciler takes from the transaction rather than from the projection: WHERE the edit was made.** Comparing two snapshots says what a paragraph now reads, and two different edits can produce the same string -- type "t" just before "that" and either "t" can be the new one. The text has no answer, and picking wrong is invisible until changes are tracked, at which point the suggestion breaks in half around an untracked character. `editPoints` reads each step's own `from`, and it is a reading rather than an inference: the step says where it applied. The diff takes it as a tie-break only, so a caller with no transaction -- an agent writing through the model -- is unaffected. It also answers the question that follows: **could a client rebuild the document from what is in the room?** If the room held the projection, a client could build _a_ document, not _the_ document. The style chain, the numbering, the `rsid`s, the fields, the bookmarks, the images and the other seventeen parts are not in it -- 68 KB of numbering, 55 KB of styles and a 23 KB image, in one corpus contract alone. R3 would be gone on the first save. So the `.docx` is the source of truth and travels OUT OF BAND, from storage. The CRDT carries only the small mutable delta: what people are actually typing. A client needs both, which is why `DocumentRoom.open` takes both, and why it applies the shared state to the model before serving anyone -- the stored file is only ever as new as the last flush. What makes that safe is determinism: two peers opening the same bytes and applying the same shared state get the same model. `test/room.test.ts` asserts it by exporting from both and comparing. ``` typing ProseMirror transaction -> reconcile -> MODEL -> binding.push -> Yjs -> peers a peer edits Yjs update -> binding -> applyTo -> MODEL -> host refreshes -> ProseMirror rebuilt an agent HTTP -> room.setBlockText -> Yjs -> MODEL, and out to every open editor saving MODEL -> the package. No projection involved, and no browser. ``` Every arrow goes THROUGH the model. Nothing is derived from the projection, and the projection is rebuilt from the model rather than patched. ## tdoc/pdf Pages to a PDF file, drawn from the same `Pagination` the screen used. Dependency-free -- objects, streams, cross-reference table, TrueType embedding and WinAnsi encoding are all written here -- for the same reason the zip reader is: it has to run in a Worker. `RenderOptions.markup` says which view to draw, and it has to match the editor's -- see [server-pdf.md](server-pdf.md#the-view-is-an-input-not-a-filter). **This is the second answer to R1, and the more interesting one.** Printing the editor from Chromium matches by construction; this draws the same pages with no browser in the loop, so any disagreement is this renderer putting something in the wrong place. It has already found three cases where the BROWSER was wrong and the model right. `screen=server` in `pnpm render-check` is the gate; `glyphs` measures how close the two PDFs put the same word. ## tdoc/editor ProseMirror over the model. Pagination renders as widget decorations inside one continuous flow rather than as separate page containers, so selection and caret work across a page boundary, and each break carries `break-after: page` so printing draws the pages the paginator chose. Headers, footers and page-number fields live in the bands of each break. **The CRDT binding is not `y-prosemirror`**, and deliberately: the shared state is model-side, so `src/crdt/binding.ts` diffs by ELEMENT IDENTITY against the model and pushes from there. A room in a Worker then needs no editor in its bundle, and the projection is free to stay lossy. **A suggestion is the one write whose result the projection cannot show.** Every other edit leaves the view already saying what the model says, because the transaction and the model agree about the text. A suggestion does not: the person deleted a character and the model kept it, struck through. So `applyToModel` reports a caret -- a paragraph identity and an offset into that paragraph's MODEL text -- and the mount rebuilds the projection and places it. A host driving `applyToModel` itself must do the same, or the next keypress addresses text the model does not have. **Two mount options are session posture rather than document content**, and both are toggles a host flips without remounting: authoring { mode: 'direct' } | { mode: 'suggest', as } what typing WRITES markup 'final' | 'all' what the screen SHOWS `markup` is an input to pagination, not a display filter: in `all` the deleted text is projected, measured, and drawn struck through, so the document has a different page count in the two views and `setMarkup` repaginates. That is the R1xR4 decision, and it is what keeps "the screen is the PDF" with no asterisk. `insertion` and `deletion` join `runStyle` and `control` as PROJECTION marks -- written on every refresh, never read back, invisible to the exporter. **The document stylesheet is part of this layer's contract**, and it ships as `tdoc/document.css`. `src/editor` emits class names and custom properties that only that file honours -- the strut-free page, paragraph spacing as padding and never margin, `font-kerning: none`, the three-band break, the row-break bands, the cell clip box, the marker box, the tab box and the whole `@media print` block. Every class it emits is prefixed `tdoc-`, so a host's own stylesheet cannot collide with it by accident (one did: a debug label on `.para::before` hid every list marker). A consumer that copies half of the contract gets a browser that disagrees with the paginator. ## Testing substrate | Thing | Where | What it proves | | ---------------- | --------------------------- | ------------------------------------------------------------------------------ | | XML fidelity | `test/xml-fidelity.test.ts` | parse → serialize is byte-identical, every XML part, every corpus document | | Package fidelity | `test/opc-fidelity.test.ts` | read → write is a zip Word still opens, with every part's bytes intact | | Fidelity report | `tools/fidelity.ts` | a per-document scorecard, run by hand, the thing to watch while coverage grows | | Corpus | `corpus/` | nine real Word contracts, gitignored, see its README | The corpus is the substrate that matters. Unit tests on synthetic XML prove the code does what it was written to do; the corpus proves it survives what Word actually emits. ## Formatting is an API, not a toolbar `~/editor/commands` is the whole formatting surface, and it is plain functions over a mounted editor. An application embedding tdoc designs whatever UI suits it -- a ribbon, a bubble, a command palette, an agent calling over an API -- and never has to know what `w:rPr` is. The demo's own bar implements no formatting: it is worth reading as a specification of the API rather than as a toolbar. applyRunFormat(editor, format) the selection, or held for the next thing typed applyParagraphFormat(editor, format) every paragraph the selection touches readFormatState(editor, context) what a toolbar draws: on, off, or 'mixed' selectedParagraphs(editor) the paragraphs, with the selected range of each Three things make it behave like an editor rather than like a batch operation. **A selection means a range, so runs get split.** A run is the unit that carries formatting, so "bold these nine characters" is only expressible if a run boundary exists at each end of them. Word does the same: select half a word, press bold, and one run becomes three. The split CLONES the original's `w:rPr` rather than rebuilding it, so a property the model does not know about -- an emboss, an east-asian layout -- survives on both halves. **A caret means the next thing typed.** With nothing selected the format is held, and applied to the characters the next transaction inserts. The inserted range comes from the TRANSACTION, which knows what it inserted, rather than from diffing the model, which can only guess. It is not cleared after one character: bold stays on while you keep typing and expires when the caret moves, as it does in every word processor. **`readFormatState` reports RESOLVED values.** A run inside a heading style is bold without saying so, and a toolbar reading only direct formatting would show the button as off while the text on screen is bold. It uses `effectiveRun`, the same resolution the renderer and the measurer use, and returns `'mixed'` where the selection disagrees with itself. ### Two things that are easy to get wrong in the host - **The view must be refreshed, not remounted.** `MountedEditor.refresh()` rebuilds from the model and puts the selection back. Remounting shows the same change and throws the selection away, which makes "select a phrase, bold it, then italicise it" impossible -- it is gone after the first click. - **A toolbar button must not take focus.** Preventing the default `mousedown` leaves focus, and therefore the selection, in the editor. Without it the selection collapses before the click handler runs. # Audit: the editor stack, 2026-09-02 (docs/audit-2026-09-02.md) # Audit: the editor stack, 2026-09-02 Scope: everything landed between `c4fd6d1` and `82b3d8f` -- the consumer seams, content controls, the CRDT and room, tracked changes, suggestions, comments, list markers, and the harness changes that went with them. 36 files, about 4,400 lines. Read for code quality, duplication, performance and practice, with the grep or the measurement beside each claim so it can be re-checked. Findings are ranked by the harm they can do, not by how much code they touch. The first section is one finding that happened four times. --- ## 1. The finding that happened four times: re-deriving a layout `layOutBlock` was being called from **four places** with **four slightly different option sets**, each caller believing it was recomputing what the paginator had computed: | caller | file | what it forgot | what happened | | ----------------------------------------------- | ----------------------- | --------------- | --------------------------------------------------------------------------------------------- | | the pagination plugin, placing a break widget | `editor/pagination.ts` | `marker` | widget placed at an uncharged line's offset -- mid-line, an extra break, 7 overflowing sheets | | the render harness, comparing heights | `tools/render-check.ts` | `marker` | every numbered paragraph reported a line taller than computed | | the PDF renderer, drawing lines | `pdf/render.ts` | `marker` | first word drawn 6pt off, glyph agreement 93%→53% | | the harness aligner, finding blocks in PDF text | `tools/render-check.ts` | the marker text | "Agreement Number:" found three pages away; every block after it wrong | All four were fixed the same way: `paginateDocument` now returns `laid: LaidBlock[]`, and the lines the pages were decided with are the only lines anyone uses. The plugin, the renderer and the harness read from it. `docs/harnesses.md` already carried the rule -- **a harness must not re-derive what it is checking** -- and the lesson of this session is that it applies to renderers too, and to anything that computes a value the paginator has already computed. **What remains.** `src/editor/bridge.ts` still computes `markerTextStart` itself rather than reading the laid block, because the bridge runs on refresh and the paginator runs on settle, and they are not yet the same pass. It calls the same function with the same inputs, which is why it agrees today; it is one edited default away from being the fifth instance. The structural fix is for the bridge to receive the laid blocks (or for the two passes to be one), and that is the first recommendation below. **A related class: the same rule written twice.** `paragraphLineHeight` was implemented in the bridge and in the measurer before this session and disagreed; `markerCharge` was measured in three different styles by three callers for about an hour. The pattern is the same as re-derivation with the function inlined. Every rule that decides geometry should have exactly one function, and every caller should be able to say which function it is. --- ## 2. Correctness: things that are wrong or one change from wrong ### 2.1 The browser's tab width is an accident — HIGH A `w:tab` renders as `` and takes its width from the arrow glyph plus `padding: 4px 11px` from **the workbench's tab-bar rule**, which shares the class name. Measured: every tab in NY_SERDA is 37-41px wide whatever stop it is going to. The model advances each tab to its real stop. They have agreed by luck for the whole life of the project, and VA_SLA -- which is built of tabs -- is at 100% on every gate because of that luck. The bridge needs each tab's real advance, which only the line breaker knows. With `pagination.laid` available, the bridge can read each tab span's advance off the laid lines and emit it as an inline width. The class should also stop colliding (see 4.1). ### 2.2 Appearance is not measured — HIGH, and structural Two visual bugs shipped with every gate at 100%: hanging list markers drawn **on top of** their text, and the marker **hidden behind the debug label** on every styled paragraph. The three gates compare page assignment; a marker on top of a word moves no lines. The `glyphs` column is the only thing that sees sub-page placement, and it is informational. Recommendation: promote `glyphs` to a gate with a per-document floor (it is at 90-100% on five documents and the rest have known causes), and add a screenshot diff on two documents to the render harness -- one with hanging markers, one with tables -- compared against a checked-in baseline. Cheap, and it is the only thing that would have caught either bug. ### 2.3 `nextParaId` can collide across writers — MEDIUM `comment-write.ts` mints a comment's `w14:paraId` from a deterministic counter over the comments already present. Two Durable Objects writing a comment into the same document concurrently -- or one DO and a browser -- produce the same id, and `commentsExtended.xml` then threads a reply under the wrong comment. The CRDT solved the same problem with `:`; comments should use random hex or the same scheme. ### 2.4 `Comments` is a mutable snapshot with a manual reload — MEDIUM `WordDocument.comments` is loaded once; every writer must call `reloadComments()` or the next reader sees the document as it was. Four writers remember to today. A lazy getter invalidated by the comments part's dirty flag would make the reload impossible to forget. ### 2.5 Blocks that share markup merge at block granularity — LOW, documented Once a block carries tracked changes it shares its XML for the life of the room, and two peers editing that block merge last-writer-wins. Recorded in `crdt/binding.ts` with the reason it cannot drop back to text. Acceptable; Word locks a co-authored paragraph. Worth a host-facing note. --- ## 3. Duplication Seven helpers exist in two or more copies. None is large; together they are the kind of drift that produced section 1. | helper | copies | where | | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------ | | "runs whose text lies within `[from, to)`" | **3** | `range.ts:runsIn` (exported), `control.ts:runsBetween`, `suggest.ts:runsWithin` | | "walk up to the owning `w:r`" | **8** | `range.ts`, `blocks.ts`, `control.ts` ×2, `suggest.ts` ×2, `comment-write.ts`, `bridge.ts` | | `attr(element, name)` | **11 files** | every model file defines its own | | `fallbackStyle` | **3** | `lines.ts` (from spans), `blocks.ts` (from the mark), `render.ts` (from a line) -- three different fallbacks | | ancestor-chain walkers | **3** | `tracked.ts:chain`, `control.ts:sdtChain`, `control.ts:ancestorNamed` | | common prefix/suffix diff | **3** | `write.ts:retextSegments`, `crdt/document.ts:splice`, `suggest.ts:suggestText` | | "highest id + 1" | **3** | `opc/package.ts` (rId), `suggest.ts` (w:id), `comment-write.ts` (w:id) | Recommendations, in order of value: 1. **One `runsIn`.** Export the one in `range.ts` and delete the other two. `control.ts` and `suggest.ts` re-implemented it because they needed document order, which `runsIn` does not guarantee -- so add the sort there. 2. **One `owningRun(node)`** in `~/xml/query`, and one `attr` beside it. Eleven copies of `attr` is not a quality problem in itself, but each is a place a future `w:val`-vs-`val` bug hides. 3. **One `commonAffixes(before, after)`** returning `{ prefix, suffix }`. The three copies agree today; `suggestText` then widens its span and `splice` does not, which is a deliberate difference that would be clearer as two callers of one function than as three loops. 4. **One `fallbackStyle`.** The three disagree about what "fallback" means. `blocks.ts`'s -- the paragraph mark's style -- is the right one; the other two should take it as an argument. 5. **One `ancestors(node, predicate, limit)`.** --- ## 4. Practices ### 4.1 Document CSS and workbench CSS share a namespace — HIGH Two collisions found this session: `.tab` (2.1 above) and `::before` on `.para` (the marker and the debug label). Both were invisible until something depended on them. `document.css` now ships as a library stylesheet, so every consumer inherits whatever its own classes happen to be called. Recommendation: prefix every class the bridge emits -- `tdoc-para`, `tdoc-tab`, `tdoc-control`, `tdoc-page-break` -- or scope every rule in `document.css` under `.ProseMirror`. The prefix is better: a consumer can `grep` for it. This is a mechanical rename across `schema.ts`, `bridge.ts`, `pagination.ts`, `document.css`, `probe.ts` and the harnesses. ### 4.2 Test expectations that matched the bug — MEDIUM Four times this session a test failed and the first response was to fix the expectation. Three of those were right (the minimal splice was behaving as designed; the initials rule was consistent; the cut was too small to see). One was wrong: the marker charge test with 60 small cuts passed while the option was doing nothing, and I went looking for a threading bug before noticing the test could not see the effect. Not a rule to add, but a habit to name: **when a new test passes on the first run, ask what it would look like if the feature were absent.** The corpus assertions -- "MITP has exactly seven Google Docs anchors and zero controls", "VA_SLA has 41 markers and 39 are in cells" -- are the good pattern, because they were written from a survey of the data rather than from the implementation. ### 4.3 `push()` in `crdt/binding.ts` is 120 lines in three passes — LOW Joins, then splits, then markup, then text, each with its own loop and the order load-bearing. Correct, tested, and hard to read. Four named functions with the ordering comment on the caller. ### 4.4 `as never` in tests — LOW Two new: `room.test.ts` initialises a field as `null as never` before the binding exists, and `markup-view.test.ts` casts a `Block` to a `Paragraph`. Both are a missing `isParagraph` narrow or a two-phase construction. Trivial, and the kind of thing that spreads. ### 4.5 The model's public surface is 120 exports — LOW `~/model/index.ts` re-exports everything. Fine for a library under construction; before Phase 6 it wants a pass to decide what a consumer should see. `writeCharacters`, `retextSegments` and the `*Change` internals are probably not it. --- ## 5. Performance Measured on NY_SERDA (56 pages, 590 blocks), Node, real fonts: | what | ms | note | | ------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------- | | `paginateDocument` | **62** | was ~45 before markers; `markerTextStart` measures every marker and `spansOf` runs per paragraph inside it | | `docFromBlocks` (a refresh) | **28** | runs after every remote update | | `listMarkers` | 0.3 | negligible; the 20ms is elsewhere | | `binding.push()` per keystroke | 1.1 | was 5.6 before the `subtreeDirty` gate | | `save()` | 9 | the whole 122 KB package | None of these is on the keystroke path, which is what the Wave 1 work was for. They are the ceiling on how fast breaks settle and how fast a remote edit appears. In order of value: ### 5.1 `paragraph.segments` re-runs the traversal on every access — MEDIUM It is a getter with no cache. `createControl` calls `splitRunAt` twice, `runsBetween` once and `controlsIn` once, and each walks the paragraph afresh; `suggestText` is similar. Within one operation on one paragraph that is four to six traversals of the same unchanged tree. A memo on the element, invalidated by the existing `subtreeDirty` flag, would make it one -- and `subtreeDirty` already means exactly "this changed since it was parsed". ### 5.2 `effectiveParagraph` is resolved three times per paragraph per refresh — MEDIUM `bridge.ts` resolves it in `paragraphCss`, again in `paragraphNode` for the marker, and a third time for the spacing neighbour. The cascade walk is a style-chain traversal each time. Resolve once at the top of `paragraphNode` and pass it down. Same shape in `render.ts`. ### 5.3 `readBlocks` serialises every `Y.Text` on every call — MEDIUM, grows with tracked blocks It calls `toString()` on each block's text and now reads each `xml` string too. `push()` calls it three times. For text this is tens of KB; a document with many tracked blocks shares tens of KB of XML per block. Read what is needed: a `pidIndex(ydoc)` for the lookups, and the text only for the blocks being compared. ### 5.4 `push()` is O(touched × n) — LOW today Each touched block does a `findIndex` over the shared array. Build a `Map` once per push. Not measurable today (1.1ms) and the fix is ten lines. ### 5.5 `paginateDocument` at 62ms is the real ceiling — the known item Incremental relayout from the first dirty block remains the answer and remains on the plan. Every other item here is a constant factor; this is the algorithm. --- ## 6. What went right, so it is kept - **Refuse by name.** Every new operation returns a reason a person can act on, and the corpus asserts the refusals as much as the successes. This is the reason none of the session's bugs produced a plausible-but-wrong document. - **One traversal.** Content controls, tracked changes, comment anchors and markup views all read `textSegments`, and the two extensions it grew (`markers`, `includeDeleted`) are options on it rather than walks beside it. Three features that each looked like they needed their own walk did not get one. - **Save and re-open before asserting.** The comment writer's tests found two bugs that reading back the tree could not have. It should be the default for anything that writes a part. - **Measure before believing.** The neutral-span check (98 paragraphs, zero height change), the keystroke harness that caught its own 70x lie, the two-view test that was too weak to see anything. The instinct to measure is right; the instinct to trust the first number is the thing to watch. --- ## 7. Recommended order — DONE 2026-09-03, `1725ebf` to `bfe3722` 1. ~~Bridge reads the laid blocks~~ **Done.** Plus cell paragraphs, which turned out to be the one place the bridge still guessed; `layOutTable` now keeps its cell layouts as `cellBlocks`. And a FIFTH re-derivation, in `table-check`, which had been reporting a 305px table disagreement that was 213px the whole time. 2. ~~Tabs at their real width; `tdoc-` prefix~~ **Done.** The tab box had to be zero-height: an inline-block with a glyph in it makes its own line box, and tab-heavy lines came out 4.6px taller until it was. The rename found three things a mechanical pass misses -- `tag.class` selectors, a file not on the emit list, and string boundaries -- and render-check said when it was complete by reporting the same numbers to the digit. 3. ~~`glyphs` as a gate~~ **Done, as a ratchet** against a checked-in per-document baseline, plus a **collision check** -- two words on one baseline whose boxes overlap -- which is the geometric form of both bugs that shipped and would have refused the marker overlap at 3.3pt. Zero across the corpus, so the gate is zero. A screenshot diff was not needed. 4. ~~The seven duplicates~~ **Done.** `attr` 11→1, the owning-run loop 8→0, three ancestor walkers →`ancestors`, three diffs →`commonAffixes`, three `runsIn` →1 (now promising document order), three id allocators →`nextNumericId`, three `fallbackStyle`s →`DEFAULT_TEXT_STYLE`. Two of the three fallbacks had disagreed, 10pt against 12pt. 5. ~~The two memos~~ **Done.** Needed a `version` on every node, bumped all the way to the root on every mutation, because `subtreeDirty` is set once and `invalidate` used to stop at the first dirty ancestor. `paginateDocument` 62→50ms. The refresh rose 28→53ms, which is the cost of item 1 being correct; one shared layout pass for refresh and settle is on the plan. 6. ~~`nextParaId`, `Comments` lazy~~ **Done.** `nextParaId` is random, as Word's are. `Comments` rebuilds itself when the parts' versions say to, and `reloadComments()` is gone. State at the end: every gate at 100% on all nine documents, total overflow one sheet (from four), Oregon at zero for the first time, VA_SLA's mean per-block error 0.0px, glyph agreement at or above every previous best and now ratcheted. 364 tests, fidelity 9/9. **The one deferral in item 5 also landed**, in `ae74136`: refresh and settle are one layout pass now, `paginateDocument` returns the `laid` blocks and the bridge builds the DOM from them, so the "fifth instance" this audit named cannot happen. The refresh went 102ms to 60ms with it. Two Pact asks landed after (`b95bdf6`, `e301822`) and 2.2's structural finding -- appearance is not measured -- is still only half answered: the glyph and collision gates exist, and nothing runs them over a document with a suggestion in it, because no harness makes one. That is item 2 on [plan.md](plan.md#what-is-left-in-order). # The harnesses (docs/harnesses.md) # The harnesses Everything in tdoc is measured against nine real Word contracts rather than fixtures. This is the reference for that machinery: what each tool measures, how to read it, and the workflow that has found every bug so far. If you are picking this up cold, read [requirements.md](requirements.md) first for what the project is trying to do, then this. ## Setup, once ```sh pnpm install cp ../../proj_pact/test-corpus/*.docx corpus/ # the nine contracts, gitignored docker build -t tdoc-render tools/pagination/container pnpm fonts # extract the container's fonts TDOC_PDF_IMAGE=tdoc-render pnpm reference # generate reference PDFs ``` Also needed on PATH: **poppler** (`brew install poppler`) for `pdftotext` and `pdfinfo`. Playwright's Chromium comes from `pnpm install` plus `npx playwright install chromium`. Everything under `corpus/` is gitignored: the contracts, the extracted fonts, the reference PDFs and the printed ones. A fresh clone has none of it and the tools say so rather than failing obscurely. ## The tools | | what it measures | needs | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | | `pnpm test` | 375 tests: unit, and corpus sweeps for fidelity, model, builder, styles, sections, the room | corpus | | `pnpm fidelity` | byte-identical round-trip, plus the **element census** ranking what the model does not yet understand | corpus | | `pnpm render-check [name]` | the layout scorecard: three comparisons plus two appearance gates, see below | everything | | `pnpm table-check [name]` | tables row by row, column by column, and cell by cell | everything | | `pnpm edit-check` | fourteen real keystroke edits per document: what applied, what was refused, and two invariants | everything | | `pnpm control-check` | content controls in the browser: drawn, atomic to the caret, and costing no height | everything | | `pnpm keystroke-check` | what a typist feels: long tasks over 50ms, and how long after the last keystroke the breaks catch up | everything | | `pnpm variants` | generates twenty page-setup variants (`zvar-*`) into the corpus, so `render-check` covers A4, legal, landscape, narrow, wide and mixed sections | corpus | | `pnpm paginate` | the paginator against the reference PDF, without a browser | corpus, reference | | `pnpm server-pdf ` | writes the browser-free PDF for one document, to open by hand | corpus, fonts | | `pnpm demo` | the workbench, for looking at a document by hand | corpus, fonts | **`render-check` counts the variants when they are present.** A fresh corpus has nine documents; after `pnpm variants` it has twenty-nine, and the summary line counts them all. The variants are excluded from the glyph ratchet (they are regenerated, so there is nothing to baseline) and included in every other gate, which is where mixed-section pagination and the narrow-page collisions show up. `pnpm table-check CA_Courts` dumps the cell detail for a named document rather than whichever happens to be worst. ## The three comparisons `pnpm render-check` reports all three. **They are different questions and only one is the product requirement.** Conflating them cost this project several days: a column labelled `screen=pdf` was in fact measuring LibreOffice agreement. | | | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `screen=model` | Does the browser DRAW what the paginator COMPUTED? An invariant. Below 100%, the bug is in the sizing and decoration pass, not the paginator — it tells you which half of the system to open | | `screen=pdf` | Does the PDF we PRINT match the screen? **This is R1.** Each break carries `break-after: page`, so the printer draws the pages the paginator chose rather than repaginating. Anything under 100% is a defect in our own pipeline | | `screen=libre` | Do we agree with LibreOffice laying the `.docx` out from scratch? A Word-fidelity proxy. Reported, **never gated** — matching Word is explicitly not a requirement, and a document using a font with no metric-compatible clone cannot match by construction | `mean±` is the average signed per-block disagreement in pixels. Watch it: a worst-case list cannot see a small error repeated over hundreds of blocks, and that shape has been the cause twice. ## How the browser is measured `tools/render/harness.ts` drives Playwright's Chromium against a dev server it starts itself (`tools/render/server.ts`). It reads numbers **out of the DOM** through a seam the workbench installs on `window`: - `demo/probe.ts` defines `window.__tdocProbe()`, which returns per-block sheet numbers, heights, line counts, and per-table row, column and cell geometry. - `tools/render/probe.ts` is the shared type. The probe asks the RENDERED document what it thinks, never the paginator. `paginateDocument` agreeing with itself proves nothing about what a person sees. To add a measurement: extend `RenderProbe`, fill it in `demo/probe.ts`, consume it in a tool. That loop is the whole method. ## The workflow that keeps working 1. **Make the harness say WHICH ONE.** Every bug in this project was found by making a measurement one level more specific: document → block → line → cell → paragraph-inside-cell. When a number is wrong and you cannot say which thing is wrong, that is the next thing to build, not a hypothesis to test. 2. **Separate the layers before guessing.** `screen=model` says whether to look at the paginator or the renderer. Line COUNT versus line HEIGHT says whether line breaking or line spacing is wrong. Column width versus row height says whether a table wrapped differently or is spaced differently. These have opposite fixes and identical symptoms. 3. **Read the browser, do not reason about it.** `getComputedStyle` and `getClientRects` on the real element settle in seconds what an hour of reasoning will not. 4. **Let the harness reject changes too.** Keeping space-before at a page top was tried, measured to make no difference, and reverted. That is the tool working. ## Traps this project has actually hit - **When the measurement agrees and the rendering does not, suspect the STYLESHEET.** Three separate demo-CSS bugs each looked exactly like a layout bug: a page-level `line-height`, a blanket paragraph `margin`, and a `float: right` debug label that shrank the first line of every styled paragraph. Our text measurement matches the browser to a sixteenth of a pixel; if a paragraph still breaks differently, it is the box. - **An optional parameter that must always be passed is a trap.** `docFromBlocks` took the measurer positionally and optionally; a call site omitted it, TypeScript said nothing, and for days no paragraph received a computed line height while every harness reported plausible numbers. - **A module that does work on import must not also export a constant.** Sprung twice: importing `reference.ts` for a path ran the whole reference generation; importing `fonts.ts` for a helper re-extracted 27 fonts from a container. Hence `paths.ts` and `font-files.ts`. - **Two traversals over one tree will disagree.** Three times: extraction versus writing on `w:br`, `w:tab` meaning both a tab character and a tab stop, and the editor bridge walking `paragraph.runs` while text walked segments. Derive from `textSegments`. - **A cell renders at its ROW height.** Every cell in a row stretches to the tallest, so a short cell reporting a huge disagreement is usually reporting its neighbour's. Read the paragraph stacks. - **`oxfmt` reformats markdown and TypeScript**, so a string replace written against a file you formatted five minutes ago may silently not match. Assert that edits applied. ## Reading a failure ``` CA_Courts_Agreement [265] paragraph px 490/ 460 lines 32/ 30 ^id ^rendered/computed ^rendered/computed ``` Same lines, different height → line spacing. Different lines, same width available → line breaking. Both agree but the sheet still overflows → the page fill accumulates differently. ## `firstModelDivergence`: which block, not how many `pnpm render-check` prints, under the scorecard, the first block where the DRAWN page assignment parts company with the COMPUTED one: ``` where the drawing first parts company with the paginator: VA_SLA block 53: drawn p12, computed p13 "SLA Review and Concurrences" ``` A `screen=model` percentage says a bug exists and nothing else. This line says where to look, and the shape of the answer is usually in what comes just BEFORE the named block: block 53 followed a 14-row table, and the bug was that split tables broke on paper but not on screen. When several documents diverge at a block that follows the same kind of neighbour, that neighbour is the bug. ## What the probe reports, and why each field exists Every field was added to answer a question that cost real time. Reading them in this order turns a percentage into a place to look: | field | question it answers | | --------------------------- | --------------------------------------------------------------------- | | `sheet` | which page did the BROWSER put this block on | | `height` | how far is the rendered block from the computed one, net of furniture | | `width` | what measure did the browser break this paragraph against | | `lines`, `lineWidths` | where did the two line breakers part | | `cells[].inner` | what measure did a cell's paragraphs break against | | `overflowing`, `overflowBy` | which sheets did not fit, and by how much | Two traps live in these numbers: - **Furniture is not content.** A break inside a block is a child of it: the widget's box, and a row break's padding, are page geometry. Every one of `height`, the table heights, the row heights and the cell heights nets it out. Forgetting it once reported MITP's table at 26792px against a model height of 1959. - **The break LABEL is not a line.** "page 29 of 43" is a text node inside the paragraph it splits. Line counting skips anything inside `.page-break`. ## The three ways a page can be wrong Distinguishing these is most of the work, and the harness now separates them: 1. **A block is mismeasured.** The `worst disagreements` list, and the `mean±` column for a small error repeated hundreds of times. 2. **The page is over-full though every block is right.** The paginator and the renderer disagree about SPACING rather than content: space before at a page top, `w:contextualSpacing`. Look for a rule one half applies and the other does not. 3. **The sizing pass disagrees with itself.** A break whose applied height differs from the height it was measured with. Its signature is content drawn on a sheet that does not exist, which `content running PAST the last sheet` asserts directly, and which the overflow count cannot see. Overflow CASCADES, so the count is not a count of bugs: a filler cannot be negative, so one over-full sheet pushes everything below it down and every later break reports overflow too. Read the first amount, fix that, measure again. ## The harness is half of every disagreement Of the last four R1 failures in this corpus, three were the measuring apparatus rather than the product: the probe counting fillers as table height, the aligner matching a repeated address on the wrong page, and `table-check` re-deriving a cell width with a formula that omitted the border. The rule that follows: **a harness must not re-derive what it is checking.** `table-check` now reads the model's own `cellWidths` rather than recomputing the share-of-grid arithmetic. ## Seeing R1 in the demo `our PDF, side by side` prints the document and shows it next to the editor, with the two page counts in the bar: what the paginator computed, and what the printer produced from it. R1 is those agreeing, and the pane says `match` or `DIFFER`. **No converter and no container.** `POST /__print` drives a headless Chromium over the demo itself: every break carries `break-before: page`, so the printer emits the pages the paginator chose rather than paginating again. LibreOffice is not involved. About two seconds a document, most of it the browser launch. The bytes posted are the ones the editor exported a moment ago, so the PDF reflects the edits. They are served back to the printing page from memory under `/__printsrc/`, never written into the corpus, so a print cannot leave a stray document in the picker. Two things it does deliberately: - **The sheet size is read off the page**, not re-derived from the .docx. Printing at a size the screen is not drawn at is the one way to make the PDF disagree with the screen by construction. - **The other pane is labelled for what it is.** `LibreOffice reference` re-lays the .docx out from scratch: the `screen=libre` proxy, never a gate, and the only one that needs a converter. Calling both of them "the reference PDF" is how the two got confused for several rounds. For deployment this is the argument the demo is making: the pipeline needs a BROWSER, not an office suite. A browser rendering service or Chromium in the function, rather than LibreOffice in an image. ## `pnpm edit-check`: the refusal surface, enumerated The reconciler refuses any transaction it cannot express as operations on `w:p` elements, and a refused edit leaves the document exactly as it was. That is the right default -- it is the only outcome that cannot corrupt a contract -- but it makes a missing feature look like a safety property, and the way missing features were being found was one at a time, by someone typing into the demo and hitting a wall. `edit-check` drives the real editor with real keystrokes and reports what happened to each of a list of everyday edits. It is not pass/fail: a refusal here is a list entry, and the list should only get shorter. A change that lengthens it should have to say why. It found that **Enter at the end of ANY paragraph was refused**, not just on the blank line where it was noticed. `splitBlock` creates the new block from the parent's DEFAULT type when the caret is at the end of one -- reasonable for a heading, wrong here, because the default carries no attributes and `pid` is how the reconciler tells a split from an insertion. `tr.split` with no types copies the node, attributes and all, so both halves carry the same pid, which is exactly the signal reconcile is looking for. That single fix took the list from four refusals to none, because Enter-twice, select-across- paragraphs, and undo-after-a-split all went through the same path. ### It also checks two invariants, which is where the worse bug was An edit that goes THROUGH can still be wrong, and refusals were the only thing being watched. Each case now also asserts: - **more than one page means a break is drawn.** The reported symptom -- delete a line above a table and the page grows to twice its length with no boundary in it -- is this invariant failing. - **the model and the view hold the same number of blocks.** The cause of the same bug, and the more serious statement of it: what is exported was no longer what is on screen. Both came from pagination being computed against the PREVIOUS model. `applyToModel` applied the transaction to the editor state before reconciling, and the pagination plugin recomputes inside that apply, so every structural edit paginated the old model against the new document. A split recorded for block 12 was drawn on whatever block 12 had become, or on nothing. Reconciling first fixes it -- `transaction.doc` is the document the transaction produces, so the reconcile never needed the state to have been applied. Turning the invariants off and putting the old order back produces eight failures across two documents, which is the only reason to trust them. ## What each comparison is called, and against what Four measurements across two tools, and the names only make sense together. Every one compares PAGE ASSIGNMENT: which block lands on which page. | name | tool | the thing measured | against | | ---------------- | ------------ | --------------------------- | ----------------------------------- | | `screen=model` | render-check | the browser's DOM | the paginator | | `screen=pdf` | render-check | the browser's DOM | the PDF Chromium prints from it | | `screen=libre` | render-check | the browser's DOM | LibreOffice re-laying out the .docx | | `screen=server` | render-check | the browser's DOM | the PDF we render ourselves | | `server=model` | server-pdf | the PDF we render ourselves | the paginator | | `server=printed` | server-pdf | the PDF we render ourselves | the PDF Chromium prints | **`screen=model` and `screen=pdf` are the gates.** The first is an internal invariant -- below 100% the drawing disagrees with the paginator. The second is R1. **`screen=libre` is never a gate.** It is a Word-fidelity proxy, and a document using a font with no metric-compatible clone cannot match by construction. **`screen=server` is the one that answers "does the browser-free PDF match the editor".** Nothing transitive about it: the browser's own block-to-sheet map on one side, the PDF we rendered on the other, in one tool. It was reached by two chains before, and both were arguments rather than measurements -- through the printed PDF (`server=printed` with `screen=pdf`) and then through the paginator (`server=model` with `screen=model`). Every link reads 100%, so both chains are sound, and neither is the thing itself: the comparisons do not place exactly the same block set, because the aligner excludes what it cannot find and the denominators differ. Two sound arguments for a claim are not a measurement of it. ## Two gates that see what page assignment cannot Added 2026-09-03, after two visual bugs shipped with every page-assignment gate at 100%: list markers drawn ON TOP of their text, and markers hidden behind a debug label. A marker on a word moves no lines, so nothing above could see it. **The gates measure agreement, not appearance**, and these two are the appearance half. **The glyph ratchet.** `glyphs` -- the share of words the two PDFs put within a point of each other -- cannot take a fixed floor, because four documents sit at 27-56% for known reasons in the browser-free renderer. It can refuse to get worse. Each document's value is checked in at `tools/pagination/glyph-baseline.json`; a run more than three points below its baseline fails, and a run above it says so and asks for `--update-baseline`. The fall this exists for was CA_Courts 93% to 53%, which stayed shipped for a commit. **Collisions.** Two words on the same baseline whose boxes overlap by more than 1.5pt, in either PDF. That is exactly what a marker on its word or a tab landing under the text after it looks like, and it is geometry rather than a screenshot, so hinting and antialiasing cannot make it flap. Zero across the corpus today, so the gate is zero there; four of the narrow variants collide, and the shape -- the same word repeatedly drawn over "Department" down a table -- says a repeated header rather than a marker. That is in the defects register, not a regression. The marker bug measured as `(a)` at 162-175pt over `All` at 150-165pt: a 3.3pt overlap this would have refused. # OOXML notes (docs/ooxml.md) # OOXML notes Working notes on ECMA-376 Part 1 (WordprocessingML, §17), kept as the model learns each area. Only the things that actually bite. Not a spec summary; read the spec for that. ## The package A `.docx` is an OPC package: a zip with a `[Content_Types].xml` at the root declaring a media type for every part, by extension (``) or by exact name (``). Relationships live in `_rels/` beside the part they belong to: the package's own in `_rels/.rels`, the main document's in `word/_rels/document.xml.rels`. Parts a contract actually uses: | Part | Holds | | ----------------------------------------- | ------------------------------------------------------------ | | `word/document.xml` | the body | | `word/styles.xml` | named styles and `docDefaults` | | `word/numbering.xml` | abstract and concrete numbering definitions | | `word/settings.xml` | compat flags, rsid table, default tab stop | | `word/fontTable.xml` | the fonts the document names, and their panose/metrics hints | | `word/theme/theme1.xml` | theme fonts, which style refs resolve through | | `word/header*.xml`, `word/footer*.xml` | one part per header/footer, referenced from `sectPr` | | `word/footnotes.xml`, `word/endnotes.xml` | note bodies, referenced by id from the run | | `word/comments.xml` | comment bodies, anchored in `document.xml` by id | | `word/media/*` | images, verbatim bytes | | `docProps/app.xml` | Word's own `` and `` at last save | `docProps/app.xml`'s `` is the only offline Word ground truth available, and it is what the pagination work measures against. It is stale on files last touched by Word 14 (Mac 2011), which write `1` with `0`. ## Nothing may be reordered Child element order in WordprocessingML is schema-significant, not stylistic. `w:pPr` must be the first child of `w:p`; `w:rPr` the first child of `w:r`. Within `w:rPr` the properties themselves have a required order (`w:rFonts`, `w:b`, `w:i`, `w:strike`, `w:color`, `w:sz`, ... ). Word will often repair a wrong order silently, which is worse than failing, because the repair is a different document. This is a large part of why the exporter prefers retained source: emitting a node we did not change cannot get its order wrong. ## Whitespace is significant, and only when it says so `` drops leading and trailing whitespace unless it carries `xml:space="preserve"`. A parser that normalises whitespace, or a serializer that drops the attribute, changes the text of the document. `" and "` becomes `"and"` and a clause changes meaning. Round-tripping this correctly is the narrowest, most load-bearing detail in the whole XML layer. ## Toggle properties are not booleans `w:b`, `w:i`, `w:caps`, `w:strike` and friends are **toggle properties**. Their semantics under the style cascade are not "last one wins": - Absent means inherit. - `` or `` means on. - `` means off. - When the same toggle is set at more than one level of the _style_ chain, the effective value is the XOR of the levels, not the innermost. Direct formatting on the run then overrides outright. Getting this wrong makes bold text in a nested style come out unbold, on a subset of documents, which is exactly the kind of bug that survives a demo. Implemented in `src/model/effective.ts`, with the spec's own worked example as a test. ## The cascade Resolution order for a run's effective properties: 1. `docDefaults` in `styles.xml` (`w:rPrDefault`, `w:pPrDefault`) 2. The paragraph's named style, and every style it inherits from via `w:basedOn`, outermost first 3. The paragraph's `w:pPr/w:rPr` (paragraph mark formatting) 4. Numbering level properties, if the paragraph is in a list 5. The run's named style (`w:rPr/w:rStyle`) and its `basedOn` chain 6. The run's direct `w:rPr` Table styles add conditional formatting bands (`w:tblStylePr` for first row, banded rows, etc.) that sit between 1 and 2 for content inside a table. `w:basedOn` can cycle in files produced by non-Word tools. Resolution must be depth-bounded. ## Numbering A paragraph is in a list when `w:pPr/w:numPr` gives a `w:numId` and a `w:ilvl`. That `w:numId` resolves through `numbering.xml` to a `w:num`, which points at a `w:abstractNumId`, which holds the `w:lvl` definitions. A `w:num` may also carry `w:lvlOverride` entries that replace a level or restart its counter. Two traps: - `w:numId="0"` means _no numbering_, and is how a paragraph opts out of a style's list. - The displayed number is not stored anywhere. It is computed by counting preceding paragraphs at the same level in the same list, honouring `w:start`, `w:lvlRestart` and overrides. Nested `1.1/1.2/1.3` numbering in contracts is this computation, and it is the thing most likely to be visibly wrong. ## Tracked changes and comments Insertions wrap runs in `w:ins`, deletions in `w:del`, each carrying `w:id`, `w:author` and `w:date`. Inside a `w:del`, text lives in `w:delText`, not `w:t` — a deleted run whose text stays in `w:t` is a corrupt document. Paragraph-mark changes are marked on `w:pPr/w:rPr/w:ins`. Comments are a range plus an anchor: `w:commentRangeStart` and `w:commentRangeEnd` with matching `w:id`, then a run containing `w:commentReference`. The body lives in `comments.xml`. Three places must agree, and `comments.xml` must be declared in `[Content_Types].xml` and related from `document.xml.rels` or Word reports the file as damaged. Author is per element, which is what makes R4's attribution requirement natural here and awkward in an editor that takes the author from mount-time configuration. ## Measurement units | Unit | Is | Used by | | --------------------- | -------------------- | ------------------------------------ | | twip | 1/20 pt, 1/1440 inch | margins, indents, page size, spacing | | half-point | 1/2 pt | `w:sz` font size | | EMU | 1/914400 inch | DrawingML extents | | eighth-point | 1/8 pt | border widths | | fiftieth of a percent | 1/50 % | `w:tblW` when `w:type="pct"` | `w:sz` is half-points, so `w:sz w:val="24"` is 12pt. Off-by-two here silently doubles a document's page count. ## Things that must survive but need not be understood `mc:AlternateContent` (a `Choice`/`Fallback` pair, where dropping either half can lose a watermark), VML shapes, `w:sdt` content controls, field codes (`w:fldChar`/`w:instrText`), custom XML parts, `w:proofErr`, smart tags, `w:rsid*` attributes, OMML math. Every one of these appears in the corpus. None of them are modelled. All of them round-trip. # Pagination, and how it is measured (docs/pagination.md) # Pagination, and how it is measured R1 says the page breaks on screen and the page breaks in the PDF are the same. This is how that claim is checked, and the harness was built **before** the paginator on purpose: a harness written afterwards tends to be written to agree with the thing it measures. ## The metric **Equality of page assignment.** Block N starts on page P, and both sides agree about P. Not pixel equality, which no two renderers will ever have. Not page-count equality either, which is far too coarse: two compensating errors cancel and the document still prints wrong. Block assignment localises the disagreement, which is the difference between a bug report and a mystery: ``` page 34 of 56, at the indemnity clause <- actionable we got 57 pages and Word got 56 <- nothing to act on ``` `comparePagination` reports four things: | | | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `agreement` | fraction of blocks both sides put on the same page. Degrades gracefully, so it is the number to watch while building | | `firstDivergence` | where it starts. Everything after a missed break is also wrong, so this is usually the only one worth reading | | `drift` | mean signed page offset. "One page short throughout" and "scrambled" have the same disagreement count and are very different problems | | `compared` | blocks neither side could place are excluded, not counted as agreement or as failure | ## How the reference side is built A PDF does not say "paragraph 412 starts on page 34". It says "page 34 contains this text". So `alignBlocksToPages` walks blocks and pages together in document order and matches text. The alignment is **sequential**: each block is looked for starting from the page the previous one landed on, never from page 1. Three bugs that avoids, all of them real: - A contract repeats itself. "Contractor shall" appears eighty times; a global search finds the wrong one and every assignment after it is wrong. - Running headers repeat the document's title on every page, so the title paragraph would otherwise match every page. - It is O(pages) rather than O(blocks × pages), which matters at 2,800 paragraphs. A block is identified by its **first five words**, matched case-insensitively with a bounded gap allowed between them. Words rather than a character prefix, and each part of that was earned: | | | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | case-insensitive | `w:caps` stores "Acceptance AND Signoff Form" and renders "ACCEPTANCE AND SIGNOFF FORM". The characters differ; the words do not | | gap between words | a signature line is `` and `Date` in two table cells: one tab in the model, thirty spaces in the PDF | | dot leaders | `Purpose1` renders as `Purpose . . . . . 1`, because `w:tab w:leader="dot"` becomes literal dots. **The largest single cause of unplaced blocks**, and exactly the part of a contract whose page numbers a reader checks | | typography folded | curly quotes and en dashes fold to ASCII on both sides, so a match does not depend on the font's encoding | | tables probe their first cell | a table's own `text` concatenates every cell with tabs; a PDF extractor reads a table in its own order, so the concatenation matches nothing | Five words is short enough that a paragraph **split across a page break** still matches on the earlier page, which is where it starts. Blocks with no text are `empty`; blocks that cannot be found are `unplaced`. Neither is guessed at, and both are excluded from the agreement statistic. Measured over the corpus: **13 of 2,167 blocks unplaced (0.6%)**, from 37 before these rules. ## Generating the reference ```sh pnpm reference # docx -> corpus/reference/*.pdf, plus a manifest pnpm paginate # the scorecard ``` `resolveConverter` picks the first available of three transports, all of which are LibreOffice doing the work: | | | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `TDOC_PDF_URL` | a converter service. Wins when set, so a deliberately configured service is not bypassed by a LibreOffice that happens to be installed | | local | `soffice --headless --convert-to pdf` | | `TDOC_PDF_IMAGE` | the same, in a container | ## The fonts decide the answer **This is the highest-risk line in the whole harness.** `proj_pact` measured a 56-page agreement coming out at 69 pages with the metric-compatible font packages removed. No error, no warning, a plausible PDF of the wrong length. So the converter used is written into `corpus/reference/manifest.json` and printed at the top of every scorecard. A reference produced by a LibreOffice with different fonts installed is a different reference, and a comparison that does not say what it compared against is not a measurement. Regeneration is explicit rather than automatic for the same reason: if the harness rebuilt its own reference whenever the converter changed, a pagination regression and a converter change would be indistinguishable. ## Word's `` is information, not a target Word writes its own page count into `docProps/app.xml` at every save, and the scorecard reports the reference PDF's delta against it. **It is not a pass condition.** Matching Microsoft Word is what makes a layout engine a multi-year project; tdoc is the authority on its own pagination, and R1 is satisfied by browser-equals-PDF regardless of what Word thought. Knowing how far off we are is still worth having, which is why it is in the table. Four corpus documents carry a real measurement. Five were last saved by Word 14 (Mac 2011) and carry a stale `1` with `0`; those report `-` rather than a delta. ## The reference environment `tools/pagination/container/` builds the image, rather than pulling a general-purpose one: ```sh docker build -t tdoc-render tools/pagination/container TDOC_PDF_IMAGE=tdoc-render pnpm reference ``` Debian slim, `libreoffice-writer` only, and an explicit font list — `fonts-liberation2` (Arial, Times New Roman, Courier New), `fonts-crosextra-carlito` (Calibri), `fonts-crosextra-caladea` (Cambria), `fonts-dejavu-core` as a deterministic fallback. 501 MB. `fonts-liberation2` and **not** `fonts-liberation`: Liberation 1.x and 2.x are different fonts, and installing both lets fontconfig choose, reintroducing the divergence the list exists to remove. **First result, on that image:** the reference matched Word's own `` exactly on all four corpus documents that carry a real measurement — 21, 56, 44 and 15 — independently reproducing `proj_pact`'s finding with a different image and a narrower font set. ## Rendering page breaks in the editor Breaks are **widget decorations inside the single editable flow**, never page containers. Selection crosses them, which is the whole reason for the constraint (see R5). Two things have to be true for a rendered sheet to be a real page: 1. **The break must be in the right place.** That is the paginator, and it is measured: the number on screen is the number in the scorecard, because the editor calls the same `paginateDocument`. 2. **The sheet must be a full page tall.** A break inserted into a flowing document only adds a gap, so the sheet above it ends wherever the text ended. A pass after layout pads each break so the next sheet begins at an exact multiple of the page height. For (2) the browser has to render text at the height the paginator measured, or the padding fights the content. Three things make that true, and all three are load-bearing: - the computed line height is emitted as an explicit `line-height`, because a browser's `normal` is its own function of the font metrics and differs from Word's rule, per line, cumulatively; - the reference container's own font files are served and registered under the document's family names, so `Times New Roman` resolves to the same Liberation Serif the reference measured; - `font-kerning: none` and `font-variant-ligatures: none`, because browsers kern by default and Word mostly does not. ## The render harness `pnpm render-check` starts the dev server, drives it in headless Chromium, and reads the numbers **out of the DOM**. That last part is the point, and it is what every earlier check was missing. `paginateDocument` agreeing with itself proves nothing about what a person sees, and a screenshot proves nothing that can be regression-tested. The harness asks the laid-out document which sheet each block physically landed on, through a probe the workbench installs on `window`. Three things are asserted per document, and they fail independently: | | | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `overflow` | sheets whose content did not fit a page. The browser rendered them taller than the paginator measured, so the filler clamped to zero and the sheet grew | | `screen=model` | the RENDERED page assignment matches the PAGINATOR's. A mismatch means the sizing pass put a break somewhere the paginator did not ask for | | `screen=pdf` | the rendered page assignment matches the reference PDF's. **This is R1, measured end to end** | It also reports the blocks the browser and the paginator most disagree about, in pixels **and in lines**. That pair is what makes a disagreement diagnosable: too many LINES means the line breaker disagrees about how much text fits on one; too tall a LINE means the line height is wrong. They have completely different causes and neither is visible from the height alone. ### What it found in its first hour Three bugs, each of which had survived visual inspection: 1. **A page-level `line-height: 1.45` in the demo's stylesheet** silently overrode the computed height wherever the inline one was missing, rendering text 1.26x taller than it was measured. Oregon_MSA showed 17 lines rendered against 17 computed with the heights 23% apart, which is only readable when you have both numbers. Fixing it took NY_SERDA's `screen=model` from 21% to 100% and its `screen=pdf` from 22% to 64%. 2. **A trailing `w:br` did not count the line it opens.** Contracts use `` as a spacer, so the loss accumulated into a page. 3. **The dev-server harness itself** could not find its own server: Vite colours the port number, so the raw output reads `localhost:[1m5175` and a pattern expecting a digit never matched. It presented as "the server never started" while the server was plainly running. The sheet arithmetic that had cost hours of browser-squinting is now `src/layout/sheets.ts`: pure, DOM-free, and unit-tested. Both of the bugs it had are pinned by name in `test/sheets.test.ts` — the gap between sheets left out of the target, and later breaks measured without the fillers the earlier ones add. ### Baseline ## Baseline First measured run of the paginator, 2026-08-31: ``` document pages overflow worst screen=model screen=pdf CA_Courts_Agreement 25 15 812 81% 10% Employment_Agreement 2 0 0 100% 100% MITP_KF_2026 36 1 617 96% 18% NDA_Template 2 0 0 100% 100% NY_SERDA_Agreement 56 29 332 100% 64% OECS_SOW_2026 1 0 0 100% 100% Oregon_MSA 42 3 56 100% 53% SOW_Template 2 0 0 100% 88% VA_SLA 14 3 977 89% 20% ``` Four documents render page-perfect. NY_SERDA and Oregon place every break exactly where the paginator asked and are within 56 pixels of a full sheet at worst. `pnpm paginate` measures the same thing one layer down, comparing the paginator to the PDF without a browser in the loop. Its overall figure is 50.4% block agreement, with NY_SERDA landing on 56 pages exactly, agreeing with both the reference and Word. The number to improve is agreement, not the page count: a document can reach the right total by accumulating an error and then cancelling it. ## The table harness `pnpm table-check` reports every table row by row and column by column, because a table's TOTAL height cannot say why it disagrees, and the two failures need opposite fixes: | | | | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | width wrong | the cells were measured at a width the browser did not use, so the text wrapped differently and every row is a different height for a reason that has nothing to do with rows | | height wrong | the widths agree, so the error is in what surrounds the text: cell margins, explicit row heights, borders, or paragraph spacing | It leads with column widths for exactly that reason: a width disagreement makes every row number below it meaningless. It then dumps the two worst tables row by row, because a UNIFORM per-row error (a constant added to every row) and a SINGLE bad row (one cell that wrapped) look identical in a total. ### What it found Column widths agree to within a pixel on every corpus table but two, so the error is height. Four guesses in the layout, each replaced with the value the document actually carries: - **Cell margins were hardcoded** at Word's default 108 twips a side. A document whose `w:tblCellMar` differs had every cell measured at the wrong width, so borderline text wrapped to a line the browser does not draw. Now read from `w:tblCellMar` and `w:tcMar`, and **emitted into the DOM as the cell's padding**, so the two cannot drift. - **A fabricated 240-twip minimum row height.** Word has no such floor; only `w:trHeight` sets a minimum. It made every short row in a 37-row schedule too tall, which is most of a page. - **`w:trHeight` was ignored** entirely, both as a floor (`atLeast`) and as an override (`exact`). Both are now honoured, and they need OPPOSITE mechanisms: `atLeast` is a minimum, which is what a CSS height on a row already means, but `exact` CLIPS, and a CSS height on a row or a cell is only ever a minimum. Clipping needs a box inside the cell that can actually be overflowed. Emitting `exact` as a plain height makes those rows GROW, which is why the first attempt made CA_Courts worse: its table 21 measured 339px against the browser's 422, and R1 fell out at 26 printed pages against 25 on screen. Clipping brought that table to 341/339 and took the corpus-wide table disagreement from 332px to 200px. - **Inside borders were not charged.** `w:sz` is in eighths of a point and it costs once per row, which on a thirty-row schedule is most of a line. Also fixed, found the same way: a **continued vertical merge** (`w:vMerge` without `w:val="restart"`) was having its content measured on every row it covered, and the demo's `.para { min-height: 1em }` rendered a six-point spacer paragraph at twelve, because `1em` is the INHERITED size. Then two more, and these are the ones that moved the numbers most: - **`w:tblW` and `w:tblInd` were ignored**, so every table was stretched to the full text column. `w:tblGrid` is not a set of proportions -- it is the width Word MEASURED -- so an `auto` table is exactly as wide as its grid. MITP_KF_2026's 37-row schedule went from **-348px to -1px**, and the worst column-width disagreement across all 22 corpus tables fell to **1 pixel**. - **Table styles were not in the cascade at all.** A `w:tblStyle` describes up to thirteen appearances -- the whole table, the first and last row, the first and last column, the horizontal and vertical bands, the four corners -- and `w:tblLook` says which apply. Order is the whole thing: bands are weaker than first/last row, and corners are strongest. Both forms of `w:tblLook` are read, because Word 2007 wrote a hexadecimal bitmask and plenty of documents still carry it. SOW_Template went to **100% agreement with the PDF**. Nested tables inside a cell were also being skipped entirely, contributing nothing but their margins. **Replacing guesses with correct readings can make the headline number worse before it makes it better**, and it did here: 894px of total disagreement, then 1036px once the guesses stopped cancelling each other, then **693px** once the widths were right. Worth expecting rather than being alarmed by. ## Fonts: normalised, not substituted The largest single class of error was documents naming fonts with no metric-compatible clone. MITP_KF_2026 is 2,187 Garamond runs and sat at 18% agreement while everything else improved. The fix is architectural rather than incremental: **the document is rewritten on import to name only approved fonts.** `WordDocument.open` does it by default, and `pnpm reference` renders the PDF from the normalised document, because that is what the app would export. Four places name a font, and missing any one leaves part of the document unaligned: | | | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `w:rFonts` | direct formatting, styles, numbering levels, headers, footnotes | | `theme1.xml` | the typefaces `w:asciiTheme="minorHAnsi"` resolves to. **The one people forget** — most Word documents reference fonts through the theme, so rewriting only `w:rFonts` leaves the majority of runs pointing at whatever the theme says | | `w:fontTable` | the declared font list | | `w:sym` | a symbol run names its font inline | Normalisation is idempotent, does not alter a character of text, and reports what it changed. The corpus turns out to name Wingdings, PMingLiU, Roboto, Georgia and ヒラギノ角ゴ Pro W3 among others; all of them now resolve to something the reference container actually has. **MITP went from 18% to 61%.** SOW_Template went to 100%. NY_SERDA fell from 64% to 56%, which is an honest regression and not yet explained — every number moved, because every document now uses different fonts than it did. ## Measuring distributed error The render harness reported the worst three blocks per document, and that could not see the biggest bug in it. NY_SERDA had **36 overflowing sheets and no single block off by more than twenty pixels**: a small systematic bias repeated six hundred times, invisible in a maximum and obvious in a mean. So the harness now reports, per document, the **mean signed per-block disagreement** and how many blocks drift by more than two pixels. Adding that column found a **-14px bias on every document in the corpus at once**, which turned out to be two bugs sitting on top of each other: 1. **Paragraph spacing was a CSS margin.** CSS collapses adjacent vertical margins to the larger of the two; Word ADDS the previous paragraph's space-after to the next one's space-before. So an ordinary document rendered shorter than it was measured, every paragraph, all the way down. A margin also sits outside the border box, so it was not even in the height the DOM reported. Spacing is now `padding`, which neither collapses nor hides. 2. **A blanket `margin: 0 0 8pt` on every paragraph**, left over from the first version of the stylesheet. It had been invisible for the whole project because it collapsed against the inline margin; the moment spacing became padding it started adding ten pixels to every paragraph. Mean per-block error afterwards: NY_SERDA **-13.1px to 0.4px**, and its drifting blocks fell from 452 of 590 to 194. Employment_Agreement and NDA_Template went from every block drifting to none. A third change was tried and **rejected on the evidence**: keeping space-before at the top of a page, rather than dropping it as Word does. The harness measured no improvement on any document, so the simpler behaviour stands. That is the harness earning its keep in the other direction. ## Cell-level diagnosis, and tab stops The table harness could localize a disagreement to a ROW, and a row is as tall as its tallest cell, so it could not say which cell — and a cell is a stack of paragraphs, so even the cell is not the end of the trail. It now reports both, with the paragraph stacks side by side: ``` CA_Courts_Agreement [21] worst cells (dom / model): r 2c1 cell 182/ 145 paras [55,18,18,18,18,18,18,18] / [34,10,15,18,18,18,18,14] ``` One thing to read carefully, and the tool says so: **a cell RENDERS at its row's height**, because every cell in a row stretches to the tallest. A short cell reporting a huge disagreement is usually reporting its neighbour's. The paragraph stacks are the signal — they are the cell's own content, unstretched. That view showed the error is per-paragraph inside cells rather than in the cell geometry, which pointed at two things: - **`effectiveParagraphMark` was missing the table style.** An empty cell paragraph has no runs, so its whole height comes from the mark, and it was resolving to the document default rather than the table's. Every empty cell in a styled table was measured at the wrong size. - **`w:tabs` explicit tab stops were ignored** entirely — every tab went to the default half-inch grid. Stops **accumulate across the cascade** rather than overriding, which is the trap: a paragraph's `w:tabs` adds to what its style defined, and `w:val="clear"` removes one. Treating the innermost as the answer, the way every other non-toggle property works, loses every stop a style contributed. **VA_SLA went from 20% to 70% agreement, and its page count from 14 to 15 — matching both the reference PDF and Word exactly.** Overall agreement went from 48.9% to **52.3%**. ## The strut The single largest R1 fix, and the least obvious. Every line box begins with a **strut**: an invisible inline box using the BLOCK's font and line-height. A paragraph was inheriting the page's 12pt while its own text was 7pt, so the strut had a different baseline-to-box relationship from the text and the line box became the union of the two -- taller than the `line-height` that had been set on it. The evidence was a signature block that specified `line-height: 10.73px` and rendered at **13px**. Both numbers were right; they were describing different boxes. The paragraph mark governs it, which is also what Word means by the formatting of the ¶ glyph: an empty paragraph is as tall as its mark, and a full one's strut is the mark too. So the bridge now emits the mark's size as the paragraph's `font-size`. **CA_Courts went from 6% to 87%**, its overflowing sheets from 18 to 10 and its mean per-block error from 2.6px to 0.2px. NY_SERDA reached 99%, and page counts came into line on every document in the corpus. ## Two demo-layer bugs that looked like layout bugs Both cost a real investigation, and both are worth remembering because the symptom pointed squarely at the library while the cause was in the workbench's stylesheet. **The style label was a float.** The workbench draws each paragraph's style name in the margin with a `::before`, and it was `float: right`. A float is IN THE FLOW: it shrank the first line of every styled paragraph and changed where that line broke, so the browser wrapped a word earlier than the paginator did. What finally pointed at it was eliminating everything else -- our text measurement matches the browser to a sixteenth of a pixel on the same string in the same face (243.65px against 243.59px), the content width matched to the twip, and the paragraph still broke differently. Absolutely positioned now. **NY_SERDA went from 84% to 97%.** **A page-level `line-height`, and a blanket paragraph margin**, both described above. The pattern is consistent enough to be a rule: when the measurement agrees and the rendering does not, suspect the stylesheet before the model. ## The residual on in-paragraph breaks A break drawn inside a paragraph is placed at the character OUR line breaker started a line at. Where the browser's line boundary differs by a word, the block-level widget splits that line and leaves a fragment: in CA_Courts's worst paragraph one line renders 78px wide, and the paragraph reports 32 lines against 30 computed. So the mechanism works -- Oregon and NY_SERDA prove it -- but it is only as accurate as the agreement between our line breaking and the browser's. Every remaining divergence in that agreement now shows up twice: once as a height difference, and once as a spurious short line. ## R1 today Remeasured 2026-09-03: ``` document pages ovfl screen=model screen=pdf screen=server glyphs screen=libre mean± CA_Courts_Agreement 26/26 0 100% 100% 100% 94% 13% -0.4 Employment_Agreement 2/2 0 100% 100% 100% 99% 100% 0.2 MITP_KF_2026 37/37 0 100% 100% 100% 31% 76% -1.0 NDA_Template 2/2 0 100% 100% 100% 100% 100% 0.2 NY_SERDA_Agreement 56/56 1 100% 100% 100% 93% 84% -2.5 OECS_SOW_2026 2/2 0 100% 100% 100% 27% 100% -1.6 Oregon_MSA 44/44 0 100% 100% 100% 93% 45% -0.5 SOW_Template 2/2 0 100% 100% 100% 56% 100% 0.5 VA_SLA 15/15 0 100% 100% 100% 37% 100% -0.0 ``` **All nine documents print exactly what is on screen, page counts match on all nine, and the PDF we draw ourselves -- with no browser anywhere -- puts every block on the same page as both.** Across the twenty generated page-setup variants (`pnpm variants`) -- A4, legal, A5, landscape, half-inch and inch-and-a-half margins, and two with mixed sections -- it holds on nineteen. The twentieth is `zvar-VA_SLA-mixed-size`, at 75%, which is mixed sections and is below. Read the columns in this order. `screen=model` is an invariant, not a score: below 100% the browser is not drawing what the paginator computed, and every other number is measuring something built on sand. `screen=pdf` is R1. `screen=server` is the same question asked of a renderer that cannot cheat by being the same engine. **`glyphs` and `screen=libre` are not gates, and they fail in opposite directions.** `glyphs` is the honest remaining weakness: it asks whether the two PDFs put the same WORD within a point of each other, which the page-assignment columns cannot see, and it is mid-work in `src/pdf/render.ts` -- the disagreement is mostly horizontal (median dx -0.7 to -2.8pt on the weak documents, median dy about zero). `screen=libre` asks whether LibreOffice agrees, which we have never promised: tdoc is the authority on its own pages. CA_Courts sits at 6% because LibreOffice makes it 21 pages against our 25, and that is a difference of opinion rather than a defect. **A percentage can move for a good reason.** Honouring explicit page breaks took Oregon's page count from 43 to 44, which is what LibreOffice and Word say, and its `screen=libre` FELL from 58% to 45%. It had been 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. Prefer the page count to the percentage when they disagree. ### What is still wrong Measured, and worth keeping in front of anyone picking this up: - **Three documents print text outside their own margins.** CA_Courts on twenty-one pages (p1 left by 36pt), NY_SERDA p2 top by 34.9pt, Oregon p2 top by 45.9pt. This is the geometry half of R1 and it is a real failure, distinct from page assignment being right. Every Oregon variant shows the same p2 fault, which says one bug rather than one document. - **Oregon is a page long from block 46**, newly visible now that its length is right, and its p2 top-margin fault is at the same place. One lead, probably one bug. - **A sheet still overflows**: NY_SERDA 50, by 9px, down from four sheets in August. The narrow and wide variants are much worse -- `zvar-Oregon_MSA-wide` overflows eight, one of them by 36px -- which says the residue is a function of how much of the column the text fills. - **Words drawn on top of each other on four narrow variants**, zero on the corpus. VA_SLA-narrow collides on thirteen pages, always the same word over "Department" down a table, which is the shape of a repeated table header rather than a marker. - **`glyphs` on OECS (27%), MITP (31%), VA_SLA (37%) and SOW (56%)**, above. Now ratcheted. - **Mixed page setups inside one document are not supported.** `zvar-VA_SLA-mixed-size` reports `screen=pdf` at 75% and 15 screen pages against 17 printed. Every document whose sections agree is at 100%, including landscape ones -- it is the CHANGE that is unhandled. - **`w:keepNext` is parsed and not enforced**, deliberately; see below. ## Breaking inside a block A page break in the middle of a long paragraph is **not** a problem, and it is worth saying so plainly because the shape of the current bug makes it look like one. Two things are separable: - **Where the break goes** is the paginator's decision, and it already works at line granularity: `fillPages` computes that lines 1-12 of a paragraph are on one page and 13-20 on the next, applying widow and orphan control while it does. - **Drawing that** needs a break marker at the right point in the DOM. ProseMirror's `Decoration.widget` takes ANY document position, including one inside a text node, so a marker can sit between line 12 and line 13. Nothing about the architecture prevents it. **Both are now built.** `fillPages` records a `Split` -- the block, the line or row it divided at, and the page -- and the editor draws it: - **A paragraph** gets a widget at the CHARACTER its next line starts at. `Line.startOffset` carries that, counted through dropped whitespace and atoms so it addresses the paragraph's text rather than the line's. Placing it at a line boundary is what makes it safe: a block-level widget inside a paragraph forces a line break there, and at a line boundary that is a break the browser was going to make anyway. - **A table** gets a class on the `` the paginator divided at, and the stylesheet gives that row `break-before: page`. A widget cannot go between two `` elements -- the browser hoists it out of the table. ### A print-only break is half a break That row rule was `@media print` only for a while, and the cost was invisible until the harness was asked WHERE the drawing first parts company with the paginator. On paper a split table broke; on screen it flowed straight through its own page break, so every block after it sat one page earlier than the model had put it. VA_SLA, the corpus document with the longest table, was stuck at `screen=model` 82% and `screen=pdf` 84% because of exactly this. `sizeSheets` now selects `.page-break, .row-break` together, in document order, and gives a row break its filler as padding on the row's cells rather than as a gap band, since a `` cannot carry one. Three consequences worth keeping in mind for any future break that is not a widget: - Print must clear the **variable**, not the padding: `--row-filler: 0px !important`. Zeroing only `padding-top` left the calc intact somewhere and printed MITP 60 pages instead of 36. - The probe must count that padding as furniture, or the split table's block height reads as the filler plus the table and the mean error goes to +159px. - The fix moved four documents at once. A break the paginator records and the screen does not draw is not a rendering detail; it is a whole class of downstream disagreement. ### The band and the filler are different things `sizeSheets` measures with the fillers removed, so the numbers describe the content alone. A `.page-break` widget keeps its own margins and gap during that pass and only `--filler` goes to zero, which is what makes `added += filler` correct: the constant part is already in the natural tops. A row break has to obey the same rule, and at first it did not: one variable carried the filler AND the band, so measuring zeroed both. Every row break then under-reported the tops below it by a whole band, the fillers computed from those tops were too large, and the flow drifted downward with each break. MITP's content ran FIVE SHEETS past the end of a 36-page document while reporting two overflowing sheets, because an overflowing sheet is a different failure: that is one sheet's content being too tall, not the sizing being self-inconsistent. So `--row-band` is set on the row by the decoration, and `--row-filler` by `sizeSheets`, and the cell adds them. `pnpm render-check` now asserts the invariant directly, under "content running PAST the last sheet": no block may be drawn on a sheet that does not exist. One more line-breaking difference surfaced while measuring it: **an overlong word breaks at the margin.** A signature rule is a run of 219 underscores with no break opportunity in it, and there are two on every contract in the corpus. Word divides it at the margin and so does a browser; a breaker that does not put the whole run on one line and reported the paragraph as a third of its real height. ## Two different questions, and only one of them is R1 This distinction took a long time to see, and it matters more than any single fix: | | | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pnpm render-check` | the browser against a **LibreOffice** PDF, which RE-PAGINATES the `.docx` from scratch. A Word-fidelity proxy: it asks whether our paginator agrees with somebody else's, and it will never be perfect because we are not reimplementing Word | | `pnpm print-check` | the browser against **the PDF printed from that browser**. The printer does not repaginate: each page break carries `break-after: page`, so it draws the pages the paginator already chose | **The second is R1.** It should be at or near 100%, and anything short of it is a defect in the print path rather than a difference of opinion between two layout engines. The first is worth keeping and worth not confusing with a product requirement. ### What print-check found ``` document screen pdf agree overflowing sheets Employment_Agreement 2 2 100% 0 NDA_Template 2 2 100% 0 SOW_Template 2 2 100% 0 OECS_SOW_2026 2 3 100% 1 MITP_KF_2026 36 13 96% 5 VA_SLA 15 16 84% 3 Oregon_MSA 42 43 40% 23 NY_SERDA_Agreement 55 56 32% 43 CA_Courts_Agreement 25 26 11% 19 ``` **The correlation is with overflow, and it is near-perfect.** Every document with no overflowing sheet prints exactly the pages that were on screen. Every document that diverges has sheets whose content did not fit. The mechanism is visible in the output: VA_SLA's printed page 3 holds 29 characters. One of our sheets was too tall, Chromium broke inside it because that is what a printer does with content that does not fit, and every page after it shifted by one. So the R1 blocker is a single thing: **sheets that overflow**. Fix the page fill and the print path follows, because nothing else about it is wrong — the breaks land exactly where the paginator put them on four documents already. ### Why sheets still overflow Not measurement. Oregon has 23 overflowing sheets with **13 of 843 blocks drifting and a mean error of zero**: every block on those sheets is measured correctly and the sheets still do not fit. The page FILL is adding up differently from the browser. Suspects, in order: 1. Space-before at a page top, which the paginator drops and the DOM renders. 2. The height charged for a paragraph split across a break — the paginator counts the lines that fit, the DOM flows the whole paragraph. 3. Table rows straddling a boundary: our paginator splits a table between rows, but the DOM holds one `` 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 `` 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.
` 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 `