diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md
index 17a9e53..fbebcb6 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`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `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. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **no text descent below it** (probed); `image.ts` gives such a frame an `image-line` class, since CSS cannot ask whether a line holds real text. Hard breaks are the only line boundaries the model shows, so the run between two of them is what gets tested — which covers the frame-break-caption idiom LibreOffice's own guides are full of; a frame alone on a *soft-wrapped* line is invisible from there. A frame that shares its line with text keeps the baseline and the descent. **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`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `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:** `wrapDist` is the gap the file keeps beside a float, in cm (Word's `distL`/`distR`, ODF's `fo:margin-left`/`-right` on the graphic style) — only the side the text flows on, since the other one is the frame's own offset. A frame that declares none reserves **nothing**, beside it or above/below it: probed, LibreOffice wraps flush against it, and a gap of our own put every figure in a text box 3mm too tall and broke a line a word early. `setImageWrap` gives a frame the file never floated the 0.32cm a word processor's own wrap command writes. **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. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **no text descent below it** (probed); `image.ts` gives such a frame an `image-line` class, since CSS cannot ask whether a line holds real text. Hard breaks are the only line boundaries the model shows, so the run between two of them is what gets tested — which covers the frame-break-caption idiom LibreOffice's own guides are full of; a frame alone on a *soft-wrapped* line is invisible from there. A frame that shares its line with text keeps the baseline and the descent. **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 6269723..7416792 100644
--- a/src/lib/editor/extensions/image.ts
+++ b/src/lib/editor/extensions/image.ts
@@ -82,17 +82,19 @@ export function framePx(px: number): number {
// 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 becomes a top margin — see the attr's note.
-export function frameMargins(wrap: WrapMode, offsetCm: unknown, boxWidthPx: number, offsetYCm?: unknown): string {
+export function frameMargins(wrap: WrapMode, offsetCm: unknown, boxWidthPx: number, offsetYCm?: unknown, distCm?: unknown): string {
const near = typeof offsetCm === 'number' ? `${Math.round(cmToPx(offsetCm))}px` : null;
- // No vertical spacing of our own: LibreOffice and Word both wrap flush against a
- // frame unless the file declares a distance (fo:margin-top/-bottom, distT/distB).
+ // The gap beside the frame is the file's own (fo:margin-*, distL/distR) and nothing
+ // where it declares none — probed: LibreOffice wraps flush against a frame whose
+ // graphic style leaves the margin out. Above and below likewise.
+ const gap = typeof distCm === 'number' && distCm > 0 ? `${Math.round(cmToPx(distCm))}px` : '0';
if (wrap === 'topBottom') {
const top = typeof offsetYCm === 'number' && offsetYCm > 0 ? `${Math.round(cmToPx(offsetYCm))}px` : '0';
return `${top} 0 0 ${near ?? '0'}`;
}
- if (wrap === 'left') return `0 14px 0 ${near ?? '0'}`;
+ if (wrap === 'left') return `0 ${gap} 0 ${near ?? '0'}`;
const far = near == null ? '0' : `calc(${COLUMN_WIDTH_CSS} - ${near} - ${boxWidthPx}px)`;
- return `0 ${far} 0 14px`;
+ return `0 ${far} 0 ${gap}`;
}
// Where an as-char frame sits against the line (ODF style:vertical-pos/-rel, probed
@@ -176,6 +178,14 @@ export const Image = Node.create({
parseHTML: el => parseCm((el as HTMLElement).getAttribute('data-wrap-offset-y')),
renderHTML: () => ({}),
},
+ // The gap the file keeps between a floating frame and the text beside it, in cm
+ // (Word's distL/distR, ODF's fo:margin-left/-right). null = none, which is what
+ // both render for a frame that declares none.
+ wrapDist: {
+ default: null,
+ parseHTML: el => parseCm((el as HTMLElement).getAttribute('data-wrap-dist')),
+ renderHTML: () => ({}),
+ },
// The page a page-anchored frame is placed on (ODF text:anchor-page-number): a
// cover graphic or a watermark, out of the text flow. wrapOffset/-Y are then the
// frame's coordinates from that page's top-left corner, not from its column.
@@ -226,6 +236,7 @@ export const Image = Node.create({
...(wrap !== 'inline' ? { 'data-wrap': wrap } : {}),
...(offset != null ? { 'data-wrap-offset': String(offset) } : {}),
...(offsetY != null ? { 'data-wrap-offset-y': String(offsetY) } : {}),
+ ...(node.attrs.wrapDist != null ? { 'data-wrap-dist': String(node.attrs.wrapDist) } : {}),
...(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) } : {}),
@@ -246,7 +257,10 @@ export const Image = Node.create({
const sel = state.selection;
if (!(sel instanceof NodeSelection) || sel.node.type.name !== this.name) return false;
// Picking a side means "put it there", so the imported offset goes with it.
- if (dispatch) dispatch(state.tr.setNodeMarkup(sel.from, undefined, { ...sel.node.attrs, wrap, wrapOffset: null, wrapOffsetY: null }));
+ // A frame the file never floated has no distance either; give it the one a
+ // word processor's own wrap command writes (0.32cm).
+ const wrapDist = wrap === 'inline' ? sel.node.attrs.wrapDist : sel.node.attrs.wrapDist ?? 0.32;
+ if (dispatch) dispatch(state.tr.setNodeMarkup(sel.from, undefined, { ...sel.node.attrs, wrap, wrapDist, wrapOffset: null, wrapOffsetY: null }));
return true;
},
};
@@ -428,7 +442,7 @@ class ImageView {
delete d.dataset.anchorPage;
if (wrap === 'left' || wrap === 'right') {
d.style.float = wrap;
- d.style.margin = frameMargins(wrap, a.wrapOffset, this.boxWidth());
+ d.style.margin = frameMargins(wrap, a.wrapOffset, this.boxWidth(), null, a.wrapDist);
} else if (wrap === 'topBottom' && (a.wrapAlign === 'left' || a.wrapAlign === 'right')) {
// Sharing its band with the frame set against the other end (the importers only
// keep wrapAlign for such a pair): each floats to its own side, so both fit.
diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts
index 1d65ffb..a68e0ce 100644
--- a/src/lib/editor/extensions/textBox.ts
+++ b/src/lib/editor/extensions/textBox.ts
@@ -38,6 +38,7 @@ export interface TextBoxAttrs {
wrap: WrapMode;
wrapOffset: number | null; // cm from the text column's left edge
wrapOffsetY: number | null; // cm below the anchor paragraph
+ wrapDist: number | null; // cm of gap to the text beside it
wrapAlign: string | null; // 'center'/'right' = set against the middle/far end
paddingCm: number; // inset ring around the text (ODF fo:padding)
shapeKind: ShapeKind;
@@ -119,6 +120,12 @@ export const TextBox = Node.create({
parseHTML: el => parseCmAttr((el as HTMLElement).getAttribute('data-wrap-offset-y')),
renderHTML: () => ({}),
},
+ // The gap to the text beside it, in cm — as on an image.
+ wrapDist: {
+ default: null,
+ parseHTML: el => parseCmAttr((el as HTMLElement).getAttribute('data-wrap-dist')),
+ renderHTML: () => ({}),
+ },
// 'center' = set against the middle of the column (ODF style:horizontal-pos): a
// figure frame keeps it after the importer lifts it out of its anchor paragraph.
wrapAlign: {
@@ -379,7 +386,7 @@ class TextBoxView {
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);
+ d.style.margin = frameMargins(a.wrap, a.wrapOffset, parseFloat(d.style.width) || 0, null, a.wrapDist);
} else if (a.wrap === 'topBottom') {
d.style.clear = 'both';
}
diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts
index f3169f1..1de82da 100644
--- a/src/lib/export/docx.ts
+++ b/src/lib/export/docx.ts
@@ -400,8 +400,10 @@ function decodeDataUri(src: string): { bytes: Uint8Array; type: 'png' | 'jpg' |
// offsetCm places the frame in the text column (Word's posOffset); without one it is
// flush to its side. offsetYCm is how far below the anchor paragraph it sits.
-function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | null, alignH?: string | null): IFloating | undefined {
+function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | null, alignH?: string | null, distCm?: number | null): IFloating | undefined {
if (wrap === 'inline') return undefined;
+ // The gap beside the frame, on both sides as Word writes it; none above or below.
+ const margins = distCm ? { left: Math.round(distCm * 360000), right: Math.round(distCm * 360000) } : undefined;
const verticalPosition = {
relative: VerticalPositionRelativeFrom.PARAGRAPH,
offset: offsetYCm != null ? Math.round(offsetYCm * 360000) : 0,
@@ -420,6 +422,7 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number |
verticalPosition,
wrap: { type: TextWrappingType.TOP_AND_BOTTOM },
allowOverlap: !!end,
+ margins,
};
}
// left: image at left, text on the right; right: mirror.
@@ -433,6 +436,7 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number |
verticalPosition,
wrap: { type: TextWrappingType.SQUARE, side },
allowOverlap: false,
+ margins,
};
}
@@ -447,12 +451,13 @@ function imageRun(node: TiptapNode): ImageRun | null {
const wrap = String(node.attrs?.wrap ?? 'inline');
const offsetCm = typeof node.attrs?.wrapOffset === 'number' ? node.attrs.wrapOffset : null;
const offsetYCm = typeof node.attrs?.wrapOffsetY === 'number' ? node.attrs.wrapOffsetY : null;
+ const distCm = typeof node.attrs?.wrapDist === 'number' ? node.attrs.wrapDist : null;
return new ImageRun({
type: decoded.type,
data: decoded.bytes,
altText: typeof node.attrs?.alt === 'string' && node.attrs.alt ? { name: node.attrs.alt, title: node.attrs.alt, description: node.attrs.alt } : undefined,
transformation: { width, height, rotation: rotation || undefined },
- floating: floatingFor(wrap, offsetCm, offsetYCm, node.attrs?.wrapAlign as string | null),
+ floating: floatingFor(wrap, offsetCm, offsetYCm, node.attrs?.wrapAlign as string | null, distCm),
});
}
@@ -466,6 +471,7 @@ type TextBoxDocx = {
wrap: 'inline' | 'left' | 'right' | 'topBottom';
offsetCm: number | null;
offsetYCm: number | null;
+ distCm: number | null;
shapeKind: 'textbox' | 'roundRect' | 'ellipse';
fill: string | null;
stroke: string | null;
@@ -484,6 +490,7 @@ function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx {
wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline',
offsetCm: typeof a.wrapOffset === 'number' ? a.wrapOffset : null,
offsetYCm: typeof a.wrapOffsetY === 'number' ? a.wrapOffsetY : null,
+ distCm: typeof a.wrapDist === 'number' ? a.wrapDist : null,
shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : 'textbox',
fill: typeof a.fillColor === 'string' && a.fillColor ? a.fillColor : null,
stroke: typeof a.strokeColor === 'string' && a.strokeColor ? a.strokeColor : null,
@@ -665,7 +672,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
? `${emu(box.offsetCm)}`
: `${align}`;
return (
- `` +
`` +
`${posH}` +
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index 940b92c..ad262e0 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; vAlign: string | 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; wrapDistCm: number | null; wrapAlign: string | null; anchorPage: number | null; vAlign: string | null };
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
@@ -422,6 +422,7 @@ function imageDescriptor(node: TiptapNode, index: number, namePrefix = 'image'):
wrap,
wrapOffsetCm: typeof node.attrs?.wrapOffset === 'number' ? round3(node.attrs.wrapOffset) : null,
wrapOffsetYCm: typeof node.attrs?.wrapOffsetY === 'number' ? round3(node.attrs.wrapOffsetY) : null,
+ wrapDistCm: typeof node.attrs?.wrapDist === 'number' ? round3(node.attrs.wrapDist) : 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,
@@ -574,6 +575,7 @@ type TextBoxExport = {
wrap: WrapMode;
wrapOffsetCm: number | null;
wrapOffsetYCm: number | null;
+ wrapDistCm: number | null;
wrapAlign: string | null;
paddingCm: number;
shapeKind: ShapeKind;
@@ -598,6 +600,7 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport {
wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline',
wrapOffsetCm: typeof a.wrapOffset === 'number' ? round3(a.wrapOffset) : null,
wrapOffsetYCm: typeof a.wrapOffsetY === 'number' ? round3(a.wrapOffsetY) : null,
+ wrapDistCm: typeof a.wrapDist === 'number' ? round3(a.wrapDist) : null,
wrapAlign: a.wrapAlign === 'center' || a.wrapAlign === 'right' ? a.wrapAlign : null,
paddingCm: typeof a.paddingCm === 'number' ? round3(a.paddingCm) : TEXTBOX_PADDING_CM,
shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : 'textbox',
@@ -2797,10 +2800,12 @@ function imageTransform(img: ImageExport): string {
// ODF style:wrap is the side TEXT flows on (inverse of the image side); horizontal-pos
// 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, align?: string | null): string {
+function imageWrapProps(wrap: WrapMode, offset: number | null, align?: string | null, distCm?: number | null): string {
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'}"`;
+ // Only the text side is written; the frame's own offset covers the other one.
+ const gap = distCm ? ` fo:margin-${wrap === 'right' ? 'left' : 'right'}="${distCm}cm"` : '';
+ if (wrap === 'left') return `style:wrap="right" style:horizontal-pos="${pos ?? 'left'}"${gap}`;
+ if (wrap === 'right') return `style:wrap="left" style:horizontal-pos="${pos ?? 'right'}"${gap}`;
return `style:wrap="none" style:horizontal-pos="${align ?? pos ?? 'center'}"`;
}
@@ -2835,7 +2840,7 @@ function imageGraphicStyle(img: ImageExport, index: number): string {
}
return (
`` +
- `` +
@@ -3084,7 +3089,7 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
// frame, and the anchor paragraph this export mints carries no alignment.
const wrap = box.wrap === 'inline'
? (box.wrapAlign ? ` style:horizontal-pos="${box.wrapAlign}" style:horizontal-rel="paragraph-content"` : '')
- : ` ${imageWrapProps(box.wrap, box.wrapOffsetCm, box.wrapAlign)} style:number-wrapped-paragraphs="no-limit"` +
+ : ` ${imageWrapProps(box.wrap, box.wrapOffsetCm, box.wrapAlign, box.wrapDistCm)} style:number-wrapped-paragraphs="no-limit"` +
` style:horizontal-rel="paragraph-content"` +
` style:vertical-pos="${box.wrapOffsetYCm != null ? 'from-top' : 'top'}" style:vertical-rel="paragraph"`;
// auto-grow only for plain text boxes; a custom-shape needs both explicitly
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
index 017e461..4ef3991 100644
--- a/src/lib/import/docx.ts
+++ b/src/lib/import/docx.ts
@@ -1375,10 +1375,11 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
if (Number.isFinite(rot) && rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
if (anchor) {
- const { wrap, offsetCm, offsetYCm, alignH } = anchorWrap(anchor, ctx);
+ const { wrap, offsetCm, offsetYCm, alignH, distCm } = anchorWrap(anchor, ctx);
attrs.wrap = wrap;
if (offsetCm != null) attrs.wrapOffset = offsetCm;
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
+ if (distCm != null) attrs.wrapDist = distCm;
if (alignH && wrap === 'topBottom') attrs.wrapAlign = alignH;
} else {
fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
@@ -1410,10 +1411,11 @@ function chartImage(drawing: Element, box: { w: number; h: number }, ctx: Ctx):
function frameNode(src: string, box: { w: number; h: number }, label: string, anchor: Element | undefined, ctx: Ctx): Node {
const attrs: Record = { src, width: box.w, height: box.h, alt: label };
if (anchor) {
- const { wrap, offsetCm, offsetYCm, alignH } = anchorWrap(anchor, ctx);
+ const { wrap, offsetCm, offsetYCm, alignH, distCm } = anchorWrap(anchor, ctx);
attrs.wrap = wrap;
if (offsetCm != null) attrs.wrapOffset = offsetCm;
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
+ if (distCm != null) attrs.wrapDist = distCm;
if (alignH && wrap === 'topBottom') attrs.wrapAlign = alignH;
} else {
fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
@@ -1447,14 +1449,19 @@ function anchorOffsetX(anchor: Element, ctx: Ctx): number | null {
// Wrap mode and place are independent: the mode is what the file's wrap element says,
// the place its position offsets. Only where neither names a side does the frame's own
// x decide which half of the column it fills (text flows on one side of a CSS float).
-function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null; offsetYCm: number | null; alignH: 'left' | 'right' | null } {
+function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null; offsetYCm: number | null; alignH: 'left' | 'right' | null; distCm: number | null } {
const offsetYCm = anchorOffsetY(anchor);
const offsetCm = anchorOffsetX(anchor, ctx);
const align = anchor.getElementsByTagNameNS(WP, 'positionH')[0]
?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim();
const alignH: 'left' | 'right' | null = align === 'right' || align === 'outside' ? 'right'
: align === 'left' || align === 'inside' ? 'left' : null;
- const at = (wrap: 'left' | 'right' | 'topBottom') => ({ wrap, offsetCm, offsetYCm, alignH });
+ // Only the side the text flows on: the gap on the other one is the frame's offset.
+ const distOf = (wrap: string) => {
+ const emu = parseInt(anchor.getAttribute(wrap === 'right' ? 'distL' : 'distR') ?? '', 10);
+ return Number.isFinite(emu) && emu > 0 ? round2(emu / 360000) : null;
+ };
+ const at = (wrap: 'left' | 'right' | 'topBottom') => ({ wrap, offsetCm, offsetYCm, alignH, distCm: distOf(wrap) });
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
@@ -1509,10 +1516,11 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct
const rot = intAttr(nsChild(spPr, A, 'xfrm'), '', 'rot');
if (rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
if (isAnchor) {
- const { wrap, offsetCm, offsetYCm } = anchorWrap(root, ctx);
+ const { wrap, offsetCm, offsetYCm, distCm } = anchorWrap(root, ctx);
attrs.wrap = wrap;
if (offsetCm != null) attrs.wrapOffset = offsetCm;
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
+ if (distCm != null) attrs.wrapDist = distCm;
}
const fillClr = nsChild(nsChild(spPr, A, 'solidFill'), A, 'srgbClr')?.getAttribute('val');
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
index abf1433..899e044 100644
--- a/src/lib/import/odt.ts
+++ b/src/lib/import/odt.ts
@@ -178,6 +178,12 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record,
if (anchor || wrapVal) {
attrs.wrap = wrapModeFromOdf(wrapVal, gp['style:horizontal-pos']);
}
+ // Only the text side of the gap is drawn — the other one is the frame's own offset.
+ if (attrs.wrap === 'left' || attrs.wrap === 'right') {
+ const side = attrs.wrap === 'right' ? 'fo:margin-left' : 'fo:margin-right';
+ const dist = lengthToCm(gp[side] ?? gp['fo:margin']);
+ if (dist != null && dist > 0) attrs.wrapDist = Math.round(dist * 1000) / 1000;
+ }
// 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 hpos = gp['style:horizontal-pos'];