The default tab interval was fixed at 1.25cm, so a file's own
w:defaultTabStop / style:tab-stop-distance was ignored and the DOCX
export wrote 1.25 back over Word's 1.27. It is a document setting now.
A stop can also carry the character it repeats across its gap. odf-kit
has no leader option and mints one automatic style per option set, so
the leader rides style:type behind a sentinel — matching on position
and alignment alone gave every stop at 6cm the same leader, which
LibreOffice drew back plain as day.
A document had one set for all of it, taken from whichever section
declared one last — so a fixture lost its letterhead entirely
and page 1 got the empty header its final section asked for.
A body block now carries `sectionBreak`, ordinal rather than indexed so
editing cannot desync it. Import resolves Word's "Link to Previous" per
reference type; export writes a set per sectPr, and on the ODF side each
section past the first gets its own master page, which the block switches
to. The layer picks a page's set from where pageBreaks reports the
sections starting, and each section's zone reaches ride
`--pb-section-reach` so a later section's letterhead makes room for
itself: page 10 of a fixture was 24.9mm out, now 1.1mm.
Editing still reaches section 1 only; the rest render read-only.
The root file is read at every session start and had grown to 87 KB, so every
session paid for knowledge it did not use. Move the per-directory detail into
nested CLAUDE.md files, which load when a file in that directory is touched,
and the five deep-dive topics into docs/. The root keeps the commands, the hard
rules and an index of where the rest lives.
Content moved verbatim — the probed Word/LibreOffice behaviour, sentinel order
and shared constants are what the code cannot say. CI fails the root over 120
lines, since a prose budget alone is what let it grow.
CSS gives ::marker no position, so Chromium set it flush against the
text — about 4mm right of where Word and LibreOffice draw it. The marker
is now the item block's ::before, placed at the hanging indent both
exports already write (LIST_HANGING_CM 0.635cm): measured against LO's
own render, the three bullet levels land within 0.04mm.
Out of flow, because in flow it grew every list line by a pixel and cost
a fixture fixture a page. `list-style: none` drops only the marker box,
so the list-item counter and <ol start> keep numbering; a bullet's own
char rides an inherited property, since attr() in a pseudo would read the
block rather than the list.
Three of them described something the reference does not do. The trailing
space is counted by LibreOffice as well; neither engine breaks after a
slash or inside a run of dots; and the hard-break line was real but came
from the paragraph mark, not the break.
Each was checked against soffice on a one-page fixture, so the record now
says what was measured rather than what was inferred.
A long URL or a German compound ran straight past the right margin, where
Word and LibreOffice break it — measured against LO in a 2cm column, the
break now falls on the same character.
`break-word` rather than `anywhere`, so a table column's minimum width is
still its longest word.
The reserved paragraph mark is inline content past the block's last BR,
so it opened a line of its own: LibreOffice set the next paragraph 4.7mm
higher than we did, and a fixture paid for every one of them.
The empty-block rule already pulled the mark out of flow for exactly this
reason — it now covers any block that ends in a break, seating the glyph
on the last line rather than the first.
The cell padding was a constant in three places that disagreed with each
other, so a narrow column lost 0.38cm it did not have: a bibliography
marker column of 0.71cm wrapped "[2] " onto three lines and every entry
grew with it, costing a fixture a whole page.
The table now carries its own margins (w:tblCellMar, direct or from the
table style; fo:padding on the ODF cell style) and both exports write
them back. A side within a twentieth of a millimetre of the default is
the default, or Word's 108 twips would tag every table.
The parity runner pinned one build number, so a fresh
`playwright-core install chromium` left it pointing at a directory that
is not there. PARITY_CHROME still overrides.
keepLines (w:keepLines, fo:keep-together) is keepNext's sibling and was the
one of the pair nobody read: a block that declares it now paginates whole,
unless it is taller than a page, where Word splits it too.
The vertical frame offset is data only from here on. A line box avoids a
float's whole margin box, so applying it made dead space where Word flows
text — measured: a fixture's figure page went from 131mm off to 187mm.
Text boxes and shapes had no offsets at all, and for images the wrap side
overrode the x: a file that names the side its text flows on lost the
coordinate, our own export included. Side and place are read separately now,
and one helper turns both into the float's margins for every frame kind.
The re-save leg is the only place that proves what we write is what LO
understands. A raised run is stored as a percentage of the font size, so the
fixture picks one that stays whole — LO rounds the percentage on save.
Letter case, the underline's shape and colour, a double strikethrough and a
freely raised run all had no home in the model, so every one of them was
dropped on import. They ride the marks now, render in CSS, and round-trip.
odf-kit's run formatting has a field for none of them, so a sentinel folds
them into the run's own automatic style — the route the character styles
already take, and it reaches every path odf-kit serializes.
Its leader is real dots now, not a border, so it scales with the font and
reaches the PDF as text. Title and entries follow the document's Heading 1
and default style, which is what the index styles of Word and LO are based on.
Word's positionV and ODF's svg:y say how far below its paragraph a frame
sits; ignoring it put every figure at the anchor itself. It is the float's
top margin, so lines that fit above the frame still flow there, as in Word.
It compares where text sits, so a dropped highlight or fill reads as green;
the visual check that catches those, and its two traps, are written down.
Also records the list marker's ~4mm offset, which the tolerance hides.
Word's highlighter is w:highlight, a palette name; only w:shd's hex fill was
read, so 121 yellow runs of a fixture imported unmarked. 'none' maps to the
same 'auto' sentinel an empty w:shd gives, so it still cancels a style's pen.
Everything else in tests/ compares documents at the model level; nothing checked
what a page actually looks like. run.mjs renders the same file through soffice
and through the real app in Chromium, reads both back as word boxes, and diffs
page count, line count, line text and line position.
The corpus stays out of the repo: make-fixtures.mjs authors the baseline .docx
files, and real-world documents are whatever you drop into the gitignored
fixtures/ yourself.
A drawing the editor cannot render was dropped outright, and everything below
it moved up: a thesis with five charts and four EMF figures lost 102cm of
layout, five whole pages of it. They now come in as a labelled box of the
drawing's own size, so the pages hold what they hold in Word.
A German document's "Inhalt" came back as "Table of Contents" — the node
carried no title of its own, so the generated one always won. Now the
imported heading rides on the node and goes back out with it.
A letterhead header is usually a couple of empty lines, the logo, then one
more — the empty leading ones were dropped on import, and the body's content
area counted every line as one text line, logo included. Page 1 of a contract
form started 19mm too high.
A heading whose successor no longer fits below it was left stranded at the
page foot; Word and LibreOffice move it down with them, which is why their
heading styles all carry w:keepNext / fo:keep-with-next. Headings do it by
default here, so the attr only marks the other blocks that ask for it.
One level deep: a run of kept-together blocks would need the pagination pass
to reconsider leaves it has already placed.
Word's Title style tightens by -0.5pt, so a title came out ~3mm wide of
LibreOffice's. letterSpacingPt joins TextProps and rides both formats
(w:spacing, fo:letter-spacing).
Line spacing needed the same care: it is proportional to the font's natural
line height, so the factor and that height are now separate CSS variables and
editor.css multiplies them in one place. Before, whichever of the two was
declared nearer simply overwrote the other — a block carrying its own font
silently dropped the style's spacing.
The block's CSS strut is the floor under every line, so a paragraph whose runs
are smaller or of another family than the style rendered too tall — 03-fonts
drifted 4mm over a page. The mark's family now rides the block like its size
already did (Word's w:pPr/w:rPr/w:rFonts, ODF the paragraph's own text
properties), and supplies the natural line height single spacing means.
A mark that declares nothing but whose runs all agree takes their font: that
is the paragraph's font, and it is what a word processor struts with.
Word anchors a wrapped frame at an offset; the model only knew a side, so a
frame the file puts 91mm in snapped to the margin and the text beside it came
out 20mm too narrow. wrapOffset carries that x (cm from the column's left
edge, Word's posOffset / ODF svg:x) and the float's outer margin renders it —
against the column vars, so an indented anchor paragraph cannot skew it.
Picking a wrap side in the toolbar clears the offset: that gesture means
"put it there".
An inline atom wider than the column is pushed onto a line of its own and
leaves the empty line above it in the document — 5mm per image. A picture the
file sizes to the column lands there anyway once its cm/EMU width is rounded
to whole px, which is how a full-width Word picture triggered it.
A paragraph holding an image rendered a line taller than it does anywhere
else: under white-space: pre-wrap the browser already shows the last line, so
ProseMirror's hack BR adds an empty one. The hack is kept where it earns its
keep — after a real break it carries the caret onto the empty line, and in an
empty block it is the only line box there is.
Word's inserted page number sits in a w:sdt, so the paragraph is not a child
of w:ftr and the whole zone came back empty. Mirrors what convertBlocks
already does for the body.
wrapText="bothSides" says nothing about the side, so every such frame floated
left — including the ones Word puts in the right half of the column, which
then pushed the text off the page's right edge instead of flowing beside it.
Word's Table Grid — the style nearly every real table names — sets space
after 0 and single line spacing in its own w:pPr, which is why a table's rows
are line-tight. The cell paragraphs were taking the document default instead.
An explicit single spacing is now written out as well: it only used to be
dropped as redundant, but the block's own style may set another one.
Word's List Paragraph carries it, so consecutive list points sit line-tight.
Without it every one of them took the document default's 8pt after, and a
document that is mostly bullets drifted more than a page.
A run with no font already fell back to the document's theme; the style did
not, so the registry held Standard and the headings without a family. Single
line spacing is measured against that family, so it resolved to the wrong one.
Word's default theme heading font, so every Word file with a Title or a
heading references it. Without a face it dropped to the browser's serif —
a serif heading at the wrong width.
A proportional line spacing multiplies the font's own single line height in
Word and LibreOffice, CSS multiplies the font size. The node attr already
scaled by Liberation Serif's 1.15; a style's line-height went out raw, so an
imported 1.08 rendered ~20% tighter than the file asks for and the drift
accumulated down the page.
odf-kit's list builder reads a run's marks itself and knows nothing about
charStyle, and applyRuns — which handles the style on every other path — never
sees a list paragraph, so a character-styled list item exported as plain text.
A pre-pass now does both of applyRuns' jobs: bake the resolved props into direct
marks (the run's own marks staying on top), which is what gets the run a span at
all, and prefix the CST sentinel so applyCharacterStyles re-points that span at
the named style. Formatting and name both round-trip.
The charStyle mark carries only a name, so resolving it needs the registry: the
plugin takes it as a `sheet` option (like ParagraphStyle), the exporters read
their own exportSheet. The style is the base, the portion's own marks stay on
top. The decoration pass now also re-runs on FORCE_PAGE_RECALC, so editing a
style repaints the markers.
The ODF marker style moves to styles.xml as a named style — LibreOffice ignores
a level's text:style-name when it resolves to an automatic style, which made the
whole ODF leg a no-op (probed both ways).
The marker took only the weight of the item's first text portion. It now takes
the family, slant, size and color too — as custom properties on every item,
since they inherit and a nested item must not take its parent's.
A named paragraph style reaches it a second way: the marker inherits the list
item's own font, and the document stylesheet only styled the item's paragraph,
so an imported document whose Standard style is Arial drew its numbers in the
editor's serif default. styleCss now emits the style's text half for the item
too (li:has(> …)).
Both file formats carry marker formatting per level, so a level definition is
written only for a list whose items agree: ODF mints a character style the level
points at, DOCX the level's w:rPr. A mixed list gets none on purpose, so
LibreOffice keeps applying its own first-portion rule.
A bold mark sits inside the item's paragraph and never reaches ::marker, so the
roman numerals of a numbered heading stayed light while their text was bold.
A plugin tags such items and editor.css weights the marker. The trigger is the
first text portion, which is the rule LibreOffice numbering itself uses.
Its size is the block's line-height floor, not just an empty line's height:
10pt text in an 11pt style kept the taller strut and every line came out 0.4mm
high. Both importers now carry it as the blockFontSize attr whenever it differs
from what the block renders at, and the ODF export emits the FSZ sentinel for a
filled block too.
A list's own indent is the offset from the level's 1.27cm base, and the
importers dropped it when the file put the list left of that (w:ind w:left="360"
is a common one), so every item rendered 6.3mm too far right. The offset is
signed now, floored at the base so the list stays inside the text column.
blockDefaults(baseRun) made the Header/Footer style's own font the yardstick,
so a run matching it imported without marks. The zone carries no styleName and
no style CSS reaches it, so it then rendered in the editor default instead —
an Arial footer came back as Liberation Serif, 2.3mm narrow per line.
The mirror of the left indent (fo:margin-right, w:ind w:right) with its own
marker on the ruler. ODF export rides the PBX sentinel: odf-kit has a paragraph
option for the left indent but none for the right one.
Clearing direct formatting now resets the first-line and right indents as well —
no named style governs either, so both are always direct formatting.
Click the strip to place a stop of the selected type, drag to move it, drag it
off to drop it; the two triangles set the paragraph's first-line and left
indent. Sticky inside the scroll area, so it clears the toolbar island and
stays aligned with the page as it scrolls or zooms.
CSS only has the fixed tab-size grid, so a tab that resolves to one of the
paragraph's stops is measured and given the exact advance as an inline margin.
A hanging indent implies a stop at the text position, as in Word.
Lists were padded 1.5rem per level on screen while the DOCX and ODT
exports place item text at LIST_LEFT_STEP_CM (1.27cm) per level, so the
editor disagreed with its own output and with Word and LibreOffice by
6.4mm on the first level, doubling with each nesting level.