A pagination pass measures the DOM the last one changed, so a fresh
document searched for its layout in public — 12 of them over 550ms on a
two-column file, a phantom third page among them. Two real causes: the
flow's overflow test had no headroom, so it read its own last answer
(880px fits undecorated, 913px overflows once the decoration pins the
column height), and a split dropped the height decorations without
clearing their cache key. The rest is inherent to measuring rendered
DOM, so the page stays hidden until its layout repeats: one visible
layout instead of 12, and 21-139ms where there are no columns.
Rows & Columns follows Word: four big buttons carrying a 28px glyph and a
two-line label, with the three deletions folded into one Entfernen dropdown
beside them so the group keeps its width.
Two lines cost the band 12px (--w-ribbon-h 96 -> 108). The labels size to
min-content, not max-width, which a wrapped box ignores; and the inserts drop
the reserved caret slot, without which the group's foot label sits 8px below
every other group's.
Three measurement bugs in columnsFlow, each worth a page on its own:
rects became lines by their top, so a raised run counted as a line of
its own (192 for 111); the split probed `left + 1`, which is a line's
*end* right-to-left, so it kept a word too many and the flow then shaved
one line per pass; and one median advance under-measured a paragraph
whose lines differ in height (2834px real against 2453 estimated), by
enough to budget 74 lines into room for 64. The chain used to flip
between one fragment and two forever; it now settles after one split.
A paragraph a style hides is dropped on import now; a hidden heading is not,
because it is the outline the chapter field reads. Records what dropping it
measured and what a real fix would need.
text:display="none" now drops the paragraph its style hides and the stored
value of a field that carries the attribute — LibreOffice lays out neither.
A hidden heading is the exception and still renders: it is the outline the
running head's chapter field reads, and dropping it blanked every head that
quotes one. The editor has no way to hold a block it does not draw.
ODF carries western, asian and complex-script font properties side by side and
a word processor picks by the character's script; the importer read only the
western pair, so a complex-script run came out at the document default.
Decided per run — the finest split the model has — so a document with no
complex script is untouched, and our own export (all three written alike)
round-trips. Measured: a two-column page's line pitch 7.5mm -> 5.9 against
LibreOffice's 6.0.
Brings the ribbon chrome, its tabs and controls, the Paragraph/Tabs dialogs
and the shared selection reader over from word_ui. No file overlap with
main's cover-graphic fix, so the merge is clean.
Orientation is one page turned, overlapping so both copies keep full size
and the upright one is traced only where the other leaves it visible. Size
measures a page with dimension arrows; Columns is a page carrying two
columns of text, matching the previews in its own menu; Header and Footer
are a whole page with the zone filled in.
Measured painted boxes: every glyph now centres on 8, 8 and spans 12.9 to
14 units, against 15.16 x 8.56 for the old Orientation.
Word shows the layout rather than naming it. The page is 20 units of text
width with a 2-unit gutter, so the columns narrow as their count rises;
surface and border come from the palette, so it follows the theme.
Only equal widths exist here — Word's Left and Right would need a per-column
width on the node.
'Überschrift 4' measures 58.4px against 56px of inner width, so the two
characters that got cut were the ones telling the five headings apart.
Six pixels wider, and no name in either locale truncates.
Ten of the fifteen caret buttons ran the same toggleMenu from both halves,
so they were split for nothing. They are single buttons now, with the caret
under the label rather than beside it — 13px narrower each, and Word's own
Paste button.
The tint takes the whole button, label included: a row mixes buttons whose
label is inside the button with pickers whose caption is a sibling, so half
of them lit up under the words and half did not. `captionClicks` gives the
caption the click to match, and the picker's trigger takes the icon box's
34px so glyphs and words land on one line.
The big split button already stacked, so the caret only needed a menu:
paste, and paste without formatting. Both call the reader the context
menu has always used.
Word offers a third, Merge Formatting. The engine reads the clipboard as
HTML or as text, so there is no third thing to offer.
Three failures from one cause — nothing had a floor. The Styles group is
the only one that may shrink, so it absorbed everything down to 8px: an
empty gallery frame whose label reached across the next group's. It now
stops at two tiles, the label truncates, and the tab strip wraps rather
than pushing the page into a horizontal scroll that left the band ending
short of its own tabs.
A chevron in the tab strip drops the band, leaving the strip; persisted, so
it survives a reload. Clicking a tab expands again, since a collapsed strip
whose tabs do nothing is a trap.
Word puts that chevron in the band's corner. It rides the strip so the band
keeps its full width: a flex row can only reserve a column, never a corner.
Skipped: Word floats the band over the document until the next click lands
in the text.
The hidden input sat inside the classic header, so where the File System
Access API is missing handleOpen clicked nothing at all. It moves out of
the chrome fork, which both paths route through.
The label drops the extension while it is there: the input has always
taken .odt, .ott and .docx.
Word's dark palette on the same metrics: Fluent's dark neutrals, chrome
#1f1f1f, and the accent lightened because #185abd on a dark chrome is
unreadable. AllBlack goes on to Word's Black theme. The lightened accent
cannot carry white text, so the File pill and the pickers' --color-primary
take a second, filled accent — identical to the first in light mode.
The group centres its controls, so the gallery sat at content height — 36px
of tile in a 67px row. The wrap opts out with align-self, and the sample
takes the height the name leaves over.
Two letters fewer buy the preview room to scale: 1px per pt with 21 as the
tile's ceiling, measured against its inner width. Heading 1 now reads a
third larger than Heading 5 instead of a pixel.
Word's Equation button is a pi, as its Symbol button beside it is an omega.
Same canvas, baseline and weight as that omega, at font-size 20 rather than
its 14: pi is lowercase, so at one size its x-height measures 1.44x smaller.
The glyph was a stem on a base with no flag, so it read as a down-tack rather
than the digit it stands for.
A caret welded to the right of a big button floated at icon height, beside the
label instead of with it, on every menu button of the Layout tab. Big split
buttons stack now, as in Word: the icon runs the command, the label and its
caret open the menu. Measured: every label in a group still tops at 87px.
Every reused picker anchors its panel to its own trigger, so the band clipped
it: the table grid showed one row of eight. `pinPanels`, one action on the
band, pins whatever panel mounts inside it, and panels now drop below the whole
band instead of over the controls under them.
Their triggers also kept the classic toolbar's compact box, so Table, Symbol
and Date & time were half the size of the buttons beside them. `.rb-captioned`
moves to global.css, replacing four copies, and gives the trigger the big
button's 34px box, a 28px glyph and the ribbon's own hover and open tints.
Sub- and superscript were SVG paths whose X sat on its own baseline, two
units apart from each other and a size larger than the B, I, U and S beside
them. They are text now, in the same font and size, with the digit on a
relative offset because a flex item ignores vertical-align. Clear formatting
stays a path, redrawn upright with its T on the measured letter baseline.
Ink top and baseline agree within 0.15px, measured in the browser.
The shared pickers frame their trigger for the classic toolbar's island; in a
ribbon row of borderless buttons that reads as a different control, and at
27-28px it broke the row's line. Every trigger is 30px there now. Redrew the
change-case and highlighter glyphs, which were malformed and off-centre.
The dialog sized each row on its own, so a long label wrapped and "pt" vs "cm"
shifted the field beside it - fixed unit width, no wrap, one 2x3 grid.
Character spacing and kerning live on a named style, not on a run — setting them
as direct formatting would need a new mark attribute and export work on both
formats. Everything else that dialog would hold is already on the Home tab.
Word puts the deep paragraph settings behind a dialog, and so do we — it is the
only place the four text-flow attrs can be set: widow/orphan control, keep with
next, keep lines together, page break before. All four already round-tripped
with nothing to change them.
The Tabs dialog types a stop exactly where the ruler can only drag one, and is
the only way to give it a leader. Alt text becomes editable rather than just
inherited from the file name.
The palette outside the theme system, the token remap that lets a reused picker
adopt it, and the two things the fixed-height band forces — a menu that leaves
the flow, and a wrapper that names the menu it owns.
Also the deletion the ribbon would unlock if classic were ever retired, so the
option stays visible without anything being removed now.
They appear while the caret is in the object and go with it, handing the strip
back to Home. Table Design carries the style gallery, shading, borders and the
style options; Table Layout the rows, columns, merge, split, alignment — and
cell margins, which round-tripped with nothing to set them.
The floating toolbars stay: a mini-toolbar beside the object and a contextual
tab above the document are both Word's, and both reuse the same pickers.
Two compounding bugs on a title/back-page background image:
- its host paragraph picks up position:relative from the line-height
rule, becoming the frame's containing block instead of the page and
offsetting it by a full page margin — now forced back to static.
- the frame always painted behind text (z-index -1); a graphic that
declares ODF's style:run-through="foreground" now paints in front,
as LibreOffice draws it, via a new inFront attr round-tripped through
ODF export/import.
Confirmed against LibreOffice's own render: the duplicated, ~2cm
displaced cover graphic now matches exactly.
References inserts the table of contents and sets how deep it goes — the depth
attribute round-tripped with no way to change it. Review holds the statistics
panel and the spell-check language, View the ruler, formatting marks and zoom;
each of those had lived only in the status bar or the secondary toolbar.
That is all seven fixed tabs.
Margins, orientation, page size, columns and breaks as menus; indent and spacing
as typed fields. Three of these had no control at all: a section break, and the
absolute left and right indent the ruler could only drag.
Each margin preset is labelled with the value it sets, so none of them hides a
default — and Normal is LibreOffice's 2 cm, the one this editor already uses.
Page break, table, picture, text box, link, bookmark, cross-reference, header,
footer, page number, date, symbol and equation. The page break had a key and no
button; the rest moved up from the secondary toolbar.
The ribbon mounts its own link, bookmark, cross-reference and formula dialogs,
so the classic path keeps the ones it has and its re-fire shim untouched. Only
one chrome is mounted, so the two sets never coexist.
Clipboard, Font, Paragraph, a style gallery of live tiles, and Find/Replace —
which had no button in either chrome before. So did change case, underline and
strikethrough line styles: the engine has carried them all along.
Two things the fixed-height band forced. Its own overflow clips a dropdown, so
a menu leaves the flow and pins itself under its trigger. And with one shared
open-menu id, each wrapper must name the menu it owns, or every sibling closes
it on mousedown and the click on a row never lands.
Font, size, mark colour and block attributes all answer the same question — the
one value the selection agrees on, blank when it mixes. One `uniform` walk now
serves all four, framework-free, so any chrome can read it inside its own
reactive wrapper.
The classic toolbar keeps its own copies for now: it is the baseline the ribbon
is compared against, and it should adopt these only once that comparison ends.
A second chrome, chosen by odf-editor-chrome and switchable from either side so
the two can be compared. The shell only — tab strip, File menu, quick-access
save/undo/redo; the tab bodies follow. The classic island stays untouched.
Word's palette lives in --w-* outside the theme blocks, and .ribbon remaps the
app's own tokens onto it, so a reused picker adopts the ribbon's look without
an edit of its own.
Node 26 defines its own `localStorage` global — undefined unless the process is
given --localstorage-file — and it shadows the one jsdom installs. Every module
that reads storage at import time threw before a test could run, so 37 of 65
files failed to collect. A setup file defines the property when it is missing.
The parity findings named the six files the corpus is built from, so every
measurement carried a document title into the history. They move to a
gitignored FINDINGS.md beside the corpus itself; the README keeps the harness.
A test that needs a document builds one, so nothing under src/ or tests/
reads the gitignored corpus — CI greps for exactly that.
The sheet took the app's slate (#1e293b), so every uncoloured run
differed from LibreOffice — and from a run the same document colours
#000000 explicitly. Light and dark themes now paint the page black on
white; allBlack still inverts it, and the PDF export no longer has to
override anything.
A sweep of the whole tree: 27 blocks ran to four lines or more. Trimmed
to what the code can't say, with two of them split back onto the
function each was really about.
A Courier New "o" was mapped to ◦ for looks. LibreOffice and Word both
draw the glyph the numbering names, so the nested lists of the gravity
summary now read like theirs.
The zone was pinned to Liberation Serif 12pt, so a run whose font the
file leaves to the default — every page-number field in a fixture
fixture — rendered serif inside an Arial footer. Both formats base the
Header/Footer style on the default paragraph style, so the zone now
takes its text half.
Word paints a link through its Hyperlink character style, never for
being one, so the editor's own blue overwrote both the file's #0000FF
and the plain black of a link carrying no style at all. The DOCX
importer marks those `plain` and the CSS leaves them alone; LibreOffice
paints every ODF text:a, so those keep the visual.
Chromium colour-manages a CMYK JPEG through its embedded ICC profile
where LibreOffice and Word convert naively, so a fixture's
logo arrived dull teal on grey instead of vivid blue on black. The
importer drops the profile — measured back onto LibreOffice's own
values, and 655 KB smaller for that one logo.
The dot advance was probed once, on the first row, and reused for every
level below it — so a level with a smaller style ran ~9% short, 11mm of
bare gap before the page number. Probed per font now (a handful of
measurements, not one per row). A fixture's contents: 122/84/72 dots per row,
exactly what LibreOffice draws.
Collapsed borders paint a shared edge once, so the spacer <tr> bridging
the gap took the row separator with it and left one fragment open —
visible in a fixture as tables missing their frame on a page. The spacer now
draws the line LibreOffice draws there: the separator the break falls on,
or the table's own box where the rows carry none. Absolutely positioned,
since a collapsed border on the spacer shifts every row below by half.
A book's chapter heading is "Chapter 1" and its title on two lines, and both
LibreOffice and Word copy that break into the index. We joined it into one line,
so a fixture's contents came out 18 rows short. The leader and page
number ride the last of an entry's lines (align-items: last baseline).
Both exports wrote one geometry for the whole document, so a title page's
margins survived import and were lost on the next save. Each ODF section master
now names a page layout of its own — the document's, shifted by the section's
difference, so what the header/footer pass folded into it survives — and every
w:sectPr carries its section's w:pgMar. DOCX import takes the document pair from
the first section, the one .tiptap's padding draws.
The parity harness spread a 458-page document's word list into Math.max and blew
the stack; the longest fixture was never measured.
.tiptap's padding draws one pair of side margins for the whole document, so a
title page declaring 3.18cm left sat 3.85mm off. The difference now rides a
--sec-inset-left/-right node decoration on the section's top-level blocks, and
everything that writes a block margin adds it in — an indent, a named style's
and a table's alignment all compose with it instead of losing to it.
Page geometry was document-wide, so a title page declaring 1cm started 1cm too
low. w:pgMar / the section's ODF page layout now ride its HfSet and feed the
per-page content area; an ODF master handing over (style:next-style-name) gives
its own layout to the section's first page and the successor's to the rest.
A spacer can only push down, so a smaller margin than the document's needs the
opposite: the section's first block gets a lifting spacer where nothing was
pushed onto its page ahead of it.
Three probed findings written down rather than fixed: an outside ::marker has
no metric influence in CSS (a bulleted line is 0.12mm short of LibreOffice's),
an unbreakable run longer than the line moves whole instead of breaking at the
margin, and a typeset formula keeps its own baseline rather than its frame's —
honouring the frame measured worse. Plus the live-vs-cached index numbers.