Files
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

23 KiB
Raw Permalink Blame History

Run and paragraph formatting

Covers the src/lib/editor/extensions/ files carrying character effects, indents, tab stops, list markers, paragraph boxes, date/time fields and cross-references.

  • textEffects.ts (TextEffects, UnderlineStyled, StrikeStyled) — the character effects beyond bold/italic that both formats carry and CSS can draw: caps (uppercase/lowercase/capitalize/smallCaps → text-transform/font-variant-caps) and textPosition (pt above the baseline → vertical-align, independent of sub/superscript) as TextStyle attrs, plus lineStyle/lineColor on the underline mark and lineStyle on strike (the <u>/<s> element draws the line, so its shape belongs there). Replaces TipTap's bare Underline/Strike in extensions.ts and hfExtensions. caps is also a TextProps key, so a named style can carry it. DOCX: w:caps/w:smallCaps (toggles — a run may switch a style's off), w:u w:val+w:color, w:dstrike, w:position (ST_SignedHpsMeasure: a bare number is half-points, else a measure with its unit). ODF: fo:text-transform/fo:font-variant, style:text-underline-style/-type/-color, style:text-line-through-type, style:text-position (a percentage of the font size, so the pt value is converted against the run's own size). odf-kit's run formatting has a field for none of them, so markTextEffects prefixes the TEF sentinel (U+E00F) on the run and applyTextEffects folds the attributes into that run's automatic text style — before applyCharacterStyles, which then clones the style that already carries them. tests/lo-roundtrip.test.ts is what proves LibreOffice reads each one back. Sub- and superscript are TipTap's own marks, but their <sup>/<sub> need editor.css's font-size: 58%; line-height: 0 on .paper (a header/footer zone carries the marks too, and its height is what pagination reserves) — the size both exporters write as style:text-position, and the zero line height keeps the raised run out of the line box the way LibreOffice does (measured: its raised run stays within 0.25pt of the plain line's box). The browser's smaller + vertical-align: super instead grew every such line by ~4px, which is enough to push a block that ends near the page edge onto the next page.
  • paragraphBox.ts (ParagraphBox) — paragraph/heading background (backgroundColor, "colored field") + per-side borders (borderTop/Right/Bottom/Left, "rule line"), as in Word/LibreOffice letterheads. Borders reuse table cells' canonical '<W>pt solid #RRGGBB' (from tableCellBorders.ts), but here null/absent = no border. Commands setParagraphBackground/setParagraphBorders(preset, spec); activeParagraphBorderPresets drives the picker states. An input rule turns a paragraph typed as ---/___/=== into an empty bottom-rule line (the border-line AutoCorrect); attrs are keepOnSplit: false so the box doesn't cascade onto the next paragraph on Enter. In both extensions.ts (body) and hfExtensions (header/footer). ODF: import reads fo:background-color/fo:border-* in blockAttrs (body) and merges them across the collapsed header/footer paragraphs (convertHfZone, first/last heuristic — bottom rule from the last line); export emits them via ParaStyle/paraStyleProps for cells/lists, a PBX-sentinel post-process (applyParagraphBoxes, odf-kit has no such options) for top-level body paragraphs, and applyHfPostProcess for the Header/Footer styles. DOCX: w:shd/w:pBdr (import readParaBox, export paraShadingOf/paraBordersOf). A style's box is not the block's: it goes to the style registry (ParaProps.borderTop…, stylePara/DocxStyles.paragraphBorders), so every paragraph the style governs draws it. ParaProps.borderPadding (pt) is the gap the file keeps between the text and its rule — Word's per-side w:pBdr w:space, ODF's fo:padding-* — and declarations puts it on the ruled sides only: a bottom rule that also padded the top would push the paragraph's first line down (measured: a Title's next block sat 1.4mm low). Where no file value exists, editor.css's flat 0.04/0.08cm on a bordered block still applies. UI: a paragraph-shading ColorPicker + ParagraphBorderPicker.svelte in ToolbarExpanded.svelte (route via activeEditor, so body and the active HF zone).
  • indent.ts (Indent) — indent attr in cm (rendered as margin-left), on paragraph/heading and bulletList/orderedList. indentMore/indentLess step a paragraph/heading by INDENT_STEP_CM = 1.25 → fo:margin-left (odf-kit indentLeft); indentListMore/indentListLess step the innermost list's indent, shifting the whole list (export adds it to the L# list-style level margins, see applyListIndents). On a list the attr is one level's step past the level above, not its absolute position: both formats store a level's indent absolutely (w:lvl/w:pPr/w:ind w:left, the label-alignment fo:margin-left) while the editor nests one 1.27cm step per level, so import subtracts the level above and the ODF export adds the running sum back per level. That is what lets a file's own level geometry survive at any depth — measured, a 0.5cm/−0.6cm pair puts LibreOffice's two levels at 1.77cm and 2.44cm, where the editor draws them. indentListForward/indentListBackward apply one Word/LibreOffice step to the list point (bullet + text): forward shifts the whole list when the cursor is in the first item or the whole list is selected (indentListMore), otherwise nests the item (sinkListItem); backward is the inverse — it peels back the innermost list's own indent first (indentListLess), then lifts a nested item out one level (liftListItem), and at the base level (no margin, not nested) does nothing. So a list point steps left one tab-stop at a time and never gets dissolved into a paragraph — Shift-Tab keeps list points as list points. They return false outside a list. indentRight is the mirror of indent (fo:margin-right / w:ind w:right; ODF export rides the PBX sentinel, since odf-kit has no right-indent option). setIndent(cm)/setIndentRight(cm)/setIndentFirst(cm) write one indent outright (what the ruler drags; the buttons step relatively) — all go through the same write walk. The exported listContext(state) (cursor in a list? whole list targeted? innermost list indent? list nested inside a parent list item?) is their shared helper. Both the toolbar's changeIndent and the Tab/Shift-Tab keymap call these: in a list Tab/Shift-Tab run indentListForward/indentListBackward; outside a list Tab inserts a real tab character (\t, rendered via the document's tab-size) and Shift-Tab runs indentLess (changeIndent uses indentMore/indentLess). The keymap bails inside table cells so the Table extension's Tab keeps navigating between cells. Indent sets priority: 1000 so its Tab/Shift-Tab win over ListItem's built-in sink/lift bindings — without it, TipTap's reverse-then-priority-sort lets the later-registered ListItem handle the key first (its liftListItem would dissolve a top-level list item into a paragraph on Shift-Tab). A list item's paragraph carries no indent of its own: the write walk skips it, and an appendTransaction clears the three attrs wherever one lands there anyway — wrapping an indented paragraph into a list would otherwise keep an indent that neither exporter writes (collectListItemStyles passes withIndents: false) and neither importer reads back (a list level's own indent is what fo:margin-left/w:ind means there).
  • tabStops.ts (TabStops) — per-paragraph tab stops (Word's w:tabs, ODF style:tab-stops). Global attr tabStops on paragraph/heading, rendered as data-tab-stops; canonical string '<cm><align code>' per stop, ;-separated ('6c;12r;16d', codes l|c|r|d), parsed only by the exported parseTabStops/formatTabStops. Positions are cm from the left text margin — the origin Word and ODF share, verified by probe (a Word stop at 100mm in a 30mm-indented paragraph stays 100mm in ODF and LibreOffice renders it there), so neither I/O side shifts them by the paragraph indent. Rendering: CSS only has the fixed tab-size grid, so a rAF measuring plugin (shaped like pageBreaks.ts, whose FORCE_PAGE_RECALC it both listens to and fires) computes each tab's advance from coordsAtPos against .tiptap's content edge and emits Decoration.inline(tab, tab+1, {style:'tab-size:0;margin-left:Npx'}) — tab-size:0 collapses the tab's own advance, the inline margin supplies the exact width, so no display change and no baseline/line-height side effects. The margin is on the left because the gap is the tab's own advance: as margin-right a caret placed after the tab rendered before the gap, i.e. at the pre-tab x until the next keystroke. The pen is read from the DOM only at the first tab of a line (side −1, the end of the content before it) and then carried along the line: stop + the natural width of the segment that follows it. Reading each tab off the DOM instead measures the advance the previous pass gave the tab before it, so the layout takes another pass to settle — and on a long document, where pagination keeps moving lines for seconds, it may never get a stable one; the Math Guide's contributor columns landed 18mm out in one run of three. nextStop takes the first custom stop right of the tab, else leaves the tab undecorated (past the last custom stop the CSS grid already does the right thing, which is also why custom stops suppress the default ones to their left). A negative indentFirst implies a stop at the block's indent (the hanging-indent stop). Centre/right/decimal take back the segment up to the next tab, measured by rangeWidth as the extent of each line it covers, added up — a range crossing an inline element yields a rect for the element's box and for the text inside it, and adding those counts the text twice. The scale comes from the editor's width: offsetHeight is rounded, and a one-line zone is short enough for that to be half a percent. Passes converge (one more tab settles per pass, MAX_PASSES), since a stop can rewrap the line. The measured layout is kept as a string key and a pass matching it dispatches nothing — but that key alone is not enough to decide the decorations are still applied: replacing the document maps them all away, so the guard also compares the live decoration count, or two documents with the same tab layout (the two DIN 5008 forms, an undo, a reopened file) would leave every tab on the CSS grid. UI: Ruler.svelte. A header/footer zone carries stops too — the left⇥centre⇥right layout rides on them — but an inactive one is static generateHTML output no plugin reaches, so layOutZoneTabs measures it straight on the DOM (HeaderFooterLayer.svelte, use:layOutTabs), wrapping each tab in a span of its own. Advances are layout px, so the zoom transform doesn't invalidate them. A decimal stop there behaves as right. Three things that zone needs and the body doesn't: the wrap marker is matched with hasAttribute (its value is '', which reads as falsy, and every pass nested another span with its own advance), the segment is measured with wrapping off (one already wrapped reads short by the space its break swallowed, which keeps it wrapped), and a stop past the line is drawn at the line's end — LibreOffice's own footer style puts its right stop at 18cm in a 17cm column. The body clamps the same way. A stop may carry a leader (w:tab w:leader / style:leader-text, one of . - _ ·), drawn as a ::before clipped to the measured gap so the fill stays out of the document's text; on the ODF side odf-kit has no leader option and mints one automatic style per option set, so the leader rides style:type behind a LEAD sentinel that applyTabLeaders unpacks — a bare position+type lookup would give two stops that agree on both the same leader. Past the last custom stop the grid is the document's own default tab interval (storage/tabInterval.ts, 1.25cm for a new document — LibreOffice's; Word's is 1.27): imported from w:defaultTabStop / the paragraph default-style's style:tab-stop-distance, applied as the --tab-interval CSS var, and always written back on export. A file that declares none falls back to its format's own value — measured, LibreOffice puts a bare A⇥X at 2cm, not at its UI default — so leaving ours implied would render differently everywhere else (ODF_IMPLIED_TAB_CM, DOCX_IMPLIED_TAB_CM).

A run of tabs wider than the line continues on the next one with the grid starting over, as LibreOffice lays it out; Chromium instead hangs the leftover tabs and starts the next line at the margin. runBreaks walks the run analytically from the pen before it — the one thing the fix can't move, so the answer is the same whether or not the break is already in place — and emits a <br> widget where the line runs out. Only runs of two or more are considered: reading a block's geometry forces a reflow, and doing it for every tab in a 48-page document slowed pagination enough to skew the parity harness.

  • listMarker.ts (ListMarker) — the marker's family, weight, slant, size and color, taken from the item's first text portion (markerFormat): a mark lives inside the item's paragraph and never reaches ::marker, which only inherits the li's own font, so a bold numbered heading would keep a light number. A character style on that portion is resolved through the registry (charStyleProps, handed in by the caller: the plugin from its sheet option, the exporters from their exportSheet); what a named paragraph style provides reaches the marker through styleCss's li:has(> …) rule instead. First portion, not the whole line, is LibreOffice's own numbering rule (verified by probe: bold-then-plain gets a bold number, plain-then-bold does not; Word instead takes the paragraph mark). Rendering: a node decoration puts --marker-family/-weight/-style/-size/-color on every listItem — they inherit, so an item without its own format resets them to initial (the guaranteed-invalid value, so the var(…, inherit) in editor.css falls back; inherit would pull the parent item's value in), and editor.css has one rule reading them on the item block's ::before — which is what draws the marker: ::marker cannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (LIST_HANGING_CM 0.635cm, what both exports write). The pseudo is floated into that indent (min-width the indent, negative margin-left): in flow it grew every list line by a pixel, and absolutely positioned a label wider than the indent — 1.1.1., viii. — printed over the text instead of pushing it. Word and LibreOffice push too, on to the next tab stop; the float stops flush after the label. list-style: none drops only the marker box, so the list-item counter and <ol start> keep numbering, and content: counter(list-item, …)/counters(list-item, '.') per data-eff-list-style replaces list-style-type; a bulletChar rides --bullet (an attr() inside the pseudo would read the block, not the list). export/pdf.ts switches the pseudo off and injects the same marker as a real span, since html2canvas cannot paint counters. The bullet shim keeps the symbol font first and takes --marker-family as its fallback. The pass also re-runs on FORCE_PAGE_RECALC, so editing a character style repaints the markers. Export: both formats carry marker formatting per level, so listMarkerFormat returns one only when every item of the list agrees — ODF mints a named style:family="text" style in styles.xml and points the level definition at it via text:style-name (collectListMarkerFormats/applyListMarkerFormats) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level's w:rPr (markerRunProps → ILevelsOptions.style.run). A mixed list gets no level format on purpose: LibreOffice then applies its first-portion rule and matches the editor, Word falls back to the paragraph mark (which only carries the size). Import needs nothing — the runs already carry the formatting. Nested lists that odf-kit restyles (applyNestedListTypes) and in-cell lists keep a plain marker. A character style inside a list item is handled by an ODF pre-pass (bakeListCharStyles) instead: odf-kit's list builder reads marks itself and knows nothing about charStyle, and applyRuns never sees a list paragraph, so the pre-pass does both of its jobs — bake the resolved props into direct marks (which is what gets the run a <text:span> at all) and prefix the CST sentinel, so applyCharacterStyles re-points that span at the named style like everywhere else. Formatting and name both round-trip. tests/unit/list-marker.test.ts covers the four LibreOffice cases, the reset, and both export legs. Both this set and listStyle.ts’s are rebuilt only for a transaction reaching into a list (touchesList — a step by its own from/to or pos, since a mark step’s position map is empty, and by what its map moved) or for the stylesheet’s own signal (SHEET_CHANGED, dispatched by Editor.svelte’s stylesheet effect); anything else maps the old decorations forward, so an edit anywhere in a long document no longer walks it whole. tests/unit/list-marker-rebuild.test.ts covers which transactions count. Label alignment (markerAlign on the list node, Word's w:lvlJc, ODF's fo:text-align on the level properties): right sets the label's far end against the hanging indent, so a wide number — the Roman numerals Word's built-in numberings use it for — grows into the margin instead of over the text. tests/unit/marker-align.test.ts round-trips it.
  • dateTimeField.ts (DateTimeField) — inline atom for an inserted date/time field (Word's "Insert Date and Time"). Attrs: kind ('date'|'time'), format (a catalog key), fixed, value (ISO local datetime captured at insert). A fixed field renders its stored value; an auto field renders the current moment on every load (renderHTML/renderText via dateTimeFieldText) — no plugin, Word/LibreOffice likewise only refresh on open. As an inline atom with no children it can't inherit the surrounding run's font from a sibling span, so it carries that run's marks on the node: insert adopts the cursor's stored marks, and both importers attach the field run's resolved marks (else it falls back to the editor default font). Command insertDateTimeField({kind,format,fixed}). Formats live in utils/dateTime.ts as a token model (one Token[] per format) so the three consumers stay in agreement: renderFormat (locale display via Intl), odfNumberStyle (<number:date-style>/<number:time-style> body), docxPicture (Word field switch). DateTimePicker.svelte (a dropdown next to SpecialCharPicker in ToolbarExpanded.svelte) lists live samples + an "update automatically" checkbox. Not in the header/footer schema (there a <text:date>/DATE field stays plain text). ODF: replaceDateTimeFields/applyDateTimeFields (sentinel U+E00A, mirrors images) emit <text:date text:date-value text:fixed style:data-style-name>/<text:time text:time-value …> + a minted per-format number style (language from the export doc language; ensureNumberNamespace adds xmlns:number); import matches the referenced number style back to a format via parseNumberStyleTokens + matchFormat; a token list the catalog does not list keys the node by its own DOCX picture (MMMM d), which findFormat parses back into tokens — so a format only a file names stays a live field and re-exports unchanged, instead of freezing at the day the producer cached. DOCX: auto → SimpleField DATE/TIME \@ "picture" with a cached run; fixed → a plain text run (Word has no fixed-date field); import maps a DATE/TIME fldSimple/complex field's picture back to a format (dateTimeFieldFromInstr).
  • bookmark.ts (Bookmark) + crossReference.ts (CrossReference) — a named range of text and the references pointing at it. A bookmark is a mark, not a point node: both formats store a range, and only a range lets a REF recompute its text. It carries one name (data-bookmark), is inclusive: false, and names are sanitized to Word's rules on insert (sanitizeBookmarkName: [A-Za-z0-9_.], no leading digit, 40 chars) — which also keeps the ODF sentinel pass free of XML escaping. A mark type excludes itself, so overlapping ranges collapse to the outermost and both importers keep the first. Word's _GoBack and every point bookmark (ODF <text:bookmark/>, a zero-length Word range) are dropped — there is no range to mark. bookmarks(doc)/findBookmark/bookmarkNames are the shared lookups (dialogs, node view, Editor.svelte's click handler). A cross-reference is an inline atom with name, format ('text'|'page') and text, the last being the cached display exactly as a Word field caches its result. Every reference of an editor resolves in one batch (CrossRefBatch) on each pm-pagecount and on a change to any of them — one bookmark scan, and every page read in the shared field round's measuring phase (scheduleFieldRound, docs/architecture/pagination.md), before any text of the round is written and on the round's own transaction; the bookmark's own text, or its page via pageOfElement/readVerticalMargins (both from pageBreaks.ts, shared with the TOC) — and leaves the cached text alone when the target is gone, which is what a reference to a deleted bookmark shows. Ctrl/Cmd+click on a reference or on an internal #name link jumps to the bookmark and selects it. Not in the header/footer schema. ODF: replaceBookmarks splices the BMS/BME sentinels (U+E013/U+E014) into the run text and swaps each crossRef for an XRF run (U+E015) — splicing into the text, not into a run emitter, is what makes them ride every odf-kit path, cells and custom paragraphs included; applyBookmarks rewrites all three to <text:bookmark-start/>, <text:bookmark-end/> and <text:bookmark-ref text:reference-format>. DOCX: the docx package covers both natively — Bookmark around the marked runs, SimpleField REF/PAGEREF … \h with the cached result, InternalHyperlink for a #name href. A range spanning paragraphs is emitted per paragraph (Word tolerates the repeated name). Import tracks open ranges in Ctx across both the block and the inline walk (Word writes a w:bookmarkStart beside paragraphs as well as beside runs) and reads REF/PAGEREF out of the fldChar state machine and w:fldSimple alike, including a field Word never resolved (no w:separate, so no cached result — the node view fills it in).