From a4bcaa9dedc8d4bd6305b55e6ea73d30bad6920b Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 11 Aug 2026 08:03:02 +0000 Subject: [PATCH] fix: a frame keeps the size its file states, to the fraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whole pixels cost a page-wide picture 0.3mm of height, and the column cap then scaled that loss into the width too. Sizes stay fractional (snapped back to a whole pixel inside the export's own 0.001cm quantization), and a sub-pixel overhang trims the width without touching the height — which is what LibreOffice draws. --- docs/architecture/frames.md | 2 +- src/lib/editor/extensions/image.ts | 19 ++++++++++++++++--- src/lib/import/docx.ts | 14 +++++++------- src/lib/import/odt.ts | 16 ++++++++-------- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md index 9aee53c..17a9e53 100644 --- a/docs/architecture/frames.md +++ b/docs/architecture/frames.md @@ -3,5 +3,5 @@ Covers `src/lib/editor/extensions/image.ts` and `textBox.ts` — node views, resize/rotate handles, wrap modes, frame offsets — and their ODF/DOCX legs. -- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`), `rotation` (CW degrees). The node view nests an `` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below). All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** a float reserves 14px beside itself and **nothing above or below** — LibreOffice and Word both wrap flush against a frame unless the file declares a distance, and 6px of our own put every figure in a text box 3mm too tall. **Vertical alignment (as-char):** `vAlign` carries ODF's `style:vertical-pos`/`-rel` pair for an inline frame — `null` (LibreOffice's `top`/`baseline` and Word's only mode) stands the frame on the text baseline, `middle`/`below` centre it on the baseline or hang it below, and `text-top`/`text-middle`/`text-bottom` measure against the character area instead (CSS `vertical-align: text-top`/`text-bottom`; nothing in CSS centres on that area, so `text-middle` uses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share). `from-top` becomes `vAlign: 'offset'` and puts the frame's top `wrapOffsetY` cm below the baseline whatever the relation says — all probed against LibreOffice, whose formula images use `middle`/`text` and `from-top` almost exclusively. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **no text descent below it** (probed); `image.ts` gives such a frame an `image-line` class, since CSS cannot ask whether a line holds real text. Hard breaks are the only line boundaries the model shows, so the run between two of them is what gets tested — which covers the frame-break-caption idiom LibreOffice's own guides are full of; a frame alone on a *soft-wrapped* line is invisible from there. A frame that shares its line with text keeps the baseline and the descent. **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. Two `topBottom` frames set against **opposite ends** of nearby paragraphs (`wrapAlign`, from Word's `positionH` align / ODF `style:horizontal-pos`) share one band and float to their own sides instead: `pairAlignedFrames` (import/odt.ts, both importers) keeps the attr only for such a pair — a lone one reserves the whole band, which is what the wrap means — and scales a pair up to 15% wider than the column down to it, since Word lets the two overlap in the middle and two floats cannot. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota. +- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `rotation` (CW degrees). The node view nests an `` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below). All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** a float reserves 14px beside itself and **nothing above or below** — LibreOffice and Word both wrap flush against a frame unless the file declares a distance, and 6px of our own put every figure in a text box 3mm too tall. **Vertical alignment (as-char):** `vAlign` carries ODF's `style:vertical-pos`/`-rel` pair for an inline frame — `null` (LibreOffice's `top`/`baseline` and Word's only mode) stands the frame on the text baseline, `middle`/`below` centre it on the baseline or hang it below, and `text-top`/`text-middle`/`text-bottom` measure against the character area instead (CSS `vertical-align: text-top`/`text-bottom`; nothing in CSS centres on that area, so `text-middle` uses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share). `from-top` becomes `vAlign: 'offset'` and puts the frame's top `wrapOffsetY` cm below the baseline whatever the relation says — all probed against LibreOffice, whose formula images use `middle`/`text` and `from-top` almost exclusively. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **no text descent below it** (probed); `image.ts` gives such a frame an `image-line` class, since CSS cannot ask whether a line holds real text. Hard breaks are the only line boundaries the model shows, so the run between two of them is what gets tested — which covers the frame-break-caption idiom LibreOffice's own guides are full of; a frame alone on a *soft-wrapped* line is invisible from there. A frame that shares its line with text keeps the baseline and the descent. **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. Two `topBottom` frames set against **opposite ends** of nearby paragraphs (`wrapAlign`, from Word's `positionH` align / ODF `style:horizontal-pos`) share one band and float to their own sides instead: `pairAlignedFrames` (import/odt.ts, both importers) keeps the attr only for such a pair — a lone one reserves the whole band, which is what the wrap means — and scales a pair up to 15% wider than the column down to it, since Word lets the two overlap in the middle and two floats cannot. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota. - **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap`), `shapeKind` (`'textbox'|'roundRect'|'ellipse'` — ellipse renders via `border-radius:50%`), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). The node has its **own group** (`group: 'textBox'`) so only the document admits it (`extensions.ts` widens Document to `'(block | textBox)+'`) — never in table cells, lists, header/footer, or other boxes; imports found there are flattened with a warning. `TextBoxView` mirrors `ImageView` (wrapper reserving the rotated bbox → rotor carrying fill/stroke/rotation → `contentDOM`); the rotor is out of flow and auto-grows, so a ResizeObserver refits the wrapper. **Click model (Word-like):** `stopEvent`/`isFrameHit` claim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the shared `image-*` handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-`draggable` only while node-selected, so PM's native block drag moves it without hijacking text selection. Commands: `insertTextBox()` (after the current top-level block, cursor inside), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`). `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically. **Spacing:** a box lifted out of an anchor paragraph that held nothing else stands in for it, so it takes that paragraph's resolved `fo:margin-top`/`-bottom` as `spaceBefore`/`spaceAfter` (`ParagraphSpacing` covers `textBox` for that). Space above is padding — it adds to the block above rather than collapsing against it — which means the node is `content-box` and the rotor, centred in the padding box, gets half of it back. diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 6ca2646..6269723 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -58,12 +58,25 @@ const COLUMN_WIDTH_CSS = // 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). +// column lands anyway. Fractional px throughout: whole ones cost a big frame 0.3mm. export function fitInlineImage(attrs: Record, maxWidthPx: number): void { const w = attrs.width; if (typeof w !== 'number' || w <= maxWidthPx) return; - if (typeof attrs.height === 'number') attrs.height = Math.max(1, Math.round((attrs.height * maxWidthPx) / w)); - attrs.width = maxWidthPx; + // Under a pixel over is our cm arithmetic disagreeing with the rendered column, not an + // oversized picture: trim the width so the line still holds it, and keep the height — + // LibreOffice lets that much overhang stand and draws the frame at its stated size. + if (typeof attrs.height === 'number' && w - maxWidthPx > 1) { + attrs.height = framePx(Math.max(1, (attrs.height * maxWidthPx) / w)); + } + attrs.width = framePx(maxWidthPx); +} + +// A frame size in doc px, from the cm or EMU the file states. Kept fractional — rounding +// a page-wide picture to whole pixels loses 0.3mm of height — but our own export +// quantizes to 0.001cm (0.04px), so a size that close to a whole pixel *is* one. +export function framePx(px: number): number { + const whole = Math.round(px); + return Math.abs(px - whole) < 0.05 ? whole : Math.round(px * 100) / 100; } // A floating frame's margins from its wrapOffset: its left edge in the text column, diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 5f45906..017e461 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -4,7 +4,7 @@ import { lengthToPt } from './styleResolver'; import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt'; import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet'; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; -import { fitInlineImage } from '../editor/extensions/image'; +import { fitInlineImage, framePx } from '../editor/extensions/image'; import { formatTabStops } from '../editor/extensions/tabStops'; import type { CapsMode, LineStyle } from '../editor/extensions/textEffects'; import { tableLookAttr } from '../styles/tableStyles'; @@ -1323,8 +1323,8 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null { if (wsp) return convertWpsShape(wsp, root, !!anchor, ctx); const extentEl = root.getElementsByTagNameNS(WP, 'extent')[0]; const boxPx = { - w: Math.round(emuToPx(intAttr(extentEl, '', 'cx') ?? 0)), - h: Math.round(emuToPx(intAttr(extentEl, '', 'cy') ?? 0)), + w: framePx(emuToPx(intAttr(extentEl, '', 'cx') ?? 0)), + h: framePx(emuToPx(intAttr(extentEl, '', 'cy') ?? 0)), }; const blip = drawing.getElementsByTagNameNS(A, 'blip')[0]; if (!blip) { @@ -1365,8 +1365,8 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null { const extent = root.getElementsByTagNameNS(WP, 'extent')[0]; const cx = intAttr(extent, '', 'cx'); const cy = intAttr(extent, '', 'cy'); - if (cx) attrs.width = Math.round(emuToPx(cx)); - if (cy) attrs.height = Math.round(emuToPx(cy)); + if (cx) attrs.width = framePx(emuToPx(cx)); + if (cy) attrs.height = framePx(emuToPx(cy)); const docPr = root.getElementsByTagNameNS(WP, 'docPr')[0]; const alt = docPr?.getAttribute('title') || docPr?.getAttribute('descr'); if (alt) attrs.alt = alt; @@ -1504,8 +1504,8 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct const extent = root.getElementsByTagNameNS(WP, 'extent')[0]; const cx = intAttr(extent, '', 'cx'); const cy = intAttr(extent, '', 'cy'); - if (cx) attrs.width = Math.round(emuToPx(cx)); - if (cy) attrs.height = Math.round(emuToPx(cy)); + if (cx) attrs.width = framePx(emuToPx(cx)); + if (cy) attrs.height = framePx(emuToPx(cy)); const rot = intAttr(nsChild(spPr, A, 'xfrm'), '', 'rot'); if (rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360; if (isAnchor) { diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index e9969e6..abf1433 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -3,7 +3,7 @@ import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, ODF_LOOK_ATTRS, normalizeColor } from '../export/odt'; import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet'; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; -import { fitInlineImage } from '../editor/extensions/image'; +import { fitInlineImage, framePx } from '../editor/extensions/image'; import { odfChartDataUrl } from './chart'; import { formatTabStops, normalizeLeader } from '../editor/extensions/tabStops'; import type { CapsMode, LineStyle } from '../editor/extensions/textEffects'; @@ -289,8 +289,8 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null { src = placeholderImage('Image', cmToPx(wCm), cmToPx(hCm)); } const attrs: Record = { src }; - if (wCm != null) attrs.width = Math.round(cmToPx(wCm)); - if (hCm != null) attrs.height = Math.round(cmToPx(hCm)); + if (wCm != null) attrs.width = framePx(cmToPx(wCm)); + if (hCm != null) attrs.height = framePx(cmToPx(hCm)); const title = frame.getElementsByTagNameNS(NS.svg, 'title')[0]?.textContent; if (title) attrs.alt = title; applyFrameRotationAndWrap(frame, attrs, ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name'))); @@ -359,10 +359,10 @@ function textBoxContent(children: Element[], ctx: Ctx): Node[] { function convertTextBoxFrame(frame: Element, textBoxEl: Element, ctx: Ctx): Node { const attrs: Record = {}; const wCm = lengthToCm(frame.getAttributeNS(NS.svg, 'width')); - if (wCm != null) attrs.width = Math.round(cmToPx(wCm)); + if (wCm != null) attrs.width = framePx(cmToPx(wCm)); const hCm = lengthToCm(textBoxEl.getAttributeNS(NS.fo, 'min-height')) ?? lengthToCm(frame.getAttributeNS(NS.svg, 'height')); - if (hCm != null) attrs.height = Math.round(cmToPx(hCm)); + if (hCm != null) attrs.height = framePx(cmToPx(hCm)); const gp = ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name')); applyFrameRotationAndWrap(frame, attrs, gp); // A box is a block of its own here, so the centring of a figure frame — which its @@ -407,7 +407,7 @@ function convertChartFrame(frame: Element, ctx: Ctx): Node | null { const doc = loadObjectDoc(obj.getAttributeNS(NS.xlink, 'href'), ctx); const src = doc && odfChartDataUrl(doc, cmToPx(wCm), cmToPx(hCm)); if (!src) return null; - const attrs: Record = { src, width: Math.round(cmToPx(wCm)), height: Math.round(cmToPx(hCm)), alt: 'Chart' }; + const attrs: Record = { src, width: framePx(cmToPx(wCm)), height: framePx(cmToPx(hCm)), alt: 'Chart' }; applyFrameRotationAndWrap(frame, attrs, ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name'))); if (!attrs.wrap || attrs.wrap === 'inline') fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm))); return { type: 'image', attrs }; @@ -466,8 +466,8 @@ function convertShape(el: Element, ctx: Ctx): Node | null { if (kind !== 'textbox') attrs.shapeKind = kind; const wCm = lengthToCm(el.getAttributeNS(NS.svg, 'width')); const hCm = lengthToCm(el.getAttributeNS(NS.svg, 'height')); - if (wCm != null) attrs.width = Math.round(cmToPx(wCm)); - if (hCm != null) attrs.height = Math.round(cmToPx(hCm)); + if (wCm != null) attrs.width = framePx(cmToPx(wCm)); + if (hCm != null) attrs.height = framePx(cmToPx(hCm)); const gp = ctx.resolver.graphicProps(el.getAttributeNS(NS.draw, 'style-name')); applyFrameRotationAndWrap(el, attrs, gp); shapeStyleAttrs(gp, attrs, true);