diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 5003c6b..6b506e4 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -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 diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 76e7810..e8b70b7 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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), }); } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index ada10c6..6b6f59f 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode }; +type ImageExport = { path: string; bytes: Uint8Array; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode; wrapOffsetCm: number | null }; function base64ToBytes(b64: string): Uint8Array { 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 ( `` + - `` + @@ -2468,8 +2471,10 @@ function imageFrameXml(img: ImageExport, index: number): string { const inner = `${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 ( - `` + + `` + `${inner}` ); } @@ -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. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 2f62dc3..5a6c382 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 465add3..1da4f5b 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -131,6 +131,10 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record, 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 → an image node. Size comes from the frame's svg