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
This commit is contained in:
Steffen Becker committed 2026-09-11 09:10:19 +02:00
1 parent 9dfaeb4032
commit b233f0e160
5 files changed
+74 -18

No files matched your search

+1 -1
View File
@@ -10,5 +10,5 @@ list markers, paragraph boxes, date/time fields and cross-references.
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` (unknown ⇒ keep the shown text). **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`).
- **`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).
+18 -1
View File
@@ -3,7 +3,7 @@
Covers `src/lib/editor/extensions/image.ts` and `textBox.ts` — node views, resize/rotate
handles, wrap modes, frame offsets — and their ODF/DOCX legs.
- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `rotation` (CW degrees). The node view nests an `<img>` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. A picture that arrives by **URL** (an `<img>` pasted or dropped from a web page) is fetched into a data-URI right after the paste (`inlineRemoteImages`); one the site withholds is dropped with a message, since only a data-URI ever reaches a file. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'|'through'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below), and `through` — Word's in-front-of / behind-text (`wp:wrapNone` + `behindDoc`), ODF's `style:wrap="run-through"` — leaves the flow entirely: `position: absolute` with no offsets keeps the static position it was anchored at, so it reserves neither width nor height, and `inFront` picks the side of the text it lands on. Read as a side float instead it reserved a near-column-wide picture's whole height, which cost the image fixture a page and ~50 reports. A text box in that mode always sits behind the text — the mode has no toolbar entry yet, so only a file can carry it in. All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** `wrapDist` is the gap the file keeps beside a float, in cm (Word's `distL`/`distR`, ODF's `fo:margin-left`/`-right` on the graphic style) — only the side the text flows on, since the other one is the frame's own offset. A frame that declares none reserves **nothing**, beside it or above/below it: probed, LibreOffice wraps flush against it, and a gap of our own put every figure in a text box 3mm too tall and broke a line a word early. `setImageWrap` gives a frame the file never floated the 0.32cm a word processor's own wrap command writes. **Vertical alignment (as-char):** `vAlign` carries ODF's `style:vertical-pos`/`-rel` pair for an inline frame — `null` (LibreOffice's `top`/`baseline` and Word's only mode) stands the frame on the text baseline, `middle`/`below` centre it on the baseline or hang it below, and `text-top`/`text-middle`/`text-bottom` measure against the character area instead (CSS `vertical-align: text-top`/`text-bottom`; nothing in CSS centres on that area, so `text-middle` uses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share). `from-top` becomes `vAlign: 'offset'` and puts the frame's top `wrapOffsetY` cm below the baseline whatever the relation says — all probed against Line truncated
- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `rotation` (CW degrees). The node view nests an `<img>` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. A picture that arrives by **URL** (an `<img>` pasted or dropped from a web page) is fetched into a data-URI right after the paste (`inlineRemoteImages`); one the site withholds is dropped with a message, since only a data-URI ever reaches a file. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'|'through'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below), and `through` — Word's in-front-of / behind-text (`wp:wrapNone` + `behindDoc`), ODF's `style:wrap="run-through"` — leaves the flow entirely: `position: absolute` with no offsets keeps the static position it was anchored at, so it reserves neither width nor height, and `inFront` picks the side of the text it lands on. Read as a side float instead it reserved a near-column-wide picture's whole height, which cost the image fixture a page and ~50 reports. A text box in that mode always sits behind the text — the mode has no toolbar entry yet, so only a file can carry it in. All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** `wrapDist` is the gap the file keeps beside a float, in cm (Word's `distL`/`distR`, ODF's `fo:margin-left`/`-right` on the graphic style) — only the side the text flows on, since the other one is the frame's own offset. A frame that declares none reserves **nothing**, beside it or above/below it: probed, LibreOffice wraps flush against it, and a gap of our own put every figure in a text box 3mm too tall and broke a line a word early. `setImageWrap` gives a frame the file never floated the 0.32cm a word processor's own wrap command writes. **Vertical alignment (as-char):** `vAlign` carries ODF's `style:vertical-pos`/`-rel` pair for an inline frame — `null` (LibreOffice's `top`/`baseline` and Word's only mode) stands the frame on the text baseline, `middle`/`below` centre it on the baseline or hang it below, and `text-top`/`text-middle`/`text-bottom` measure against the character area instead (CSS `vertical-align: text-top`/`text-bottom`; nothing in CSS centres on that area, so `text-middle` uses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share). `from-top` becomes `vAlign: 'offset'` and puts the frame's top `wrapOffsetY` cm below the baseline whatever the relation says — all probed against Line truncated
- **`textBox.ts`** (`TextBox`) — an **inline** text box / basic shape carrying editable block content (`group: 'inline'`, `inline: true`, `content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`) — the frame both formats write, a character in a run that holds paragraphs. It rides a paragraph, so `inline` really is in the line, and a box reaches a table cell or a list item through their paragraphs, as one does in Word. Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). **Wrap** follows `ImageView.applyWrap` exactly: `inline` is an `inline-block` in the line, `left`/`right` are floats, `topBottom` a **full-width float** whose wrapper spans the column while `placeInBand` moves the rotor across it, `through` leaves the flow. Never `display:block` — that splits the paragraph's inline content into anonymous block boxes and ProseMirror drops the following page-break spacer. A paragraph holding a box paginates **atomically** (`pageBreaks.ts`), whichever way the box wraps. `TextBoxView` mirrors `ImageView` (wrapper reserving the rotated bbox → rotor carrying fill/stroke/rotation → `contentDOM`); the rotor is out of flow and auto-grows, so a ResizeObserver refits the wrapper. **Click model (Word-like):** `stopEvent`/`isFrameHit` claim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the shared `image-*` handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-`draggable` only while node-selected, so PM's native drag moves it without hijacking text selection. **Reaching the position beside the box:** a frame nobody is editing is an atom to the browser — a `contenteditable="false"` node decoration, dropped while the caret is in its text — and `ArrowLeft`/`ArrowRight` at the box's first/last text position step out to `before`/`after` the node. Without both, a box that starts its paragraph is a trap: there is no text position beside it, so the browser pulls the caret meant for the box's own place in the line into the frame and everything typed there lands inside it. The frame then has to place the caret itself on the click that enters it (`onFrameMouseDown`, `stopEvent`), since a non-editable one gets none from the browser. A nested `contentEditable` island is not the way — ProseMirror's `hasFocus` is `activeElement == view.dom`, and an island holding focus makes it read no selection at all. Commands: `insertTextBox()` (at the caret, cursor inside; refused inside a box), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`), `setTextBoxAlign()`. **Two guards** the schema cannot express live in the extension's plugin: a `filterTransaction` refuses a box inside a box — both word processors refuse it and neither format's writer has a place for one — and an `appendTransaction` grows a selection ending inside a box it did not start in out to the box's own bounds, since deleting such a range dissolves the frame and spills its text into the body. `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke, the alignment and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. **DOCX autofit:** the shape body writes `<a:noAutofit/>`, which is what LibreOffice writes for the same frame — read as `spAutoFit` it gives the frame `fo:min-height="0"` and draws its text detached from the shape, over whatever follows (probed). The cost is the ODF leg's auto-grow: content taller than the declared height is clipped there, DOCX having no `fo:min-height`. **LibreOffice puts a `wrapTopAndBottom` anchor's band above its anchor paragraph** whatever the anchor says — probed against `posOffset` 0/635, `align top`, `relativeFrom` line/column, `allowOverlap`, and against a file LibreOffice exported itself; `wp:inline` is the one form it places right, and a band-wrapped box keeps `wp:anchor` anyway (Word is the reference for `.docx`). **Where it sits across the column:** only a **band-wrapped** box has a place of its own — `wrapAlign` (`null`/`center`/`right`, drawn by `placeInBand`), and the toolbar's three buttons show for that mode alone, dropping any `wrapOffset` when onLine truncated
## A box in static HTML
@@ -45,6 +45,20 @@ stops selecting once anything follows it (a caption is the usual first such bloc
nothing is hidden, since text flows beside a frame and never across it. A page-anchored
frame is excluded — it carries its own place in the stack (`inFront`).
A frame **behind** the text cannot be lifted at all — it has to paint under the text —
so the browser hit-tests the page and every paragraph over it first and a cover picture
took no click whatever. `behindTextPlugin` (`image.ts`) walks `elementsFromPoint` for the
topmost such frame and hands the mousedown to its own node view, which selects and drags
it as a click on any other frame does; a text run painted over the point stops the walk,
so text keeps the caret, as it does in both products.
## Stacking behind the text
Among frames behind the text a **picture paints over a shape** — probed against
LibreOffice, which puts a cover photo over the blocks a template layers under it whatever
stacking order the file names. So the box's node view sets `z-index: -2` and a picture
keeps -1, with the page sheets moving to -3 (`PageSheetLayer`).
## Shapes (`utils/shapes.ts`)
Every kind past the three CSS can draw is **one polygon in a 0…100 box**, given once and
@@ -93,6 +107,9 @@ double arrow.
- An anchored frame's DOCX `positionV` posOffset is floored at one twip: on exactly 0,
LibreOffice's wrap layout paints a neighbouring inline box's text outside its shape
(probed; LO never writes less itself). The importer reads the twip back as no offset.
- A line is a frame of **no height**, and a zero-high SVG viewport turns rendering off
altogether (per spec), so `applyLine` gives it at least the stroke and lets the line
overflow it by half, as it does in any flat frame.
## Freeform outlines (`shapePath`)
A drawing this editor offers no tool to author — a polygon, a polyline, a bezier curve,
+6 -3
View File
@@ -554,9 +554,12 @@
position: static;
height: auto;
}
/* The paragraph's space above is band height as well. The visible zone anchors its
text at the band edge instead, so only the measurement takes it. */
.hf-measure .hf-zone :global(p) {
/* The paragraph's space above is band height as well — and in a header it is drawn
too, which is what puts a rule line at the foot of the band it belongs to. A
footer's carries the zone's own gap to the body (import/odt.ts), so it stays a
measurement there: drawn, it would push the text off the page. */
.hf-measure .hf-zone :global(p),
.hf-header :global(p) {
margin-top: var(--space-before, 0);
}
+16
View File
@@ -65,6 +65,15 @@ dropped — neither word processor honours one inside a table.
## Header/footer import
- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. `style:page-usage="mirrored"` — or `"left"`/`"right"`, which is one side of a book and mirrors just as surely — marks the left/right pair as **inner/outer**, so an even page swaps them (`margins.mirrored`, set only when true). `style:writing-mode` starting `rl` is a right-to-left page (`rtl`); DOCX reads the section's `w:bidi` for the same thing. The band LibreOffice lays out is **`max(declared height, zone content + the zone's own gap)`**, and the body starts at the page margin plus it — unless the gap is **dynamic** (`style:dynamic-spacing="true"`, which is what LibreOffice writes for a Word header), where it is absorbed rather than added and the band is `max(height, content)`. Probed at min-height 7.51mm, gap 6.51mm, 12pt lines: `false` → 11.37 / 21.11 / 35.71mm for 1 / 3 / 6 lines, `true` → 7.51 / 14.60 / 29.21. Probed on a 20mm top margin, 10pt zone: one line + `fo:margin-bottom` 0 → body at 24.07mm, the same with 5mm → 29.07, three lines → 37.21, one line under `fo:min-height="20mm"` → 40.00 (the floor wins, the gap is not added on top). A zone paragraph that *wraps* counts as the lines it renders, and its own `fo:padding` counts too. The **last** paragraph's `fo:margin-bottom` is no part of the band, in either zone — probed: a header of one 10pt line with a 12mm bottom margin puts the body at the 6mm min-height, and a footer's is dropped the same way, while a margin *between* two of the zone's paragraphs counts in full (two lines 12mm apart → a 20.16mm band). The collapsed paragraph therefore carries only the margins between the source paragraphs, so a zone's own trailing margin does not survive a re-save; nothing renders differently for it. Missing that put a page's body 10.3mm low on every page of one file. The importer folds only `max(height, gap)` into the page margin — the floor it can know without laying the zone out — and the gap plus the zone's and the paragraphs' `fo:padding` join the collapsed paragraph's spacing on the body-facing side (`convertHfZone`'s `bandCm`), so the runtime's **measured** band (`src/lib/components/CLAUDE.md`) sees them; `max(margin, dist + measured)` is then LibreOffice's `rawTop + max(height, content + gap)` exactly. Verified against a copy of one file's own header: LibreOffice puts the body at 33.71mm, the editor at 33.79. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. A **text box** has no block node to live in, so its paragraphs become lines of the zone ahead of the one anchoring it (warned): Word's own converter flattens the same document the same way, and dropping it cost one letterhead 7.6mm of band on every page. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports.
A **table in a DOCX zone** goes the same way as a text box: its cells' paragraphs become
the zone's lines (`hfParagraphs`), and since the one-cell table is what every Word
template draws its rule line with, the cell's own borders and shading become the
collapsed paragraph's box (`hfCellBox`, `w:tcBorders` over the table's `w:tblBorders`).
Its rows' `w:trHeight` beyond the lines they hold rides that paragraph's **space above**
(`hfRowExtraPt`), which is both what the measured band grows by and what puts the rule at
the row's foot — a header zone draws its space above, where a footer's carries the ODF
gap to the body and only measures it (`HeaderFooterLayer`). Read as no zone at all, a
2.2cm header band left every page's body 8mm high.
## Defaults on DOCX import
@@ -112,6 +121,13 @@ numbering, so `DocxStyles.level()` resolves through the link (style → its `w:n
as a bullet list. A linked numId's list gets `listStyleName` (the style's `w:name`), no
per-level attrs, and its definition lands in `sheet.list` (`listStyleFromDocx`).
**A shape's fill and stroke resolve the theme.** `<a:schemeClr>` is what a template's own
colours are named by, so `themeColors` reads theme1.xml's scheme by slot — plus the
`tx1`/`bg1`/`tx2`/`bg2` aliases Word's default mapping gives it — and `drawingColor`
applies the `lumMod`/`lumOff`/`tint`/`shade` modifiers per channel (they are defined on
luminance; channel-wise is within a shade of it and needs no colour space). Read as
sRGB-only, a cover page came back with no blocks and no divider lines at all.
Body text with no resolved font falls back to the *document's own theme minor font*
(`docx.ts` `runMarks`), not the editor default — Word's implicit body default. Headings don't
(they keep the editor heading default); DOCX page margins default to Word's 2.54cm.
+33 -13
View File
@@ -3375,10 +3375,13 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc {
});
const para: Node = { type: 'paragraph', content: inline };
const attrs: Record<string, string> = {};
const attrs: Record<string, string | number> = {};
// The zone is one paragraph here, so its strut is the whole band's line height —
// runs that agree on a size must set it, or a 10pt footer reserves 12pt lines.
applyUniformRunFont(attrs, inline);
const linePt = parseFloat(String(attrs.fontSize ?? '')) || HF_LINE_PT;
const extraPt = hfRowExtraPt(hfParagraphs(root), linePt);
if (extraPt > 1) attrs.spaceBefore = Math.round(extraPt * 100) / 100;
if (textAlign) attrs.textAlign = textAlign;
if (stops) attrs.tabStops = stops;
Object.assign(attrs, box);
@@ -3410,17 +3413,41 @@ function hfParagraphs(el: Element, ctx?: Ctx): Element[] {
return out;
}
// The zone's own line height where no run says otherwise — the 12pt the zone renders at.
const HF_LINE_PT = 12;
// A zone table's rows are as tall as the file says (w:trHeight), and the zone collapses
// to one paragraph: the height its lines do not fill rides the space above them, which
// is what the band measures and what puts the rule a cell draws at the row's foot.
function hfRowExtraPt(paras: Element[], linePt: number): number {
const lines = new Map<Element, number>();
for (const p of paras) {
const tr = ancestorNamed(p, 'tr');
if (tr) lines.set(tr, (lines.get(tr) ?? 0) + 1);
}
let extra = 0;
for (const [tr, n] of lines) {
const tw = intAttr(fc(fc(tr, 'trPr'), 'trHeight'), W, 'val');
if (tw) extra += Math.max(0, (tw / 20) - n * linePt * 1.15);
}
return extra;
}
function ancestorNamed(el: Element, localName: string): Element | null {
for (let e = el.parentElement; e; e = e.parentElement) {
if (e.namespaceURI === W && e.localName === localName) return e;
}
return null;
}
// The box a zone paragraph inherits from the table cell around it: the rule line a
// header draws is a one-cell table's border in every Word template. The cell's own
// w:tcBorders win over the table's, as they do in a body table.
function hfCellBox(p: Element): Record<string, string> {
let tc: Element | null = null;
for (let e = p.parentElement; e; e = e.parentElement) {
if (e.namespaceURI === W && e.localName === 'tc') { tc = e; break; }
}
const tc = ancestorNamed(p, 'tc');
if (!tc) return {};
const tcPr = fc(tc, 'tcPr');
const layers = [fc(tcPr, 'tcBorders'), fc(fc(tblOf(tc), 'tblPr'), 'tblBorders')];
const layers = [fc(tcPr, 'tcBorders'), fc(fc(ancestorNamed(tc, 'tbl'), 'tblPr'), 'tblBorders')];
const out: Record<string, string> = {};
for (const [wSide, attr] of PARA_BORDER_SIDES) {
let v: string | null | undefined;
@@ -3433,13 +3460,6 @@ function hfCellBox(p: Element): Record<string, string> {
return out;
}
function tblOf(el: Element): Element | null {
for (let e = el.parentElement; e; e = e.parentElement) {
if (e.namespaceURI === W && e.localName === 'tbl') return e;
}
return null;
}
// Collapse several source paragraphs' box props into one (mirror of odt.ts mergeHfBox).
function mergeHfBox(maps: Record<string, string>[]): Record<string, string> {
const out: Record<string, string> = {};