Word's w:lvlJc / ODF's fo:text-align on the level: set right, the label grows into the margin instead of over the text it labels. That is what the built-in Roman numberings use, and a fixture's numerals collided without it.
16 KiB
16 KiB
Run and paragraph formatting
Covers the src/lib/editor/extensions/ files carrying character effects, indents, tab stops,
list markers, paragraph boxes and date/time fields.
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.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). 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).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 position is read with side −1 (the end of the content before the tab), which lies outside the tab's own span — measuring the tab itself would fold the margin just applied back into the next pass's reading.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 as the sum of its client rects (rangeWidth) — reading its span end to end instead made a segment that its own advance had pushed onto the next line measure as wrapped, and the alignment could never recover. 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. 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. 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 absolutely positioned at that indent, because in flow it grew every list line by a pixel.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. 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(unknown ⇒ keep the shown text). 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).