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.
This commit is contained in:
Steffen Becker committed 2026-08-10 20:48:31 +00:00
1 parent 970ad3776a
commit af144362fe
6 files changed
+133 -7

No files matched your search

+1 -1
View File
@@ -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 `<img>` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below). All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** 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 `<img>` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below). All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** 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 Line truncated
- **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap`), `shapeKind` (`'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.
+34
View File
@@ -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)`;
}
+25 -4
View File
@@ -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<ArrayBuffer>; 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<ArrayBuffer>; 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<ArrayBuffer> {
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<string, string> = {
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"/></style:style>`
);
}
if (img.wrap === 'inline') return '';
if (img.wrap === 'inline') {
const v = INLINE_VALIGN_ODF[img.vAlign ?? ''];
return v
? `<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
`<style:graphic-properties style:wrap="none" ${v}/></style:style>`
: '';
}
return (
`<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
`<style:graphic-properties ${imageWrapProps(img.wrap, img.wrapOffsetCm, img.wrapAlign)}` +
@@ -2833,9 +2851,12 @@ function imageFrameXml(img: ImageExport, index: number): string {
const anchor = img.anchorPage != null
? ` text:anchor-type="page" text:anchor-page-number="${img.anchorPage}"`
: ` text:anchor-type="${floats ? 'paragraph' : 'as-char'}"`;
const styleName = floats ? ` draw:style-name="ImgFr${index + 1}"` : '';
const named = floats || (img.vAlign != null && img.vAlign in INLINE_VALIGN_ODF);
const styleName = named ? ` draw:style-name="ImgFr${index + 1}"` : '';
const x = img.wrapOffsetCm != null && floats && !img.wrapAlign ? ` svg:x="${img.wrapOffsetCm}cm"` : '';
const y = img.wrapOffsetYCm != null && floats ? ` svg:y="${img.wrapOffsetYCm}cm"` : '';
// An as-char frame carries svg:y only for the offset alignment, which is what it means.
const y = img.wrapOffsetYCm != null && (floats || img.vAlign === 'offset')
? ` svg:y="${img.wrapOffsetYCm}cm"` : '';
return (
`<draw:frame draw:name="Image${index + 1}"${styleName}${anchor} draw:z-index="${index}"${dims}${x}${y}${imageTransform(img)}>` +
`${inner}</draw:frame>`
+21 -1
View File
@@ -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<string, unknown>, 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<string, unknown>, 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']);
+3 -1
View File
@@ -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
+49
View File
@@ -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);
});
});