fix: draw sub/superscript at 58% and out of the line box

The browser's `smaller` + `vertical-align: super` grows every line holding a
raised run by ~4px. LibreOffice keeps its raised run within 0.25pt of the plain
line's box (measured off its PDF), and both exporters already write
`style:text-position="super 58%"` — only the screen diverged. The same three
declarations already sit on the footnote anchor. Scoped to the paper, since a
header/footer zone carries the marks and its height is reserved off the page.

Measured: a table LibreOffice fits above the page edge now fits here too,
instead of moving to the next page.
This commit is contained in:
Steffen Becker committed 2026-08-30 10:06:44 +00:00
1 parent 1424d7c23f
commit c115b11c8d
2 files changed
+9 -1

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
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.
- **`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).
- **`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. **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`).
+8
View File
@@ -161,6 +161,14 @@
padding: 0.04cm 0.08cm;
}
/* Both exporters write style:text-position="super 58%": that size, and a zero line
height so the raised run stays out of the line box, as it does in LibreOffice. On
the paper, not the body — a header/footer zone carries the marks and is reserved. */
.paper :is(sup, sub) {
font-size: 58%;
line-height: 0;
}
/* Hyperlinks (link.ts). Ctrl/Cmd+click opens them (Editor.svelte). */
/* The browser's own link colour is off first: a link Word left unstyled is drawn like the
text around it, since there the blue is a character style, not a property of being a