Both formats carry a vertical anchor on the box body — ODF
draw:textarea-vertical-align, DOCX wps:bodyPr/@anchor — and both
sides of the I/O hard-coded "top", so an imported box dropped its
centering and a saved one never got it. New textVAlign attr, read
and written in both formats, rendered by flexing the rotor (the
handles are absolute and stay out of that flow), set in the Shape
Format tab beside the vertical-text button.
The ribbon mounts only the open tab, so the link, bookmark, cross-
reference and formula dialogs in InsertTab.svelte heard neither Ctrl+K,
nor the context menu, nor a double-click on a formula while any other
tab was up. They move to Ribbon.svelte; the Insert tab's buttons fire
the same events, and a formula event without a position opens the empty
dialog the button used to open directly.
Same leaf-node defect as the frames: setNodeMarkup replaces the atom and
the node selection maps off it, so the dialog's Apply dropped the
selection ring from the formula it had just changed. The frames' helper
is now shared as atomAttrTr and carries the leaf's own marks.
setNodeMarkup replaces a leaf node outright, so the node selection maps
off it: setting the wrap mode, resizing or rotating closed the frame's
floating toolbar and the ribbon's contextual tab, which made picking a
second mode take an extra click on the picture. frameAttrTr puts the
selection back where it stood.
Both frame toolbars and the ribbon's Arrange group now offer the two
run-through entries; they are one wrap mode, told apart by inFront.
Picking any mode by hand drops the offsets and the coordinate system
they were measured in (droppedFrameAttrs), so a page-placed frame set
to a side wrap no longer exports page-relative coordinates for a
paragraph-relative offset.
A frame out of the flow has no text position to re-anchor to, so the
drag adds the pointer delta to its own offsets (startFreeMove).
Word's positionH never counts from the anchor character, and the page a
frame lands on changes as pagination inserts spacers above it. placeFromPage
measures the frame's static position and turns both offsets into the margins
that reach the page corner — x from the text column, y from the page top —
and every pagination pass re-places them. A run-through text box can now sit
in front of the text (inFront) instead of always behind it.
Word's w:tblpPr takes a table out of the flow; the editor kept it in, so
whatever followed — and every frame anchored to it — sat as far down as the
table is tall. It now arrives as the box whose content a table may be, which
is the shape LibreOffice keeps such a table in, and both exporters write it
back as the file's own floating one. A drawing in one of its cells is
ordinary table text, so the box-in-box guard lets it stand.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
The 78px cap cannot wrap a word without a space in it, so a German compound
painted past the button and across its neighbour. A hugging label drops the
cap and sets its own width.
The base Enter chain's liftEmptyBlock lifted an empty block out of the
section (and split the fragment mid-run), so the caret left column 1 for
the page below instead of moving on to column 2. Inside a section Enter
now only splits the block; a list keeps its own escape.
Claude-Session: https://claude.ai/code/session_01Gpw1ctsR8FrdNMrpqePtik
The zone's table rows carry their height in w:trHeight, which the collapsed
paragraph had no way to hold: a 2.2cm header band read as one line and every
page's body started 8mm high. The height its lines do not fill now rides the
paragraph's space above, which the band measurement already takes — and a
header draws that space too, so the rule line the cell carries lands at the
foot of the band instead of under the first line. A footer's space above stays
a measurement: it holds the ODF zone's own gap to the body.
Measured on one template: 15 parity reports against LibreOffice down to 6.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
Both indexes and the cross-references each read it for themselves, twice per round in
the index's case: 59 readings of it over a load where 37 do. The round hands one to
every reader instead, so they all resolve their pages against the same grid, and the
lazy holders they kept go with it. No measurable time — the style pass a reading forces
is owed to the geometry read that follows it either way.
Reading an added node's position computes the style of the whole document, and the band
rebuilds its galleries whenever a document is opened: 300 ms of that during a load, for
40 gallery buttons and no panel at all. A click or a key in the band still classifies its
records at once; anything else is looked at a task later, where the style is there for
free. A 460-page file is on screen 270 ms earlier, every panel pinned as before.
Every field resolving against the settled layout — each index's page numbers, every
cross-reference — registers with scheduleFieldRound instead of taking a frame of its
own. A round measures for all of them before any of them writes, since a write between
two measurements lays the whole document out again, and their cached results ride one
transaction: a dispatch of its own costs a view update over the document, 55 ms per
index on a 460-page file. That file settles about 220 ms earlier, with every spacer,
page number and reference exactly where it was on five documents.
A single ResizeObserver refits the frames, reading each rotor before any wrapper is
written: a per-view one arrives in a callback of its own, so the browser laid the
document out again between two frames. pm-pagecount is announced only where the layout
differs from the last announcement — every reader re-reads the whole document from it and
writes back, which is another pass. Both node views also leave the DOM alone when update()
brings the same attrs object. A 460-page file holding 450 frames and two indexes settles
2.4 s earlier and is named 200 ms earlier, with every spacer and page number where it was.
Cross-references resolve in one batch per pass, the index reads every leader gap
before filling one, and a zone's tabs lay out with the other zones' — and only
where a field asks for the page count or the chapter map.
List marker and list style decorations are rebuilt only for a transaction
reaching into a list; the unsaved dot compares documents structurally, and the
autosave serializes when it writes rather than per keystroke.
The columns flow applies every op it can judge in a pass, so a section pages in
one pass. Two long files that took 33 s and 20 s now settle in 9 s and 2 s.
A `:has()` left of `+` in editor.css made Chromium restyle every element
under .tiptap on each inserted child, so the band-clearing rule keys on a
data-wrap-band attribute the imageLine plugin writes. getLineRects reuses
one Range instead of leaving hundreds alive. TipTap's Placeholder, which
hit-tested the viewport on every keystroke, is replaced by placeholder.ts.
docPosBeforeElement is memoised per pass. At 124 pages the pass after a
split drops from 410 to 150 ms and the split keystroke from 219 to 50 ms.
An edit's pass waits for 300 ms of quiet instead of running between keys, so
while the typing goes on only the keystroke itself costs. When the quiet comes,
edits that only put text into one textblock and left the content's bottom where
the last pass had it skip the pass: the block is as tall as before, so nothing
below it moved. A break inside the block still runs it. Measured at the top of
a 124-page document: 41 ms a keystroke in Chromium, no pass for a letter typed
and taken back. The dom run holds both.
The page-break decorations are mapped through every transaction and each
spacer widget carries a key, so a pass that lands the same spacers keeps
their DOM instead of rebuilding all of them. An edit within 150 ms of the
last pass waits for a pause in the typing. Measured at the top of a
124-page document: 1.5 s a keystroke before, 40 ms after. The dom run
holds the budget.
The shot follows the chapter heading rather than a fixed page, so it keeps both
page designs in view if the break ever moves. The gallery notes why the folio
and the document page differ there.
The book carries the chapter and the title towards the outer edge and its page
number centred at the foot; the newsletter opens with the masthead and runs a
head with a rule from the second page on. The thesis keeps chapter and number
in the head, so the three read as three page designs.
openApp() passes page options through, so the showcase run sets
deviceScaleFactor 2 — same CSS viewport and layout as the tests, 2800x2000 px
images that stay sharp where the README scales them down.
The MathML operator dictionary makes ( [ { | stretchy, so a plain bracket grew
to the tallest thing in its row — next to a sum with a fraction, C(b) came out
in full row height. Only a fence node emits stretchy brackets now.
A shot names a ribbon tab and, optionally, where the caret goes: the tables
one lands in a cell, so the contextual Table Layout tab is up. The floating
table toolbar is hidden while shooting — it covered the table the ribbon is
already showing. `data-tab` on the tab buttons is what the script clicks.
Header and footer go first, each with its own ops, and a variant flag is
flipped while a zone is open. The round trip compares a list's drawn kind and
marker rather than its node type and style name — a list that loses its name to
a formatted marker carries the style's own char — and skips the marks of an
inline atom, which no file keeps. A caret is snapped to a text position, a
swallowed File-menu click is retried, and WebKit's converging ResizeObserver
round is no page error. A new corpus pair brings all six zones through the
import path; the baseline holds it and a re-recorded showcase entry.
The two variant flags (a first page of its own, separate even pages) and the
header/footer distances were reachable only from the expanded toolbar. The
ribbon mode now has them in an Options menu beside the zone buttons.
A page break reaches the file only from a body block or a list item — a cell,
a frame and a note body all drop it — and an indent on a list item's paragraph
is the same; a plugin sweeps both. A note anchor keeps no marks and stays out
of a text box. Sub- and superscript exclude each other, a font stack the
contenteditable leaves behind keeps its first family, and a weight Bold already
claims is no second attribute. A table's first row is all header cells or none
(the promotion carrying each cell's own attrs), a replace into a table reads the
lost column weight back from the column, and a list item holds no table.
An empty first master page was swallowed by the next one's match, so the
watermark and the fold marks missed the first section entirely.
Every master of a section now writes the running zone, blank where its own
page has none — the layout takes the band off the margin for the whole
section, and an opening page lost 0.8cm of its own.
Both importers compared whether a run underlines, not how, so a red or wavy
line under a plainly underlining style came back plain.
tests/layout/baseline.json records where each page starts and what the load cost, per
engine and platform; a page starting elsewhere is a layout change the run prints page by
page, a load over three times its recorded time a regression, and LAYOUT_UPDATE=1 records
it again. The monkey draws frame sizes, wraps, alignment and column counts too, holds the
history against the edits rather than the caches the editor recomputes, and leaves it
unchecked where the columns flow rewrote the document outside it. The differences both
importers make by design are named in the comparison; unhoist moved to normalize.ts.
An .odt kept only the first paragraph of a list item; the rest ride it SEG-separated and
are split back out inside the same item. A header row the table did not ask to repeat
came back as ordinary cells, and only DOCX wrote it. An endnote's picture pointed at a
relationship the package never minted, and neither note's pictures were read through
their own part's relationships. A frame band-wrapped flush left is at no side of its own.
Blocks wrapped into columns drop a page break nothing there honours, and a cell's content
expression now says what both exporters can write into one.
lo-fuzz.test.ts hands every fuzz seed to LibreOffice as .odt and .docx and
compares what comes back, listing the differences LibreOffice makes by design.
test:layout opens the corpus, the showcase and fuzz seeds in the browser, holds
the page count against LibreOffice and checks the lines for overlap, margin
escapes and stranded headings. test:monkey types at random on corpus documents
and asserts the schema, undo to the start, redo, and that both saved formats
read back. browser.mjs now carries the dev server and the settle helper.
A section opening on a side hands its one-page master over to a follow-up
master instead of asking for a right-only page, which doubled the page count.
DOCX names its default style Normal and marks it as the default, zone tabs
stay tabs, footnote numbering rides in the section properties, and w:position
counts half-points. The importer keeps content controls, shape text boxes and
index page numbers, and opens no phantom section on the leading master.
Editing rejects the nesting the formats cannot hold, note repairs ride the
undo history, and the showcase files are rebuilt.
scripts/showcase/run.mjs builds three sample documents plus a review
variant as .odt and .docx through the app's own exporters in headless
Chromium, opens each through the file dialog and takes the README
screenshots; docs/showcase/ holds the results. The README gets a
light/dark hero picture and a gallery of four tiles.
openApp() launches chromium, firefox or webkit from playwright-core; the smoke
and DOM runs pass in each. CI runs both per engine, WebKit on macOS for
Safari's text shaping. build.target is set explicitly to the browsers the
bundle promises. CLAUDE.md is back under its 120-line budget.
A click lands wherever the element's centre happens to be, and a break before
the empty last paragraph yields no page; the editor's focus() lands on the next
animation frame, so a key sent before it was lost now and then.
npm run test:dom opens a corpus document in headless Chromium and holds
its page count against a settle, a reload, a zoom, a page break and its
undo, plus the unsaved dot — the components the vitest suite cannot see.
The smoke run now shares its boot with it (tests/browser.mjs).
It came in by its URL, showed from the web and was dropped without a word
by every export. It is now fetched into a data-URI right after the paste,
sized like a picked file; one the site withholds is removed with a message.
The password itself is never stored, so a reload left the document open and
the next save wrote the protected file unencrypted without a word. The fact
of the protection now survives; the save dialog asks for the password again
or lets the protection go, and backing out cancels the save.
The corpus leg out of one format and into the other compared block types and
text, so anything the trip dropped below that passed. It now compares every
attribute each node carries, with the values a format states in its own units
excluded: proportional vs absolute column widths, and a formula's inline flag,
which ODF has no place for.
Two the stricter leg caught: a note lost the paragraph style it names and fell
to the stock 10pt, and a link stored an attribute equal to its default.
Corrects two probed claims that measurement contradicted: a hard page break
does *not* swallow the block's space above (only an automatic one does), and
the header band is max(declared height, content + gap) rather than a line
count. Adds the chapter-numbering model, the mirrored master pair, and the
harness ceiling a CSS-drawn label is.
The marker pseudo is floated into the indent instead of absolutely
positioned, so a label the indent cannot hold — a "1.1.1." outline number,
a Roman numeral — moves the first line right the way both word processors
do, rather than printing over the words it labels. Out of flow either way,
so no list line grows.
The PDF clone's injected marker follows suit, and its chain now reads the
effective numbering and walks to the outermost multilevel list: a list
numbered by a named style had none of its own and printed a bare separator.
Five legs in tests/lo-password.test.ts, driven through the repo's first UNO
helper because --convert-to cannot pass a password; self-skips without soffice
or python3-uno and runs with npm run test:lo. The probed format details live in
docs/architecture/encryption.md.
Capped at 78px, so "Display for review" costs the group a column of width
instead of one of the widest buttons in the band. The caret moves up beside
the icon to free that second line, which lets the split button drop its
stacked layout and read as a row again.
The clipboard carries HTML, and in an HTML string any block tag inside a
<p> closes it — so a copied box arrived as loose paragraphs. It is now
serialized as an inline span carrying its blocks as JSON, read back by a
matching parse rule; its plain text stays in the span for readers that
cannot take the attribute back.
A box that starts its paragraph was a trap: there is no text position beside
it, so the browser pulled the caret meant for the box's own place in the line
into the frame and everything typed there landed inside it.
A frame nobody is editing is now an atom to the browser — a contenteditable
node decoration, dropped while the caret is in its text, the frame placing the
caret itself on the click that enters it. ArrowLeft/ArrowRight at the box's
first and last text position step out to it. The decoration walk enters blocks,
without which it never reached an inline box at all.
A box in the line is a character its paragraph places, as a picture is, so the
three frame-toolbar buttons were the paragraph's own alignment under a frame
icon — a second place to look for the same thing. They now show for the band
wrap alone, where nothing else can express the position.
The ordinary command needed one correction to carry that: a selected box is one
node whose range covers its own paragraphs, so aligning it moved the frame and
re-set its text in the same keystroke.
textBox becomes an inline node carrying block content, like the frame both
formats write, so "in line with text" really is in the line and a box reaches
a table cell or a list item through their paragraphs. Both exports write it
where it sits (an as-char draw:frame / a wp:inline drawing) and both imports
leave it there, which drops the hoisting machinery on either side.
Two guards the schema cannot express: no box inside a box, and a selection
reaching into a box grows to the box's bounds rather than dissolving it.
The shape body wrote spAutoFit, which LibreOffice reads as min-height 0
and then draws the box's text detached from the shape, over whatever
follows it — measured on a 90px box: the frame collapsed to one line and
its text landed 22pt below it, on the next paragraph. noAutofit is what
LibreOffice writes for the same frame; with it the text sits inside the
box at its declared height. Content taller than that is clipped, DOCX
having no fo:min-height for the ODF leg's auto-grow.
wrapAlign only ever came from a file, so a box inserted here stayed at
the left in both modes that leave it in flow. The floating toolbar now
offers left/centre/right for `inline` and `topBottom` (a side wrap names
the side itself) and drops a coordinate placement when one is picked.
DOCX carried neither: `topBottom` now writes wp:align and an as-char box
the w:jc of its rebuilt marker paragraph, both read back on import. ODF
already wrote both. Measured centred at 297px of a 76…718px column, and
rendered through LibreOffice from .odt and .docx alike.