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:
1 parent
5ee4eec5d7
commit
4cda289272
18 files changed
+566
-121
No files matched your search
+2
-2
@@ -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**
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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> = {};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user