From f33e708b8495c7dcffbef135b50d04338a02399e Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 12 Sep 2026 02:29:08 +0200 Subject: [PATCH] feat: a text box anchors its text top, middle or bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both formats carry a vertical anchor on the box body — ODF draw:textarea-vertical-align, DOCX wps:bodyPr/@anchor — and both sides of the I/O hard-coded "top", so an imported box dropped its centering and a saved one never got it. New textVAlign attr, read and written in both formats, rendered by flexing the rotor (the handles are absolute and stay out of that flow), set in the Shape Format tab beside the vertical-text button. --- docs/architecture/frames.md | 2 +- src/lib/components/ribbon/Ribbon.svelte | 1 + .../components/ribbon/tabs/FrameTabs.svelte | 19 ++++++- src/lib/editor/extensions/textBox.ts | 22 +++++++++ src/lib/export/docx.ts | 6 ++- src/lib/export/odt.ts | 6 ++- src/lib/i18n/locales/de.ts | 3 ++ src/lib/i18n/locales/en.ts | 3 ++ src/lib/import/CLAUDE.md | 2 +- src/lib/import/docx.ts | 4 ++ src/lib/import/odt.ts | 13 +++-- tests/normalize.ts | 2 +- tests/unit/text-box-valign.test.ts | 49 +++++++++++++++++++ 13 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 tests/unit/text-box-valign.test.ts diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md index a2ec888..b960f3a 100644 --- a/docs/architecture/frames.md +++ b/docs/architecture/frames.md @@ -4,7 +4,7 @@ Covers `src/lib/editor/extensions/image.ts` and `textBox.ts` — node views, res 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`, 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. A picture that arrives by **URL** (an `` pasted or dropped from a web page) is fetched into a data-URI right after the paste (`inlineRemoteImages`); one the site withholds is dropped with a message, since only a data-URI ever reaches a file. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'|'through'`) 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), and `through` — Word's in-front-of / behind-text (`wp:wrapNone` + `behindDoc`), ODF's `style:wrap="run-through"` — leaves the flow entirely: `position: absolute` with no offsets keeps the static position it was anchored at, so it reserves neither width nor height, and `inFront` picks the side of the text it lands on. A frame the file places against the **page** takes both offsets from the page's corner instead (see `wrapFromPage`). Read as a side float instead it reserved a near-column-wide picture's whole height, which cost the image fixture a page and ~50 reports. A text box in that mode sits behind the text unless the file puts it in front (`inFront`, z-index 1 against its −2). Both frame toolbars and the ribbon's Arrange group offer the mode as the two entries Word's layout options have — behind the text and in front of it, one mode told apart by `inFront`. Picking **any** mode by hand drops the offsets and the frame of reference they were measured in (`droppedFrameAttrs`: `wrapFromPage`, `anchorPage`, `inFront`), or a page-placed frame set to a side wrap would export as page-relative with an offset that now counts from a paragraph. Every attribute change puts the node selection back (`atomAttrTr`): `setNodeMarkup` replaces a **leaf** node outright, so the selection maps off it and the frame would deselect itself — both its toolbars closing — on every wrap, resize or rotation commit. 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 frame that is **out of the flow** (`through`, or page-anchored) moves it by its own offsets instead (`startFreeMove`, shared with `textBox.ts`): nothing wraps around it, so there is no text position to re-anchor to — and a page-placed frame is re-placed from its offsets on every pagination pass, so re-anchoring moved it nowhere at all. Those offsets count from a point the drag cannot move (the anchor's static position, the page's corner), so the pointer delta is simply added to them; the preview rides the node view's own `applyWrap`, and the commit is one undo step. 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:** `wrapDist` is the gap the file keeps beside a float, in cm (Word's `distL`/`distR`, ODF's `fo:margin-left`/`-right` on the graphic style) — only the side the text flows on, since the other one is the frame's own offset. A frame that declares none reserves **nothing**, beside it or above/below it: probed, LibreOffice wraps flush against it, and a gap of our own put every figure in a text box 3mm too tall and broke a line a word early. `setImageWrap` gives a frame the file never floated the 0.32cm a word processor's own wrap command writes. **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. `wrapFromPage` says that the frame is placed against the **page** the anchor lands on (Word's `positionV relativeFrom="page"`, ODF's `style:vertical-rel="page"` — how a cover page's blocks are placed): `placeFromPage` (`pageBreaks.ts`, deferred a frame so the frame is laid out first) reads that page off the page grid and measures the frame's static position, turning both offsets into the margins that reach the page's corner — `wrapOffsetY` below the page top, `wrapOffset` from the text column, which is where Word measures a `positionH` from and not where the anchor character happens to sit. Every pagination pass re-places them, since the spacers it inserts move the anchor under them. The frame stays anchored in the flow it belongs to and no page number is stored. `wrapOffsetY` (`positionV`, `svg:y`) is otherwise 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. The **block after the anchor paragraph clears the band** (`imageLinePlugin` marks the block with `data-wrap-band`, an `editor.css` sibling rule clears the next one): a float only pushes lines that intersect it, so a short block (a heading) would slot into the clearance gap a prior side float opens above the band — both word processors start it below; a page-break spacer and the block holding a band-sharing partner frame are exempt. The marker is an attribute, not a `:has()` in the selector: with `:has()` left of `+`, Chromium restyled every element under `.tiptap` on each insertion there — a spacer, a split paragraph — 150 ms at 124 pages, twice per keystroke that moved a page break. 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`) — an **inline** text box / basic shape carrying editable block content (`group: 'inline'`, `inline: true`, `content: '(paragraph|heading|bulletList|orderedList)+ | table'`, `isolating`) — the frame both formats write, a character in a run that holds paragraphs. It rides a paragraph, so `inline` really is in the line, and a box reaches a table cell or a list item through their paragraphs, as one does in Word. Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). **Wrap** follows `ImageView.applyWrap` exactly: `inline` is an `inline-block` in the line, `left`/`right` are floats, `topBottom` a **full-width float** whose wrapper spans the column while `placeInBand` moves the rotor across it, `through` leaves the flow. Never `display:block` — that splits the paragraph's inline content into anonymous block boxes and ProseMirror drops the following page-break spacer. A paragraph holding a box paginates **atomically** (`pageBreaks.ts`), whichever way the box wraps. `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 drag moves it without hijacking text selection — except out of the flow, where the ring drag runs `startFreeMove` (PM's node move would re-anchor it). **Reaching the position beside the box:** a frame nobody is editing is an atom to the browser — a `contenteditable="false"` node decoration, dropped while the caret is in its text — and `ArrowLeft`/`ArrowRight` at the box's first/last text position step out to `before`/`after` the node. Without both, a box that starts its paragraph is a trap: there is no text position beside it, so the browser pulls the caret meant for the box's own place in the line into the frame and everything typed there lands inside it. The frame then has to place the caret itself on the click that enters it (`onFrameMouseDown`, `stopEvent`), since a non-editable one gets none from the browser. A nested `contentEditable` island is not the way — ProseMirror's `hasFocus` is `activeElement == view.dom`, and an island holding focus makes it read no selection at all. Commands: `insertTextBox()` (at the caret, cursor inside; refused inside a box), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`), `setTextBoxAlign()`. **Two guards** the schema cannot express live in the extension's plugin: a `filterTransaction` refuses a box inside a box — both word processors refuse it and neither format's writer has a place for one — and an `appendTransaction` grows a selection ending inside a box it did not start in out to the box's own bounds, since deleting such a range dissolves the frame and spills its text into the body. `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke, the alignment and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. **DOCX autofit:** the shape body writes ``, which is what LibreOffice writes for the same frame — read as `spAutoFit` it gives the frame `fo:min-height="0"` and draws its text detached from the shape, over whatever follows (probed). The cost is the ODF leg's auto-grow: content taller than the declared height is clipped there, DOCX having no `fo:min-height`. **LibreOffice puts a `wrapTopAndBottom` anchor's band above its anchor paragraph** whatever the anchor says — probed against `posOffset` 0/635, `align top`, `relativeFrom` line/column, `allowOverlap`, and against a file LibreOffice exported itself; `wp:inline` is the one form it places right, and a band-wrapped box keeps `wp:anchor` anyway (Word is the reference for `.docx`). **Where it sits across the column:** only a **band-wrapped** box has a place of its own — `wrapAlign` (`null`/`center`/`right`, drawn by `placeInBand`), and the toolbar's three buttons show for that mode alone, dropping any `wrapOffset` when one is picked. A side wrap names the side itself; a box **in the line** is a character its paragraph places, exactly as a picture is, so the ordinary paragraph alignment does it and a second control on the frame toolbar would only be the same thing under a frame icon. That ordinary command needs one correction, though (`TextAlignInFrames`): a **selected** box is one node whose range covers its own paragraphs, so it would align the box's contents along with the block holding it — with the frame selected only that block is meant (`setTextBoxAlign`), while the caret inside the box still aligns that text. An as-char frame must name the **baseline pair** explicitly in ODF (`style:vertical-pos="top" style:vertical-rel="baseline"`): the `Frame` parent hangs a frame that names none below the line, where a style-less as-char frame (an image) stands on it — probed, and the measured difference between the two formats until it was written. +- **`textBox.ts`** (`TextBox`) — an **inline** text box / basic shape carrying editable block content (`group: 'inline'`, `inline: true`, `content: '(paragraph|heading|bulletList|orderedList)+ | table'`, `isolating`) — the frame both formats write, a character in a run that holds paragraphs. It rides a paragraph, so `inline` really is in the line, and a box reaches a table cell or a list item through their paragraphs, as one does in Word. Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). **Wrap** follows `ImageView.applyWrap` exactly: `inline` is an `inline-block` in the line, `left`/`right` are floats, `topBottom` a **full-width float** whose wrapper spans the column while `placeInBand` moves the rotor across it, `through` leaves the flow. Never `display:block` — that splits the paragraph's inline content into anonymous block boxes and ProseMirror drops the following page-break spacer. A paragraph holding a box paginates **atomically** (`pageBreaks.ts`), whichever way the box wraps. `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 drag moves it without hijacking text selection — except out of the flow, where the ring drag runs `startFreeMove` (PM's node move would re-anchor it). **Reaching the position beside the box:** a frame nobody is editing is an atom to the browser — a `contenteditable="false"` node decoration, dropped while the caret is in its text — and `ArrowLeft`/`ArrowRight` at the box's first/last text position step out to `before`/`after` the node. Without both, a box that starts its paragraph is a trap: there is no text position beside it, so the browser pulls the caret meant for the box's own place in the line into the frame and everything typed there lands inside it. The frame then has to place the caret itself on the click that enters it (`onFrameMouseDown`, `stopEvent`), since a non-editable one gets none from the browser. A nested `contentEditable` island is not the way — ProseMirror's `hasFocus` is `activeElement == view.dom`, and an island holding focus makes it read no selection at all. Commands: `insertTextBox()` (at the caret, cursor inside; refused inside a box), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`), `setTextBoxAlign()`. **Two guards** the schema cannot express live in the extension's plugin: a `filterTransaction` refuses a box inside a box — both word processors refuse it and neither format's writer has a place for one — and an `appendTransaction` grows a selection ending inside a box it did not start in out to the box's own bounds, since deleting such a range dissolves the frame and spills its text into the body. `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke, the alignment and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. **Vertical anchor:** `textVAlign` (`top`/`middle`/`bottom`) is where the text sits in a box taller than it is — ODF's `draw:textarea-vertical-align`, Word's `wps:bodyPr anchor` (its two spread modes read as bottom; nothing in CSS spreads lines). The node view flexes the rotor, whose handles are positioned absolutely and stay out of that flow. Set in the ribbon's Shape Format tab, beside the vertical-text button; not on the frame toolbar, which is the compact subset. The name is not `vAlign` — that attr is a *frame's* own place on the line (above), and the as-char import path writes it on a box too. **DOCX autofit:** the shape body writes ``, which is what LibreOffice writes for the same frame — read as `spAutoFit` it gives the frame `fo:min-height="0"` and draws its text detached from the shape, over whatever follows (probed). The cost is the ODF leg's auto-grow: content taller than the declared height is clipped there, DOCX having no `fo:min-height`. **LibreOffice puts a `wrapTopAndBottom` anchor's band above its anchor paragraph** whatever the anchor says — probed against `posOffset` 0/635, `align top`, `relativeFrom` line/column, `allowOverlap`, and against a file LibreOffice exported itself; `wp:inline` is the one form it places right, and a band-wrapped box keeps `wp:anchor` anyway (Word is the reference for `.docx`). **Where it sits across the column:** only a **band-wrapped** box has a place of its own — `wrapAlign` (`null`/`center`/`right`, drawn by `placeInBand`), and the toolbar's three buttons show for that mode alone, dropping any `wrapOffset` when one is picked. A side wrap names the side itself; a box **in the line** is a character its paragraph places, exactly as a picture is, so the ordinary paragraph alignment does it and a second control on the frame toolbar would only be the same thing under a frame icon. That ordinary command needs one correction, though (`TextAlignInFrames`): a **selected** box is one node whose range covers its own paragraphs, so it would align the box's contents along with the block holding it — with the frame selected only that block is meant (`setTextBoxAlign`), while the caret inside the box still aligns that text. An as-char frame must name the **baseline pair** explicitly in ODF (`style:vertical-pos="top" style:vertical-rel="baseline"`): the `Frame` parent hangs a frame that names none below the line, where a style-less as-char frame (an image) stands on it — probed, and the measured difference between the two formats until it was written. ## A floating table diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 6528cfa..ac9c019 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -489,6 +489,7 @@ strokeColor={frameAttrs?.strokeColor as string | null} strokeWidthPt={frameAttrs?.strokeWidthPt as number} textVertical={frameAttrs?.textVertical === true} + textVAlign={frameAttrs?.textVAlign as never} /> {/if} diff --git a/src/lib/components/ribbon/tabs/FrameTabs.svelte b/src/lib/components/ribbon/tabs/FrameTabs.svelte index 3ff183a..fd62adf 100644 --- a/src/lib/components/ribbon/tabs/FrameTabs.svelte +++ b/src/lib/components/ribbon/tabs/FrameTabs.svelte @@ -7,12 +7,12 @@ import ShapePicker from '../../ShapePicker.svelte'; import CaptionDialog from '../../CaptionDialog.svelte'; import { droppedFrameAttrs, type WrapMode } from '../../../editor/extensions/image'; - import type { ShapeKind } from '../../../editor/extensions/textBox'; + import type { ShapeKind, TextVAlign } from '../../../editor/extensions/textBox'; import { t } from '../../../i18n/i18n.svelte'; // Word's Picture Format and Shape Format: the same wrap modes, plus a shape's // own fill, outline and kind. - let { editor, which, wrap, inFront = false, alt = '', shapeKind, fillColor, strokeColor, strokeWidthPt, textVertical = false }: { + let { editor, which, wrap, inFront = false, alt = '', shapeKind, fillColor, strokeColor, strokeWidthPt, textVertical = false, textVAlign = 'top' }: { editor: Editor | null; which: 'picture' | 'shape'; wrap: WrapMode; @@ -23,6 +23,7 @@ strokeColor?: string | null; strokeWidthPt?: number; textVertical?: boolean; + textVAlign?: TextVAlign; } = $props(); // The two run-through entries are one mode, told apart by which side of the text the @@ -38,6 +39,12 @@ ]; const active = $derived(wrap === 'through' ? (inFront ? 'front' : 'behind') : wrap); + const VALIGNS: { key: TextVAlign; icon: 'alignTop' | 'alignMiddle' | 'alignBottom'; label: () => string }[] = [ + { key: 'top', icon: 'alignTop', label: () => t().textBox.vAlignTop }, + { key: 'middle', icon: 'alignMiddle', label: () => t().textBox.vAlignMiddle }, + { key: 'bottom', icon: 'alignBottom', label: () => t().textBox.vAlignBottom }, + ]; + let captionOpen = $state(false); function setWrap(w: WrapChoice) { @@ -150,6 +157,14 @@ active={textVertical} onclick={() => editor?.chain().focus().setTextBoxAttrs({ textVertical: !textVertical }).run()} /> + {#each VALIGNS as v} + editor?.chain().focus().setTextBoxAttrs({ textVAlign: v.key }).run()} + /> + {/each} {/if} diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts index ff27a17..276fd85 100644 --- a/src/lib/editor/extensions/textBox.ts +++ b/src/lib/editor/extensions/textBox.ts @@ -26,6 +26,10 @@ const parseCmAttr = (v: string | null): number | null => { export type { ShapeKind }; +// Vertical anchor of the text inside the frame (ODF draw:textarea-vertical-align, +// DOCX wps:bodyPr/@anchor t|ctr|b). +export type TextVAlign = 'top' | 'middle' | 'bottom'; + // Fixed text inset inside the frame; exported as fo:padding / wps:bodyPr insets. export const TEXTBOX_PADDING_CM = 0.15; const paddingPx = (cm: unknown) => ((typeof cm === 'number' ? cm : TEXTBOX_PADDING_CM) * 96) / 2.54; @@ -57,6 +61,7 @@ export interface TextBoxAttrs { shapePath: string | null; // a freeform's own outline, in the 0…100 box flipV: boolean; // a line runs bottom-left → top-right instead textVertical: boolean; // text runs top-to-bottom, right-to-left + textVAlign: TextVAlign; // where the text sits in a box taller than it is fillColor: string | null; strokeColor: string | null; strokeWidthPt: number; @@ -260,6 +265,16 @@ export const TextBox = Node.create({ parseHTML: el => (el as HTMLElement).getAttribute('data-text-vertical') === 'true', renderHTML: () => ({}), }, + // Where the text sits in a box taller than the text: both formats anchor it + // top, middle or bottom. + textVAlign: { + default: 'top' as TextVAlign, + parseHTML: el => { + const v = (el as HTMLElement).getAttribute('data-text-valign'); + return v === 'middle' || v === 'bottom' ? v : 'top'; + }, + renderHTML: () => ({}), + }, fillColor: { default: '#FFFFFF', parseHTML: el => (el as HTMLElement).getAttribute('data-fill') || null, @@ -303,6 +318,8 @@ export const TextBox = Node.create({ a.shapeKind !== 'textbox' ? `border-radius:${shapeRadius(a.shapeKind)}` : '', a.rotation ? `transform:rotate(${a.rotation}deg)` : '', `padding:${paddingPx(a.paddingCm).toFixed(2)}px`, + a.textVAlign !== 'top' + ? `display:flex;flex-direction:column;justify-content:${a.textVAlign === 'middle' ? 'center' : 'flex-end'}` : '', ].filter(Boolean).join(';'); return ['div', mergeAttributes(HTMLAttributes, { 'data-textbox': '', @@ -315,6 +332,7 @@ export const TextBox = Node.create({ ...(a.shapePath ? { 'data-shape-path': a.shapePath } : {}), ...(a.flipV ? { 'data-flip-v': 'true' } : {}), ...(a.textVertical ? { 'data-text-vertical': 'true' } : {}), + ...(a.textVAlign !== 'top' ? { 'data-text-valign': a.textVAlign } : {}), ...(a.fillColor ? { 'data-fill': a.fillColor } : {}), ...(a.strokeColor ? { 'data-stroke': a.strokeColor } : {}), ...(a.strokeWidthPt !== 1 ? { 'data-stroke-width': String(a.strokeWidthPt) } : {}), @@ -589,6 +607,10 @@ class TextBoxView { this.contentDOM.style.display = line ? 'none' : ''; // Vertical text: the browser flows it, so the box needs nothing but the mode. this.contentDOM.style.writingMode = a.textVertical ? 'vertical-rl' : ''; + // Vertical anchor: the handles are absolute, so the rotor can flex its one child. + this.rotor.style.display = a.textVAlign === 'top' ? '' : 'flex'; + this.rotor.style.flexDirection = 'column'; + this.rotor.style.justifyContent = a.textVAlign === 'middle' ? 'center' : a.textVAlign === 'bottom' ? 'flex-end' : ''; this.applyOutline(); this.applyLine(); this.applyWrap(); diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index f880093..38c98b3 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -18,7 +18,7 @@ import type { } from 'docx'; import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate'; import { isSvgDataUrl, svgToPngDataUrl } from '../import/imageFormats'; -import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; +import { TEXTBOX_PADDING_CM, type TextVAlign } from '../editor/extensions/textBox'; import { SHAPES, isShapeKind, isLineKind, drawingMlPath, type ShapeKind } from '../utils/shapes'; import { cellFormatCode, isCellFormat } from '../utils/cellFormat'; import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; @@ -970,6 +970,7 @@ type TextBoxDocx = { shapePath: string | null; flipV: boolean; textVertical: boolean; + textVAlign: TextVAlign; fill: string | null; stroke: string | null; strokeWidthPt: number; @@ -998,6 +999,7 @@ function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx { shapePath: typeof a.shapePath === 'string' && a.shapePath ? a.shapePath : null, flipV: a.flipV === true, textVertical: a.textVertical === true, + textVAlign: a.textVAlign === 'middle' || a.textVAlign === 'bottom' ? a.textVAlign : 'top', fill: typeof a.fillColor === 'string' && a.fillColor ? a.fillColor : null, stroke: typeof a.strokeColor === 'string' && a.strokeColor ? a.strokeColor : null, strokeWidthPt: typeof a.strokeWidthPt === 'number' && a.strokeWidthPt > 0 ? a.strokeWidthPt : 1, @@ -1370,7 +1372,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): s `${geom}${line ? '' : fill}${ln}` + body + `${autofit}` + + ` lIns="${inset}" tIns="${inset}" rIns="${inset}" bIns="${inset}" anchor="${box.textVAlign === 'middle' ? 'ctr' : box.textVAlign === 'bottom' ? 'b' : 't'}">${autofit}` + ``; const graphic = `` + diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 294c1b8..08f28dd 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -27,7 +27,7 @@ type TableStyleRef = { name: string; look: TableLook }; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders'; import { parseCellPadding, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; -import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; +import { TEXTBOX_PADDING_CM, type TextVAlign } from '../editor/extensions/textBox'; import { numberLocale, parseCellNumber, toWriterFormula, type CellRef, type NumberLocale } from '../utils/tableFormula'; import { SHAPES, arrowHeadCm, isShapeKind, isLineKind, odfEnhancedGeometry, odfEnhancedPath, type ShapeKind } from '../utils/shapes'; import { normalizeLeader, parseTabStops } from '../editor/extensions/tabStops'; @@ -993,6 +993,7 @@ type TextBoxExport = { shapePath: string | null; flipV: boolean; textVertical: boolean; + textVAlign: TextVAlign; fill: string | null; stroke: string | null; strokeWidthPt: number; @@ -1022,6 +1023,7 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport { shapePath: typeof a.shapePath === 'string' && a.shapePath ? a.shapePath : null, flipV: a.flipV === true, textVertical: a.textVertical === true, + textVAlign: a.textVAlign === 'middle' || a.textVAlign === 'bottom' ? a.textVAlign : 'top', fill: typeof a.fillColor === 'string' && a.fillColor ? a.fillColor : null, stroke: typeof a.strokeColor === 'string' && a.strokeColor ? a.strokeColor : null, strokeWidthPt: typeof a.strokeWidthPt === 'number' && a.strokeWidthPt > 0 ? a.strokeWidthPt : 1, @@ -4562,7 +4564,7 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string { return ( `` + `${vertical}` + + `${grow} draw:textarea-vertical-align="${box.textVAlign}"${vertMode}${wrap}/>${vertical}` + `` ); } diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index eb9cf05..1714174 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -353,6 +353,9 @@ const de: Messages = { lineDoubleArrow: 'Doppelpfeil', }, verticalText: 'Senkrechter Text', + vAlignTop: 'Text oben', + vAlignMiddle: 'Text mittig', + vAlignBottom: 'Text unten', fillColor: 'Füllfarbe', chooseFillColor: 'Füllfarbe wählen', noFill: 'Keine Füllung', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 05813b7..d9b249b 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -359,6 +359,9 @@ const en = { lineDoubleArrow: 'Double arrow', }, verticalText: 'Vertical text', + vAlignTop: 'Align text top', + vAlignMiddle: 'Align text middle', + vAlignBottom: 'Align text bottom', fillColor: 'Fill color', chooseFillColor: 'Choose fill color', noFill: 'No fill', diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 5d6157b..78f10b0 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -41,7 +41,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( - **A band-wrapped DOCX frame flush left is at no side of its own** (`anchorWrap`): the exporter writes exactly that — `wp:align` left with `allowOverlap="0"` — for a frame with no `wrapAlign`, and reserves the overlap for the side-by-side pair a real left/right band frame makes. Read as an alignment, a full-width figure came back floating. - **An as-char text box is placed by its paragraph**, which is where its alignment stays — the box keeps only the `style:horizontal-pos` a band-wrapped frame declares of its own. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default. - **A cell's number format** is on its *style* in ODF (`style:data-style-name` → a `number:number-style`/`percentage-style`/`currency-style`/`date-style`, read for its family, decimals and grouping) and on the field in DOCX (the `\#` picture, or `\@` for a date, which is not part of the formula and is cut out of it). Only a formula cell keeps one, and the file's own currency symbol and date order give way to the document language's. -- **Vertical text in a box** is the style's writing mode in ODF (a frame's graphic properties — where LibreOffice writes it — or a drawing shape's paragraph properties; both top-to-bottom modes) and `w:bodyPr vert` in DOCX (`vert`/`eaVert`/`mongolianVert`/`wordArtVert`; `vert270` reads bottom-to-top, which the browser cannot lay out, so it is not one). VML says the same as `layout-flow:vertical` on the textbox. +- **Vertical text in a box** is the style's writing mode in ODF (a frame's graphic properties — where LibreOffice writes it — or a drawing shape's paragraph properties; both top-to-bottom modes) and `w:bodyPr vert` in DOCX (`vert`/`eaVert`/`mongolianVert`/`wordArtVert`; `vert270` reads bottom-to-top, which the browser cannot lay out, so it is not one). VML says the same as `layout-flow:vertical` on the textbox. The box's **vertical anchor** (`textVAlign`) is `draw:textarea-vertical-align` in the graphic properties and `w:bodyPr anchor` in DOCX (`ctr` → middle, `b`/`just`/`dist` → bottom). - **Ruby**: `text:ruby` and `w:ruby` are one atom here (`ruby.ts`), so only the two texts are read — the base out of `text:ruby-base` / `w:rubyBase`, the reading out of `text:ruby-text` / `w:rt`. A file's own alignment and position are dropped; both products' defaults are what the exports write. - **A TOC field's entry styles**: Word regenerates its rows from the styles named `toc 1`…`toc 9` (the ids are localized — `Verzeichnis1` in a German file — so `w:name` is what identifies them), and the cached rows are skipped, so `tocLevelStyles` reads them straight off styles.xml into the node's `levelStyles`. `registryName` maps them onto **Contents 1…10**, LibreOffice's own names, which is what keeps one document's ODF and DOCX legs pointing at the same registry entry. Without them the index falls back to the editor's 0.5cm per level and drops the file's own space-after — measured on a 60-row index: a 5.1mm row pitch against LibreOffice's 6.9mm. - **Bibliography**: a `text:bibliography-mark`'s every `text:` attribute but the identifier and the type is a source field, whatever the file names it, so nothing is lost on a format we have no UI for. Word's `CITATION` names a tag only — the record comes from `customXml/item*.xml` (each is probed for a `b:Sources` root), and a tag no source declares still cites with the field's cached text. `BIBLIOGRAPHY` and `text:bibliography` both become the index node — and Word caches that field's rows as a **`w:tbl`** inside the field, so every block between the field's `separate` and its `end` is skipped, not only the paragraphs; imported, that table printed the whole list a second time (measured: two pages). `b:Sources StyleName` picks the citation style (`citationStyleFromDocx`). diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 21ab888..b4bb1c6 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -2431,6 +2431,10 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct const bodyPr = nsChild(wsp, WPS, 'bodyPr'); const vert = bodyPr?.getAttribute('vert') ?? 'horz'; if (['vert', 'eaVert', 'mongolianVert', 'wordArtVert'].includes(vert)) attrs.textVertical = true; + // Where the text sits in a box taller than it is: bodyPr/@anchor, top by default. + const vAnchor = bodyPr?.getAttribute('anchor'); + if (vAnchor === 'ctr') attrs.textVAlign = 'middle'; + else if (vAnchor === 'b' || vAnchor === 'just' || vAnchor === 'dist') attrs.textVAlign = 'bottom'; // The ring inside the box (w:lIns, EMU). Word's own default is 0.25cm on the sides; // only a value the editor's own ring does not already draw is worth the attr. const lIns = bodyPr?.getAttribute('lIns'); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index cc78471..f873692 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -247,11 +247,14 @@ function boxWrapAlign(gp: PropMap, attrs: Record): void { // Vertical text in any box shape: the style's writing mode, both of ODF's top-to-bottom // modes (the editor has the one direction the browser lays out). A text frame carries it // in the graphic properties, a drawing shape in its style's paragraph properties. -function boxTextVertical(el: Element, ctx: Ctx, attrs: Record): void { +function boxTextFlow(el: Element, ctx: Ctx, attrs: Record): void { const name = el.getAttributeNS(NS.draw, 'style-name'); - const mode = ctx.resolver.graphicProps(name)['style:writing-mode'] - ?? ctx.resolver.graphicParaProps(name)['style:writing-mode']; + const gp = ctx.resolver.graphicProps(name); + const mode = gp['style:writing-mode'] ?? ctx.resolver.graphicParaProps(name)['style:writing-mode']; if (mode === 'tb-rl' || mode === 'tb-lr' || mode === 'tb') attrs.textVertical = true; + // Where the text sits in a box taller than it is; 'justify' spreads it, top-anchored. + const anchor = gp['draw:textarea-vertical-align']; + if (anchor === 'middle' || anchor === 'bottom') attrs.textVAlign = anchor; } function applyFrameRotationAndWrap(el: Element, attrs: Record, gp: PropMap, contentCm = 0): void { @@ -512,7 +515,7 @@ function convertTextBoxFrame(frame: Element, textBoxEl: Element, ctx: Ctx): Node const padCm = lengthToCm(gp['fo:padding']); if (padCm != null && Math.abs(padCm - TEXTBOX_PADDING_CM) > 0.01) attrs.paddingCm = Math.round(padCm * 1000) / 1000; shapeStyleAttrs(gp, attrs, false); - boxTextVertical(frame, ctx, attrs); + boxTextFlow(frame, ctx, attrs); return { type: 'textBox', attrs, content: textBoxContent(Array.from(textBoxEl.children), ctx, wCm) }; } @@ -620,7 +623,7 @@ function convertShape(el: Element, ctx: Ctx): Node | null { applyFrameRotationAndWrap(el, attrs, gp, ctx.contentWidthCm); boxWrapAlign(gp, attrs); shapeStyleAttrs(gp, attrs, true); - boxTextVertical(el, ctx, attrs); + boxTextFlow(el, ctx, attrs); return { type: 'textBox', attrs, content: textBoxContent(Array.from(el.children), ctx, wCm) }; } diff --git a/tests/normalize.ts b/tests/normalize.ts index f091e43..c5e6d9a 100644 --- a/tests/normalize.ts +++ b/tests/normalize.ts @@ -12,7 +12,7 @@ const ORDERED_DEFAULTS: Record = { fixed: false, key1: '', joinPrev: false, // pageBreaks.ts's split marker; the export merges the halves marginLeft: 0, marginRight: 0, marginTop: 0, marginBottom: 0, // a table's, once a command has touched them - alt: '', inFront: false, paddingCm: 0.15, flipV: false, textVertical: false, // the editor's own picture and box defaults + alt: '', inFront: false, paddingCm: 0.15, flipV: false, textVertical: false, textVAlign: 'top', // the editor's own picture and box defaults wrapFromPage: false, // index attrs the DOCX TOC field has no switch for come back at their defaults maxLevel: MAX_HEADING_LEVEL, leader: '.', citationStyle: 'key', diff --git a/tests/unit/text-box-valign.test.ts b/tests/unit/text-box-valign.test.ts new file mode 100644 index 0000000..f474f05 --- /dev/null +++ b/tests/unit/text-box-valign.test.ts @@ -0,0 +1,49 @@ +// Where the text sits in a box taller than it is: ODF's draw:textarea-vertical-align, +// Word's wps:bodyPr/@anchor. +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../../src/lib/export/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importOdt } from '../../src/lib/import/odt'; +import { importDocx } from '../../src/lib/import/docx'; + +type N = any; + +const doc = (attrs: N): N => ({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'davor' }] }, + { type: 'paragraph', content: [{ + type: 'textBox', + attrs: { width: 200, height: 160, ...attrs }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: '53%' }] }], + }] }], +}); +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + +const boxOf = (r: N): N => { + const find = (n: N): N => n?.type === 'textBox' ? n : (n?.content ?? []).map(find).find(Boolean); + return find((r.content as N)); +}; + +describe('a vertically anchored text box', () => { + it('round-trips through ODF', async () => { + const bytes = await buildOdt(doc({ textVAlign: 'middle' }), margins, 'portrait'); + expect(strFromU8(unzipSync(bytes)['content.xml'])).toContain('draw:textarea-vertical-align="middle"'); + expect(boxOf(importOdt(bytes))?.attrs.textVAlign).toBe('middle'); + }); + + it('round-trips through DOCX', async () => { + const bytes = await buildDocx(doc({ textVAlign: 'bottom' }), margins, 'portrait'); + expect(strFromU8(unzipSync(bytes)['word/document.xml'])).toContain('anchor="b"'); + expect(boxOf(importDocx(bytes))?.attrs.textVAlign).toBe('bottom'); + }); + + it('leaves a top-anchored box alone', async () => { + const odt = await buildOdt(doc({}), margins, 'portrait'); + expect(strFromU8(unzipSync(odt)['content.xml'])).toContain('draw:textarea-vertical-align="top"'); + expect(boxOf(importOdt(odt))?.attrs.textVAlign).toBeUndefined(); + const docx = await buildDocx(doc({}), margins, 'portrait'); + expect(strFromU8(unzipSync(docx)['word/document.xml'])).toContain('anchor="t"'); + expect(boxOf(importDocx(docx))?.attrs.textVAlign).toBeUndefined(); + }); +});