Word wraps every citation in a w:sdt; the inline walk ignored the wrapper, so
its runs never reached the paragraph. a fixture fixture lost every [7]-style
reference with them, and now matches LibreOffice's page count.
Word's w:lvlJc / ODF's fo:text-align on the level: set right, the label grows
into the margin instead of over the text it labels. That is what the built-in
Roman numberings use, and a fixture's numerals collided without it.
Both anchor offsets now render: the x moves the rotor inside the full-width
float, the y the float's top. Chromium puts every line after such a float
below it, so a frame set below the paragraph top is sunk behind the text at
import and the node view measures what those lines already cover.
A formula node stores only its LaTeX source; the MathML on screen, the ODF
formula object and Word's OMML are derived from it, and both importers parse
foreign markup back into that source, so an imported formula stays editable.
Word documents were losing their formulas outright: m:oMath sits beside the w:r
runs in its own namespace, past convertInline's `namespaceURI !== W` guard.
Only the top level's own w:ind w:left (ODF: the label-alignment margin)
was read; every level below it was forced to the editor's 1.27cm step.
Both formats store a level's indent absolutely while the editor nests one
step per level, so import now subtracts the level above and the ODF
export adds the running sum back per level (DOCX already accumulated).
Measured: a 0.5cm/-0.6cm pair puts LibreOffice's two levels at 1.77cm and
2.44cm, and the editor draws them at 1.769 and 2.44. Parity corpus
unchanged.
w:tcMar and an ODF cell style's own fo:padding were both dropped: the
table's value (in ODF the first cell's) stood for every cell.
cellPadding is now an attr on tableCell/tableHeader too, measured against
the table's rather than the editor default, so only a real disagreement
carries one. --cell-pad inherits, so the cell's own declaration wins for
its own td and the CSS is unchanged.
Checked against LibreOffice: a 1.2cm cell in a 0.3cm table puts its text
at 332.2pt where the grid column starts at 297.6.
The zones already imported, rendered and exported per section, but only
section 1 was editable state — the rest were marked read-only.
Editing now targets the section of the page it starts on: a double-click
edits the zone under the pointer, the Layout-panel buttons the current
page's. zoneKey/writeZone route the single live editor's read and its
write-back to either the six per-zone props or the extraHfSections entry.
Measured against LibreOffice on a purpose-built fixture the rule is
right, and on the corpus it is a loss: one fixture gains 22 issues.
The strut is also what holds up a line of no runs and, through the
reserved ::after, the paragraph mark on the last line.
LibreOffice restarts the tab grid on the continuation line; Chromium
hangs the leftover tabs and starts the next line at the margin, 160mm
away in the repro. The run is walked from the pen before it — the one
thing the fix can't move, so the answer holds whether or not the break
is already in place — and a break widget goes where the line runs out.
Measuring this turned up why the two disagreed in the first place: a
file that declares no tab interval falls back to ODF's own 2cm, not to
LibreOffice's UI default of 1.25, so leaving ours implied on export
rendered every tab elsewhere. Both exports declare it now, and both
imports fall back to their format's value rather than to ours.
Only runs of two or more are walked: reading a block's geometry forces
a reflow, and doing it per tab across 48 pages slowed pagination enough
that the parity harness compared a layout still settling.
The left⇥centre⇥right layout every letterhead uses rides on them, and a
zone had none: Word keeps them on the Header style, ODF on the zone
paragraph's, and neither was read or written.
An inactive zone is static generateHTML output no plugin reaches, so it
is measured straight on the DOM. Two bugs surfaced doing it: a segment
its own advance had pushed onto the next line measured as wrapped, and
the alignment then had no way back — it takes the sum of the segment's
client rects now. And the scale came from offsetHeight, which rounds
half a percent away on a zone one line tall.
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.
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.
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.
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.
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.
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.
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.
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.
Only the block's left margin was read, so w:firstLine, w:hanging and
fo:text-indent were dropped: every hanging-indent paragraph rendered with
its first line flush with the rest instead of outdented.
Stored as the signed indentFirst attr in cm (negative = hanging) and
rendered as CSS text-indent. Word splits it into two exclusive
attributes by sign, ODF keeps one signed value.
w:evenAndOddHeaders, w:titlePg and w:pageBreakBefore were treated as on
whenever the element existed, but w:val="false"/"0"/"off" turns them off
and both Word and the docx lib write that explicit form.
A file with <w:evenAndOddHeaders w:val="false"/> therefore took the
even-page header variant on every second page, which is empty — the
header and footer vanished from page 2 onwards.
Widow-orphan control was hardcoded on. It is a per-paragraph property in
both formats, so a file that turns it off paginated differently from
Word and LibreOffice.
Stored as the widowControl attr next to breakBefore. DOCX resolves
w:widowControl direct-then-style-chain-then-docDefaults; ODF reads
fo:widows/fo:orphans, where only an explicit 0 means off (absent is the
XSL-FO default of 2). Export rides the existing PBX sentinel rather than
adding a pass.
Cells padded 0.1cm on all sides; Word and LibreOffice use 0.19cm left and
right and none top or bottom. Text sat 0.7mm too far left and every row
was 2mm too tall, so the offset accumulated down a table.
odf-kit only writes the fo:padding shorthand, which ODF defines as a
single length — LibreOffice drops a two-value form — so the export
expands it per side.