From 2830e8a9d48c929540113c6bc1be1fa256d68b05 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 8 Aug 2026 22:51:39 +0000 Subject: [PATCH] feat: a top-and-bottom frame is drawn where the file places it Both anchor offsets now render: the x moves the rotor inside the full-width float, the y the float's top. Chromium puts every line after such a float below it, so a frame set below the paragraph top is sunk behind the text at import and the node view measures what those lines already cover. --- docs/architecture/frames.md | 2 +- src/lib/editor/extensions/image.ts | 38 ++++++++++++++++---- src/lib/editor/extensions/textBox.ts | 2 +- src/lib/export/docx.ts | 4 ++- src/lib/export/odt.ts | 9 +++-- src/lib/import/docx.ts | 5 +-- src/lib/import/odt.ts | 15 +++++++- tests/render-parity/README.md | 10 +++--- tests/unit/frame-offset.test.ts | 53 ++++++++++++++++++++++++++++ 9 files changed, 116 insertions(+), 22 deletions(-) create mode 100644 tests/unit/frame-offset.test.ts diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md index 10e01a8..d06bf82 100644 --- a/docs/architecture/frames.md +++ b/docs/architecture/frames.md @@ -3,5 +3,5 @@ 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`), `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). **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`. `wrapOffsetY` (`positionV`, `svg:y`) round-trips but is **not rendered**: a line box avoids a float's whole *margin* box, so a top margin would make dead space where Word flows text (measured on the thesis fixture: 131mm off became 187mm, and the document grew a page). Browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented), so a frame's vertical place stays the anchor paragraph's. 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`), `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). **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. 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` (`'textbox'|'roundRect'|'ellipse'` — ellipse renders via `border-radius:50%`), `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. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically. diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 153af78..2500d0e 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -67,10 +67,13 @@ export function fitInlineImage(attrs: Record, maxWidthPx: numbe // A floating frame's margins from its wrapOffset: its left edge in the text column, // measured against the live column vars an indented anchor cannot skew (a right float -// is placed from the far side). wrapOffsetY is data only — see the note on the attr. -export function frameMargins(wrap: WrapMode, offsetCm: unknown, boxWidthPx: number): string { - if (wrap === 'topBottom') return '6px 0'; +// is placed from the far side). wrapOffsetY becomes a top margin — see the attr's note. +export function frameMargins(wrap: WrapMode, offsetCm: unknown, boxWidthPx: number, offsetYCm?: unknown): string { const near = typeof offsetCm === 'number' ? `${Math.round(cmToPx(offsetCm))}px` : null; + if (wrap === 'topBottom') { + const top = typeof offsetYCm === 'number' && offsetYCm > 0 ? `${Math.round(cmToPx(offsetYCm))}px` : '6px'; + return `${top} 0 6px ${near ?? '0'}`; + } if (wrap === 'left') return `0 14px 6px ${near ?? '0'}`; const far = near == null ? '0' : `calc(${COLUMN_WIDTH_CSS} - ${near} - ${boxWidthPx}px)`; return `0 ${far} 6px 14px`; @@ -129,8 +132,8 @@ export const Image = Node.create({ renderHTML: () => ({}), }, // How far below its anchor paragraph the frame sits, in cm (Word's positionV - // posOffset, ODF svg:y). Kept for the file only: a line box avoids a float's whole - // margin box, so rendering it as a top margin makes dead space Word fills with text. + // posOffset, ODF svg:y). Drawn as the float's top margin for `topBottom`, where no + // text sits beside the frame; a side float would push away lines Word keeps. wrapOffsetY: { default: null, parseHTML: el => parseCm((el as HTMLElement).getAttribute('data-wrap-offset-y')), @@ -250,6 +253,8 @@ class ImageView { this.applyLayout(this.attrW(), this.attrH(), this.attrRot()); this.applyWrap(); + // The frame is not in the document yet, so a sunk one has nothing to measure against. + requestAnimationFrame(() => this.sinkToOffset()); } private showBadge(w: number, h: number): void { @@ -306,8 +311,29 @@ class ImageView { d.style.float = 'left'; d.style.clear = 'both'; d.style.width = '100%'; - d.style.margin = frameMargins(wrap, a.wrapOffset, this.boxWidth()); + d.style.margin = frameMargins(wrap, null, 0, a.wrapOffsetY); + this.sinkToOffset(); } + // The wrapper spans the column, so the picture's own x is the rotor's place in it + // (the rotor is centred by CSS); every other mode positions the wrapper itself. + const x = wrap === 'topBottom' ? a.wrapOffset : null; + this.rotor.style.left = typeof x === 'number' + ? `${Math.round(cmToPx(x)) + (parseFloat(this.rotor.style.width) || 0) / 2}px` : ''; + } + + // The offset counts from the anchor paragraph's top. Where text precedes the frame + // (the importers sink one behind the text — a full-width float pushes every following + // line under itself) the lines already cover part of it, so the rest is measured. + private sinkToOffset(): void { + const y = this.node.attrs.wrapOffsetY; + const p = this.dom.parentElement; + if (typeof y !== 'number' || y <= 0 || !p || !this.dom.previousSibling) return; + this.dom.style.marginTop = '0px'; + const box = p.getBoundingClientRect(); + const scale = box.width / p.offsetWidth || 1; + const above = (this.dom.getBoundingClientRect().top - box.top) / scale; + const gap = clamp(Math.round(cmToPx(y) - above), 6, pageContentHeightPx()); + this.dom.style.marginTop = `${gap}px`; } // Drag an image to re-anchor it live to the text position under the cursor (text diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts index 5ecbe81..58a0200 100644 --- a/src/lib/editor/extensions/textBox.ts +++ b/src/lib/editor/extensions/textBox.ts @@ -359,7 +359,7 @@ class TextBoxView { const a = this.attrs(); d.style.float = ''; d.style.clear = ''; - d.style.margin = frameMargins('topBottom', null, 0); + 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); diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 8ad2552..00b2c81 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -366,7 +366,9 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | }; if (wrap === 'topBottom') { return { - horizontalPosition: { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT }, + horizontalPosition: offsetCm != null + ? { relative: HorizontalPositionRelativeFrom.MARGIN, offset: Math.round(offsetCm * 360000) } + : { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT }, verticalPosition, wrap: { type: TextWrappingType.TOP_AND_BOTTOM }, allowOverlap: false, diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index f3358c9..0a9394c 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -2689,10 +2689,10 @@ function imageTransform(img: ImageExport): string { // places the frame on that side. topBottom ⇒ no wrap, centred. // An offset frame is placed by coordinate instead (svg:x on the frame). function imageWrapProps(wrap: WrapMode, offset: number | null): string { - const pos = offset != null && wrap !== 'topBottom' ? 'from-left' : null; + const pos = offset != null ? 'from-left' : null; if (wrap === 'left') return `style:wrap="right" style:horizontal-pos="${pos ?? 'left'}"`; if (wrap === 'right') return `style:wrap="left" style:horizontal-pos="${pos ?? 'right'}"`; - return 'style:wrap="none" style:horizontal-pos="center"'; + return `style:wrap="none" style:horizontal-pos="${pos ?? 'center'}"`; } // Graphic style for a floating frame (wrap + side, anchored to the paragraph top). @@ -2720,8 +2720,7 @@ function imageFrameXml(img: ImageExport, index: number): string { const inner = `${title}`; const anchor = img.wrap === 'inline' ? 'as-char' : 'paragraph'; const styleName = img.wrap === 'inline' ? '' : ` draw:style-name="ImgFr${index + 1}"`; - const x = img.wrapOffsetCm != null && img.wrap !== 'inline' && img.wrap !== 'topBottom' - ? ` svg:x="${img.wrapOffsetCm}cm"` : ''; + const x = img.wrapOffsetCm != null && img.wrap !== 'inline' ? ` svg:x="${img.wrapOffsetCm}cm"` : ''; const y = img.wrapOffsetYCm != null && img.wrap !== 'inline' ? ` svg:y="${img.wrapOffsetYCm}cm"` : ''; return ( `` + @@ -2945,7 +2944,7 @@ function textBoxXml(box: TextBoxExport, inner: string, index: number): string { const anchor = box.wrap === 'inline' ? 'as-char' : 'paragraph'; const transform = frameTransform(box.rotationDeg, box.widthCm, box.heightCm); const at = box.wrap === 'inline' ? '' - : (box.wrapOffsetCm != null && box.wrap !== 'topBottom' ? ` svg:x="${box.wrapOffsetCm}cm"` : '') + + : (box.wrapOffsetCm != null ? ` svg:x="${box.wrapOffsetCm}cm"` : '') + (box.wrapOffsetYCm != null ? ` svg:y="${box.wrapOffsetYCm}cm"` : ''); const common = ` draw:style-name="TbxFr${n}" text:anchor-type="${anchor}" draw:z-index="${index}"` + diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 2f0a49d..319033c 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -18,7 +18,7 @@ import { formatFromCm, type PageFormat } from '../storage/pageFormat'; import { clampTabInterval, DOCX_IMPLIED_TAB_CM } from '../storage/tabInterval'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter'; -import { applyUniformRunFont, type OdtImportResult } from './odt'; +import { applyUniformRunFont, sinkOffsetFrames, type OdtImportResult } from './odt'; import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts'; import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; import { astToLatex } from '../math/latex'; @@ -747,6 +747,7 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: const ff = paragraphMarkFont(ppr, ctx, baseRun, defaults.fonts); if (ff) attrs.fontFamily = ff; applyUniformRunFont(attrs, content); + sinkOffsetFrames(content); const node: Node = { type: level ? 'heading' : 'paragraph' }; if (level) attrs.level = level; @@ -1293,7 +1294,7 @@ function anchorOffsetX(anchor: Element, ctx: Ctx): number | null { function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null; offsetYCm: number | null } { const offsetYCm = anchorOffsetY(anchor); const offsetCm = anchorOffsetX(anchor, ctx); - const at = (wrap: 'left' | 'right' | 'topBottom') => ({ wrap, offsetCm: wrap === 'topBottom' ? null : offsetCm, offsetYCm }); + const at = (wrap: 'left' | 'right' | 'topBottom') => ({ wrap, offsetCm, offsetYCm }); 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 8cb56b1..4347abf 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -146,7 +146,7 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record, // A frame placed by coordinate (style:horizontal-pos="from-left") keeps its x in the // column; the wrap mode alone would snap it to a side. const x = gp['style:horizontal-pos'] === 'from-left' ? lengthToCm(el.getAttributeNS(NS.svg, 'x')) : null; - if (x != null && attrs.wrap && attrs.wrap !== 'topBottom') attrs.wrapOffset = Math.round(x * 100) / 100; + if (x != null && attrs.wrap) attrs.wrapOffset = Math.round(x * 100) / 100; // Likewise down the page, but only against the anchor paragraph — a page-relative // frame is placed absolutely, which one in the text flow cannot be. const rel = gp['style:vertical-rel']; @@ -177,6 +177,18 @@ export function applyUniformRunFont(attrs: Record, content: { t if (attrs.fontSize == null && size) attrs.fontSize = size; } +// A top-and-bottom frame set below its paragraph's top sinks behind the paragraph's +// text: a full-width float pushes every following line under itself, so where there is +// text the offset can only be drawn as the lines standing above the frame. +export function sinkOffsetFrames(content: { type: string; text?: string; attrs?: Record }[]): void { + const sinks = (n: { type: string; attrs?: Record }) => + n.type === 'image' && n.attrs?.wrap === 'topBottom' && (n.attrs.wrapOffsetY as number) > 0; + if (!content.some(sinks) || !content.some(n => n.type === 'text' && n.text?.trim())) return; + const frames = content.filter(sinks); + for (const f of frames) content.splice(content.indexOf(f), 1); + content.push(...frames); +} + // A → an image node. Size comes from the frame's svg // geometry (cm → px). as-char frames stay inline; paragraph/page-anchored frames // with a wrap become floating (wrap mode + svg:x/svg:y position). @@ -1016,6 +1028,7 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = const markFont = resolver.fontFamilyOf(baseTextProps); if (markFont && !defaults.fonts.has(markFont.toLowerCase())) attrs.fontFamily = markFont; applyUniformRunFont(attrs, content); + sinkOffsetFrames(content); const node: Node = { type: isHeading ? 'heading' : 'paragraph' }; if (isHeading) attrs.level = level; diff --git a/tests/render-parity/README.md b/tests/render-parity/README.md index 376f64a..6be0331 100644 --- a/tests/render-parity/README.md +++ b/tests/render-parity/README.md @@ -90,11 +90,11 @@ wrapped pictures, one table) and the thesis (a 48-page thesis with charts, metafile figures and a German TOC) — still report. The thesis is at 48 pages against LibreOffice's 48, with 62 line-level differences, most of them the justification rule above: -- **A frame's vertical anchor offset is kept but not drawn.** Both offsets round-trip - now (`wrapOffset`/`wrapOffsetY`, images and text boxes alike), but only the horizontal - one is rendered: a line box avoids a float's whole *margin* box, so applying the - vertical one as a top margin makes dead space where Word flows text — measured, the - thesis' figure page went from 131mm off to 187mm and the document grew a page. +- **A hoisted text box loses the vertical offset it was anchored by.** A frame's own + offsets are drawn now (`docs/architecture/frames.md`), but a text box anchored inside a + paragraph is a block node here, so the importer lifts it out and it simply follows that + paragraph. On the thesis' figure page that leaves its caption 4.7mm high — LibreOffice + places the box by its own 19.52cm, we place it after the picture. - **Page geometry is document-wide.** Headers and footers are per section (each is editable in place), but margins, orientation and format are not — a file whose sections disagree on them keeps the last one's. diff --git a/tests/unit/frame-offset.test.ts b/tests/unit/frame-offset.test.ts new file mode 100644 index 0000000..506f36f --- /dev/null +++ b/tests/unit/frame-offset.test.ts @@ -0,0 +1,53 @@ +// A top-and-bottom frame's own place: Word's positionH/positionV posOffset, ODF's +// svg:x/svg:y. Both offsets round-trip, and one set below its paragraph's top sinks +// behind that paragraph's text (a full-width float pushes following lines under it). +import { describe, it, expect } from 'vitest'; +import { buildOdt } from '../../src/lib/export/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importOdt, sinkOffsetFrames } 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 doc: N = { + type: 'doc', + content: [{ + type: 'paragraph', + content: [{ type: 'text', text: 'anchor text' }, IMG({ wrap: 'topBottom', wrapOffset: 3.5, wrapOffsetY: 5.75 })], + }], +}; + +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 top-and-bottom frame’s offsets', () => { + it('round-trips both offsets through ODF', async () => { + const img = frame((await importOdt(await buildOdt(doc, margins, 'portrait'))).content); + expect(img.attrs.wrapOffset).toBeCloseTo(3.5, 2); + expect(img.attrs.wrapOffsetY).toBeCloseTo(5.75, 2); + }); + + it('round-trips both offsets through DOCX', async () => { + const img = frame(importDocx(await buildDocx(doc, margins, 'portrait')).content); + expect(img.attrs.wrapOffset).toBeCloseTo(3.5, 2); + expect(img.attrs.wrapOffsetY).toBeCloseTo(5.75, 2); + }); + + it('sinks a frame set below the paragraph top behind its text', () => { + const content = [IMG({ wrap: 'topBottom', wrapOffsetY: 5.75 }), { type: 'text', text: 'hi' }]; + sinkOffsetFrames(content); + expect(content.map((n: N) => n.type)).toEqual(['text', 'image']); + }); + + it('leaves a frame at the paragraph top, and one with no text to stand behind', () => { + const flush = [IMG({ wrap: 'topBottom' }), { type: 'text', text: 'hi' }]; + sinkOffsetFrames(flush); + expect(flush.map((n: N) => n.type)).toEqual(['image', 'text']); + const alone = [IMG({ wrap: 'topBottom', wrapOffsetY: 5.75 })]; + sinkOffsetFrames(alone); + expect(alone.map((n: N) => n.type)).toEqual(['image']); + }); +});