feat: a shape can be a triangle, a star or a block arrow

Twelve presets beside the three rectangular ones. Each outline is one
polygon in utils/shapes.ts, given once: the node view draws it as an SVG
path, the ODF export scales the same points into draw:enhanced-path, and
Word gets the preset's name and draws its own geometry.

A gallery in the box toolbar and the ribbon renders its tiles from that
same table, so a new shape needs no icon. Verified through LibreOffice
from both formats; a preset we can't draw still imports as a warning.
This commit is contained in:
Steffen Becker committed 2026-08-14 08:02:08 +02:00
1 parent 5ee4eec5d7
commit 4cda289272
18 files changed
+566 -121

No files matched your search

+2 -2
View File
@@ -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**
+24 -1
View File
@@ -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 `<img>` 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`Line truncated
- **`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.
+120
View File
@@ -0,0 +1,120 @@
<script lang="ts">
import { SHAPES, shapePath, type ShapeKind } from '../utils/shapes';
import { t } from '../i18n/i18n.svelte';
// The shape gallery, shared by the floating box toolbar and the ribbon's Shape tab.
// Every tile is drawn from the same preset the editor and both exports use, so a
// shape added to `utils/shapes.ts` needs no icon of its own.
let { value, onPick, compact = false }: {
value: ShapeKind;
onPick: (k: ShapeKind) => void;
compact?: boolean;
} = $props();
const KINDS = Object.keys(SHAPES) as ShapeKind[];
let open = $state(false);
let host = $state<HTMLElement | null>(null);
const label = (k: ShapeKind) => t().textBox.shapes[k];
function onWindowClick(e: MouseEvent) {
if (open && host && !host.contains(e.target as HTMLElement)) open = false;
}
</script>
<svelte:window onclick={onWindowClick} />
<div class="shape-picker" bind:this={host}>
<button
class="shape-btn"
class:compact
title={t().textBox.chooseShape}
aria-label={t().textBox.chooseShape}
aria-haspopup="true"
aria-expanded={open}
onclick={() => (open = !open)}
>
{@render tile(value)}
<span class="chevron" aria-hidden="true">▾</span>
</button>
{#if open}
<div class="shape-menu" role="menu">
{#each KINDS as k}
<button
class="shape-tile"
class:active={value === k}
role="menuitemradio"
aria-checked={value === k}
title={label(k)}
aria-label={label(k)}
onclick={() => { onPick(k); open = false; }}
>
{@render tile(k)}
</button>
{/each}
</div>
{/if}
</div>
{#snippet tile(k: ShapeKind)}
<svg viewBox="0 0 100 100" fill="none" aria-hidden="true">
{#if k === 'ellipse'}
<ellipse cx="50" cy="50" rx="45" ry="35" stroke="currentColor" stroke-width="7" />
{:else if k === 'roundRect'}
<rect x="5" y="15" width="90" height="70" rx="18" stroke="currentColor" stroke-width="7" />
{:else if k === 'textbox'}
<rect x="5" y="15" width="90" height="70" stroke="currentColor" stroke-width="7" />
{:else}
<path d={shapePath(k)} stroke="currentColor" stroke-width="7" stroke-linejoin="round" />
{/if}
</svg>
{/snippet}
<style>
.shape-picker { position: relative; display: inline-flex; }
.shape-btn {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px 4px;
border: none;
border-radius: 4px;
background: none;
color: inherit;
cursor: pointer;
}
.shape-btn:hover { background: var(--w-hover, rgba(0, 0, 0, 0.08)); }
.shape-btn svg { width: 22px; height: 22px; }
.shape-btn.compact svg { width: 18px; height: 18px; }
.chevron { font-size: 9px; line-height: 1; }
.shape-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 60;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 2px;
padding: 6px;
border: 1px solid var(--w-border, #d0d0d0);
border-radius: 6px;
background: var(--w-surface, #fff);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
}
.shape-tile {
display: inline-flex;
padding: 5px;
border: 1px solid transparent;
border-radius: 4px;
background: none;
color: inherit;
cursor: pointer;
}
.shape-tile svg { width: 22px; height: 22px; }
.shape-tile:hover { background: var(--w-hover, rgba(0, 0, 0, 0.08)); }
.shape-tile.active { border-color: var(--accent, #3b82f6); }
</style>
+2 -31
View File
@@ -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];
</script>
@@ -106,30 +100,7 @@
<span class="tb-sep"></span>
{#each shapes as s}
<button
class="tb-btn"
class:active={shapeKind === s}
title={shapeTitle(s)}
aria-label={shapeTitle(s)}
aria-pressed={shapeKind === s}
onclick={() => set({ shapeKind: s })}
>
{#if s === 'textbox'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="2.5" y="4.5" width="13" height="9" stroke="currentColor" stroke-width="1.3" />
</svg>
{:else if s === 'roundRect'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="2.5" y="4.5" width="13" height="9" rx="3" stroke="currentColor" stroke-width="1.3" />
</svg>
{:else}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<ellipse cx="9" cy="9" rx="6.5" ry="4.5" stroke="currentColor" stroke-width="1.3" />
</svg>
{/if}
</button>
{/each}
<ShapePicker value={shapeKind} onPick={(k) => set({ shapeKind: k })} compact />
<span class="tb-sep"></span>
-3
View File
@@ -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'],
@@ -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 @@
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.shapeStyles}>
{#each SHAPES as s}
<RibbonButton variant="big" icon={s.key === 'ellipse' ? 'shapeEllipse' : s.key === 'roundRect' ? 'shapeRound' : 'shapeRect'} label={s.label()} title={s.label()} active={shapeKind === s.key} onclick={() => editor?.chain().focus().setTextBoxAttrs({ shapeKind: s.key }).run()} />
{/each}
<div class="rb-captioned" use:captionClicks>
<ShapePicker
value={shapeKind ?? 'textbox'}
onPick={(k) => editor?.chain().focus().setTextBoxAttrs({ shapeKind: k }).run()}
/>
<span class="rb-caption">{t().textBox.shape}</span>
</div>
<div class="rb-captioned" use:captionClicks>
<ColorPicker
{editor}
+68 -20
View File
@@ -6,6 +6,7 @@ import type { EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { EditorView } from '@tiptap/pm/view';
import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, type WrapMode } from './image';
import { SHAPES, shapePath, isShapeKind, type ShapeKind } from '../../utils/shapes';
// cm attribute value → number, for the frame offsets (px ones use parsePx).
const parseCmAttr = (v: string | null): number | null => {
@@ -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 <draw:frame>/<draw:text-box>/<draw:custom-shape> and DOCX <wps:wsp>.
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
+4 -10
View File
@@ -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 || '<w:p/>';
}
const PRST_BY_KIND: Record<TextBoxDocx['shapeKind'], string> = {
textbox: 'rect',
roundRect: 'roundRect',
ellipse: 'ellipse',
};
// The full <w:drawing> 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 {
`<wps:wsp xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">` +
`<wps:cNvSpPr txBox="1"/>` +
`<wps:spPr><a:xfrm${rot}><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
`<a:prstGeom prst="${PRST_BY_KIND[box.shapeKind]}"><a:avLst/></a:prstGeom>${fill}${ln}</wps:spPr>` +
`<a:prstGeom prst="${SHAPES[box.shapeKind].prst}"><a:avLst/></a:prstGeom>${fill}${ln}</wps:spPr>` +
`<wps:txbx><w:txbxContent>${txbxContentXml(box.content)}</w:txbxContent></wps:txbx>` +
`<wps:bodyPr rot="0" vert="horz" wrap="square" lIns="${inset}" tIns="${inset}" rIns="${inset}" bIns="${inset}" anchor="t">${autofit}</wps:bodyPr>` +
`</wps:wsp>`;
+4 -17
View File
@@ -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:
'<draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="ellipse"' +
' draw:text-areas="3163 3163 18437 18437"' +
' draw:enhanced-path="U 10800 10800 10800 10800 0 360 Z N"/>',
roundRect:
'<draw:enhanced-geometry svg:viewBox="0 0 21600 21600" 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"/>',
};
// 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 <draw:frame>/<draw:text-box>
// for plain text boxes (height = fo:min-height, so it grows with content like the
// editor), or a <draw:custom-shape> with preset geometry for roundRect/ellipse.
// editor), or a <draw:custom-shape> 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 (
`<draw:custom-shape draw:name="Shape${n}"${common} svg:height="${box.heightCm}cm"${transform}>` +
`${inner}${ENHANCED_GEOMETRY[box.shapeKind]}</draw:custom-shape>`
`${inner}${odfEnhancedGeometry(box.shapeKind) ?? ''}</draw:custom-shape>`
);
}
+19 -3
View File
@@ -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',
+19 -3
View File
@@ -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',
+4 -10
View File
@@ -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<string, 'textbox' | 'roundRect' | 'ellipse'> = {
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<string, unknown>, fill: string | null,
}
}
// A DrawingML <wps:wsp> (text box or preset shape) → a textBox node. Unsupported
// preset geometries (stars, arrows, …) are dropped with a warning. All property
// A DrawingML <wps:wsp> (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<string, unknown> = {};
+6 -8
View File
@@ -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');
+171
View File
@@ -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 <draw:enhanced-path> 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 <draw:enhanced-geometry draw:type>. */
odf: string;
/** DrawingML <a:prstGeom prst>. */
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<ShapeKind, ShapePreset> = {
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<Record<ShapeKind, string>> = {
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 `<draw:enhanced-geometry/>` 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 : `<draw:enhanced-geometry svg:viewBox="0 0 ${VB} ${VB}"${body}/>`;
}
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;
}
+13
View File
@@ -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;
+1 -1
View File
@@ -789,7 +789,7 @@ describe('DOCX import of foreign text boxes / shapes', () => {
<w:p><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">
<wp:extent cx="914400" cy="914400"/><wp:docPr id="1" name="Star"/>
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
<wps:wsp><wps:spPr><a:prstGeom prst="star5"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
<wps:wsp><wps:spPr><a:prstGeom prst="smileyFace"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
</a:graphicData></a:graphic>
</wp:inline></w:drawing></w:r></w:p>
<w:p><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">
+36 -3
View File
@@ -1287,6 +1287,7 @@ describe('Leg 7: foreign shapes/text boxes → importOdt', () => {
<text:p><draw:rect draw:style-name="gr2" text:anchor-type="as-char" svg:width="5.08cm" svg:height="2.54cm"><text:p>rect text</text:p></draw:rect></text:p>
<text:p><draw:custom-shape draw:style-name="gr1" text:anchor-type="as-char" svg:width="5.08cm" svg:height="2.54cm"><text:p>lo ellipse</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:glue-points="10800 0 3163 3163 0 10800" draw:text-areas="3163 3163 18437 18437" draw:type="ellipse" draw:enhanced-path="U 10800 10800 10800 10800 0 360 Z N"/></draw:custom-shape></text:p>
<text:p><draw:custom-shape text:anchor-type="as-char" svg:width="2cm" svg:height="2cm"><text:p>star</text:p><draw:enhanced-geometry draw:type="star5"/></draw:custom-shape></text:p>
<text:p><draw:custom-shape text:anchor-type="as-char" svg:width="2cm" svg:height="2cm"><text:p>smiley</text:p><draw:enhanced-geometry draw:type="smiley"/></draw:custom-shape></text:p>
<text:p><draw:line svg:x1="0cm" svg:y1="0cm" svg:x2="5cm" svg:y2="0cm"/></text:p>
<table:table>
<table:table-column/>
@@ -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('<w:ins ') && !xml.includes('<w:del '));
});
});
describe('Leg 24: preset shapes beyond rect / round-rect / ellipse', () => {
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(`<a:prstGeom prst="${prst}">`), xml.match(/prst="[^"]*"/g));
}
check('round-trips', kinds(importDocx(bytes)) === 'triangle,star5,rightArrow', kinds(importDocx(bytes)));
});
});
+65
View File
@@ -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(
'<draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="diamond"' +
' draw:text-areas="5400 5400 16200 16200"' +
' draw:enhanced-path="M 10800 0 L 21600 10800 10800 21600 0 10800 Z N"/>',
);
// 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();
});
});