An as-char text box takes its centring from the paragraph it is anchored
in, and the importer lifts it out of that paragraph — so every figure in
a fixture sat at the left margin. Carry style:horizontal-pos on the
box (an image needs none: with no side to float to it centres anyway).
Every row carried 200 dots — clipped on screen, but the clipboard, the
PDF and any measurement got all of them. Cut each to what its gap holds,
and step the levels by 0.5cm, which is what LibreOffice's Contents styles
use. The parity runner now waits for the page numbers to settle too.
Three faults in the zone layout: the wrap marker's value is '', so every
pass nested another span around the tab and added its advance again; a
stop past the line (18cm in a 17cm footer) hung the page number off the
page; and a segment measured after it had wrapped reads short by the
space the break swallowed, which kept it wrapped.
A block at a page top loses its space above, which shortens it, which
moves what follows — so the next pass read a layout its own decoration
had made, the two states alternated, and the ping-pong damper froze
whichever came first. Add the dropped space back when measuring and take
it off the flow below instead: same page, but the same one every time.
A hyperlink in a fixture's copyright footer threw generateHTML and
blanked the whole zone. Drop what the one-paragraph schema lacks, keep
its text. A master handing over to a successor is also the different-first-
page flag, so a title master with no zones leaves page one blank.
The default master was the one literally named Standard, which a document
need never reference: a fixture's body runs on OOoPageStyle, so its
footer band went unreserved and every page held ~15mm too much text.
An index longer than a page has no inner document positions for a
pagination spacer, so it now breaks itself between entries.
Two causes: rangeWidth added a rect per inline box *and* per text node, so
a segment inside a span measured double; and the pen was read back from
the DOM, where it already carried the previous pass's advance. Carrying
the pen along the line settles both, and the layout no longer depends on
how many frames pagination takes.
The rule read the padding it had just zeroed, so it flipped every pass and
the ping-pong damper froze it on whichever state came first. Read the
block's own --space-before, which the decoration doesn't touch.
style:master-page-name on a paragraph style is LibreOffice's "page break
with page style": the block always opens a page, except for the master
the document itself opens with.
The LibreOffice "page break" paragraph style carries the break on its
own end, not on the block that follows; unread, a fixture's title
page ran straight into the copyright page.
ODF text:anchor-type="page" is a cover graphic or a watermark: out of
flow, placed from its page's corner, behind the text. In flow, the two
full-page frames of a fixture's covers cost three pages.
A word processor's paragraph frame includes the space below it, so a
block whose text fits but whose spacing doesn't moves to the next page —
where the space above is then dropped, which the page-top rule missed
for a block pushed by its own spacer (its measured top still excludes
that push). Keep-with-next counts the same pair of spacings. Both probed
against LibreOffice; the OPG fixture's pages 3-6 now match it line for
line.
The style-registry path divided an ODF fo:line-height by 1.15 where the
block path, both DOCX paths and the export all treat it as the plain
percentage, so every paragraph whose spacing came from its style
rendered 15% too tight. Its suppression yardstick is the block's own
style too, so a 100% under a 115% style stays as direct formatting.
LibreOffice keeps a paragraph's space above and below in one item, so a
style that declares only one of them does not inherit the other from its
parent chain — the missing half falls back to the default paragraph
style. Probed against soffice with the OPG fixture's own styles, where
it is the difference between a heading sitting flush and 4mm too low.
LibreOffice adds the upper block's space-below to the lower one's
space-above, and drops the space-above where a page break put the block
at a page top — but for a Word document it takes only the larger of the
two (all probed against soffice). CSS margins can only collapse, so
space-before rides --space-before and editor.css renders it as padding
('add') or margin ('max'); the model is read from settings.xml, assumed
for DOCX, and written back on ODF export.
odf-kit writes `<office:automatic-styles/>` self-closed when it minted
nothing, and every post-pass injected by replacing the closing tag — so
in such a document the page-break, spacing, list and text-effect styles
were dropped and only the style-name reference survived. One
injectAutomaticStyles helper now serves all of them, content.xml and
styles.xml alike.
A positioned frame in a header is out of flow — the title-page background or
watermark. It keeps its wrap and its offset from the page corner, and the
layer paints it per page under the body instead of flowing it inline, where
it reserved a page-tall line of body space.
The page surface moves from .tiptap to .paper so the image has a layer
between the sheet and the text; ODF export writes it back page-anchored.
The TOC node regenerated over every heading level and minted a title of its
own. A file asking for level 1 only (text:outline-level, Word's TOC \o range)
got a 43-entry index instead of 12 — a full page where LibreOffice needs a
quarter, plus a second "Table of Contents" under the file's own heading.
maxLevel and an empty title now round-trip through both formats.
An ODF master page whose style:next-style-name names another master applies
to a single page. Read as the section's running header, this file's full-page
title background repeated on every page and its height pushed each block onto
its own — 276 pages instead of 16.
Its zones now become the first-page variants, the successor's the running
ones, and a positioned frame in a zone is dropped as in DOCX.
An autosaved document that hangs or throws while the editor comes up was
reloaded from localStorage on every refresh, leaving no way back into the app.
Loading now raises a boot flag that only a completed startup clears; finding it
still set means the last attempt never finished, so the document moves to
odf-editor-doc-broken and the app starts empty with a message.
Deleting a document down to one paragraph left its page-break spacer in the
DOM, so an empty second page kept showing. The ping-pong guard reset its
memory to '', which is also the key of a spacer-free layout, so the pass that
removed every spacer looked like an oscillation and never dispatched. The
reset now uses null.
Bookmarks are a mark, not a point: both formats store a range, and only a
range lets a REF recompute its text. A cross-reference caches its display
like a Word field and resolves live on each pagination settle — the
bookmark's text, or the page it renders on — keeping the cached value when
the target is gone. Ctrl+click jumps to it, internal #name links too.
ODF splices its sentinels into the run text so cells and custom paragraphs
need no separate emitter; DOCX needs no post-pass at all.
The DOCX importer read w:tblBorders off the table but never off its table
style, so a table whose style declares none (Word's Normal Table, and the
thesis' Formel-tab around every formula) fell back to the editor's 0.5pt
black default. Resolved per side along the w:basedOn chain, and an
undeclared side is not drawn — LibreOffice writes fo:border="none" for
exactly those tables when it re-saves the file. The ODF importer already
treated an undeclared side that way.
A top-and-bottom frame carrying positionH align / style:horizontal-pos
keeps it only where a nearby frame is set against the other end
(pairAlignedFrames, both importers); such a pair floats to its own side
instead of filling the band, so the two sit side by side as they do in
LibreOffice. A pair slightly wider than the column is scaled to it —
Word lets the two overlap, two floats cannot.
A formula wider than the text area painted over the app background
instead of being cut at the page edge, where LibreOffice cuts it
(thesis fixture, page 16). `overflow-x: clip` so .paper does not
become a scroll box.
An EMF is replayed into SVG (import/emf.ts): window/viewport plus world
transform, a DC stack, pens/brushes/fonts, the poly and path records,
EXTTEXTOUT and embedded DIBs. It rides the lazy pre-pass both importers
already use for TIFF, so a document without one pays nothing.
a fixture fixture's seven MATLAB plots arrive as pictures now.
<chart:chart> in the object's sub-document, read through the same picture as
the DOCX leg: series values by cell range out of its own local-table, colours
from the series style, bounds from the axis style.
word/charts/chartN.xml → an SVG picture of the frame's size: series from the
producer's numCache, theme accent colours, titles, gridlines and the axis
bounds the file fixes. Bar, line, area, scatter and pie; the rest keep the box.
Only a body block carries a styleName, so measuring the others against their
own style suppressed everything the style gave them and nothing re-applied it.
Word's caption style lost its italic and colour in every text box that way.
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.
A spacing above single puts all of its leading after the line (probed against
LibreOffice at 150%); CSS splits it. The block is shifted back up by that half,
which leaves the flow — and pagination — where it was.
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.
An as-character image is now a line box in getLineRects, so a paragraph of
stacked images breaks between them instead of overflowing the page. Only a
floated image keeps its paragraph atomic, and the leaf's height reaches down
to the lowest float — a float hangs out of the paragraph's box, so
offsetHeight alone let the image walk off the page foot. prevPlacementsKey
damps the ping-pong that overhang causes between two layouts.
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.
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.
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.
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.
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.
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.