diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a1d1d..091cac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,7 +79,7 @@ - Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip - Bookmarks and cross-references: name a selection, then insert a reference to it (its text, its number or its page); the reference follows the text it points at. Round-trips to ODF `text:bookmark-start`/`text:bookmark-ref` and Word's `Bookmark` + `REF`/`PAGEREF` fields - Manual page break (Ctrl+Enter); round-trips to ODF `fo:break-before` -- Text boxes and basic shapes (rectangle / rounded rectangle / ellipse): editable block content, fill and border colors, border width, resize/rotate handles, text wrap (inline / left / right / top-bottom) like images; floating toolbar for wrap, shape kind and colors. Round-trips to ODF `draw:frame`/`draw:text-box` + `draw:custom-shape` and DOCX DrawingML `wps:wsp`/`wps:txbx` (imports Word's `mc:AlternateContent` and legacy VML text boxes too) +- Text boxes and basic shapes — rectangle, rounded rectangle, ellipse, triangles, diamond, pentagon, hexagon, five-point star, trapezoid, parallelogram and the four block arrows: editable block content, fill and border colors, border width, resize/rotate handles, text wrap (inline / left / right / top-bottom) like images; a shape gallery in the floating toolbar and in the ribbon's Shape tab. Each outline is one polygon in `utils/shapes.ts` that the editor draws as SVG and the ODF export scales into its `draw:enhanced-path`, so the shape on screen is the shape in the file; Word gets the preset's name and draws its own. Round-trips to ODF `draw:frame`/`draw:text-box` + `draw:custom-shape` and DOCX DrawingML `wps:wsp`/`wps:txbx` (imports Word's `mc:AlternateContent` and legacy VML text boxes too) **Page & layout** - Page margins (cm) and page orientation (portrait / landscape) @@ -131,7 +131,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. **Content an imported document loses** - Charts are **drawn** from the file (`import/chart.ts`: DrawingML `chartN.xml` and ODF `chart:chart`), but as a picture, not a chart object — a re-export carries the drawing and the numbers behind it are no longer editable. The same holds for an **EMF** metafile (`import/emf.ts`): it is drawn, but as the SVG picture it was rebuilt into, and only from the record set a plot consists of — a hatched brush, a clipping region or a rotated bitmap is skipped. **WMF/SVM** metafiles and OLE objects still keep their box and a placeholder label, and export writes that back out: WMF is a different (16-bit) record format, SVM is StarOffice-proprietary, and an OLE object cannot be rendered without its application -- Shapes beyond rect / round-rect / ellipse: lines, arrows, connectors, polygons, freeform, and rotated shape text (dropped on import with a warning) +- Shapes with no text area of their own: lines, arrows as a *line* rather than a block arrow, connectors and freeform curves — they are two endpoints, not a box, and ODF gives each its own element (`draw:line`, `draw:polyline`) rather than a `draw:custom-shape` preset. Rotated shape text (`w:bodyPr vert`) too. All dropped on import with a warning - Text boxes in the DOCX export: lists inside a box are flattened to literal-marker paragraphs (`•` / `1.`) and images inside a box are dropped — the box XML is injected by a post-pack string pass (`export/docx.ts` `applyTextBoxesDocx`) that mints no numbering.xml or media/rels entries. The ODT export has neither limitation **Missing while writing** diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md index fbebcb6..61b99df 100644 --- a/docs/architecture/frames.md +++ b/docs/architecture/frames.md @@ -4,4 +4,27 @@ 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. **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:** `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. `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. +- **`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` (see the shape table below), `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. + +## Shapes (`utils/shapes.ts`) + +Every kind past the three CSS can draw is **one polygon in a 0…100 box**, given once and +used three ways: the node view draws it as an SVG `path` (`preserveAspectRatio="none"` to +stretch it to the frame, `vector-effect="non-scaling-stroke"` so the line stays even under +that distortion), the ODF export scales the same points into the 21600 viewBox of +`draw:enhanced-path`, and Word gets the preset's `prst` name and draws its own geometry. +Adding a shape is one entry in `SHAPES` — no icon, no export branch, no importer case. + +- The three rectangular kinds keep the geometry **LibreOffice itself writes** (the + round-rectangle path pre-evaluated for modifier 3600); a polygon derives its own. +- A ring (pentagon, star) is stretched onto the whole box per axis (`fit`): an + odd-cornered shape leaves gaps at its bounds, and the presets both word processors draw + fill their frame. +- `textArea` is where text goes inside the outline. It travels as ODF `draw:text-areas` + and pads `.textbox-content` in the editor — where a polygon's frame carries **no** + padding of its own, so the outline covers its whole box and the ring moves to the text. + LibreOffice ignores the text area of a preset it recognizes and sets the text at the + frame's top left instead, as it already does for our ellipse. +- An importer maps the file's `draw:type`/`prst` back through the same table; + `shapeFromOdfType`/`shapeFromPrst` return **null** for a preset we can't draw, which is + what makes the warning possible instead of flattening it to a rectangle. diff --git a/src/lib/components/ShapePicker.svelte b/src/lib/components/ShapePicker.svelte new file mode 100644 index 0000000..9533562 --- /dev/null +++ b/src/lib/components/ShapePicker.svelte @@ -0,0 +1,120 @@ + + + + +
+ + + {#if open} + + {/if} +
+ +{#snippet tile(k: ShapeKind)} + +{/snippet} + + diff --git a/src/lib/components/TextBoxToolbar.svelte b/src/lib/components/TextBoxToolbar.svelte index 30dce07..b59f134 100644 --- a/src/lib/components/TextBoxToolbar.svelte +++ b/src/lib/components/TextBoxToolbar.svelte @@ -3,6 +3,7 @@ import type { WrapMode } from '../editor/extensions/image'; import type { ShapeKind, TextBoxAttrs } from '../editor/extensions/textBox'; import ColorPicker from './ColorPicker.svelte'; + import ShapePicker from './ShapePicker.svelte'; import { t } from '../i18n/i18n.svelte'; let { @@ -40,13 +41,6 @@ : t().textBox.wrapTopBottom; } - const shapes: ShapeKind[] = ['textbox', 'roundRect', 'ellipse']; - function shapeTitle(k: ShapeKind): string { - return k === 'textbox' ? t().textBox.shapeRect - : k === 'roundRect' ? t().textBox.shapeRoundRect - : t().textBox.shapeEllipse; - } - const strokeWidths = [0.5, 1, 2.25]; @@ -106,30 +100,7 @@ - {#each shapes as s} - - {/each} + set({ shapeKind: k })} compact /> diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index 25590cb..7f84a4f 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -72,9 +72,6 @@ export const STROKED = { wrapLeft: ['M2 3.5h6v9H2z', 'M9.5 4.5h4.5M9.5 7h4.5M9.5 9.5h4.5M9.5 12h4.5'], wrapRight: ['M8 3.5h6v9H8z', 'M2 4.5h4.5M2 7h4.5M2 9.5h4.5M2 12h4.5'], wrapTopBottom: ['M4 6.5h8v3H4z', 'M2 3h12M2 13h12'], - shapeRect: ['M2 4h12v8H2z'], - shapeRound: ['M4 4h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z'], - shapeEllipse: ['M8 12.5c3.6 0 6.5-2 6.5-4.5S11.6 3.5 8 3.5 1.5 5.5 1.5 8s2.9 4.5 6.5 4.5z'], toc: ['M2 3h6M2 6.5h5M2 10h6M2 13.5h4', 'M11 3h3M10 6.5h4M11 10h3M10 13.5h4'], tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'], caption: ['M2 2.5h12v7.5H2z', 'M2 13h7'], diff --git a/src/lib/components/ribbon/tabs/FrameTabs.svelte b/src/lib/components/ribbon/tabs/FrameTabs.svelte index 2b6f2f9..1230354 100644 --- a/src/lib/components/ribbon/tabs/FrameTabs.svelte +++ b/src/lib/components/ribbon/tabs/FrameTabs.svelte @@ -4,6 +4,7 @@ import RibbonGroup from '../RibbonGroup.svelte'; import RibbonButton from '../RibbonButton.svelte'; import ColorPicker from '../../ColorPicker.svelte'; + import ShapePicker from '../../ShapePicker.svelte'; import type { WrapMode } from '../../../editor/extensions/image'; import type { ShapeKind } from '../../../editor/extensions/textBox'; import { t } from '../../../i18n/i18n.svelte'; @@ -28,12 +29,6 @@ { key: 'topBottom', icon: 'wrapTopBottom', label: () => t().image.wrapTopBottom }, ]; - const SHAPES: { key: ShapeKind; label: () => string }[] = [ - { key: 'textbox', label: () => t().textBox.shapeRect }, - { key: 'roundRect', label: () => t().textBox.shapeRoundRect }, - { key: 'ellipse', label: () => t().textBox.shapeEllipse }, - ]; - function setWrap(w: WrapMode) { if (which === 'picture') editor?.chain().focus().setImageWrap(w).run(); else editor?.chain().focus().setTextBoxAttrs({ wrap: w }).run(); @@ -68,9 +63,13 @@
- {#each SHAPES as s} - editor?.chain().focus().setTextBoxAttrs({ shapeKind: s.key }).run()} /> - {/each} +
+ editor?.chain().focus().setTextBoxAttrs({ shapeKind: k }).run()} + /> + {t().textBox.shape} +
{ @@ -17,7 +18,7 @@ const parseCmAttr = (v: string | null): number | null => { // stroke, and the image's wrap model (inline = in-flow; left/right/topBottom float). // Round-trips to ODF // and DOCX . -export type ShapeKind = 'textbox' | 'roundRect' | 'ellipse'; +export type { ShapeKind }; // Fixed text inset inside the frame; exported as fo:padding / wps:bodyPr insets. export const TEXTBOX_PADDING_CM = 0.15; @@ -142,7 +143,10 @@ export const TextBox = Node.create({ }, shapeKind: { default: 'textbox', - parseHTML: el => (el as HTMLElement).getAttribute('data-shape') ?? 'textbox', + parseHTML: el => { + const k = (el as HTMLElement).getAttribute('data-shape'); + return isShapeKind(k) ? k : 'textbox'; + }, renderHTML: () => ({}), }, fillColor: { @@ -175,8 +179,10 @@ export const TextBox = Node.create({ const style = [ a.width ? `width:${a.width}px` : '', a.height ? `min-height:${a.height}px` : '', - a.fillColor ? `background:${a.fillColor}` : '', - a.strokeColor ? `border:${a.strokeWidthPt * PX_PER_PT}px solid ${a.strokeColor}` : '', + // A polygon shape paints itself, so the box behind it stays bare. + a.fillColor && !SHAPES[a.shapeKind]?.points ? `background:${a.fillColor}` : '', + a.strokeColor && !SHAPES[a.shapeKind]?.points + ? `border:${a.strokeWidthPt * PX_PER_PT}px solid ${a.strokeColor}` : '', a.shapeKind !== 'textbox' ? `border-radius:${shapeRadius(a.shapeKind)}` : '', a.rotation ? `transform:rotate(${a.rotation}deg)` : '', `padding:${paddingPx(a.paddingCm).toFixed(2)}px`, @@ -269,9 +275,10 @@ class TextBoxView { private getPos: () => number; private observer: ResizeObserver | null = null; private resizing = false; - // Last ellipse text-area inset applied; guards the ResizeObserver feedback loop. - private lastInsetX = -1; - private lastInsetY = -1; + // The polygon outline, for a shape CSS cannot draw; null for the three it can. + private outline: SVGPathElement | null = null; + // Last text-area inset applied; guards the ResizeObserver feedback loop. + private lastInset = ''; constructor(node: PMNode, editor: Editor, getPos: () => number) { this.node = node; @@ -332,35 +339,76 @@ class TextBoxView { const a = this.attrs(); this.rotor.style.width = a.width ? `${a.width}px` : `${DEFAULT_WIDTH_PX}px`; this.rotor.style.minHeight = a.height ? `${a.height}px` : ''; - this.rotor.style.background = a.fillColor ?? 'transparent'; - this.rotor.style.border = a.strokeColor + // A polygon shape paints its own fill and stroke, so the box behind it stays bare. + const poly = !!SHAPES[a.shapeKind]?.points; + // The outline covers the padding box, so the frame's own ring moves to the text + // where a polygon draws it — applyShapeInset adds it back in. + this.rotor.style.padding = poly ? '0' : `${paddingPx(a.paddingCm).toFixed(2)}px`; + this.rotor.style.background = !poly && a.fillColor ? a.fillColor : 'transparent'; + this.rotor.style.border = !poly && a.strokeColor ? `${a.strokeWidthPt * PX_PER_PT}px solid ${a.strokeColor}` : 'none'; this.rotor.style.borderRadius = shapeRadius(a.shapeKind); this.rotor.style.transform = `translate(-50%, -50%) rotate(${a.rotation}deg)`; + this.applyOutline(); this.applyWrap(); this.applyShapeInset(); this.fitWrapper(); } - // For an ellipse, pad the content into the inscribed rectangle so text stays inside - // the oval. The vertical inset feeds back into the auto-grown height, so rewrite it - // only past a threshold — the ResizeObserver then settles (ratio < 0.5 converges). + // The outline of a shape CSS can't draw, behind the text. preserveAspectRatio="none" + // stretches the 0…100 box to the frame, and non-scaling-stroke keeps the line even + // width under that distortion. + private applyOutline(): void { + const a = this.attrs(); + const d = shapePath(a.shapeKind); + if (!d) { + this.outline?.parentElement?.remove(); + this.outline = null; + return; + } + if (!this.outline) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'textbox-outline'); + svg.setAttribute('viewBox', '0 0 100 100'); + svg.setAttribute('preserveAspectRatio', 'none'); + this.outline = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + this.outline.setAttribute('vector-effect', 'non-scaling-stroke'); + svg.appendChild(this.outline); + this.rotor.insertBefore(svg, this.rotor.firstChild); + } + this.outline.setAttribute('d', d); + this.outline.setAttribute('fill', a.fillColor ?? 'none'); + this.outline.setAttribute('stroke', a.strokeColor ?? 'none'); + this.outline.setAttribute('stroke-width', String(a.strokeWidthPt * PX_PER_PT)); + } + + // Pad the content into the shape's own text area — the ellipse's inscribed rectangle, + // a polygon's `textArea` — so text stays inside the outline. The vertical inset feeds + // back into the auto-grown height, so rewrite it only past a threshold; the + // ResizeObserver then settles (every ratio below 0.5 converges). private applyShapeInset(): void { - if (this.attrs().shapeKind !== 'ellipse') { + const kind = this.attrs().shapeKind; + const area = SHAPES[kind]?.textArea; + if (kind !== 'ellipse' && !area) { if (this.contentDOM.style.padding) this.contentDOM.style.padding = ''; - this.lastInsetX = this.lastInsetY = -1; + this.lastInset = ''; return; } const w = this.rotor.offsetWidth; const h = this.rotor.offsetHeight; if (!w || !h) return; - const insetX = w * ELLIPSE_INSET_RATIO; - const insetY = h * ELLIPSE_INSET_RATIO; - if (Math.abs(insetX - this.lastInsetX) < 0.5 && Math.abs(insetY - this.lastInsetY) < 0.5) return; - this.lastInsetX = insetX; - this.lastInsetY = insetY; - this.contentDOM.style.padding = `${insetY.toFixed(2)}px ${insetX.toFixed(2)}px`; + const [l, t, r, b] = area + ? [area[0] / 100, area[1] / 100, 1 - area[2] / 100, 1 - area[3] / 100] + : [ELLIPSE_INSET_RATIO, ELLIPSE_INSET_RATIO, ELLIPSE_INSET_RATIO, ELLIPSE_INSET_RATIO]; + // Whole px: the inset feeds back into the height, and rounding is what settles it. + // A polygon carries the frame's own ring here, its rotor having none. + const ring = area ? paddingPx(this.attrs().paddingCm) : 0; + const px = (v: number, base: number) => `${Math.round(v * base + ring)}px`; + const inset = `${px(t, h)} ${px(r, w)} ${px(b, h)} ${px(l, w)}`; + if (inset === this.lastInset) return; + this.lastInset = inset; + this.contentDOM.style.padding = inset; } // Size the wrapper to the rotor's rotated bounding box so surrounding text diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index cec992f..ca2bea4 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -16,6 +16,7 @@ import type { } from 'docx'; import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate'; import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; +import { SHAPES, isShapeKind, type ShapeKind } from '../utils/shapes'; import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; @@ -610,7 +611,7 @@ type TextBoxDocx = { offsetCm: number | null; offsetYCm: number | null; distCm: number | null; - shapeKind: 'textbox' | 'roundRect' | 'ellipse'; + shapeKind: ShapeKind; fill: string | null; stroke: string | null; strokeWidthPt: number; @@ -620,7 +621,6 @@ type TextBoxDocx = { function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx { const a = node.attrs ?? {}; const wrapAttr = a.wrap; - const kind = a.shapeKind; return { widthPx: typeof a.width === 'number' && a.width > 0 ? Math.round(a.width) : 280, heightPx: typeof a.height === 'number' && a.height > 0 ? Math.round(a.height) : 96, @@ -629,7 +629,7 @@ function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx { offsetCm: typeof a.wrapOffset === 'number' ? a.wrapOffset : null, offsetYCm: typeof a.wrapOffsetY === 'number' ? a.wrapOffsetY : null, distCm: typeof a.wrapDist === 'number' ? a.wrapDist : null, - shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : 'textbox', + shapeKind: isShapeKind(a.shapeKind) ? a.shapeKind : 'textbox', 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, @@ -761,12 +761,6 @@ function txbxContentXml(blocks: TiptapNode[]): string { return out || ''; } -const PRST_BY_KIND: Record = { - textbox: 'rect', - roundRect: 'roundRect', - ellipse: 'ellipse', -}; - // The full for one text box. Namespaces are declared inline on the // wp/a/wps elements so the output never depends on what the library declares on // the document root. @@ -787,7 +781,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string { `` + `` + `` + - `${fill}${ln}` + + `${fill}${ln}` + `${txbxContentXml(box.content)}` + `${autofit}` + ``; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 4fc1523..05d8499 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -23,6 +23,7 @@ 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 { isShapeKind, odfEnhancedGeometry, type ShapeKind } from '../utils/shapes'; import { normalizeLeader, parseTabStops } from '../editor/extensions/tabStops'; import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker'; import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, formatOrdinal, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; @@ -786,7 +787,6 @@ function replaceBookmarks(node: TiptapNode, refs: CrossRefExport[]): TiptapNode } // One text box / shape, collected by replaceTextBoxes and emitted by applyTextBoxes. -type ShapeKind = 'textbox' | 'roundRect' | 'ellipse'; type TextBoxExport = { widthCm: number; heightCm: number; @@ -811,7 +811,6 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport { const pxToCm = (px: number) => round3((px * 2.54) / 96); const a = node.attrs ?? {}; const wrapAttr = a.wrap; - const kind = a.shapeKind; return { widthCm: pxToCm(typeof a.width === 'number' && a.width > 0 ? a.width : 280), heightCm: pxToCm(typeof a.height === 'number' && a.height > 0 ? a.height : 96), @@ -822,7 +821,7 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport { wrapDistCm: typeof a.wrapDist === 'number' ? round3(a.wrapDist) : null, wrapAlign: a.wrapAlign === 'center' || a.wrapAlign === 'right' ? a.wrapAlign : null, paddingCm: typeof a.paddingCm === 'number' ? round3(a.paddingCm) : TEXTBOX_PADDING_CM, - shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : 'textbox', + shapeKind: isShapeKind(a.shapeKind) ? a.shapeKind : 'textbox', 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, @@ -3546,18 +3545,6 @@ function applyDateTimeFields(odtBytes: Uint8Array, fields: DateTimeFieldExport[] return rezipOdt(files); } -// Static enhanced geometry for the two non-rectangular shape kinds, matching what -// LibreOffice emits (round-rectangle path pre-evaluated for modifier 3600). -const ENHANCED_GEOMETRY: Record<'roundRect' | 'ellipse', string> = { - ellipse: - '', - roundRect: - '', -}; - // Graphic style for a text box / shape: fill, stroke, text padding, auto-grow, and — // for floating boxes — the same wrap/position props as floating images. function textBoxGraphicStyle(box: TextBoxExport, index: number): string { @@ -3601,7 +3588,7 @@ function textBoxAnchorStyle(box: TextBoxExport, index: number): string { // The drawing element wrapping a box's serialized blocks: a / // for plain text boxes (height = fo:min-height, so it grows with content like the -// editor), or a with preset geometry for roundRect/ellipse. +// editor), or a with the preset geometry of `utils/shapes.ts`. function textBoxXml(box: TextBoxExport, inner: string, index: number): string { const n = index + 1; const anchor = box.wrap === 'inline' ? 'as-char' : 'paragraph'; @@ -3622,7 +3609,7 @@ function textBoxXml(box: TextBoxExport, inner: string, index: number): string { } return ( `` + - `${inner}${ENHANCED_GEOMETRY[box.shapeKind]}` + `${inner}${odfEnhancedGeometry(box.shapeKind) ?? ''}` ); } diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 73b44c3..007f4da 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -266,9 +266,25 @@ const de: Messages = { wrapLeft: 'Textumbruch — Feld links', wrapRight: 'Textumbruch — Feld rechts', wrapTopBottom: 'Oben und unten', - shapeRect: 'Rechteck', - shapeRoundRect: 'Abgerundetes Rechteck', - shapeEllipse: 'Ellipse', + shape: 'Form', + chooseShape: 'Form auswählen', + shapes: { + textbox: 'Rechteck', + roundRect: 'Abgerundetes Rechteck', + ellipse: 'Ellipse', + triangle: 'Gleichschenkliges Dreieck', + rightTriangle: 'Rechtwinkliges Dreieck', + diamond: 'Raute', + pentagon: 'Fünfeck', + hexagon: 'Sechseck', + star5: 'Fünfzackiger Stern', + trapezoid: 'Trapez', + parallelogram: 'Parallelogramm', + rightArrow: 'Pfeil nach rechts', + leftArrow: 'Pfeil nach links', + upArrow: 'Pfeil nach oben', + downArrow: 'Pfeil nach 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 5878b71..5da97f0 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -270,9 +270,25 @@ const en = { wrapLeft: 'Wrap text — box left', wrapRight: 'Wrap text — box right', wrapTopBottom: 'Top and bottom', - shapeRect: 'Rectangle', - shapeRoundRect: 'Rounded rectangle', - shapeEllipse: 'Ellipse', + shape: 'Shape', + chooseShape: 'Choose a shape', + shapes: { + textbox: 'Rectangle', + roundRect: 'Rounded rectangle', + ellipse: 'Ellipse', + triangle: 'Isosceles triangle', + rightTriangle: 'Right triangle', + diamond: 'Diamond', + pentagon: 'Pentagon', + hexagon: 'Hexagon', + star5: 'Five-point star', + trapezoid: 'Trapezoid', + parallelogram: 'Parallelogram', + rightArrow: 'Right arrow', + leftArrow: 'Left arrow', + upArrow: 'Up arrow', + downArrow: 'Down arrow', + }, fillColor: 'Fill color', chooseFillColor: 'Choose fill color', noFill: 'No fill', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 320fab7..9c2f393 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -11,6 +11,7 @@ import { tableLookAttr } from '../styles/tableStyles'; import { formatOrdinal, orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes'; import { DATE_FORMATS, TIME_FORMATS, docxPicture, toDateValue } from '../utils/dateTime'; +import { shapeFromPrst } from '../utils/shapes'; import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats'; import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; @@ -1712,12 +1713,6 @@ function nsChild(el: Element | null, ns: string, localName: string): Element | n return null; } -const KIND_BY_PRST: Record = { - rect: 'textbox', - roundRect: 'roundRect', - ellipse: 'ellipse', -}; - // Fill/stroke attrs from a shape's spPr (or VML equivalents), suppressing the editor // defaults (white fill, 1pt black stroke); none → explicit null, since omitting the // attr would re-apply the default. @@ -1729,13 +1724,12 @@ function setShapeStyleAttrs(attrs: Record, fill: string | null, } } -// A DrawingML (text box or preset shape) → a textBox node. Unsupported -// preset geometries (stars, arrows, …) are dropped with a warning. All property +// A DrawingML (text box or preset shape) → a textBox node. A preset +// `utils/shapes.ts` can't draw (a connector, a freeform) is dropped with a warning. All property // lookups are scoped to spPr so a nested image's fill/xfrm can't leak in. function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ctx): Node | null { const spPr = nsChild(wsp, WPS, 'spPr'); - const prst = nsChild(spPr, A, 'prstGeom')?.getAttribute('prst') ?? 'rect'; - const kind = KIND_BY_PRST[prst]; + const kind = shapeFromPrst(nsChild(spPr, A, 'prstGeom')?.getAttribute('prst') ?? 'rect'); if (!kind) { ctx.warnings.add('Unsupported shapes were removed'); return null; } const attrs: Record = {}; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index ac0c446..3bce8a8 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -11,6 +11,7 @@ import { TABLE_REGIONS, tableLookAttr, type TableLook, type TableRegion } from ' import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes'; import { matchFormat, toDateValue, type Token } from '../utils/dateTime'; +import { shapeFromOdfType, type ShapeKind } from '../utils/shapes'; import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats'; import { astToLatex } from '../math/latex'; import { parseMathml } from '../math/mathml'; @@ -484,19 +485,16 @@ function loadObjectDoc(href: string | null, ctx: Ctx): Document | null { return doc.documentElement && !doc.getElementsByTagName('parsererror').length ? doc : null; } -// draw:rect / draw:ellipse / draw:custom-shape (rect/round-rect/ellipse preset) → a -// textBox with the matching shapeKind; the shape's text is its content. Other -// custom-shape presets (stars, arrows, …) are dropped with a warning. +// draw:rect / draw:ellipse / draw:custom-shape (any preset `utils/shapes.ts` knows) → a +// textBox with the matching shapeKind; the shape's text is its content. A preset we +// can't draw (a connector, a freeform) is dropped with a warning. function convertShape(el: Element, ctx: Ctx): Node | null { - let kind: 'textbox' | 'roundRect' | 'ellipse' | null = null; + let kind: ShapeKind | null = null; if (el.localName === 'rect') kind = 'textbox'; else if (el.localName === 'ellipse') kind = 'ellipse'; else { const geo = el.getElementsByTagNameNS(NS.draw, 'enhanced-geometry')[0]; - const type = geo?.getAttributeNS(NS.draw, 'type') ?? ''; - if (type === 'ellipse' || type === 'circle') kind = 'ellipse'; - else if (type === 'round-rectangle') kind = 'roundRect'; - else if (type === 'rectangle' || !type) kind = 'textbox'; + kind = shapeFromOdfType(geo?.getAttributeNS(NS.draw, 'type')); } if (!kind) { ctx.warnings.add('Unsupported shapes were removed'); diff --git a/src/lib/utils/shapes.ts b/src/lib/utils/shapes.ts new file mode 100644 index 0000000..ed615d1 --- /dev/null +++ b/src/lib/utils/shapes.ts @@ -0,0 +1,171 @@ +// The basic shapes a text box can take. Each non-rectangular one is a polygon given +// once in a 0…100 box: the editor draws it as an SVG path, the ODF export scales the +// same points into the 21600 viewBox wants, and Word gets the +// preset's name and draws its own geometry. + +export type ShapeKind = + | 'textbox' | 'roundRect' | 'ellipse' + | 'triangle' | 'rightTriangle' | 'diamond' | 'pentagon' | 'hexagon' | 'star5' + | 'trapezoid' | 'parallelogram' + | 'rightArrow' | 'leftArrow' | 'upArrow' | 'downArrow'; + +type Point = readonly [number, number]; + +export type ShapePreset = { + /** ODF . */ + odf: string; + /** DrawingML . */ + prst: string; + /** The outline, 0…100 both ways; absent for the three CSS-drawn kinds. */ + points?: readonly Point[]; + /** Where text goes inside the outline: x0 y0 x1 y1, same 0…100 box. */ + textArea?: readonly [number, number, number, number]; +}; + +// A regular n-gon's corners on the circle around the box, first one straight up. +function ring(n: number, r: number, turn = 0): Point[] { + return Array.from({ length: n }, (_, i) => { + const a = (-90 + turn + (360 / n) * i) * (Math.PI / 180); + return [50 + r * Math.cos(a), 50 + r * Math.sin(a)] as Point; + }); +} + +// Stretch a ring onto the whole box, per axis: an odd-cornered shape leaves gaps at +// its bounds, and the presets both word processors draw fill their frame. +function fit(pts: Point[]): Point[] { + const span = (i: 0 | 1) => { + const vs = pts.map((p) => p[i]); + const lo = Math.min(...vs); + return { lo, k: 100 / (Math.max(...vs) - lo) }; + }; + const x = span(0); + const y = span(1); + return pts.map(([px, py]) => [(px - x.lo) * x.k, (py - y.lo) * y.k] as Point); +} + +// A star's corners alternate between the two circles; 0.382 is the ratio a regular +// pentagram gives, which is the one both word processors draw. +function star(n: number, outer: number, inner: number): Point[] { + const o = ring(n, outer); + const i = ring(n, inner, 360 / (2 * n)); + return fit(o.flatMap((p, k) => [p, i[k]])); +} + +export const SHAPES: Record = { + textbox: { odf: 'rectangle', prst: 'rect' }, + roundRect: { odf: 'round-rectangle', prst: 'roundRect' }, + ellipse: { odf: 'ellipse', prst: 'ellipse' }, + triangle: { + odf: 'isosceles-triangle', prst: 'triangle', + points: [[50, 0], [100, 100], [0, 100]], textArea: [25, 50, 75, 100], + }, + rightTriangle: { + odf: 'right-triangle', prst: 'rtTriangle', + points: [[0, 0], [100, 100], [0, 100]], textArea: [5, 50, 50, 95], + }, + diamond: { + odf: 'diamond', prst: 'diamond', + points: [[50, 0], [100, 50], [50, 100], [0, 50]], textArea: [25, 25, 75, 75], + }, + pentagon: { + odf: 'pentagon', prst: 'pentagon', + points: fit(ring(5, 50)), textArea: [20, 35, 80, 85], + }, + hexagon: { + odf: 'hexagon', prst: 'hexagon', + points: [[25, 0], [75, 0], [100, 50], [75, 100], [25, 100], [0, 50]], textArea: [15, 5, 85, 95], + }, + star5: { + odf: 'star5', prst: 'star5', + points: star(5, 50, 19.1), textArea: [30, 35, 70, 70], + }, + trapezoid: { + odf: 'trapezoid', prst: 'trapezoid', + points: [[25, 0], [75, 0], [100, 100], [0, 100]], textArea: [25, 5, 75, 95], + }, + parallelogram: { + odf: 'parallelogram', prst: 'parallelogram', + points: [[25, 0], [100, 0], [75, 100], [0, 100]], textArea: [25, 5, 75, 95], + }, + rightArrow: { + odf: 'right-arrow', prst: 'rightArrow', + points: [[0, 25], [50, 25], [50, 0], [100, 50], [50, 100], [50, 75], [0, 75]], + textArea: [5, 30, 50, 70], + }, + leftArrow: { + odf: 'left-arrow', prst: 'leftArrow', + points: [[100, 25], [50, 25], [50, 0], [0, 50], [50, 100], [50, 75], [100, 75]], + textArea: [50, 30, 95, 70], + }, + upArrow: { + odf: 'up-arrow', prst: 'upArrow', + points: [[25, 100], [25, 50], [0, 50], [50, 0], [100, 50], [75, 50], [75, 100]], + textArea: [30, 50, 70, 95], + }, + downArrow: { + odf: 'down-arrow', prst: 'downArrow', + points: [[25, 0], [25, 50], [0, 50], [50, 100], [100, 50], [75, 50], [75, 0]], + textArea: [30, 5, 70, 50], + }, +}; + +/** The kinds drawn as a polygon — everything past the three CSS ones. */ +export const POLYGON_KINDS = (Object.keys(SHAPES) as ShapeKind[]).filter((k) => SHAPES[k].points); + +export function isShapeKind(v: unknown): v is ShapeKind { + return typeof v === 'string' && v in SHAPES; +} + +const r2 = (n: number) => Math.round(n * 100) / 100; + +/** The outline as an SVG `d`, in the 0…100 box the node view's viewBox uses. */ +export function shapePath(kind: ShapeKind): string | null { + const pts = SHAPES[kind].points; + if (!pts) return null; + return `M ${pts.map(([x, y]) => `${r2(x)},${r2(y)}`).join(' L ')} Z`; +} + +// ODF's viewBox for a custom shape. The two rectangular kinds keep the geometry +// LibreOffice itself writes (round-rectangle pre-evaluated for modifier 3600); the +// polygons derive theirs from the same points the editor draws. +const VB = 21600; +const s = (n: number) => Math.round((n * VB) / 100); + +const STATIC_GEOMETRY: Partial> = { + ellipse: + ' draw:type="ellipse" draw:text-areas="3163 3163 18437 18437"' + + ' draw:enhanced-path="U 10800 10800 10800 10800 0 360 Z N"', + roundRect: + ' draw:type="round-rectangle" draw:modifiers="3600"' + + ' draw:enhanced-path="M 3600 0 X 0 3600 L 0 18000 Y 3600 21600 L 18000 21600' + + ' X 21600 18000 L 21600 3600 Y 18000 0 Z N"', +}; + +/** The whole `` element for a shape, or null for a plain box. */ +export function odfEnhancedGeometry(kind: ShapeKind): string | null { + const preset = SHAPES[kind]; + const body = STATIC_GEOMETRY[kind] ?? (preset.points + ? ` draw:type="${preset.odf}"` + + (preset.textArea ? ` draw:text-areas="${preset.textArea.map(s).join(' ')}"` : '') + + // One M, one L, then bare pairs — the form LibreOffice writes itself. + ` draw:enhanced-path="M ${s(preset.points[0][0])} ${s(preset.points[0][1])} L ` + + `${preset.points.slice(1).map(([x, y]) => `${s(x)} ${s(y)}`).join(' ')} Z N"` + : null); + return body === null ? null : ``; +} + +const BY_ODF = new Map((Object.keys(SHAPES) as ShapeKind[]).map((k) => [SHAPES[k].odf, k])); +const BY_PRST = new Map((Object.keys(SHAPES) as ShapeKind[]).map((k) => [SHAPES[k].prst, k])); + +// null = a preset we don't draw, which the importers report rather than flatten to a +// rectangle. LibreOffice writes `circle` for an ellipse constrained to one. +export function shapeFromOdfType(type: string | null | undefined): ShapeKind | null { + if (!type) return 'textbox'; + if (type === 'circle') return 'ellipse'; + return BY_ODF.get(type) ?? null; +} + +export function shapeFromPrst(prst: string | null | undefined): ShapeKind | null { + if (!prst) return 'textbox'; + return BY_PRST.get(prst) ?? null; +} diff --git a/src/styles/editor.css b/src/styles/editor.css index ccc6e7c..8a0139b 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -513,9 +513,22 @@ .paper .tiptap .textbox-rotor { cursor: move; } +/* The outline of a shape CSS can't draw, behind the text. Such a frame carries no + padding of its own (the text inset moves to .textbox-content), so the padding box + this covers is the frame. overflow stays visible or the stroke is clipped in half + at the viewBox edge. */ +.paper .tiptap .textbox-outline { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} .paper .tiptap .textbox-content { cursor: text; min-height: 1em; + position: relative; } .paper .tiptap .textbox-content > :first-child { margin-top: 0; diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 2eb59f9..75f7a35 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -789,7 +789,7 @@ describe('DOCX import of foreign text boxes / shapes', () => { - + diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 833c95b..8362d11 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -1287,6 +1287,7 @@ describe('Leg 7: foreign shapes/text boxes → importOdt', () => { rect text lo ellipse star + smiley @@ -1302,9 +1303,9 @@ describe('Leg 7: foreign shapes/text boxes → importOdt', () => { const f = importOdt(foreign); const c = f.content.content!; const boxes = c.filter((n: N) => n.type === 'textBox'); - check('3 supported shapes imported', boxes.length === 3, c.map((n: N) => n.type)); + check('4 supported shapes imported', boxes.length === 4, c.map((n: N) => n.type)); - const [floatBox, rect, ellipse] = boxes; + const [floatBox, rect, ellipse, star] = boxes; check('frame: free x/y collapses to wrap side (right)', floatBox?.attrs?.wrap === 'right', floatBox?.attrs); check('frame: 2in → 192px, min-height 1in → 96px', floatBox?.attrs?.width === 192 && floatBox?.attrs?.height === 96, floatBox?.attrs); check('frame: fill + stroke from graphic style', floatBox?.attrs?.fillColor === '#CCFFCC' && floatBox?.attrs?.strokeColor === '#003300', floatBox?.attrs); @@ -1314,7 +1315,8 @@ describe('Leg 7: foreign shapes/text boxes → importOdt', () => { check('rect: imports as plain textbox, transparent', rect?.attrs?.shapeKind === undefined && rect?.attrs?.fillColor === null && rect?.attrs?.strokeColor === null, rect?.attrs); check('rect: text preserved', rect?.content?.[0]?.content?.[0]?.text === 'rect text', rect?.content); check('custom-shape ellipse: shapeKind + geometry', ellipse?.attrs?.shapeKind === 'ellipse' && ellipse?.attrs?.width === 192 && ellipse?.attrs?.height === 96, ellipse?.attrs); - check('star5 dropped with warning', f.warnings.includes('Unsupported shapes were removed'), f.warnings); + check('star5: preset kept', star?.attrs?.shapeKind === 'star5', star?.attrs); + check('a preset we can\'t draw is dropped with a warning', f.warnings.includes('Unsupported shapes were removed'), f.warnings); check('draw:line dropped with warning', f.warnings.includes('Drawings were removed'), f.warnings); const table = c.find((n: N) => n.type === 'table'); @@ -2190,3 +2192,34 @@ describe('Leg 23: recorded revisions', () => { check('no w:ins/w:del', !xml.includes(' { + const shape = (kind: string, text: string): N => + TBX({ width: 200, height: 120, shapeKind: kind }, P(null, T(text))); + const doc: N = { type: 'doc', content: [ + shape('triangle', 'tri'), shape('star5', 'star'), shape('rightArrow', 'arrow'), + ] }; + const kinds = (back: N) => (back.content.content ?? []) + .filter((n: N) => n.type === 'textBox').map((n: N) => n.attrs?.shapeKind).join(','); + + it('ODT: each preset gets its draw:type, its own path and its text area', async () => { + const bytes = await buildOdt(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['content.xml']); + for (const type of ['isosceles-triangle', 'star5', 'right-arrow']) { + check(`draw:type="${type}"`, xml.includes(`draw:type="${type}"`), xml.match(/draw:type="[^"]*"/g)); + } + check('the triangle carries its own path', xml.includes('draw:enhanced-path="M 10800 0 L 21600 21600 0 21600 Z N"'), + xml.match(/draw:enhanced-path="[^"]*"/g)); + check('a text area travels', xml.includes('draw:text-areas="5400 10800 16200 21600"')); + check('round-trips', kinds(importOdt(bytes)) === 'triangle,star5,rightArrow', kinds(importOdt(bytes))); + }); + + it('DOCX: each preset gets its prstGeom, and back', async () => { + const bytes = await buildDocx(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['word/document.xml']); + for (const prst of ['triangle', 'star5', 'rightArrow']) { + check(`prst="${prst}"`, xml.includes(``), xml.match(/prst="[^"]*"/g)); + } + check('round-trips', kinds(importDocx(bytes)) === 'triangle,star5,rightArrow', kinds(importDocx(bytes))); + }); +}); diff --git a/tests/unit/shapes.test.ts b/tests/unit/shapes.test.ts new file mode 100644 index 0000000..615884f --- /dev/null +++ b/tests/unit/shapes.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { + SHAPES, POLYGON_KINDS, shapePath, odfEnhancedGeometry, shapeFromOdfType, shapeFromPrst, + type ShapeKind, +} from '../../src/lib/utils/shapes'; + +describe('shape presets', () => { + it('names every kind once per format', () => { + const kinds = Object.keys(SHAPES) as ShapeKind[]; + expect(new Set(kinds.map((k) => SHAPES[k].odf)).size).toBe(kinds.length); + expect(new Set(kinds.map((k) => SHAPES[k].prst)).size).toBe(kinds.length); + }); + + it('round-trips a kind through both formats', () => { + for (const k of Object.keys(SHAPES) as ShapeKind[]) { + expect(shapeFromOdfType(SHAPES[k].odf)).toBe(k); + expect(shapeFromPrst(SHAPES[k].prst)).toBe(k); + } + // A preset we can't draw stays unknown rather than flattening to a rectangle. + expect(shapeFromOdfType('bent-connector')).toBeNull(); + expect(shapeFromPrst('bentConnector3')).toBeNull(); + expect(shapeFromOdfType('circle')).toBe('ellipse'); + }); + + it('keeps every polygon inside its box', () => { + for (const k of POLYGON_KINDS) { + const pts = SHAPES[k].points!; + expect(pts.length).toBeGreaterThanOrEqual(3); + for (const [x, y] of pts) { + expect(x).toBeGreaterThanOrEqual(0); + expect(x).toBeLessThanOrEqual(100); + expect(y).toBeGreaterThanOrEqual(0); + expect(y).toBeLessThanOrEqual(100); + } + // The outline has to touch all four edges, or the shape doesn't fill its frame. + expect(Math.min(...pts.map((p) => p[0]))).toBeCloseTo(0, 1); + expect(Math.max(...pts.map((p) => p[0]))).toBeCloseTo(100, 1); + expect(Math.min(...pts.map((p) => p[1]))).toBeCloseTo(0, 1); + expect(Math.max(...pts.map((p) => p[1]))).toBeCloseTo(100, 1); + } + }); + + it('keeps every text area inside its outline box', () => { + for (const k of POLYGON_KINDS) { + const [x0, y0, x1, y1] = SHAPES[k].textArea!; + expect(x0).toBeLessThan(x1); + expect(y0).toBeLessThan(y1); + expect(x0).toBeGreaterThanOrEqual(0); + expect(y1).toBeLessThanOrEqual(100); + } + }); + + it('derives the ODF geometry from the same points', () => { + expect(shapePath('diamond')).toBe('M 50,0 L 100,50 L 50,100 L 0,50 Z'); + expect(odfEnhancedGeometry('diamond')).toBe( + '', + ); + // The two rectangular kinds keep LibreOffice's own geometry, curves and all. + expect(odfEnhancedGeometry('ellipse')).toContain('U 10800 10800 10800 10800 0 360 Z N'); + expect(odfEnhancedGeometry('textbox')).toBeNull(); + expect(shapePath('textbox')).toBeNull(); + }); +});