The default tab interval was fixed at 1.25cm, so a file's own w:defaultTabStop / style:tab-stop-distance was ignored and the DOCX export wrote 1.25 back over Word's 1.27. It is a document setting now. A stop can also carry the character it repeats across its gap. odf-kit has no leader option and mints one automatic style per option set, so the leader rides style:type behind a sentinel — matching on position and alignment alone gave every stop at 6cm the same leader, which LibreOffice drew back plain as day.
14 KiB
14 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).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 measure the segment up to the next tab; a segment that wraps degrades to left. Passes converge (one more tab settles per pass,MAX_PASSES), since a stop can rewrap the line. UI:Ruler.svelte. Body only — an inactive header/footer zone is staticgenerateHTMLoutput no plugin reaches. 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, LibreOffice's 1.25cm; Word's is 1.27): imported fromw:defaultTabStop/ the paragraph default-style'sstyle:tab-stop-distance, applied as the--tab-intervalCSS var, and written back on export — the ODF side only when it differs from LibreOffice's, so a file that never declared one doesn't gain it.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.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).