feat: a frame the text runs through reserves no space at all

Word's in-front-of / behind-text (wp:wrapNone + behindDoc) and ODF's
style:wrap="run-through" both read as a side float, so a near-column-wide
picture reserved its whole height and pushed the text 23-52mm down. They
are a wrap mode of their own now: out of flow at the static position it
was anchored at, z-order from the file (inFront). Both exports carry it
back; tests/unit/wrap-through.test.ts covers the round trip.
The image fixture 72 -> 38 over both formats and 7 pages -> 6, matching
the reference; corpus 1108 -> 1074, no other fixture moved.
This commit is contained in:
Steffen Becker committed 2026-08-17 13:45:39 +02:00
1 parent 9c4b768982
commit 847fe59d3d
9 files changed
+127 -20

No files matched your search

+1 -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. **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`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. **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 **nLine truncated
- **`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
+19 -3
View File
@@ -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<ReturnType> {
@@ -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);
+6 -1
View File
@@ -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
+1 -1
View File
@@ -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 `<text:line-break/>`/`<text:tab/>` 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 `<draw:frame text:anchor-type="as-char" svg:width/height><draw:image xlink:href="Pictures/imageN.png"/></draw:frame>`, adds the binary `Pictures/` entries to the zip, and appends a `<manifest:file-entry>` 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(<rad>) 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 `<style:style style:family="graphic">` 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 `<draw:frame text:anchor-type="as-char" svg:width/height><draw:image xlink:href="Pictures/imageN.png"/></draw:frame>`, adds the binary `Pictures/` entries to the zip, and appends a `<manifest:file-entry>` 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(<rad>) 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 `<style:style style:family="graphic">` 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 `<text:p>` 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 `<text:h>`/`<text:p>`/`<text:list>` (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 `<table:covered-table-cell>` 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 `'<W>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 `<draw:frame><draw:text-box fo:min-height>` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `<draw:custom-shape>` + preset `<draw:enhanced-geometry>` (`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 `<style:paragraph-properties style:writing-mode="tb-rl"/>` 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 `<w:abstractNum>`/`<w:num>` pair above its highest id, appended **before** the first `<w:num>` 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.
+20 -7
View File
@@ -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 `<w:drawing><wp:inline ${WP_NS} distT="0" distB="0" distL="0" distR="0">${extent}${docPr}${graphic}</wp:inline></w:drawing>`;
}
// wrapText names the side TEXT flows on (inverse of the box side), like ODT.
const wrapEl = box.wrap === 'topBottom'
? '<wp:wrapTopAndBottom/>'
const wrapEl = box.wrap === 'through' ? '<wp:wrapNone/>'
: box.wrap === 'topBottom' ? '<wp:wrapTopAndBottom/>'
: `<wp:wrapSquare wrapText="${box.wrap === 'right' ? 'left' : 'right'}"/>`;
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
: `<wp:align>${align}</wp:align>`;
return (
`<w:drawing><wp:anchor ${WP_NS} distT="0" distB="0" distL="${emu(box.distCm ?? 0)}" distR="${emu(box.distCm ?? 0)}"` +
` simplePos="0" relativeHeight="${251658240 + index}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="0">` +
` simplePos="0" relativeHeight="${251658240 + index}" behindDoc="${box.wrap === 'through' ? 1 : 0}" locked="0" layoutInCell="1" allowOverlap="${box.wrap === 'through' ? 1 : 0}">` +
`<wp:simplePos x="0" y="0"/>` +
`<wp:positionH relativeFrom="margin">${posH}</wp:positionH>` +
`<wp:positionV relativeFrom="paragraph"><wp:posOffset>${emu(box.offsetYCm ?? 0)}</wp:posOffset></wp:positionV>` +
+15 -4
View File
@@ -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<ArrayBuffer>; 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<ArrayBuffer> {
@@ -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"/></style:style>`
);
}
if (img.wrap === 'through') {
return (
`<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
`<style:graphic-properties style:wrap="run-through" style:run-through="${img.inFront ? 'foreground' : 'background'}"` +
` style:horizontal-rel="paragraph-content" style:horizontal-pos="${img.wrapOffsetCm != null ? 'from-left' : 'left'}"` +
` style:vertical-rel="paragraph" style:vertical-pos="${img.wrapOffsetYCm != null ? 'from-top' : 'top'}"/></style:style>`
);
}
if (img.wrap === 'inline') {
const v = INLINE_VALIGN_ODF[img.vAlign ?? ''];
return v
+7 -2
View File
@@ -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
+6 -1
View File
@@ -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<string, unknown>,
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';
+52
View File
@@ -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');
});
});