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
23 KiB
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) andtextPosition(pt above the baseline →vertical-align, independent of sub/superscript) as TextStyle attrs, pluslineStyle/lineColoron the underline mark andlineStyleon strike (the<u>/<s>element draws the line, so its shape belongs there). Replaces TipTap's bare Underline/Strike inextensions.tsandhfExtensions.capsis also aTextPropskey, 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, somarkTextEffectsprefixes theTEFsentinel (U+E00F) on the run andapplyTextEffectsfolds the attributes into that run's automatic text style — beforeapplyCharacterStyles, which then clones the style that already carries them.tests/lo-roundtrip.test.tsis what proves LibreOffice reads each one back. Sub- and superscript are TipTap's own marks, but their<sup>/<sub>neededitor.css'sfont-size: 58%; line-height: 0on.paper(a header/footer zone carries the marks too, and its height is what pagination reserves) — the size both exporters write asstyle: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'ssmaller+vertical-align: superinstead 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'(fromtableCellBorders.ts), but herenull/absent = no border. CommandssetParagraphBackground/setParagraphBorders(preset, spec);activeParagraphBorderPresetsdrives the picker states. An input rule turns a paragraph typed as---/___/===into an empty bottom-rule line (the border-line AutoCorrect); attrs arekeepOnSplit: falseso the box doesn't cascade onto the next paragraph on Enter. In bothextensions.ts(body) andhfExtensions(header/footer). ODF: import readsfo:background-color/fo:border-*inblockAttrs(body) and merges them across the collapsed header/footer paragraphs (convertHfZone, first/last heuristic — bottom rule from the last line); export emits them viaParaStyle/paraStylePropsfor cells/lists, aPBX-sentinel post-process (applyParagraphBoxes, odf-kit has no such options) for top-level body paragraphs, andapplyHfPostProcessfor the Header/Footer styles. DOCX:w:shd/w:pBdr(importreadParaBox, exportparaShadingOf/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-sidew:pBdr w:space, ODF'sfo:padding-*— anddeclarationsputs 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-shadingColorPicker+ParagraphBorderPicker.svelteinToolbarExpanded.svelte(route viaactiveEditor, so body and the active HF zone).indent.ts(Indent) —indentattr in cm (rendered asmargin-left), on paragraph/heading and bulletList/orderedList.indentMore/indentLessstep a paragraph/heading byINDENT_STEP_CM = 1.25→fo:margin-left(odf-kitindentLeft);indentListMore/indentListLessstep the innermost list'sindent, shifting the whole list (export adds it to the L# list-style level margins, seeapplyListIndents). 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-alignmentfo: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/indentListBackwardapply 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 ownindentfirst (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.indentRightis the mirror ofindent(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 samewritewalk. The exportedlistContext(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'schangeIndentand the Tab/Shift-Tab keymap call these: in a list Tab/Shift-Tab runindentListForward/indentListBackward; outside a list Tab inserts a real tab character (\t, rendered via the document'stab-size) and Shift-Tab runsindentLess(changeIndentusesindentMore/indentLess). The keymap bails inside table cells so the Table extension's Tab keeps navigating between cells.Indentsetspriority: 1000so its Tab/Shift-Tab win overListItem's built-in sink/lift bindings — without it, TipTap's reverse-then-priority-sort lets the later-registeredListItemhandle the key first (itsliftListItemwould dissolve a top-level list item into a paragraph on Shift-Tab). A list item's paragraph carries no indent of its own: thewritewalk skips it, and anappendTransactionclears the three attrs wherever one lands there anyway — wrapping an indented paragraph into a list would otherwise keep an indent that neither exporter writes (collectListItemStylespasseswithIndents: false) and neither importer reads back (a list level's own indent is whatfo:margin-left/w:indmeans there).tabStops.ts(TabStops) — per-paragraph tab stops (Word'sw:tabs, ODFstyle:tab-stops). Global attrtabStopson paragraph/heading, rendered asdata-tab-stops; canonical string'<cm><align code>'per stop,;-separated ('6c;12r;16d', codesl|c|r|d), parsed only by the exportedparseTabStops/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 fixedtab-sizegrid, so a rAF measuring plugin (shaped likepageBreaks.ts, whoseFORCE_PAGE_RECALCit both listens to and fires) computes each tab's advance fromcoordsAtPosagainst.tiptap's content edge and emitsDecoration.inline(tab, tab+1, {style:'tab-size:0;margin-left:Npx'})—tab-size:0collapses the tab's own advance, the inline margin supplies the exact width, so nodisplaychange and no baseline/line-height side effects. The margin is on the left because the gap is the tab's own advance: asmargin-righta 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.nextStoptakes 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 negativeindentFirstimplies a stop at the block's indent (the hanging-indent stop). Centre/right/decimal take back the segment up to the next tab, measured byrangeWidthas 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:offsetHeightis 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 staticgenerateHTMLoutput no plugin reaches, solayOutZoneTabsmeasures 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 withhasAttribute(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::beforeclipped 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 ridesstyle:typebehind aLEADsentinel thatapplyTabLeadersunpacks — 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 fromw:defaultTabStop/ the paragraph default-style'sstyle:tab-stop-distance, applied as the--tab-intervalCSS var, and always written back on export. A file that declares none falls back to its format's own value — measured, LibreOffice puts a bareA⇥Xat 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 theli'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 itssheetoption, the exporters from theirexportSheet); what a named paragraph style provides reaches the marker throughstyleCss'sli: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/-coloron everylistItem— they inherit, so an item without its own format resets them toinitial(the guaranteed-invalid value, so thevar(…, inherit)ineditor.cssfalls back;inheritwould pull the parent item's value in), andeditor.csshas one rule reading them on the item block's::before— which is what draws the marker:::markercannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (LIST_HANGING_CM0.635cm, what both exports write). The pseudo is floated into that indent (min-widththe indent, negativemargin-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: nonedrops only the marker box, so thelist-itemcounter and<ol start>keep numbering, andcontent: counter(list-item, …)/counters(list-item, '.')perdata-eff-list-stylereplaceslist-style-type; abulletCharrides--bullet(anattr()inside the pseudo would read the block, not the list).export/pdf.tsswitches 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-familyas its fallback. The pass also re-runs onFORCE_PAGE_RECALC, so editing a character style repaints the markers. Export: both formats carry marker formatting per level, solistMarkerFormatreturns one only when every item of the list agrees — ODF mints a namedstyle:family="text"style in styles.xml and points the level definition at it viatext:style-name(collectListMarkerFormats/applyListMarkerFormats) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level'sw: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 aboutcharStyle, andapplyRunsnever 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 theCSTsentinel, soapplyCharacterStylesre-points that span at the named style like everywhere else. Formatting and name both round-trip.tests/unit/list-marker.test.tscovers the four LibreOffice cases, the reset, and both export legs. Both this set andlistStyle.ts’s are rebuilt only for a transaction reaching into a list (touchesList— a step by its ownfrom/toorpos, 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 byEditor.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.tscovers which transactions count. Label alignment (markerAlignon the list node, Word'sw:lvlJc, ODF'sfo:text-alignon the level properties):rightsets 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.tsround-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 storedvalue; an auto field renders the current moment on every load (renderHTML/renderTextviadateTimeFieldText) — 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). CommandinsertDateTimeField({kind,format,fixed}). Formats live inutils/dateTime.tsas a token model (oneToken[]per format) so the three consumers stay in agreement:renderFormat(locale display viaIntl),odfNumberStyle(<number:date-style>/<number:time-style>body),docxPicture(Word field switch).DateTimePicker.svelte(a dropdown next toSpecialCharPickerinToolbarExpanded.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;ensureNumberNamespaceaddsxmlns:number); import matches the referenced number style back to a format viaparseNumberStyleTokens+matchFormat; a token list the catalog does not list keys the node by its own DOCX picture (MMMM d), whichfindFormatparses 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 →SimpleFieldDATE/TIME \@ "picture"with a cached run; fixed → a plain text run (Word has no fixed-date field); import maps aDATE/TIMEfldSimple/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 aREFrecompute its text. It carries onename(data-bookmark), isinclusive: 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_GoBackand every point bookmark (ODF<text:bookmark/>, a zero-length Word range) are dropped — there is no range to mark.bookmarks(doc)/findBookmark/bookmarkNamesare the shared lookups (dialogs, node view,Editor.svelte's click handler). A cross-reference is an inline atom withname,format('text'|'page') andtext, 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 eachpm-pagecountand 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 viapageOfElement/readVerticalMargins(both frompageBreaks.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#namelink jumps to the bookmark and selects it. Not in the header/footer schema. ODF:replaceBookmarkssplices theBMS/BMEsentinels (U+E013/U+E014) into the run text and swaps eachcrossReffor anXRFrun (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;applyBookmarksrewrites all three to<text:bookmark-start/>,<text:bookmark-end/>and<text:bookmark-ref text:reference-format>. DOCX: thedocxpackage covers both natively —Bookmarkaround the marked runs,SimpleFieldREF/PAGEREF … \hwith the cached result,InternalHyperlinkfor a#namehref. A range spanning paragraphs is emitted per paragraph (Word tolerates the repeated name). Import tracks open ranges inCtxacross both the block and the inline walk (Word writes aw:bookmarkStartbeside paragraphs as well as beside runs) and readsREF/PAGEREFout of thefldCharstate machine andw:fldSimplealike, including a field Word never resolved (now:separate, so no cached result — the node view fills it in).