diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md index 52ec43b..b63da91 100644 --- a/docs/architecture/frames.md +++ b/docs/architecture/frames.md @@ -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 `` 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. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'`) 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). 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 LibreOffice, whose formula images use `middle`/`text` and `from-top` almost exclusively. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **no text descent below it** (probed); `image.ts` gives such a frame an `image-line` class, since CSS cannot ask whether a line holds real text. Hard breaks are the only line boundaries the model shows, so the run between two of them is what gets tested — which covers the frame-break-caption idiom LibreOffice's own guides are full of; a frame alone on a *soft-wrapped* line is invisible from there. A frame that shares its line with text keeps the baseline and the descent. **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. Two `topBottom` frames set against **opposite ends** of nearby paragraphs (`wrapAlign`, from Word's `positionH` align / ODF `style:horizontal-pos`) share one band and float to their own sides instead: `pairAlignedFrames` (import/odt.ts, both importers) keeps the attr only for such a pair — a lone one reserves the whole band, which is what the wrap means — and scales a pair up to 15% wider than the column down to it, since Word lets the two overlap in the middle and two floats cannot. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota. +- **`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 `` 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. **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 LibreOffice, whose formula images use `middle`/`text` and `from-top` almost exclusively. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **no text descent below it** (probed); `image.ts` gives such a frame an `image-line` class, since CSS cannot ask whether a line holds real text. Hard breaks are the only line boundaries the model shows, so the run between two of them is what gets tested — which covers the frame-break-caption idiom LibreOffice's own guides are full of; a frame alone on a *soft-wrapped* line is invisible from there. A frame that shares its line with text keeps the baseline and the descent. **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. Two `topBottom` frames set against **opposite ends** of nearby paragraphs (`wrapAlign`, from Word's `positionH` align / ODF `style:horizontal-pos`) share one band and float to their own sides instead: `pairAlignedFrames` (import/odt.ts, both importers) keeps the attr only for such a pair — a lone one reserves the whole band, which is what the wrap means — and scales a pair up to 15% wider than the column down to it, since Word lets the two overlap in the middle and two floats cannot. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota. - **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). The node has its **own group** (`group: 'textBox'`) so only the document admits it (`extensions.ts` widens Document to `'(block | textBox)+'`) — never in table cells, lists, header/footer, or other boxes; imports found there are flattened with a warning. `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 block drag moves it without hijacking text selection. Commands: `insertTextBox()` (after the current top-level block, cursor inside), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`). `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke 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 frame style's own writing mode in ODF (in its *paragraph* properties — probed: in the graphic properties LibreOffice drops it) 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. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically. **Spacing:** a box lifted out of an anchor paragraph that held nothing else stands in for it, so it takes that paragraph's resolved `fo:margin-top`/`-bottom` as `spaceBefore`/`spaceAfter` (`ParagraphSpacing` covers `textBox` for that). Space above is padding — it adds to the block above rather than collapsing against it — which means the node is `content-box` and the rotor, centred in the padding box, gets half of it back. ## Clicking a float diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 783b66d..23cb734 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -12,8 +12,9 @@ import { readVerticalMargins } from './pageBreaks'; // draw:transform/style:wrap. A floating image floats at its anchor; drag re-anchors it. // 'inline' = as-character; 'left'/'right' = square wrap (text on the open side); -// 'topBottom' = no side wrap (text only above/below). -export type WrapMode = 'inline' | 'left' | 'right' | 'topBottom'; +// 'topBottom' = no side wrap (text only above/below); 'through' = the text runs over +// or under it (`inFront` picks which), so the frame reserves nothing at all. +export type WrapMode = 'inline' | 'left' | 'right' | 'topBottom' | 'through'; declare module '@tiptap/core' { interface Commands { @@ -98,6 +99,16 @@ export function frameMargins(wrap: WrapMode, offsetCm: unknown, boxWidthPx: numb return `0 ${far} 0 ${gap}`; } +// Word's behind-text / in-front-of-text, ODF run-through: the text runs over or under +// the frame, so it reserves nothing. Absolute with no offsets keeps the static position +// it was anchored at; the file's own offsets ride as margins from there. +export function applyRunThrough(el: HTMLElement, offsetCm: unknown, offsetYCm: unknown, inFront: boolean): void { + const px = (cm: unknown) => (typeof cm === 'number' ? Math.round(cmToPx(cm)) : 0); + el.style.position = 'absolute'; + el.style.margin = `${px(offsetYCm)}px 0 0 ${px(offsetCm)}px`; + el.style.zIndex = inFront ? '1' : '-1'; +} + // Where an as-char frame sits against the line (ODF style:vertical-pos/-rel, probed // against LibreOffice): its bottom on the baseline by default, `text-*` against the // character area, `offset` its top wrapOffsetY cm below the baseline. @@ -396,7 +407,7 @@ class ImageView { private attrW(): number | null { const w = this.node.attrs.width; return typeof w === 'number' ? w : null; } private attrH(): number | null { const h = this.node.attrs.height; return typeof h === 'number' ? h : null; } private attrRot(): number { return (this.node.attrs.rotation as number) || 0; } - private attrWrap(): WrapMode { const w = this.node.attrs.wrap; return w === 'left' || w === 'right' || w === 'topBottom' ? w : 'inline'; } + private attrWrap(): WrapMode { const w = this.node.attrs.wrap; return w === 'left' || w === 'right' || w === 'topBottom' || w === 'through' ? w : 'inline'; } // The wrapper's reserved (rotated) width, which applyLayout has just written. private boxWidth(): number { return parseFloat(this.dom.style.width) || this.attrW() || 0; } @@ -443,12 +454,17 @@ class ImageView { d.style.zIndex = ''; d.style.top = ''; d.style.left = ''; + this.rotor.style.top = ''; const a = this.node.attrs; if (typeof a.anchorPage === 'number' && a.anchorPage > 0) { this.applyPageAnchor(a.anchorPage); return; } delete d.dataset.anchorPage; + if (wrap === 'through') { + applyRunThrough(d, a.wrapOffset, a.wrapOffsetY, a.inFront === true); + return; + } if (wrap === 'left' || wrap === 'right') { d.style.float = wrap; d.style.margin = frameMargins(wrap, a.wrapOffset, this.boxWidth(), null, a.wrapDist); diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts index 59c57b9..a5840cf 100644 --- a/src/lib/editor/extensions/textBox.ts +++ b/src/lib/editor/extensions/textBox.ts @@ -5,7 +5,7 @@ import { NodeSelection, TextSelection, Plugin } from '@tiptap/pm/state'; import type { EditorState } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; import type { EditorView } from '@tiptap/pm/view'; -import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, type WrapMode } from './image'; +import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, applyRunThrough, type WrapMode } from './image'; import { SHAPES, shapePath, linePaths, arrowHeadPx, isShapeKind, isLineKind, type ShapeKind } from '../../utils/shapes'; // cm attribute value → number, for the frame offsets (px ones use parsePx). @@ -507,12 +507,17 @@ class TextBoxView { const a = this.attrs(); d.style.float = ''; d.style.clear = ''; + d.style.position = ''; + d.style.zIndex = ''; d.style.margin = frameMargins('topBottom', a.wrap === 'topBottom' ? a.wrapOffset : null, 0); if (a.wrap === 'left' || a.wrap === 'right') { d.style.float = a.wrap; d.style.margin = frameMargins(a.wrap, a.wrapOffset, parseFloat(d.style.width) || 0, null, a.wrapDist); } else if (a.wrap === 'topBottom') { d.style.clear = 'both'; + } else if (a.wrap === 'through') { + // Behind the text, which is what a shape with no run-through of its own exports as. + applyRunThrough(d, a.wrapOffset, a.wrapOffsetY, false); } // The anchor paragraph's spacing, which a lifted box stands in for: space above as // padding so it adds to the block above (editor.css), space below as the margin the diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 97d69f9..39dd1c0 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -24,7 +24,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`. - **Custom node types** (`injectCustomTypes`): paragraphs/headings carrying `lineHeight`/`textAlign`/spacing attrs — or runs with a `fontWeight` textStyle attr, which odf-kit's native path drops — are renamed to `__cust_p__`/`__cust_h__` and emitted via odf-kit's `unknownNodeHandler` (odf-kit ignores those node attrs otherwise). Tables are renamed to `__cust_table__` and built by `exportTable` with an explicit cell border (odf-kit's native table path emits no borders → invisible in LibreOffice/Word). - **Hard breaks & tabs** (`replaceHardBreaks`/`replaceTabs`/`applyInlineSentinels`): `hardBreak` nodes become `LBR`-sentinel text and tab chars become `TAB`-sentinel text so both survive every odf-kit path (a literal `\t` would collapse to a space), then the sentinels are rewritten to ``/`` in content.xml. -- **Images** (`replaceImages`/`applyImages`): odf-kit's native image path rounds dimensions to whole cm and doesn't reach our cell path, so images bypass it. `replaceImages` (a pre-pass) swaps each `image` node for an `IMG`-sentinel text run and collects the bytes (decoded data-URI) + cm geometry (px→cm via `round3`); the sentinel rides every odf-kit path including table cells. `applyImages` (after `applyCellBlocks`) rewrites each sentinel to a ``, adds the binary `Pictures/` entries to the zip, and appends a `` per picture to `META-INF/manifest.xml`. Integer px ⇄ round3-cm ⇄ px is sub-pixel exact, so size is unchanged. A rotated image also gets a `draw:transform="rotate() translate(...)"` (ODF rotate is CCW radians, ours CW degrees; the translate re-centres it on the unrotated box). A **floating** image (`wrap !== 'inline'`) instead uses `text:anchor-type="paragraph"` + a minted `` carrying `style:wrap` + `style:horizontal-pos` (image-left → `wrap="right" horizontal-pos="left"`; topBottom → `wrap="none" horizontal-pos="center"`) injected into content.xml automatic-styles. +- **Images** (`replaceImages`/`applyImages`): odf-kit's native image path rounds dimensions to whole cm and doesn't reach our cell path, so images bypass it. `replaceImages` (a pre-pass) swaps each `image` node for an `IMG`-sentinel text run and collects the bytes (decoded data-URI) + cm geometry (px→cm via `round3`); the sentinel rides every odf-kit path including table cells. `applyImages` (after `applyCellBlocks`) rewrites each sentinel to a ``, adds the binary `Pictures/` entries to the zip, and appends a `` per picture to `META-INF/manifest.xml`. Integer px ⇄ round3-cm ⇄ px is sub-pixel exact, so size is unchanged. A rotated image also gets a `draw:transform="rotate() translate(...)"` (ODF rotate is CCW radians, ours CW degrees; the translate re-centres it on the unrotated box). A **floating** image (`wrap !== 'inline'`) instead uses `text:anchor-type="paragraph"` + a minted `` carrying `style:wrap` + `style:horizontal-pos` (image-left → `wrap="right" horizontal-pos="left"`; topBottom → `wrap="none" horizontal-pos="center"`; through → `wrap="run-through"` + `style:run-through`) injected into content.xml automatic-styles. - **Tables:** column widths come from the editor's per-column weights (`tableColumnWidthsCm`, summing exactly to the text width, with `table:align="margins"`). **Table margins:** a table's `marginLeft`/`marginRight` shrink that width and are written by `applyTableMargins` into the table's automatic style (`style:width` + `fo:margin-left`/`-right`; odf-kit names table styles `Table1`, `Table2`, … in document order, matching the descriptor list `exportTable` fills). odf-kit serializes each cell to a single `` of runs; `exportTable` emits all of a cell's content into that paragraph separated by a `SEG` sentinel and records a `CellBlock[]` descriptor, then `applyCellBlocks` splits on `SEG` and rebuilds real ``/``/`` (incl. nested lists) with minted automatic paragraph styles. **Merged cells:** `exportTable` passes each cell's `colspan`/`rowspan` as odf-kit `CellOptions.colSpan`/`rowSpan`; odf-kit emits `table:number-columns/rows-spanned` and auto-generates the `` placeholders (which never match the `applyCellBlocks` cell regex, so `cellBlocks` stays one-per-real-cell aligned). Import already reads spans/covered cells in `convertTable`. **Cell shading:** a cell's `backgroundColor` attr is passed as `CellOptions.backgroundColor` (normalized), which odf-kit mints into the cell style as `fo:background-color`. **Cell borders:** the per-side border attrs (tableCellBorders.ts) are passed as `CellOptions.borderTop/Right/Bottom/Left` (`'none'` or `'pt solid #RRGGBB'`) overriding the table-level default border per side. - **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a top-level `textBox` is **hoisted** — `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) swaps it for `TBX`-sentinel marker paragraphs (`S{i}`…`E{i}`, U+E008) bracketing its child blocks at top level, so every existing pass (custom attrs, list styles, inline sentinels, images) serializes them unchanged and document-order-indexed passes stay aligned. `applyTextBoxes` (after `applyImages`) wraps each serialized S…E region into `` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `` + preset `` (`utils/shapes.ts`; see `docs/architecture/frames.md`) inside a fresh anchor paragraph, minting a `TbxFr{n}` graphic style (fill/stroke/padding + the image wrap props, plus `` for a box whose text runs top-to-bottom — probed: in the graphic properties LibreOffice drops it; `draw:auto-grow-height="true"` for boxes, both auto-grows explicitly **false** for shapes or LibreOffice's autofit shrinks them to their text). - **A text box's own parts, DOCX** (`applyTextBoxesDocx`): the `docx` package can't emit a DrawingML shape, so the box XML is a hand-built string — and its content needs parts the package never hears about. The pass holds the whole unzipped package, so it mints them itself: a picture becomes `word/media/tbx{n}.{ext}` plus a `Relationship` above the package's highest `rId` (Content_Types already declares the raster defaults), a list an ``/`` pair above its highest id, appended **before** the first `` because every abstract definition has to precede them. The numbering ids only exist once the packed file is in hand, so the paragraphs carry a `TXBX_NUM` sentinel that the same pass substitutes. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 3151158..e47f2a6 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -661,7 +661,7 @@ function decodeDataUri(src: string): { bytes: Uint8Array; type: 'png' | 'jpg' | // offsetCm places the frame in the text column (Word's posOffset); without one it is // flush to its side. offsetYCm is how far below the anchor paragraph it sits. -function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | null, alignH?: string | null, distCm?: number | null): IFloating | undefined { +function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | null, alignH?: string | null, distCm?: number | null, inFront?: boolean): IFloating | undefined { if (wrap === 'inline') return undefined; // The gap beside the frame, on both sides as Word writes it; none above or below. const margins = distCm ? { left: Math.round(distCm * 360000), right: Math.round(distCm * 360000) } : undefined; @@ -669,6 +669,19 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | relative: VerticalPositionRelativeFrom.PARAGRAPH, offset: offsetYCm != null ? Math.round(offsetYCm * 360000) : 0, }; + if (wrap === 'through') { + // Word's in-front-of / behind-text: no wrap at all, and behindDoc names which side + // of the text the frame lands on. It overlaps by definition, so allowOverlap holds. + return { + horizontalPosition: offsetCm != null + ? { relative: HorizontalPositionRelativeFrom.MARGIN, offset: Math.round(offsetCm * 360000) } + : { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT }, + verticalPosition, + wrap: { type: TextWrappingType.NONE }, + behindDocument: !inFront, + allowOverlap: true, + }; + } if (wrap === 'topBottom') { // A frame sharing its band with another is set against one end of it, and the two // may overlap vertically — that is what puts them side by side. @@ -718,7 +731,7 @@ function imageRun(node: TiptapNode): ImageRun | null { data: decoded.bytes, altText: typeof node.attrs?.alt === 'string' && node.attrs.alt ? { name: node.attrs.alt, title: node.attrs.alt, description: node.attrs.alt } : undefined, transformation: { width, height, rotation: rotation || undefined }, - floating: floatingFor(wrap, offsetCm, offsetYCm, node.attrs?.wrapAlign as string | null, distCm), + floating: floatingFor(wrap, offsetCm, offsetYCm, node.attrs?.wrapAlign as string | null, distCm, node.attrs?.inFront === true), }); } @@ -729,7 +742,7 @@ type TextBoxDocx = { widthPx: number; heightPx: number; rotationDeg: number; - wrap: 'inline' | 'left' | 'right' | 'topBottom'; + wrap: 'inline' | 'left' | 'right' | 'topBottom' | 'through'; offsetCm: number | null; offsetYCm: number | null; distCm: number | null; @@ -750,7 +763,7 @@ function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx { widthPx: typeof a.width === 'number' && a.width > 0 ? Math.round(a.width) : 280, heightPx: typeof a.height === 'number' && a.height > 0 ? Math.round(a.height) : 96, rotationDeg: typeof a.rotation === 'number' ? a.rotation : 0, - wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline', + wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' || wrapAttr === 'through' ? wrapAttr : 'inline', offsetCm: typeof a.wrapOffset === 'number' ? a.wrapOffset : null, offsetYCm: typeof a.wrapOffsetY === 'number' ? a.wrapOffsetY : null, distCm: typeof a.wrapDist === 'number' ? a.wrapDist : null, @@ -1133,8 +1146,8 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): s return `${extent}${docPr}${graphic}`; } // wrapText names the side TEXT flows on (inverse of the box side), like ODT. - const wrapEl = box.wrap === 'topBottom' - ? '' + const wrapEl = box.wrap === 'through' ? '' + : box.wrap === 'topBottom' ? '' : ``; const align = box.wrap === 'right' ? 'right' : 'left'; const emu = (cm: number) => Math.round(cm * 360000); @@ -1144,7 +1157,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): s : `${align}`; return ( `` + + ` simplePos="0" relativeHeight="${251658240 + index}" behindDoc="${box.wrap === 'through' ? 1 : 0}" locked="0" layoutInCell="1" allowOverlap="${box.wrap === 'through' ? 1 : 0}">` + `` + `${posH}` + `${emu(box.offsetYCm ?? 0)}` + diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 3d30655..526686c 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -430,8 +430,8 @@ function replaceSectionBreaks(doc: TiptapNode): TiptapNode { // One embedded picture, collected by replaceImages and emitted by applyImages. // bytes is ArrayBuffer-backed to match fflate's zip entry map. rotationDeg is CW; -// wrap floats the frame at its anchor paragraph (left/right/top-bottom). -type WrapMode = 'inline' | 'left' | 'right' | 'topBottom'; +// wrap floats the frame at its anchor paragraph (left/right/top-bottom/run-through). +type WrapMode = 'inline' | 'left' | 'right' | 'topBottom' | 'through'; type ImageExport = { path: string; bytes: Uint8Array; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode; wrapOffsetCm: number | null; wrapOffsetYCm: number | null; wrapDistCm: number | null; wrapAlign: string | null; anchorPage: number | null; vAlign: string | null; inFront: boolean }; function base64ToBytes(b64: string): Uint8Array { @@ -463,7 +463,7 @@ function imageDescriptor(node: TiptapNode, index: number, namePrefix = 'image'): const w = typeof node.attrs?.width === 'number' ? node.attrs.width : 0; const h = typeof node.attrs?.height === 'number' ? node.attrs.height : 0; const wrapAttr = node.attrs?.wrap; - const wrap: WrapMode = wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline'; + const wrap: WrapMode = wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' || wrapAttr === 'through' ? wrapAttr : 'inline'; return { path: `Pictures/${namePrefix}${index + 1}.${EXT_BY_MIME[mimeType] ?? 'png'}`, bytes, @@ -988,7 +988,7 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport { widthCm: pxToCm(typeof a.width === 'number' && a.width > 0 ? a.width : 280), heightCm: pxToCm(typeof a.height === 'number' && a.height > 0 ? a.height : 96), rotationDeg: typeof a.rotation === 'number' ? a.rotation : 0, - wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline', + wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' || wrapAttr === 'through' ? wrapAttr : 'inline', wrapOffsetCm: typeof a.wrapOffset === 'number' ? round3(a.wrapOffset) : null, wrapOffsetYCm: typeof a.wrapOffsetY === 'number' ? round3(a.wrapOffsetY) : null, wrapDistCm: typeof a.wrapDist === 'number' ? round3(a.wrapDist) : null, @@ -3687,6 +3687,9 @@ function imageWrapProps(wrap: WrapMode, offset: number | null, align?: string | const pos = offset != null ? 'from-left' : null; // Only the text side is written; the frame's own offset covers the other one. const gap = distCm ? ` fo:margin-${wrap === 'right' ? 'left' : 'right'}="${distCm}cm"` : ''; + // run-through reserves nothing; without style:run-through it sits behind the text, + // which is LibreOffice's own default for a frame in that mode. + if (wrap === 'through') return `style:wrap="run-through" style:horizontal-pos="${pos ?? 'left'}"`; if (wrap === 'left') return `style:wrap="right" style:horizontal-pos="${pos ?? 'left'}"${gap}`; if (wrap === 'right') return `style:wrap="left" style:horizontal-pos="${pos ?? 'right'}"${gap}`; return `style:wrap="none" style:horizontal-pos="${align ?? pos ?? noneAlign}"`; @@ -3714,6 +3717,14 @@ function imageGraphicStyle(img: ImageExport, index: number): string { ` style:vertical-rel="page" style:vertical-pos="from-top"/>` ); } + if (img.wrap === 'through') { + return ( + `` + + `` + ); + } if (img.wrap === 'inline') { const v = INLINE_VALIGN_ODF[img.vAlign ?? '']; return v diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index cc792a6..431a67d 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1851,6 +1851,7 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null { if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm; if (distCm != null) attrs.wrapDist = distCm; if (alignH && wrap === 'topBottom') attrs.wrapAlign = alignH; + if (wrap === 'through') attrs.inFront = anchor.getAttribute('behindDoc') !== '1'; } else { fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm))); } @@ -1887,6 +1888,7 @@ function frameNode(src: string, box: { w: number; h: number }, label: string, an if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm; if (distCm != null) attrs.wrapDist = distCm; if (alignH && wrap === 'topBottom') attrs.wrapAlign = alignH; + if (wrap === 'through') attrs.inFront = anchor.getAttribute('behindDoc') !== '1'; } else { fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm))); } @@ -1919,7 +1921,7 @@ function anchorOffsetX(anchor: Element, ctx: Ctx): number | null { // Wrap mode and place are independent: the mode is what the file's wrap element says, // the place its position offsets. Only where neither names a side does the frame's own // x decide which half of the column it fills (text flows on one side of a CSS float). -function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null; offsetYCm: number | null; alignH: 'left' | 'right' | null; distCm: number | null } { +function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom' | 'through'; offsetCm: number | null; offsetYCm: number | null; alignH: 'left' | 'right' | null; distCm: number | null } { const offsetYCm = anchorOffsetY(anchor); const offsetCm = anchorOffsetX(anchor, ctx); const align = anchor.getElementsByTagNameNS(WP, 'positionH')[0] @@ -1931,7 +1933,10 @@ function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topB const emu = parseInt(anchor.getAttribute(wrap === 'right' ? 'distL' : 'distR') ?? '', 10); return Number.isFinite(emu) && emu > 0 ? round2(emu / 360000) : null; }; - const at = (wrap: 'left' | 'right' | 'topBottom') => ({ wrap, offsetCm, offsetYCm, alignH, distCm: distOf(wrap) }); + const at = (wrap: 'left' | 'right' | 'topBottom' | 'through') => ({ wrap, offsetCm, offsetYCm, alignH, distCm: distOf(wrap) }); + // wrapNone is Word's in-front-of / behind-text: the text runs through the frame, so it + // reserves neither width nor height. behindDoc picks the side of the text it lands on. + if (anchor.getElementsByTagNameNS(WP, 'wrapNone')[0]) return at('through'); if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return at('topBottom'); const wt = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0]?.getAttribute('wrapText'); if (wt === 'right') return at('left'); // text on right ⇒ image on left diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index bf00f02..c94e18f 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -180,7 +180,10 @@ function loadImageDataUrl(href: string, ctx: Ctx): string | null { // ODF style:wrap names the side TEXT flows on (inverse of the image side); pick a // side from horizontal-pos when the value doesn't name one (parallel/dynamic/…). -function wrapModeFromOdf(wrapVal: string | undefined, hpos: string | undefined): 'left' | 'right' | 'topBottom' { +function wrapModeFromOdf(wrapVal: string | undefined, hpos: string | undefined): 'left' | 'right' | 'topBottom' | 'through' { + // run-through is the text running over or under the frame (style:run-through picks + // which), so it reserves nothing — ODF's `none` is the one that means above-and-below. + if (wrapVal === 'run-through') return 'through'; if (wrapVal === 'none') return 'topBottom'; if (wrapVal === 'left') return 'right'; // text on left ⇒ image on the right if (wrapVal === 'right') return 'left'; // text on right ⇒ image on the left @@ -241,6 +244,8 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record, if (anchor || wrapVal) { attrs.wrap = wrapModeFromOdf(wrapVal, gp['style:horizontal-pos']); } + // "background" (LibreOffice's default) sits behind the text, "foreground" in front. + if (attrs.wrap === 'through' && gp['style:run-through'] === 'foreground') attrs.inFront = true; // Only the text side of the gap is drawn — the other one is the frame's own offset. if (attrs.wrap === 'left' || attrs.wrap === 'right') { const side = attrs.wrap === 'right' ? 'fo:margin-left' : 'fo:margin-right'; diff --git a/tests/unit/wrap-through.test.ts b/tests/unit/wrap-through.test.ts new file mode 100644 index 0000000..76ceb12 --- /dev/null +++ b/tests/unit/wrap-through.test.ts @@ -0,0 +1,52 @@ +// Word's in-front-of / behind-text (wp:wrapNone + behindDoc) and ODF's run-through: +// the text runs over or under the frame, so it reserves neither width nor height. +import { describe, it, expect } from 'vitest'; +import { buildOdt } from '../../src/lib/export/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importOdt } from '../../src/lib/import/odt'; +import { importDocx } from '../../src/lib/import/docx'; + +type N = any; + +const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; +const IMG = (attrs: N): N => ({ type: 'image', attrs: { src: PNG, width: 200, height: 120, ...attrs } }); + +const docWith = (attrs: N): N => ({ + type: 'doc', + content: [{ type: 'paragraph', content: [IMG(attrs), { type: 'text', text: 'anchor text' }] }], +}); + +const frame = (d: N) => d.content[0].content.find((n: N) => n.type === 'image'); +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + +describe('a run-through frame', () => { + it('round-trips the mode and its z-order through ODF', async () => { + const front = frame((await importOdt(await buildOdt(docWith({ wrap: 'through', inFront: true }), margins, 'portrait'))).content); + expect(front.attrs.wrap).toBe('through'); + expect(front.attrs.inFront).toBe(true); + const behind = frame((await importOdt(await buildOdt(docWith({ wrap: 'through' }), margins, 'portrait'))).content); + expect(behind.attrs.wrap).toBe('through'); + expect(behind.attrs.inFront).not.toBe(true); + }); + + it('round-trips the mode and its z-order through DOCX', async () => { + const front = frame(importDocx(await buildDocx(docWith({ wrap: 'through', inFront: true }), margins, 'portrait')).content); + expect(front.attrs.wrap).toBe('through'); + expect(front.attrs.inFront).toBe(true); + const behind = frame(importDocx(await buildDocx(docWith({ wrap: 'through' }), margins, 'portrait')).content); + expect(behind.attrs.wrap).toBe('through'); + expect(behind.attrs.inFront).toBe(false); + }); + + it('keeps its offsets, which place it against the anchor rather than reserve space', async () => { + const img = frame((await importOdt(await buildOdt(docWith({ wrap: 'through', wrapOffset: 1.5, wrapOffsetY: 4.25 }), margins, 'portrait'))).content); + expect(img.attrs.wrapOffset).toBeCloseTo(1.5, 2); + expect(img.attrs.wrapOffsetY).toBeCloseTo(4.25, 2); + }); + + // ODF's `none` is the one that means above-and-below; only run-through reserves nothing. + it('is not what ODF style:wrap="none" means', async () => { + const img = frame((await importOdt(await buildOdt(docWith({ wrap: 'topBottom' }), margins, 'portrait'))).content); + expect(img.attrs.wrap).toBe('topBottom'); + }); +});