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']);
+ });
+});