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