119 Commits
Author SHA1 Message Date
Steffen Becker f33e708b84 feat: a text box anchors its text top, middle or bottom
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.
2026-09-12 02:35:02 +02:00
Steffen Becker fc5c39322f fix: the event-opened dialogs live where they are always mounted
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.
2026-09-12 02:35:01 +02:00
Steffen Becker 57dccb26fa fix: editing a formula no longer deselects it
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.
2026-09-12 02:35:01 +02:00
Steffen Becker b06357536a fix: an attribute change no longer deselects a picture
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.
2026-09-12 02:35:01 +02:00
Steffen Becker 5b287ef57b feat: behind and in front of the text, pickable and draggable
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).
2026-09-12 02:35:00 +02:00
Steffen Becker 1789505431 fix: a page-placed frame is measured from the page's corner
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.
2026-09-11 21:30:53 +02:00
Steffen Becker fd82080115 feat: a floating table opens as the frame that holds 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
2026-09-11 20:06:22 +02:00
Steffen Becker 6d53e4f5c4 fix: a long ribbon label widens its button instead of overpainting
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.
2026-09-11 15:42:16 +02:00
Steffen Becker 5c0ac978f4 fix: Enter keeps an empty line inside the columns section
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
2026-09-11 14:40:26 +02:00
Steffen Becker b233f0e160 fix: a header zone is as tall as the row the file draws it in
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
2026-09-11 09:10:19 +02:00
Steffen Becker 2f51292587 refactor: a field round reads the page grid once
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.
2026-09-09 09:01:29 +02:00
Steffen Becker fed2ccedad perf: the ribbon looks for a mounted panel where one can be
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.
2026-09-09 08:48:02 +02:00
Steffen Becker 7e687b6ccb perf: the document's fields answer a pass in one round
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.
2026-09-09 00:51:54 +02:00
Steffen Becker 50c7ec5236 perf: one refit for every frame, and a pass that moves nothing stays quiet
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.
2026-09-09 00:08:36 +02:00
Steffen Becker da857fcbb8 perf: the load passes batch their field work and flow a section whole
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.
2026-09-08 22:31:01 +02:00
Steffen Becker 5db1c37468 perf: the pagination pass no longer restyles or churns the whole document
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.
2026-09-08 20:16:35 +02:00
Steffen Becker 2fd9c8a06c perf: typing waits out the pass, and a block as tall as before skips it
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.
2026-09-08 17:35:09 +02:00
Steffen Becker 50aeb4a570 perf: a keystroke keeps its spacers, and typing waits for a pause
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.
2026-09-08 16:48:41 +02:00
Steffen Becker 6d4a7e08df feat: the ribbon's Insert tab carries the zone options
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.
2026-09-06 19:07:23 +02:00
Steffen Becker 611ec76ba4 fix: the editor holds no shape either file would drop
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.
2026-09-06 19:07:20 +02:00
Steffen Becker 76c9564028 fix: a list item's further blocks, a header row and a note's picture survive the save
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.
2026-09-06 19:07:16 +02:00
Steffen Becker ad7b840662 fix: a picture pasted from a web page is fetched into the document
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.
2026-09-04 19:30:12 +02:00
Steffen Becker cb0db7dd96 fix: a reload keeps the document protected, and the first save asks for the password
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.
2026-09-04 19:30:12 +02:00
Steffen Becker 759759a997 test: the cross-format leg compares the look, not only the words
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.
2026-09-04 19:30:11 +02:00
Steffen Becker bc87912e5e docs: the page-start spacing switch reaches the space-above rule 2026-09-04 19:30:10 +02:00
Steffen Becker 43d9a864d4 docs: the band is measured, a manual break keeps its space, headings number
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.
2026-09-04 19:30:06 +02:00
Steffen Becker 583bc0ddd8 fix: a list number wider than the hanging indent pushes the text
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.
2026-09-04 19:30:04 +02:00
Steffen Becker b3b8e58bd4 test: LibreOffice opens what we encrypt, and we open what it encrypts
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.
2026-09-04 19:30:04 +02:00
Steffen Becker 90a1a03fc6 fix: a big ribbon button's label wraps onto two lines
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.
2026-09-04 19:30:03 +02:00
Steffen Becker f1f9a4df15 fix: a text box survives a paste from another window
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.
2026-09-04 19:30:02 +02:00
Steffen Becker 8d3139439c fix: the caret reaches the position a text box occupies
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.
2026-09-04 19:30:01 +02:00
Steffen Becker f0cf382fb0 fix: only a band-wrapped text box gets the alignment buttons
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.
2026-09-04 19:30:01 +02:00
Steffen Becker 060dcd7380 feat: a text box sits in the line of text
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.
2026-09-04 19:30:01 +02:00
Steffen Becker 21999afbc6 fix: a DOCX text box keeps the height it declares
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.
2026-09-04 19:30:01 +02:00
Steffen Becker 6081a85626 feat: set where an in-flow text box sits across the column
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.
2026-09-04 19:30:00 +02:00
Steffen Becker b892accdd5 fix: redraw tab stops when the document is replaced
The measuring pass keeps the layout it last drew as a string key and skips
the redraw when a pass measures the same again. Replacing the document maps
every decoration away, so an identical layout has to be drawn again — the
guard now compares the live decoration count as well. Two documents with
the same tab layout otherwise left every tab on the default grid.
2026-08-30 11:51:45 +00:00
Steffen Becker c115b11c8d fix: draw sub/superscript at 58% and out of the line box
The browser's `smaller` + `vertical-align: super` grows every line holding a
raised run by ~4px. LibreOffice keeps its raised run within 0.25pt of the plain
line's box (measured off its PDF), and both exporters already write
`style:text-position="super 58%"` — only the screen diverged. The same three
declarations already sit on the footnote anchor. Scoped to the paper, since a
header/footer zone carries the marks and its height is reserved off the page.

Measured: a table LibreOffice fits above the page edge now fits here too,
instead of moving to the next page.
2026-08-30 10:06:44 +00:00
Steffen Becker 1424d7c23f docs: note that Word puts the endnote list on the last page
LibreOffice opens a fresh page for it and we follow, so a Word-made PDF
of a document with endnotes is one page shorter — measured by rendering
one .docx in both.
2026-08-30 10:06:44 +00:00
Steffen Becker 4aef4d7641 fix: keep rows a cell spans on one page, and clear floats before an in-flow box
A break between spanned rows stretched the cell across the gap: the
merged table drew over the page gap and the next page's header. A
group too tall for a page still breaks per row. An in-flow text box
took the same treatment for floats — CSS lays a block box over one
instead of beside it, which had the vertical frame covering its
neighbour. Both match LibreOffice's render of the same document.
2026-08-30 10:06:44 +00:00
Steffen Becker 9bfee4e0bd fix: keep the note prefix/suffix out of the anchor
The affixes are note-area text: LibreOffice draws its Before/After
around the number in the footnote area only, while the anchor stayed
the bare number. The editor showed them at both.
2026-08-30 10:06:43 +00:00
Steffen Becker 35059d8126 fix: export plain text boxes as Writer text frames
Without a parent chain to the Frame style LibreOffice read the box as a
drawing object, whose text loses lists, nested images and borders. The
frame takes fo:background-color/fo:border and its writing mode in the
graphic properties; an inline box's centring rides the anchor paragraph.
The import reads the frame spellings back and drops LO's from-top/0
respelling of the default alignment.
2026-08-30 10:06:43 +00:00
Steffen Becker c4f32bb380 fix: clear the block after a topBottom frame below its band
A float only pushes lines that intersect it, so a short block (a heading)
slotted into the clearance gap a prior side float opens above the band.
Word and LibreOffice both start the next paragraph below the band; a
page-break spacer and the partner block of a wrapAlign pair are exempt.
2026-08-30 10:06:42 +00:00
Steffen Becker 92a2421a83 fix: keep the paragraph mark in flow inside a columns section
The always-on absolutely-positioned ¶ pseudo made Chromium give up
balancing the multicol: every block stacked into column 1, while Word
and LibreOffice balance the section end. Static inside .columns-node
(zero width, line breaking unchanged); past a trailing BR the mark is
dropped there so an empty paragraph stays one line.
2026-08-30 10:06:42 +00:00
Steffen Becker 9a6282fce9 fix: floor a DOCX anchor's paragraph posOffset at one twip
Anchored exactly on the paragraph top (posOffset 0), LibreOffice's wrap
layout derails and paints a neighbouring inline text box's content
outside its shape; LO itself never writes less than a twip there.
Probed by bisecting a broken box against LO's own DOCX export of the
same document. The importer rounds the sub-visible offset back to none,
so nothing accretes on the round trip.
2026-08-30 10:06:42 +00:00
Steffen Becker 6bbf4286c3 style: the floating-island chrome is dev-only in the menu
The appearance menu's "Modern (beta)" entry sits behind
import.meta.env.DEV, so a production build offers no way into the
island chrome until it catches up with the ribbon. The chrome itself
and a stored 'modern' pref keep working.
2026-08-20 14:56:48 +02:00
Steffen Becker 10b650c64c refactor: the floating island's mode id is 'modern', matching its label
The id said 'classic' while the UI called the ribbon Classic and the
island Modern (beta). Type, i18n keys, comments and docs now agree;
a stored legacy 'classic' still selects the island.
2026-08-17 20:05:15 +02:00
Steffen Becker 501bfe501f fix: a first visit opens the ribbon, not the floating island
The tabbed chrome is the layout users know; the stored choice still wins.
2026-08-17 20:03:06 +02:00
Steffen Becker 847fe59d3d feat: a frame the text runs through reserves no space at all
Word's in-front-of / behind-text (wp:wrapNone + behindDoc) and ODF's
style:wrap="run-through" both read as a side float, so a near-column-wide
picture reserved its whole height and pushed the text 23-52mm down. They
are a wrap mode of their own now: out of flow at the static position it
was anchored at, z-order from the file (inFront). Both exports carry it
back; tests/unit/wrap-through.test.ts covers the round trip.
The image fixture 72 -> 38 over both formats and 7 pages -> 6, matching
the reference; corpus 1108 -> 1074, no other fixture moved.
2026-08-17 13:45:39 +02:00
Steffen Becker a62fa11cc1 feat: the index options menu switches its page numbers
References ▸ Options, where the depth already lives: Word's "Show page numbers"
as a checkbox, over the pageNumbers attr both formats now carry. The menu is no
longer only levels, so its button says Options.
Its node view repainted on the entries alone, so nothing an attr changes ever
reached the rows — leader, title and level styles included. Keyed on the look
as well now, minus the cached entries the pass writes back itself.
2026-08-17 00:09:48 +02:00
Steffen Becker 6c731b9233 feat: insert caption is in the picture and table tabs too
References keeps it, as in both products, but that is a tab away from the
object being captioned. The contextual tabs mount their own CaptionDialog —
it reads the caret for category and position, so the copies take no props.
2026-08-16 22:22:29 +02:00