From af144362fecdbb991ade5b61abd249ac504d0842 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Mon, 10 Aug 2026 20:48:31 +0000 Subject: [PATCH] feat: an as-char frame sits where the file says it does ODF style:vertical-pos/-rel on an inline frame, which every LibreOffice formula image carries: baseline, character area, or a plain offset from the baseline. The default moves from the line-box bottom to the baseline, so a line with a picture in it keeps the descent below it, as LibreOffice and Word both do. --- docs/architecture/frames.md | 2 +- src/lib/editor/extensions/image.ts | 34 +++++++++++++++++++++ src/lib/export/odt.ts | 29 +++++++++++++++--- src/lib/import/odt.ts | 22 +++++++++++++- src/styles/editor.css | 4 ++- tests/unit/inline-valign.test.ts | 49 ++++++++++++++++++++++++++++++ 6 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 tests/unit/inline-valign.test.ts diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md index 8c50fdb..636953a 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). **Wrap distance:** a float reserves 14px beside itself and **nothing above or below** — LibreOffice and Word both wrap flush against a frame unless the file declares a distance, and 6px of our own put every figure in a text box 3mm too tall. **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. Two `topBottom` frames set against **opposite ends** of nearby paragraphs (`wrapAlign`, from Word's `positionH` align / ODF `style:horizontal-pos`) share one band and float to their own sides instead: `pairAlignedFrames` (import/odt.ts, both importers) keeps the attr only for such a pair — a lone one reserves the whole band, which is what the wrap means — and scales a pair up to 15% wider than the column down to it, since Word lets the two overlap in the middle and two floats cannot. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota. +- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`), `rotation` (CW degrees). The node view nests an `` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below). All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** a float reserves 14px beside itself and **nothing above or below** — LibreOffice and Word both wrap flush against a frame unless the file declares a distance, and 6px of our own put every figure in a text box 3mm too tall. **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. **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. Two `topBottom` frames set against **opposite ends** of nearby paragraphs (`wrapAlign`, from Word's `positionH` align / ODF `style:horizontal-pos`) share one band and float to their own sides instead: `pairAlignedFrames` (import/odt.ts, both importers) keeps the attr only for such a pair — a lone one reserves the whole band, which is what the wrap means — and scales a pair up to 15% wider than the column down to it, since Word lets the two overlap in the middle and two floats cannot. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota. - **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap`), `shapeKind` (`'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. **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. diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 21d98d5..f2c2cfe 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -81,6 +81,27 @@ export function frameMargins(wrap: WrapMode, offsetCm: unknown, boxWidthPx: numb return `0 ${far} 0 14px`; } +// 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. +export type InlineVAlign = 'middle' | 'below' | 'text-top' | 'text-middle' | 'text-bottom' | 'offset'; + +export function inlineVerticalAlign(vAlign: unknown, boxHeightPx: number, offsetYCm: unknown): string { + if (!boxHeightPx) return ''; + switch (vAlign) { + case 'middle': return `${-boxHeightPx / 2}px`; + case 'below': return `${-boxHeightPx}px`; + case 'text-top': return 'text-top'; + case 'text-bottom': return 'text-bottom'; + // No CSS keyword centres on the character area; 0.36em stands in for half of + // (ascent − descent), which Liberation Serif/Sans, Times and Arial all share. + case 'text-middle': return `calc(0.36em - ${boxHeightPx / 2}px)`; + case 'offset': + return typeof offsetYCm === 'number' ? `${-(cmToPx(offsetYCm) + boxHeightPx)}px` : ''; + default: return ''; + } +} + // The page text height in px, capping how tall an image can be stretched. Read live // from the :root vars the editor maintains (orientation/margins change them). export function pageContentHeightPx(): number { @@ -157,6 +178,13 @@ export const Image = Node.create({ parseHTML: el => (el as HTMLElement).getAttribute('data-wrap-align') || null, renderHTML: () => ({}), }, + // Where an as-char frame sits against the line (see inlineVerticalAlign). + // null = its bottom on the baseline, which is LibreOffice's and Word's default. + vAlign: { + default: null, + parseHTML: el => (el as HTMLElement).getAttribute('data-v-align') || null, + renderHTML: () => ({}), + }, }; }, @@ -171,10 +199,12 @@ export const Image = Node.create({ const wrap = (node.attrs.wrap as WrapMode) || 'inline'; const offset = node.attrs.wrapOffset as number | null; const offsetY = node.attrs.wrapOffsetY as number | null; + const va = h ? inlineVerticalAlign(node.attrs.vAlign, h, offsetY) : ''; const style = [ w ? `width:${w}px` : '', h ? `height:${h}px` : '', rot ? `transform:rotate(${rot}deg)` : '', + va ? `vertical-align:${va}` : '', ].filter(Boolean).join(';'); return ['img', mergeAttributes(HTMLAttributes, { ...(style ? { style } : {}), @@ -183,6 +213,7 @@ export const Image = Node.create({ ...(offset != null ? { 'data-wrap-offset': String(offset) } : {}), ...(offsetY != null ? { 'data-wrap-offset-y': String(offsetY) } : {}), ...(node.attrs.wrapAlign ? { 'data-wrap-align': String(node.attrs.wrapAlign) } : {}), + ...(node.attrs.vAlign ? { 'data-v-align': String(node.attrs.vAlign) } : {}), ...(node.attrs.anchorPage ? { 'data-anchor-page': String(node.attrs.anchorPage) } : {}), })]; }, @@ -301,11 +332,14 @@ class ImageView { const bh = Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad)); this.dom.style.width = `${bw}px`; this.dom.style.height = `${bh}px`; + this.dom.style.verticalAlign = + inlineVerticalAlign(this.node.attrs.vAlign, bh, this.node.attrs.wrapOffsetY); } else { this.rotor.style.width = ''; this.rotor.style.height = ''; this.dom.style.width = ''; this.dom.style.height = ''; + this.dom.style.verticalAlign = ''; } this.rotor.style.transform = `translate(-50%, -50%) rotate(${deg}deg)`; } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 4ce679a..cf283d5 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -379,7 +379,7 @@ function replaceSectionBreaks(doc: TiptapNode): TiptapNode { // 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'; -type ImageExport = { path: string; bytes: Uint8Array; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode; wrapOffsetCm: number | null; wrapOffsetYCm: number | null; wrapAlign: string | null; anchorPage: number | null }; +type ImageExport = { path: string; bytes: Uint8Array; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode; wrapOffsetCm: number | null; wrapOffsetYCm: number | null; wrapAlign: string | null; anchorPage: number | null; vAlign: string | null }; function base64ToBytes(b64: string): Uint8Array { const bin = atob(b64); @@ -424,6 +424,7 @@ function imageDescriptor(node: TiptapNode, index: number, namePrefix = 'image'): wrapOffsetYCm: typeof node.attrs?.wrapOffsetY === 'number' ? round3(node.attrs.wrapOffsetY) : null, wrapAlign: node.attrs?.wrapAlign === 'left' || node.attrs?.wrapAlign === 'right' ? node.attrs.wrapAlign : null, anchorPage: typeof node.attrs?.anchorPage === 'number' && node.attrs.anchorPage > 0 ? node.attrs.anchorPage : null, + vAlign: typeof node.attrs?.vAlign === 'string' ? node.attrs.vAlign : null, }; } @@ -2798,6 +2799,17 @@ function imageWrapProps(wrap: WrapMode, offset: number | null, align?: string | return `style:wrap="none" style:horizontal-pos="${align ?? pos ?? 'center'}"`; } +// The inverse of the importer's as-char alignment map (import/odt.ts): a frame with no +// entry stands on the baseline, which is what a style-less as-char frame does anyway. +const INLINE_VALIGN_ODF: Record = { + middle: 'style:vertical-pos="middle" style:vertical-rel="baseline"', + below: 'style:vertical-pos="bottom" style:vertical-rel="baseline"', + 'text-top': 'style:vertical-pos="top" style:vertical-rel="text"', + 'text-middle': 'style:vertical-pos="middle" style:vertical-rel="text"', + 'text-bottom': 'style:vertical-pos="bottom" style:vertical-rel="text"', + offset: 'style:vertical-pos="from-top" style:vertical-rel="text"', +}; + // Graphic style for a floating frame (wrap + side, anchored to the paragraph top). // Inline images need none. Injected into content.xml automatic-styles by applyImages. function imageGraphicStyle(img: ImageExport, index: number): string { @@ -2809,7 +2821,13 @@ function imageGraphicStyle(img: ImageExport, index: number): string { ` style:vertical-rel="page" style:vertical-pos="from-top"/>` ); } - if (img.wrap === 'inline') return ''; + if (img.wrap === 'inline') { + const v = INLINE_VALIGN_ODF[img.vAlign ?? '']; + return v + ? `` + + `` + : ''; + } return ( `` + `` + `${inner}` diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 964cd5f..9aec948 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -149,11 +149,31 @@ function frameRotationDeg(el: Element): number { // Rotation + wrap attrs, shared by images, text boxes and shapes. A non-as-char anchor or // an explicit style:wrap floats the element (free x/y collapse to the nearest side). An // explicit as-char anchor stays inline: LibreOffice's Graphics style carries a style:wrap. +// Where an as-char frame sits against the line. `baseline`/`char` measure from the text +// baseline, `text`/`line` from the character area, and `from-top` puts the frame's top +// svg:y below the baseline whatever the relation says (all probed against LibreOffice). +function applyInlineVAlign(el: Element, attrs: Record, gp: PropMap): void { + const pos = gp['style:vertical-pos']; + const rel = gp['style:vertical-rel']; + const area = rel === 'text' || rel === 'line'; + if (pos === 'from-top') { + const y = lengthToCm(el.getAttributeNS(NS.svg, 'y')); + if (y == null) return; + attrs.vAlign = 'offset'; + attrs.wrapOffsetY = Math.round(y * 1000) / 1000; + } else if (pos === 'middle') attrs.vAlign = area ? 'text-middle' : 'middle'; + else if (pos === 'bottom') attrs.vAlign = area ? 'text-bottom' : 'below'; + else if (pos === 'top' && area) attrs.vAlign = 'text-top'; +} + function applyFrameRotationAndWrap(el: Element, attrs: Record, gp: PropMap): void { const deg = frameRotationDeg(el); if (deg) attrs.rotation = deg; const anchor = el.getAttributeNS(NS.text, 'anchor-type'); - if (anchor === 'as-char') return; + if (anchor === 'as-char') { + applyInlineVAlign(el, attrs, gp); + return; + } const wrapVal = gp['style:wrap']; if (anchor || wrapVal) { attrs.wrap = wrapModeFromOdf(wrapVal, gp['style:horizontal-pos']); diff --git a/src/styles/editor.css b/src/styles/editor.css index dca4b7a..74a1229 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -274,7 +274,9 @@ .paper .tiptap .image-node { position: relative; display: inline-block; - vertical-align: bottom; + /* An as-char frame stands on the text baseline in LibreOffice and Word alike; the + file may say otherwise, and image.ts then overrides this per frame. */ + vertical-align: baseline; line-height: 0; } /* While selected, lift the node (and its handles) above the header/footer layer diff --git a/tests/unit/inline-valign.test.ts b/tests/unit/inline-valign.test.ts new file mode 100644 index 0000000..2cd1f64 --- /dev/null +++ b/tests/unit/inline-valign.test.ts @@ -0,0 +1,49 @@ +// Where an as-char frame sits against the line: ODF style:vertical-pos/-rel in, a CSS +// vertical-align out, and back to the same pair on export. +import { describe, it, expect } from 'vitest'; +import { buildOdt } from '../../src/lib/export/odt'; +import { importOdt } from '../../src/lib/import/odt'; +import { inlineVerticalAlign } from '../../src/lib/editor/extensions/image'; + +type N = any; + +const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; +const IMG = (attrs: N): N => ({ type: 'image', attrs: { src: PNG, width: 80, height: 40, ...attrs } }); +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + +const doc = (attrs: N): N => ({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }, IMG(attrs)] }], +}); +const frame = (d: N) => d.content[0].content.find((n: N) => n.type === 'image'); + +describe('an as-char frame’s vertical alignment', () => { + it('lowers the box by what the alignment puts below the baseline', () => { + expect(inlineVerticalAlign(null, 40, null)).toBe(''); + expect(inlineVerticalAlign('middle', 40, null)).toBe('-20px'); + expect(inlineVerticalAlign('below', 40, null)).toBe('-40px'); + expect(inlineVerticalAlign('text-top', 40, null)).toBe('text-top'); + expect(inlineVerticalAlign('text-bottom', 40, null)).toBe('text-bottom'); + expect(inlineVerticalAlign('text-middle', 40, null)).toBe('calc(0.36em - 20px)'); + // The frame's top an offset below the baseline, so its bottom is that plus its height. + expect(parseFloat(inlineVerticalAlign('offset', 40, -0.529))).toBeCloseTo(-20, 0); + }); + + it('round-trips through ODF', async () => { + for (const v of ['middle', 'below', 'text-top', 'text-middle', 'text-bottom']) { + const img = frame((await importOdt(await buildOdt(doc({ vAlign: v }), margins, 'portrait'))).content); + expect(img.attrs.vAlign, v).toBe(v); + } + const off = frame((await importOdt( + await buildOdt(doc({ vAlign: 'offset', wrapOffsetY: -0.377 }), margins, 'portrait'), + )).content); + expect(off.attrs.vAlign).toBe('offset'); + expect(off.attrs.wrapOffsetY).toBeCloseTo(-0.377, 3); + }); + + it('leaves a baseline-aligned frame without a graphic style', async () => { + const odt = await buildOdt(doc({}), margins, 'portrait'); + const img = frame((await importOdt(odt)).content); + expect(img.attrs.vAlign ?? null).toBe(null); + }); +});