feat: a floating frame keeps its distance from the anchor paragraph

Word's positionV and ODF's svg:y say how far below its paragraph a frame
sits; ignoring it put every figure at the anchor itself. It is the float's
top margin, so lines that fit above the frame still flow there, as in Word.
This commit is contained in:
Steffen Becker committed 2026-08-08 09:22:03 +00:00
1 parent 58607d92e9
commit 34c529e3c4
7 files changed
+67 -24

No files matched your search

+17 -4
View File
@@ -117,6 +117,13 @@ export const Image = Node.create({
parseHTML: el => parseCm((el as HTMLElement).getAttribute('data-wrap-offset')),
renderHTML: () => ({}),
},
// How far below its anchor paragraph the frame sits, in cm (Word's positionV
// posOffset, ODF svg:y). null = at the anchor.
wrapOffsetY: {
default: null,
parseHTML: el => parseCm((el as HTMLElement).getAttribute('data-wrap-offset-y')),
renderHTML: () => ({}),
},
};
},
@@ -130,6 +137,7 @@ export const Image = Node.create({
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 offsetY = node.attrs.wrapOffsetY as number | null;
const style = [
w ? `width:${w}px` : '',
h ? `height:${h}px` : '',
@@ -140,6 +148,7 @@ export const Image = Node.create({
...(rot ? { 'data-rotation': String(rot) } : {}),
...(wrap !== 'inline' ? { 'data-wrap': wrap } : {}),
...(offset != null ? { 'data-wrap-offset': String(offset) } : {}),
...(offsetY != null ? { 'data-wrap-offset-y': String(offsetY) } : {}),
})];
},
@@ -157,7 +166,7 @@ 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 }));
if (dispatch) dispatch(state.tr.setNodeMarkup(sel.from, undefined, { ...sel.node.attrs, wrap, wrapOffset: null, wrapOffsetY: null }));
return true;
},
};
@@ -281,12 +290,16 @@ class ImageView {
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)`;
// Down from the anchor paragraph. Lines that fit entirely above the frame's top
// edge still flow there, which is what a word processor does with the same offset.
const offsetY = this.node.attrs.wrapOffsetY as number | null;
const down = offsetY == null ? null : `${Math.round(cmToPx(offsetY))}px`;
if (wrap === 'left') {
d.style.float = 'left';
d.style.margin = `0 14px 6px ${near}`;
d.style.margin = `${down ?? '0'} 14px 6px ${near}`;
} else if (wrap === 'right') {
d.style.float = 'right';
d.style.margin = `0 ${far} 6px 14px`;
d.style.margin = `${down ?? '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
@@ -294,7 +307,7 @@ class ImageView {
d.style.float = 'left';
d.style.clear = 'both';
d.style.width = '100%';
d.style.margin = '6px 0';
d.style.margin = `${down ?? '6px'} 0 6px`;
}
}
+8 -4
View File
@@ -314,10 +314,13 @@ 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.
function floatingFor(wrap: string, offsetCm: number | null): IFloating | undefined {
// flush to its side. offsetYCm is how far below the anchor paragraph it sits.
function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | null): IFloating | undefined {
if (wrap === 'inline') return undefined;
const verticalPosition = { relative: VerticalPositionRelativeFrom.PARAGRAPH, offset: 0 };
const verticalPosition = {
relative: VerticalPositionRelativeFrom.PARAGRAPH,
offset: offsetYCm != null ? Math.round(offsetYCm * 360000) : 0,
};
if (wrap === 'topBottom') {
return {
horizontalPosition: { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT },
@@ -350,12 +353,13 @@ function imageRun(node: TiptapNode): ImageRun | null {
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;
const offsetYCm = typeof node.attrs?.wrapOffsetY === 'number' ? node.attrs.wrapOffsetY : 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),
floating: floatingFor(wrap, offsetCm, offsetYCm),
});
}
+5 -3
View File
@@ -324,7 +324,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; wrapOffsetCm: 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 };
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
const bin = atob(b64);
@@ -366,6 +366,7 @@ function imageDescriptor(node: TiptapNode, index: number, namePrefix = 'image'):
rotationDeg: typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0,
wrap,
wrapOffsetCm: typeof node.attrs?.wrapOffset === 'number' ? round3(node.attrs.wrapOffset) : null,
wrapOffsetYCm: typeof node.attrs?.wrapOffsetY === 'number' ? round3(node.attrs.wrapOffsetY) : null,
};
}
@@ -2473,7 +2474,7 @@ function imageGraphicStyle(img: ImageExport, index: number): string {
`<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"/>` +
` style:vertical-pos="${img.wrapOffsetYCm != null ? 'from-top' : 'top'}" style:vertical-rel="paragraph"/>` +
`</style:style>`
);
}
@@ -2491,8 +2492,9 @@ function imageFrameXml(img: ImageExport, index: number): string {
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"` : '';
const y = img.wrapOffsetYCm != null && img.wrap !== 'inline' ? ` svg:y="${img.wrapOffsetYCm}cm"` : '';
return (
`<draw:frame draw:name="Image${index + 1}"${styleName} text:anchor-type="${anchor}" draw:z-index="${index}"${dims}${x}${imageTransform(img)}>` +
`<draw:frame draw:name="Image${index + 1}"${styleName} text:anchor-type="${anchor}" draw:z-index="${index}"${dims}${x}${y}${imageTransform(img)}>` +
`${inner}</draw:frame>`
);
}
+25 -10
View File
@@ -1187,9 +1187,10 @@ 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 } = anchorWrap(anchor, ctx);
const { wrap, offsetCm, offsetYCm } = anchorWrap(anchor, ctx);
attrs.wrap = wrap;
if (offsetCm != null) attrs.wrapOffset = offsetCm;
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
} else {
fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
}
@@ -1203,34 +1204,48 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
function placeholderNode(box: { w: number; h: number }, label: string, anchor: Element | undefined, ctx: Ctx): Node {
const attrs: Record<string, unknown> = { src: placeholderImage(label, box.w, box.h), width: box.w, height: box.h, alt: label };
if (anchor) {
const { wrap, offsetCm } = anchorWrap(anchor, ctx);
const { wrap, offsetCm, offsetYCm } = anchorWrap(anchor, ctx);
attrs.wrap = wrap;
if (offsetCm != null) attrs.wrapOffset = offsetCm;
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
} else {
fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
}
return { type: 'image', attrs };
}
function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null } {
if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return { wrap: 'topBottom', offsetCm: null };
// How far below its anchor paragraph the frame sits. Only the paragraph- and
// line-relative forms have a CSS equivalent (the float's top margin); page- or
// margin-relative ones are absolute on the sheet, which a frame in flow cannot be.
function anchorOffsetY(anchor: Element): number | null {
const posV = anchor.getElementsByTagNameNS(WP, 'positionV')[0];
const from = posV?.getAttribute('relativeFrom');
if (from !== 'paragraph' && from !== 'line') return null;
const off = parseInt(posV?.getElementsByTagNameNS(WP, 'posOffset')[0]?.textContent ?? '', 10);
return Number.isFinite(off) && off > 0 ? round2(off / 360000) : null;
}
function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null; offsetYCm: number | null } {
const offsetYCm = anchorOffsetY(anchor);
const at = (wrap: 'left' | 'right' | 'topBottom', offsetCm: number | null = null) => ({ wrap, offsetCm, offsetYCm });
if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return at('topBottom');
const sq = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0];
const wt = sq?.getAttribute('wrapText');
if (wt === 'right') return { wrap: 'left', offsetCm: null }; // text on right ⇒ image on left
if (wt === 'left') return { wrap: 'right', offsetCm: null };
if (wt === 'right') return at('left'); // text on right ⇒ image on left
if (wt === 'left') return at('right');
const posH = anchor.getElementsByTagNameNS(WP, 'positionH')[0];
const align = posH?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim();
if (align === 'right' || align === 'outside') return { wrap: 'right', offsetCm: null };
if (align) return { wrap: 'left', offsetCm: null };
if (align === 'right' || align === 'outside') return at('right');
if (align) return at('left');
const off = parseInt(posH?.getElementsByTagNameNS(WP, 'posOffset')[0]?.textContent ?? '', 10);
if (!Number.isFinite(off)) return { wrap: 'left', offsetCm: null };
if (!Number.isFinite(off)) return at('left');
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;
const left = off + base;
const wrap = left + cx / 2 > cmToEmu(ctx.contentWidthCm) / 2 ? 'right' : 'left';
return { wrap, offsetCm: round2(left / 360000) };
return at(wrap, round2(left / 360000));
}
// ---- text boxes / shapes ------------------------------------------------------
+6
View File
@@ -135,6 +135,12 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>,
// 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;
// Likewise down the page, but only against the anchor paragraph — a page-relative
// frame is placed absolutely, which one in the text flow cannot be.
const rel = gp['style:vertical-rel'];
const y = gp['style:vertical-pos'] === 'from-top' && (!rel || rel.startsWith('paragraph') || rel === 'line')
? lengthToCm(el.getAttributeNS(NS.svg, 'y')) : null;
if (y != null && y > 0 && attrs.wrap) attrs.wrapOffsetY = Math.round(y * 100) / 100;
}
// A paragraph mark that declares nothing keeps the style's font, but the runs may all
+2 -1
View File
@@ -49,7 +49,7 @@ describe('DOCX export → import round trip', () => {
li(para('ml one'), { type: 'orderedList', content: [li(para('ml one-one'))] }),
li(para('ml two')),
] },
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', alt: 'pic' } }]),
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', wrapOffsetY: 2.5, alt: 'pic' } }]),
{ type: 'textBox', attrs: { width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 }, content: [
para('box text'),
para([text('bold in box', [{ type: 'bold' }])]),
@@ -280,6 +280,7 @@ describe('DOCX export → import round trip', () => {
expect(img.attrs.width).toBe(100);
expect(img.attrs.height).toBe(80);
expect(img.attrs.wrap).toBe('left');
expect(img.attrs.wrapOffsetY).toBe(2.5); // positionV posOffset, paragraph-relative
expect(img.attrs.src.startsWith('data:image/png')).toBe(true);
});
+4 -2
View File
@@ -31,10 +31,11 @@ const ROW = (...cells: N[]): N => ({ type: 'tableRow', content: cells });
// A tiny valid PNG; only its bytes matter for the round-trip (no image decoding).
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const IMGN = (width: number, height: number, alt?: string, rotation?: number, wrap?: string): N =>
const IMGN = (width: number, height: number, alt?: string, rotation?: number, wrap?: string, wrapOffsetY?: number): N =>
({ type: 'image', attrs: {
src: PNG, width, height,
...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}),
...(wrapOffsetY ? { wrapOffsetY } : {}),
} });
const TBX = (attrs: N, ...content: N[]): N => ({ type: 'textBox', attrs, content });
const COLS = (attrs: N, ...content: N[]): N => ({ type: 'columns', attrs, content });
@@ -74,7 +75,7 @@ const fixture: N = {
P(null, T('before\ttab\tafter')),
P(null, T('logo: '), IMGN(100, 50, 'Logo')),
P(null, T('rotated: '), IMGN(120, 80, 'Rotated', 30)),
P(null, T('wrapped left '), IMGN(90, 60, 'Float', 0, 'left'), T(' text flows beside it')),
P(null, T('wrapped left '), IMGN(90, 60, 'Float', 0, 'left', 2.5), T(' text flows beside it')),
P(null, T('top/bottom '), IMGN(70, 50, 'Banner', 0, 'topBottom')),
TBX({ width: 288, height: 96 }, P(null, T('box para one')), P(null, T('box '), T('bold', { type: 'bold' }))),
TBX({ width: 192, height: 80, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, P(null, T('in ellipse'))),
@@ -269,6 +270,7 @@ describe('Leg 1: editor → buildOdt → importOdt', () => {
check('image rotation round-trips (30°, 120×80)', rotImg?.attrs?.rotation === 30 && rotImg?.attrs?.width === 120 && rotImg?.attrs?.height === 80, rotImg?.attrs);
const wrapImg = collectImages(res.content).find((i: N) => i.attrs?.wrap === 'left');
check('image wrap=left round-trips', wrapImg?.attrs?.width === 90 && wrapImg?.attrs?.height === 60, wrapImg?.attrs);
check('image vertical anchor offset round-trips (svg:y)', wrapImg?.attrs?.wrapOffsetY === 2.5, wrapImg?.attrs);
const tbImg = collectImages(res.content).find((i: N) => i.attrs?.wrap === 'topBottom');
check('image wrap=topBottom round-trips', !!tbImg, tbImg?.attrs);