diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index ee33dbb..c27b252 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -65,6 +65,19 @@ export function fitInlineImage(attrs: Record, maxWidthPx: numbe attrs.width = maxWidthPx; } +// A floating frame's margins from its offsets: wrapOffset is 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 how far below its anchor it sits. +export function frameMargins(wrap: WrapMode, offsetCm: unknown, offsetYCm: unknown, boxWidthPx: number): string { + const px = (v: unknown) => (typeof v === 'number' ? `${Math.round(cmToPx(v))}px` : null); + const down = px(offsetYCm); + if (wrap === 'topBottom') return `${down ?? '6px'} 0 6px`; + const near = px(offsetCm); + if (wrap === 'left') return `${down ?? '0'} 14px 6px ${near ?? '0'}`; + const far = near == null ? '0' : `calc(${COLUMN_WIDTH_CSS} - ${near} - ${boxWidthPx}px)`; + return `${down ?? '0'} ${far} 6px 14px`; +} + // 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 { @@ -274,8 +287,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). wrapOffset moves it inside the - // column via the outer margin. The live re-anchor drag keeps it where the text is. + // (left/right) or only above/below it (topBottom). The live re-anchor drag keeps it + // where the text is. private applyWrap(): void { const d = this.dom; const wrap = this.attrWrap(); @@ -283,23 +296,10 @@ 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)`; - // 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 = `${down ?? '0'} 14px 6px ${near}`; - } else if (wrap === 'right') { - d.style.float = 'right'; - d.style.margin = `${down ?? '0'} ${far} 6px 14px`; + const a = this.node.attrs; + if (wrap === 'left' || wrap === 'right') { + d.style.float = wrap; + d.style.margin = frameMargins(wrap, a.wrapOffset, a.wrapOffsetY, this.boxWidth()); } 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 @@ -307,7 +307,7 @@ class ImageView { d.style.float = 'left'; d.style.clear = 'both'; d.style.width = '100%'; - d.style.margin = `${down ?? '6px'} 0 6px`; + d.style.margin = frameMargins(wrap, a.wrapOffset, a.wrapOffsetY, this.boxWidth()); } } diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts index 26e9616..67696a4 100644 --- a/src/lib/editor/extensions/textBox.ts +++ b/src/lib/editor/extensions/textBox.ts @@ -5,7 +5,13 @@ import { NodeSelection, TextSelection, Plugin } from '@tiptap/pm/state'; import type { EditorState } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; import type { EditorView } from '@tiptap/pm/view'; -import { HANDLES, MIN_SIZE_PX, clamp, parsePx, pageContentHeightPx, type WrapMode } from './image'; +import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, type WrapMode } from './image'; + +// cm attribute value → number, for the frame offsets (px ones use parsePx). +const parseCmAttr = (v: string | null): number | null => { + const n = v == null ? NaN : parseFloat(v); + return Number.isFinite(n) ? n : null; +}; // A text box / basic shape: a block-level frame with editable block content, a fill, a // stroke, and the image's wrap model (inline = in-flow; left/right/topBottom float). @@ -30,6 +36,8 @@ export interface TextBoxAttrs { height: number | null; rotation: number; wrap: WrapMode; + wrapOffset: number | null; // cm from the text column's left edge + wrapOffsetY: number | null; // cm below the anchor paragraph shapeKind: ShapeKind; fillColor: string | null; strokeColor: string | null; @@ -98,6 +106,17 @@ export const TextBox = Node.create({ parseHTML: el => (el as HTMLElement).getAttribute('data-wrap') ?? 'inline', renderHTML: () => ({}), }, + // Where the frame sits relative to its anchor, in cm — as on an image. + wrapOffset: { + default: null, + parseHTML: el => parseCmAttr((el as HTMLElement).getAttribute('data-wrap-offset')), + renderHTML: () => ({}), + }, + wrapOffsetY: { + default: null, + parseHTML: el => parseCmAttr((el as HTMLElement).getAttribute('data-wrap-offset-y')), + renderHTML: () => ({}), + }, shapeKind: { default: 'textbox', parseHTML: el => (el as HTMLElement).getAttribute('data-shape') ?? 'textbox', @@ -337,17 +356,14 @@ class TextBoxView { // both render in-flow for a block-level box (they differ only in export anchoring). private applyWrap(): void { const d = this.dom; + const a = this.attrs(); d.style.float = ''; d.style.clear = ''; - d.style.margin = '6px 0'; - const wrap = this.attrs().wrap; - if (wrap === 'left') { - d.style.float = 'left'; - d.style.margin = '0 14px 6px 0'; - } else if (wrap === 'right') { - d.style.float = 'right'; - d.style.margin = '0 0 6px 14px'; - } else if (wrap === 'topBottom') { + d.style.margin = frameMargins('topBottom', null, a.wrapOffsetY, 0); + if (a.wrap === 'left' || a.wrap === 'right') { + d.style.float = a.wrap; + d.style.margin = frameMargins(a.wrap, a.wrapOffset, a.wrapOffsetY, parseFloat(d.style.width) || 0); + } else if (a.wrap === 'topBottom') { d.style.clear = 'both'; } } diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 7de9c1f..463919c 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -390,6 +390,8 @@ type TextBoxDocx = { heightPx: number; rotationDeg: number; wrap: 'inline' | 'left' | 'right' | 'topBottom'; + offsetCm: number | null; + offsetYCm: number | null; shapeKind: 'textbox' | 'roundRect' | 'ellipse'; fill: string | null; stroke: string | null; @@ -406,6 +408,8 @@ function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx { heightPx: typeof a.height === 'number' && a.height > 0 ? Math.round(a.height) : 96, rotationDeg: typeof a.rotation === 'number' ? a.rotation : 0, 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, 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, @@ -582,12 +586,16 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string { ? '' : ``; const align = box.wrap === 'right' ? 'right' : 'left'; + const emu = (cm: number) => Math.round(cm * 360000); + const posH = box.offsetCm != null && box.wrap !== 'topBottom' + ? `${emu(box.offsetCm)}` + : `${align}`; return ( `` + `` + - `${align}` + - `0` + + `${posH}` + + `${emu(box.offsetYCm ?? 0)}` + `${extent}${wrapEl}${docPr}${graphic}` ); } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 5da1211..e621261 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -451,6 +451,8 @@ type TextBoxExport = { heightCm: number; rotationDeg: number; wrap: WrapMode; + wrapOffsetCm: number | null; + wrapOffsetYCm: number | null; shapeKind: ShapeKind; fill: string | null; stroke: string | null; @@ -468,6 +470,8 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport { heightCm: pxToCm(typeof a.height === 'number' && a.height > 0 ? a.height : 96), rotationDeg: typeof a.rotation === 'number' ? a.rotation : 0, 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, 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, @@ -2756,8 +2760,9 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string { : 'draw:stroke="none"'; const wrap = box.wrap === 'inline' ? '' - : ` ${imageWrapProps(box.wrap, null)} style:number-wrapped-paragraphs="no-limit"` + - ` style:horizontal-rel="paragraph-content" style:vertical-pos="top" style:vertical-rel="paragraph"`; + : ` ${imageWrapProps(box.wrap, box.wrapOffsetCm)} 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 // false, or LibreOffice's shape autofit shrinks it to its text. const grow = box.shapeKind === 'textbox' @@ -2778,9 +2783,12 @@ function textBoxXml(box: TextBoxExport, inner: string, index: number): string { const n = index + 1; const anchor = box.wrap === 'inline' ? 'as-char' : 'paragraph'; const transform = frameTransform(box.rotationDeg, box.widthCm, box.heightCm); + const at = box.wrap === 'inline' ? '' + : (box.wrapOffsetCm != null && box.wrap !== 'topBottom' ? ` svg:x="${box.wrapOffsetCm}cm"` : '') + + (box.wrapOffsetYCm != null ? ` svg:y="${box.wrapOffsetYCm}cm"` : ''); const common = ` draw:style-name="TbxFr${n}" text:anchor-type="${anchor}" draw:z-index="${index}"` + - ` svg:width="${box.widthCm}cm"`; + ` svg:width="${box.widthCm}cm"${at}`; if (box.shapeKind === 'textbox') { // svg:height for consumers without auto-grow; fo:min-height is the real semantic // (height = minimum, content grows the box) and wins on our own re-import. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index b501ed3..65aba4d 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1228,9 +1228,6 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null { return { type: 'image', attrs }; } -// 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. // An undrawable frame as an image node of the same size, wrapped like the real one. 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 }; @@ -1256,27 +1253,36 @@ function anchorOffsetY(anchor: Element): number | null { return Number.isFinite(off) && off > 0 ? round2(off / 360000) : null; } +// The frame's own x in the text column, cm from its left edge. null where the file +// names an alignment instead of a coordinate. A page-relative offset counts from the +// sheet edge — shift it to the column so one number serves both. +function anchorOffsetX(anchor: Element, ctx: Ctx): number | null { + const posH = anchor.getElementsByTagNameNS(WP, 'positionH')[0]; + if (posH?.getElementsByTagNameNS(WP, 'align')[0]) return null; + const off = parseInt(posH?.getElementsByTagNameNS(WP, 'posOffset')[0]?.textContent ?? '', 10); + if (!Number.isFinite(off)) return null; + const base = posH?.getAttribute('relativeFrom') === 'page' ? -cmToEmu(ctx.leftMarginCm) : 0; + return round2((off + base) / 360000); +} + +// 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 } { const offsetYCm = anchorOffsetY(anchor); - const at = (wrap: 'left' | 'right' | 'topBottom', offsetCm: number | null = null) => ({ wrap, offsetCm, offsetYCm }); + const offsetCm = anchorOffsetX(anchor, ctx); + const at = (wrap: 'left' | 'right' | 'topBottom') => ({ wrap, offsetCm: wrap === 'topBottom' ? null : offsetCm, offsetYCm }); if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return at('topBottom'); - const sq = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0]; - const wt = sq?.getAttribute('wrapText'); + const wt = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0]?.getAttribute('wrapText'); 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(); + const align = anchor.getElementsByTagNameNS(WP, 'positionH')[0] + ?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim(); 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 at('left'); + if (offsetCm == null) 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 at(wrap, round2(left / 360000)); + return at(cmToEmu(offsetCm) + cx / 2 > cmToEmu(ctx.contentWidthCm) / 2 ? 'right' : 'left'); } // ---- text boxes / shapes ------------------------------------------------------ @@ -1321,7 +1327,12 @@ 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).wrap; + if (isAnchor) { + const { wrap, offsetCm, offsetYCm } = anchorWrap(root, ctx); + attrs.wrap = wrap; + if (offsetCm != null) attrs.wrapOffset = offsetCm; + if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm; + } const fillClr = nsChild(nsChild(spPr, A, 'solidFill'), A, 'srgbClr')?.getAttribute('val'); const fill = fillClr ? hexColor(fillClr) ?? null : null; diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 715cdf5..584db55 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -62,7 +62,7 @@ describe('DOCX export → import round trip', () => { para('box text'), para([text('bold in box', [{ type: 'bold' }])]), ] }, - { type: 'textBox', attrs: { width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] }, + { type: 'textBox', attrs: { width: 192, height: 96, wrap: 'right', wrapOffset: 6, wrapOffsetY: 1.5, shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] }, { type: 'columns', attrs: { count: 2, gapCm: 0.8 }, content: [para('newspaper one'), para('newspaper two')] }, { type: 'table', content: [ { type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] }, @@ -317,6 +317,8 @@ describe('DOCX export → import round trip', () => { expect(hasMark(walk(plain, 'text')[1], 'bold')).toBe(true); expect(ellipse.attrs.shapeKind).toBe('ellipse'); expect(ellipse.attrs.wrap).toBe('right'); + expect(ellipse.attrs.wrapOffset).toBeCloseTo(6, 1); + expect(ellipse.attrs.wrapOffsetY).toBeCloseTo(1.5, 1); expect(ellipse.attrs.rotation).toBe(30); expect(ellipse.attrs.fillColor).toBe('#FFEE00'); expect(ellipse.attrs.strokeColor).toBe('#FF0000'); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 8bc8c37..52a8ab7 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -87,7 +87,7 @@ const fixture: N = { 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'))), + TBX({ width: 192, height: 80, wrap: 'right', wrapOffset: 6, wrapOffsetY: 1.5, shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, P(null, T('in ellipse'))), COLS({ count: 2, gapCm: 0.5 }, P(null, T('newspaper column text one')), P(null, T('newspaper column text two'))), { type: 'bulletList', content: [ LI(P(null, T('bullet one'))),