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

+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;