diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 6b506e4..ee33dbb 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -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`; } } diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index bce758c..7820a17 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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), }); } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 652dbca..5e33167 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode; wrapOffsetCm: 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 }; function base64ToBytes(b64: string): Uint8Array { 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:vertical-pos="${img.wrapOffsetYCm != null ? 'from-top' : 'top'}" style:vertical-rel="paragraph"/>` + `` ); } @@ -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 ( - `` + + `` + `${inner}` ); } diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 7e45b49..50e3a31 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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 = { 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 ------------------------------------------------------ diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 3b86c89..672c135 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -135,6 +135,12 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record, // 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 diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index e01c7cc..2d95892 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -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); }); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index eaf26fb..4c56c80 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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);