feat: place a floating image at its x in the text column

Word anchors a wrapped frame at an offset; the model only knew a side, so a
frame the file puts 91mm in snapped to the margin and the text beside it came
out 20mm too narrow. wrapOffset carries that x (cm from the column's left
edge, Word's posOffset / ODF svg:x) and the float's outer margin renders it —
against the column vars, so an indented anchor paragraph cannot skew it.

Picking a wrap side in the toolbar clears the offset: that gesture means
"put it there".
This commit is contained in:
Steffen Becker committed 2026-08-08 08:30:05 +00:00
1 parent 273654640c
commit d2701d38ae
5 files changed
+81 -27

No files matched your search

+35 -5
View File
@@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { NodeSelection } from '@tiptap/pm/state';
import { dropCursor } from '@tiptap/pm/dropcursor';
import { cmToPx } from '../../storage/pageMargins';
// Inline, as-character image, or a floating text-wrapped frame (wrap = flow mode);
// width/height are doc px @96dpi, rotation CW degrees. Export → cm + ODF
@@ -42,8 +43,18 @@ export function parsePx(value: string | null): number | null {
return Number.isFinite(n) ? Math.round(n) : null;
}
function parseCm(value: string | null): number | null {
if (!value) return null;
const n = parseFloat(value);
return Number.isFinite(n) ? n : null;
}
export const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
// The page text width, live from the vars the editor maintains (margins/orientation).
const COLUMN_WIDTH_CSS =
'calc(var(--user-page-width) - var(--user-margin-left) - var(--user-margin-right))';
// An as-character image wider than the text column takes a line of its own and leaves an
// empty one above it. Fitting it to the column is where an image the file sizes to the
// column lands anyway, once its cm/EMU width is rounded to whole px (importers).
@@ -99,6 +110,13 @@ export const Image = Node.create({
parseHTML: el => (el as HTMLElement).getAttribute('data-wrap') ?? 'inline',
renderHTML: () => ({}),
},
// Where a floating frame sits in the text column: cm from the column's left edge
// to the frame's left edge (Word's posOffset, ODF svg:x). null = flush to its side.
wrapOffset: {
default: null,
parseHTML: el => parseCm((el as HTMLElement).getAttribute('data-wrap-offset')),
renderHTML: () => ({}),
},
};
},
@@ -111,6 +129,7 @@ export const Image = Node.create({
const h = node.attrs.height as number | null;
const rot = (node.attrs.rotation as number) || 0;
const wrap = (node.attrs.wrap as WrapMode) || 'inline';
const offset = node.attrs.wrapOffset as number | null;
const style = [
w ? `width:${w}px` : '',
h ? `height:${h}px` : '',
@@ -120,6 +139,7 @@ export const Image = Node.create({
...(style ? { style } : {}),
...(rot ? { 'data-rotation': String(rot) } : {}),
...(wrap !== 'inline' ? { 'data-wrap': wrap } : {}),
...(offset != null ? { 'data-wrap-offset': String(offset) } : {}),
})];
},
@@ -136,7 +156,8 @@ export const Image = Node.create({
({ state, dispatch }) => {
const sel = state.selection;
if (!(sel instanceof NodeSelection) || sel.node.type.name !== this.name) return false;
if (dispatch) dispatch(state.tr.setNodeMarkup(sel.from, undefined, { ...sel.node.attrs, wrap }));
// 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 }));
return true;
},
};
@@ -220,6 +241,8 @@ class ImageView {
private attrH(): number | null { const h = this.node.attrs.height; return typeof h === 'number' ? h : null; }
private attrRot(): number { return (this.node.attrs.rotation as number) || 0; }
private attrWrap(): WrapMode { const w = this.node.attrs.wrap; return w === 'left' || w === 'right' || w === 'topBottom' ? w : 'inline'; }
// The wrapper's reserved (rotated) width, which applyLayout has just written.
private boxWidth(): number { return parseFloat(this.dom.style.width) || this.attrW() || 0; }
// Size the rotor to w×h, rotate it about its centre, and grow the axis-aligned
// wrapper to the rotated bounding box so the line reserves the right space.
@@ -242,8 +265,8 @@ class ImageView {
}
// Float the wrapper per wrap mode so text flows beside it at its anchor paragraph
// (left/right) or only above/below it (topBottom). The live re-anchor drag keeps it
// floating where the text actually is.
// (left/right) or only above/below it (topBottom). wrapOffset moves it inside the
// column via the outer margin. The live re-anchor drag keeps it where the text is.
private applyWrap(): void {
const d = this.dom;
const wrap = this.attrWrap();
@@ -251,12 +274,19 @@ class ImageView {
d.style.display = '';
d.style.clear = '';
d.style.margin = '';
const offset = this.node.attrs.wrapOffset as number | null;
// The offset is the frame's left edge in the text column. A right float is placed
// from the other side, so its margin is what the column has left over — measured
// against the live column vars, which an indented anchor paragraph cannot skew.
const near = offset == null ? '0' : `${Math.round(cmToPx(offset))}px`;
const far = offset == null ? '0'
: `calc(${COLUMN_WIDTH_CSS} - ${Math.round(cmToPx(offset))}px - ${this.boxWidth()}px)`;
if (wrap === 'left') {
d.style.float = 'left';
d.style.margin = '0 14px 6px 0';
d.style.margin = `0 14px 6px ${near}`;
} else if (wrap === 'right') {
d.style.float = 'right';
d.style.margin = '0 0 6px 14px';
d.style.margin = `0 ${far} 6px 14px`;
} else if (wrap === 'topBottom') {
// A full-width float (not display:block — which on an inline atom node view
// disrupts ProseMirror's view + page-break spacer widgets): text can only flow
+9 -3
View File
@@ -313,7 +313,9 @@ function decodeDataUri(src: string): { bytes: Uint8Array; type: 'png' | 'jpg' |
}
}
function floatingFor(wrap: string): IFloating | undefined {
// offsetCm places the frame in the text column (Word's posOffset); without one it is
// flush to its side.
function floatingFor(wrap: string, offsetCm: number | null): IFloating | undefined {
if (wrap === 'inline') return undefined;
const verticalPosition = { relative: VerticalPositionRelativeFrom.PARAGRAPH, offset: 0 };
if (wrap === 'topBottom') {
@@ -327,8 +329,11 @@ function floatingFor(wrap: string): IFloating | undefined {
// left: image at left, text on the right; right: mirror.
const align = wrap === 'right' ? HorizontalPositionAlign.RIGHT : HorizontalPositionAlign.LEFT;
const side = wrap === 'right' ? TextWrappingSide.LEFT : TextWrappingSide.RIGHT;
const horizontalPosition = offsetCm != null
? { relative: HorizontalPositionRelativeFrom.MARGIN, offset: Math.round(offsetCm * 360000) }
: { relative: HorizontalPositionRelativeFrom.MARGIN, align };
return {
horizontalPosition: { relative: HorizontalPositionRelativeFrom.MARGIN, align },
horizontalPosition,
verticalPosition,
wrap: { type: TextWrappingType.SQUARE, side },
allowOverlap: false,
@@ -344,12 +349,13 @@ function imageRun(node: TiptapNode): ImageRun | null {
const height = typeof node.attrs?.height === 'number' && node.attrs.height > 0 ? Math.round(node.attrs.height) : 150;
const rotation = typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0;
const wrap = String(node.attrs?.wrap ?? 'inline');
const offsetCm = typeof node.attrs?.wrapOffset === 'number' ? node.attrs.wrapOffset : 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),
floating: floatingFor(wrap, offsetCm),
});
}
+12 -7
View File
@@ -322,7 +322,7 @@ function replacePageBreaks(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 };
type ImageExport = { path: string; bytes: Uint8Array<ArrayBuffer>; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode; wrapOffsetCm: number | null };
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
const bin = atob(b64);
@@ -363,6 +363,7 @@ function imageDescriptor(node: TiptapNode, index: number, namePrefix = 'image'):
alt: typeof node.attrs?.alt === 'string' ? node.attrs.alt : '',
rotationDeg: typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0,
wrap,
wrapOffsetCm: typeof node.attrs?.wrapOffset === 'number' ? round3(node.attrs.wrapOffset) : null,
};
}
@@ -2437,9 +2438,11 @@ 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.
function imageWrapProps(wrap: WrapMode): string {
if (wrap === 'left') return 'style:wrap="right" style:horizontal-pos="left"';
if (wrap === 'right') return 'style:wrap="left" style:horizontal-pos="right"';
// An offset frame is placed by coordinate instead (svg:x on the frame).
function imageWrapProps(wrap: WrapMode, offset: number | null): string {
const pos = offset != null && wrap !== 'topBottom' ? 'from-left' : null;
if (wrap === 'left') return `style:wrap="right" style:horizontal-pos="${pos ?? 'left'}"`;
if (wrap === 'right') return `style:wrap="left" style:horizontal-pos="${pos ?? 'right'}"`;
return 'style:wrap="none" style:horizontal-pos="center"';
}
@@ -2449,7 +2452,7 @@ function imageGraphicStyle(img: ImageExport, index: number): string {
if (img.wrap === 'inline') return '';
return (
`<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
`<style:graphic-properties ${imageWrapProps(img.wrap)}` +
`<style:graphic-properties ${imageWrapProps(img.wrap, img.wrapOffsetCm)}` +
` style:number-wrapped-paragraphs="no-limit"` +
` style:horizontal-rel="paragraph-content"` +
` style:vertical-pos="top" style:vertical-rel="paragraph"/>` +
@@ -2468,8 +2471,10 @@ function imageFrameXml(img: ImageExport, index: number): string {
const inner = `<draw:image xlink:href="${img.path}"/>${title}`;
const anchor = img.wrap === 'inline' ? 'as-char' : 'paragraph';
const styleName = img.wrap === 'inline' ? '' : ` draw:style-name="ImgFr${index + 1}"`;
const x = img.wrapOffsetCm != null && img.wrap !== 'inline' && img.wrap !== 'topBottom'
? ` svg:x="${img.wrapOffsetCm}cm"` : '';
return (
`<draw:frame draw:name="Image${index + 1}"${styleName} text:anchor-type="${anchor}" draw:z-index="${index}"${dims}${imageTransform(img)}>` +
`<draw:frame draw:name="Image${index + 1}"${styleName} text:anchor-type="${anchor}" draw:z-index="${index}"${dims}${x}${imageTransform(img)}>` +
`${inner}</draw:frame>`
);
}
@@ -2611,7 +2616,7 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
: 'draw:stroke="none"';
const wrap = box.wrap === 'inline'
? ''
: ` ${imageWrapProps(box.wrap)} style:number-wrapped-paragraphs="no-limit"` +
: ` ${imageWrapProps(box.wrap, null)} style:number-wrapped-paragraphs="no-limit"` +
` style:horizontal-rel="paragraph-content" style:vertical-pos="top" style:vertical-rel="paragraph"`;
// auto-grow only for plain text boxes; a custom-shape needs both explicitly
// false, or LibreOffice's shape autofit shrinks it to its text.
+21 -12
View File
@@ -1142,29 +1142,38 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
const rot = xfrm ? parseInt(xfrm.getAttribute('rot') ?? '', 10) : NaN;
if (Number.isFinite(rot) && rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
if (anchor) attrs.wrap = anchorWrap(anchor, ctx);
else fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
if (anchor) {
const { wrap, offsetCm } = anchorWrap(anchor, ctx);
attrs.wrap = wrap;
if (offsetCm != null) attrs.wrapOffset = offsetCm;
} else {
fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
}
return { type: 'image', attrs };
}
function anchorWrap(anchor: Element, ctx: Ctx): 'left' | 'right' | 'topBottom' {
if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return 'topBottom';
// The wrap mode, plus where the frame sits in the text column (cm from its left edge,
// null when the file only names a side). Text can only flow on one side of a CSS float,
// so the side is the half of the column the frame does *not* fill.
function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null } {
if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return { wrap: 'topBottom', offsetCm: null };
const sq = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0];
const wt = sq?.getAttribute('wrapText');
if (wt === 'right') return 'left'; // text on right ⇒ image on left
if (wt === 'left') return 'right';
// wrapText="bothSides" says nothing about the side, so the frame's own x decides it:
// the editor has no free position, only a side, and the nearer one is that side.
if (wt === 'right') return { wrap: 'left', offsetCm: null }; // text on right ⇒ image on left
if (wt === 'left') return { wrap: 'right', offsetCm: null };
const posH = anchor.getElementsByTagNameNS(WP, 'positionH')[0];
const align = posH?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim();
if (align) return align === 'right' || align === 'outside' ? 'right' : 'left';
if (align === 'right' || align === 'outside') return { wrap: 'right', offsetCm: null };
if (align) return { wrap: 'left', offsetCm: null };
const off = parseInt(posH?.getElementsByTagNameNS(WP, 'posOffset')[0]?.textContent ?? '', 10);
if (!Number.isFinite(off)) return 'left';
if (!Number.isFinite(off)) return { wrap: 'left', offsetCm: null };
const cx = intAttr(anchor.getElementsByTagNameNS(WP, 'extent')[0], '', 'cx') ?? 0;
// A page-relative offset counts from the sheet edge, everything else from the text
// column — shift it there so one comparison serves both.
const base = posH?.getAttribute('relativeFrom') === 'page' ? -cmToEmu(ctx.leftMarginCm) : 0;
return off + base + cx / 2 > cmToEmu(ctx.contentWidthCm) / 2 ? 'right' : 'left';
const left = off + base;
const wrap = left + cx / 2 > cmToEmu(ctx.contentWidthCm) / 2 ? 'right' : 'left';
return { wrap, offsetCm: round2(left / 360000) };
}
// ---- text boxes / shapes ------------------------------------------------------
@@ -1209,7 +1218,7 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct
if (cy) attrs.height = Math.round(emuToPx(cy));
const rot = intAttr(nsChild(spPr, A, 'xfrm'), '', 'rot');
if (rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
if (isAnchor) attrs.wrap = anchorWrap(root, ctx);
if (isAnchor) attrs.wrap = anchorWrap(root, ctx).wrap;
const fillClr = nsChild(nsChild(spPr, A, 'solidFill'), A, 'srgbClr')?.getAttribute('val');
const fill = fillClr ? hexColor(fillClr) ?? null : null;
+4
View File
@@ -131,6 +131,10 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>,
if (anchor || wrapVal) {
attrs.wrap = wrapModeFromOdf(wrapVal, gp['style:horizontal-pos']);
}
// A frame placed by coordinate (style:horizontal-pos="from-left") keeps its x in the
// column; the wrap mode alone would snap it to a side.
const x = gp['style:horizontal-pos'] === 'from-left' ? lengthToCm(el.getAttributeNS(NS.svg, 'x')) : null;
if (x != null && attrs.wrap && attrs.wrap !== 'topBottom') attrs.wrapOffset = Math.round(x * 100) / 100;
}
// A <draw:frame><draw:image> → an image node. Size comes from the frame's svg