feat: textbox and shapes

This commit is contained in:
Steffen Becker committed 2026-07-03 22:53:38 +02:00
1 parent 6731134d06
commit 0aa9399f93
19 files changed
+1856 -57

No files matched your search

+11 -1
View File
@@ -39,6 +39,7 @@
- Table of contents: generated from headings (H1–H3) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
- Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip
- 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)
**Page & layout**
- Page margins (cm) and page orientation (portrait / landscape)
@@ -77,7 +78,7 @@ Planned word-processor features (to match Word/LibreOffice):
- Heading levels H4–H6 (currently clamped to H3)
- Paragraph borders / shading
- Track changes (revisions)
- Text boxes / shapes / drawings (currently dropped on import)
- Lines / arrows / connectors / freeform and other shape presets (currently dropped on import with a warning; text boxes + rect/round-rect/ellipse are supported)
- Equations / formulas
- Cross-references / bookmarks
- Named paragraph / character styles (style gallery)
@@ -105,6 +106,15 @@ Other:
`2.`…), nested lists indented. Content is preserved and readable, but loses
list/heading semantics (no auto-numbering, not recognised as a heading).
Noted 2026-06-06.
- 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 cannot mint numbering.xml or media/rels entries.
ODT export has neither limitation. Noted 2026-07-03.
- A plain text box's height is a *minimum* (content grows the box, like the
editor). LibreOffice recomputes auto-grow heights on open, so a re-saved box
keeps its content but sheds excess empty height. Shapes (rect/ellipse) export
with fixed geometry instead. Noted 2026-07-03.
---
+47
View File
@@ -10,7 +10,9 @@
import TableToolbar from './TableToolbar.svelte';
import TableSplitDialog from './TableSplitDialog.svelte';
import ImageToolbar from './ImageToolbar.svelte';
import TextBoxToolbar from './TextBoxToolbar.svelte';
import type { WrapMode } from '../editor/extensions/image';
import { findTextBox, type ShapeKind } from '../editor/extensions/textBox';
import { NodeSelection } from '@tiptap/pm/state';
import SpellContextMenu from './SpellContextMenu.svelte';
import HeaderFooterLayer from './HeaderFooterLayer.svelte';
@@ -287,6 +289,38 @@
};
}
// --- Floating text-box toolbar ---
// Shown when a text box is node-selected or the cursor is inside one; positioned
// just above it. Hidden while the image toolbar shows (an image inside a box).
let textBoxUi = $state<{
visible: boolean; top: number; left: number;
wrap: WrapMode; shapeKind: ShapeKind; fillColor: string | null;
strokeColor: string | null; strokeWidthPt: number;
}>({ visible: false, top: 0, left: 0, wrap: 'inline', shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 });
function recomputeTextBoxUi() {
const ed = editor;
const found = ed && editorContainer && !imageUi.visible ? findTextBox(ed.state) : null;
const dom = found ? ed!.view.nodeDOM(found.pos) : null;
if (!found || !(dom instanceof HTMLElement)) {
if (textBoxUi.visible) textBoxUi = { ...textBoxUi, visible: false };
return;
}
const r = dom.getBoundingClientRect();
const cRect = editorContainer.getBoundingClientRect();
const a = found.node.attrs;
textBoxUi = {
visible: true,
top: r.top - cRect.top + editorContainer.scrollTop,
left: r.left - cRect.left + editorContainer.scrollLeft,
wrap: (a.wrap as WrapMode) || 'inline',
shapeKind: (a.shapeKind as ShapeKind) || 'textbox',
fillColor: (a.fillColor as string | null) ?? null,
strokeColor: (a.strokeColor as string | null) ?? null,
strokeWidthPt: (a.strokeWidthPt as number) ?? 1,
};
}
// Table page-break overlay: pageBreaks.ts reports (via pm-pagecount) where a continuous
// table box crosses a page boundary, each as a band in doc px. Rendered in .band-layer
// inside the scaled .paper — a mask hides borders in the margins, a stripe is the gap.
@@ -340,6 +374,7 @@
tableUiRaf = requestAnimationFrame(() => {
recomputeTableUi();
recomputeImageUi();
recomputeTextBoxUi();
recomputeBands();
});
}
@@ -672,6 +707,18 @@
{#if imageUi.visible}
<ImageToolbar {editor} top={imageUi.top} left={imageUi.left} wrap={imageUi.wrap} />
{/if}
{#if textBoxUi.visible}
<TextBoxToolbar
{editor}
top={textBoxUi.top}
left={textBoxUi.left}
wrap={textBoxUi.wrap}
shapeKind={textBoxUi.shapeKind}
fillColor={textBoxUi.fillColor}
strokeColor={textBoxUi.strokeColor}
strokeWidthPt={textBoxUi.strokeWidthPt}
/>
{/if}
{#if spellMenu.visible}
<SpellContextMenu
top={spellMenu.top}
+246
View File
@@ -0,0 +1,246 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import type { WrapMode } from '../editor/extensions/image';
import type { ShapeKind, TextBoxAttrs } from '../editor/extensions/textBox';
import ColorPicker from './ColorPicker.svelte';
let {
editor,
top,
left,
wrap,
shapeKind,
fillColor,
strokeColor,
strokeWidthPt,
}: {
editor: Editor | null;
top: number;
left: number;
wrap: WrapMode;
shapeKind: ShapeKind;
fillColor: string | null;
strokeColor: string | null;
strokeWidthPt: number;
} = $props();
let fillOpen = $state(false);
let strokeOpen = $state(false);
function set(attrs: Partial<TextBoxAttrs>) {
editor?.chain().focus().setTextBoxAttrs(attrs).run();
}
const wrapModes: { mode: WrapMode; title: string }[] = [
{ mode: 'inline', title: 'In line with text' },
{ mode: 'left', title: 'Wrap text — box left' },
{ mode: 'right', title: 'Wrap text — box right' },
{ mode: 'topBottom', title: 'Top and bottom' },
];
const shapes: { kind: ShapeKind; title: string }[] = [
{ kind: 'textbox', title: 'Rectangle' },
{ kind: 'roundRect', title: 'Rounded rectangle' },
{ kind: 'ellipse', title: 'Ellipse' },
];
const strokeWidths = [0.5, 1, 2.25];
</script>
<div
class="tb-toolbar"
style="top: {top}px; left: {left}px;"
role="toolbar"
tabindex="-1"
aria-label="Text box options"
onmousedown={(e) => {
// Keep the box selection/caret; buttons work via click.
if ((e.target as HTMLElement).closest('select') === null) e.preventDefault();
}}
>
{#each wrapModes as m}
<button
class="tb-btn"
class:active={wrap === m.mode}
title={m.title}
aria-label={m.title}
aria-pressed={wrap === m.mode}
onclick={() => set({ wrap: m.mode })}
>
{#if m.mode === 'inline'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="12" x2="5.5" y2="12" stroke="currentColor" stroke-width="1.2" />
<rect x="6.5" y="6" width="5" height="6" rx="1" fill="currentColor" />
<line x1="12.5" y1="12" x2="15.5" y2="12" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="15" x2="15.5" y2="15" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else if m.mode === 'left'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
<rect x="2.5" y="6" width="6" height="6" rx="1" fill="currentColor" />
<line x1="10" y1="7" x2="15.5" y2="7" stroke="currentColor" stroke-width="1.2" />
<line x1="10" y1="10" x2="15.5" y2="10" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="15" x2="15.5" y2="15" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else if m.mode === 'right'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
<rect x="9.5" y="6" width="6" height="6" rx="1" fill="currentColor" />
<line x1="2.5" y1="7" x2="8" y2="7" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="10" x2="8" y2="10" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="15" x2="15.5" y2="15" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
<rect x="6" y="6" width="6" height="6" rx="1" fill="currentColor" />
<line x1="2.5" y1="15" x2="15.5" y2="15" stroke="currentColor" stroke-width="1.2" />
</svg>
{/if}
</button>
{/each}
<span class="tb-sep"></span>
{#each shapes as s}
<button
class="tb-btn"
class:active={shapeKind === s.kind}
title={s.title}
aria-label={s.title}
aria-pressed={shapeKind === s.kind}
onclick={() => set({ shapeKind: s.kind })}
>
{#if s.kind === '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.kind === '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}
<span class="tb-sep"></span>
<ColorPicker
{editor}
bind:open={fillOpen}
currentColor={fillColor}
defaultColor="#FFFFFF"
title="Fill color"
chevronTitle="Choose fill color"
clearLabel="No fill"
onOpen={() => (strokeOpen = false)}
onApply={(c) => set({ fillColor: c })}
onClear={() => set({ fillColor: null })}
>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M7.5 2.5l6 6-5 5a1.4 1.4 0 0 1-2 0l-4-4a1.4 1.4 0 0 1 0-2z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M7.5 2.5L6 1" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
<path d="M15 12c0 1-.7 2-1.5 2S12 13 12 12c0-.8 1.5-2.3 1.5-2.3S15 11.2 15 12z" fill="currentColor" stroke="none"/>
</svg>
{/snippet}
</ColorPicker>
<ColorPicker
{editor}
bind:open={strokeOpen}
currentColor={strokeColor}
defaultColor="#000000"
title="Border color"
chevronTitle="Choose border color"
clearLabel="No border"
onOpen={() => (fillOpen = false)}
onApply={(c) => set({ strokeColor: c })}
onClear={() => set({ strokeColor: null })}
>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="3" y="3" width="12" height="12" rx="1" stroke="currentColor" stroke-width="1.6" />
<rect x="6.5" y="6.5" width="5" height="5" stroke="currentColor" stroke-width="0.9" stroke-dasharray="1.6 1.2" />
</svg>
{/snippet}
</ColorPicker>
<select
class="tb-width"
title="Border width"
aria-label="Border width"
value={String(strokeWidthPt)}
onchange={(e) => set({ strokeWidthPt: parseFloat((e.target as HTMLSelectElement).value) })}
>
{#each strokeWidths as w}
<option value={String(w)}>{w} pt</option>
{/each}
{#if !strokeWidths.includes(strokeWidthPt)}
<option value={String(strokeWidthPt)}>{strokeWidthPt} pt</option>
{/if}
</select>
</div>
<style>
.tb-toolbar {
position: absolute;
transform: translateY(calc(-100% - 6px));
z-index: 150;
display: flex;
align-items: center;
gap: 1px;
padding: 3px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14);
}
.tb-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.7rem;
height: 1.7rem;
min-width: unset;
padding: 0;
border: none;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.tb-btn:hover {
background: var(--color-btn-hover);
}
.tb-btn.active {
background: var(--color-btn-active, var(--color-btn-hover));
color: var(--color-accent, #3b82f6);
}
.tb-sep {
width: 1px;
height: 1.1rem;
margin: 0 3px;
background: var(--color-border);
}
.tb-width {
height: 1.7rem;
padding: 0 0.2rem;
border: 1px solid var(--color-border);
border-radius: calc(var(--radius) - 2px);
background: var(--color-surface);
color: var(--color-text);
font-size: 0.72rem;
}
</style>
+11
View File
@@ -1231,6 +1231,17 @@
style="display:none"
onchange={onImageFile}
/>
<button
onclick={() => editor?.chain().focus().insertTextBox().run()}
disabled={!!hfActive}
title={hfActive ? 'Text boxes are not available in headers/footers' : 'Insert text box'}
aria-label="Insert text box"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1.5" y="3.5" width="13" height="9" rx="1" stroke="currentColor" />
<path d="M5.5 6.5h5M8 6.5v4" stroke="currentColor" stroke-linecap="round" />
</svg>
</button>
<button
onclick={insertToc}
disabled={!!hfActive}
+6 -1
View File
@@ -39,10 +39,12 @@ import { TableCellBackground } from './extensions/tableCellBackground';
import { TableHeaderRow } from './extensions/tableHeaderRow';
import { TrailingNode } from './extensions/trailingNode';
import { Image } from './extensions/image';
import { TextBox } from './extensions/textBox';
import { TableOfContents } from './extensions/tableOfContents';
export const extensions = [
Document,
// textBox has its own group so only the document (not cells/lists) admits it.
Document.extend({ content: '(block | textBox)+' }),
Paragraph,
Text,
Bold,
@@ -67,6 +69,9 @@ export const extensions = [
// Inline, as-character image; round-trips to ODF <draw:frame>/<draw:image>
// (export/odt.ts replaceImages/applyImages, import/odt.ts).
Image,
// Block-level text box / basic shape with editable content; round-trips to ODF
// draw:frame/draw:text-box + draw:custom-shape and DOCX wps:wsp/wps:txbx.
TextBox,
// Generated table of contents from headings; round-trips to ODF
// <text:table-of-content> (export/odt.ts, import/odt.ts) and a DOCX TOC field.
TableOfContents,
+6 -5
View File
@@ -21,10 +21,11 @@ declare module '@tiptap/core' {
}
}
const MIN_SIZE_PX = 24;
export const MIN_SIZE_PX = 24;
// Corners keep aspect (Word-style); edges (n/s/e/w) change one dimension only.
const HANDLES: { k: string; x: -1 | 0 | 1; y: -1 | 0 | 1; aspect: boolean }[] = [
// Shared with textBox.ts, whose node view uses the same handle set.
export const HANDLES: { k: string; x: -1 | 0 | 1; y: -1 | 0 | 1; aspect: boolean }[] = [
{ k: 'nw', x: -1, y: -1, aspect: true },
{ k: 'n', x: 0, y: -1, aspect: false },
{ k: 'ne', x: 1, y: -1, aspect: true },
@@ -35,17 +36,17 @@ const HANDLES: { k: string; x: -1 | 0 | 1; y: -1 | 0 | 1; aspect: boolean }[] =
{ k: 'w', x: -1, y: 0, aspect: false },
];
function parsePx(value: string | null): number | null {
export function parsePx(value: string | null): number | null {
if (!value) return null;
const n = parseFloat(value);
return Number.isFinite(n) ? Math.round(n) : null;
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
export const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
// The page text height in px, capping how tall an image can be stretched. Read live
// from the :root vars the editor maintains (orientation/margins change them).
function pageContentHeightPx(): number {
export function pageContentHeightPx(): number {
const cs = getComputedStyle(document.documentElement);
const h =
parseFloat(cs.getPropertyValue('--user-page-height')) -
+12
View File
@@ -643,6 +643,18 @@ export const PageBreaks = Extension.create({
});
continue;
}
// A text box (textBox.ts) paginates atomically: its content lives inside a
// rotated/floated node view where a line-split spacer can't render sanely.
if (child.classList.contains('textbox-node')) {
leaves.push({
el: child,
kind: 'atomic',
naturalTop: topWithin(child) - cumulativeSpacerHeight,
naturalHeight: child.offsetHeight,
inTableCell,
});
continue;
}
// A paragraph holding a floated/block image paginates atomically (pushed
// whole, spacer placed before it): a line-split spacer inside it would sit
// next to the float, where ProseMirror drops the widget.
+486
View File
@@ -0,0 +1,486 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { NodeSelection, TextSelection } from '@tiptap/pm/state';
import type { EditorState } from '@tiptap/pm/state';
import { HANDLES, MIN_SIZE_PX, clamp, parsePx, pageContentHeightPx, type WrapMode } from './image';
// A text box / basic shape: a block-level frame with editable block content, a fill,
// a stroke, and the image's wrap model (inline = in-flow; left/right/topBottom float).
// Round-trips to ODF <draw:frame>/<draw:text-box> or <draw:custom-shape>, and to
// DOCX DrawingML <wps:wsp>/<wps:txbx>.
export type ShapeKind = 'textbox' | 'roundRect' | 'ellipse';
// Fixed text inset inside the frame; exported as fo:padding / wps:bodyPr insets.
export const TEXTBOX_PADDING_CM = 0.15;
const PADDING_PX = (TEXTBOX_PADDING_CM * 96) / 2.54;
const DEFAULT_WIDTH_PX = 280;
const DEFAULT_HEIGHT_PX = 96;
const PX_PER_PT = 96 / 72;
export interface TextBoxAttrs {
width: number | null;
height: number | null;
rotation: number;
wrap: WrapMode;
shapeKind: ShapeKind;
fillColor: string | null;
strokeColor: string | null;
strokeWidthPt: number;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
textBox: {
insertTextBox: () => ReturnType;
setTextBoxAttrs: (attrs: Partial<TextBoxAttrs>) => ReturnType;
};
}
}
// The text box enclosing the selection: a NodeSelection on one, or any ancestor of
// the cursor. Used by the commands and by the floating toolbar's visibility check.
export function findTextBox(state: EditorState): { pos: number; node: PMNode } | null {
const sel = state.selection;
if (sel instanceof NodeSelection && sel.node.type.name === 'textBox') {
return { pos: sel.from, node: sel.node };
}
const { $from } = sel;
for (let d = $from.depth; d > 0; d--) {
const n = $from.node(d);
if (n.type.name === 'textBox') return { pos: $from.before(d), node: n };
}
return null;
}
function shapeRadius(kind: ShapeKind): string {
return kind === 'ellipse' ? '50%' : kind === 'roundRect' ? '8px' : '0';
}
export const TextBox = Node.create({
name: 'textBox',
// Own group (not `block`): only the doc admits it, so it can't nest in table
// cells, list items, or other text boxes.
group: 'textBox',
content: '(paragraph | heading | bulletList | orderedList)+',
isolating: true,
defining: true,
draggable: true,
selectable: true,
addAttributes() {
return {
// px @96dpi like the image; height is a min-height (content never clips).
width: {
default: null,
parseHTML: el => parsePx((el as HTMLElement).style.width),
renderHTML: () => ({}),
},
height: {
default: null,
parseHTML: el => parsePx((el as HTMLElement).style.minHeight),
renderHTML: () => ({}),
},
rotation: {
default: 0,
parseHTML: el => parsePx((el as HTMLElement).getAttribute('data-rotation')) ?? 0,
renderHTML: () => ({}),
},
wrap: {
default: 'inline',
parseHTML: el => (el as HTMLElement).getAttribute('data-wrap') ?? 'inline',
renderHTML: () => ({}),
},
shapeKind: {
default: 'textbox',
parseHTML: el => (el as HTMLElement).getAttribute('data-shape') ?? 'textbox',
renderHTML: () => ({}),
},
fillColor: {
default: '#FFFFFF',
parseHTML: el => (el as HTMLElement).getAttribute('data-fill') || null,
renderHTML: () => ({}),
},
strokeColor: {
default: '#000000',
parseHTML: el => (el as HTMLElement).getAttribute('data-stroke') || null,
renderHTML: () => ({}),
},
strokeWidthPt: {
default: 1,
parseHTML: el => {
const n = parseFloat((el as HTMLElement).getAttribute('data-stroke-width') ?? '');
return Number.isFinite(n) ? n : 1;
},
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-textbox]' }];
},
renderHTML({ HTMLAttributes, node }) {
const a = node.attrs as TextBoxAttrs;
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.shapeKind !== 'textbox' ? `border-radius:${shapeRadius(a.shapeKind)}` : '',
a.rotation ? `transform:rotate(${a.rotation}deg)` : '',
`padding:${PADDING_PX.toFixed(2)}px`,
].filter(Boolean).join(';');
return ['div', mergeAttributes(HTMLAttributes, {
'data-textbox': '',
style,
...(a.rotation ? { 'data-rotation': String(a.rotation) } : {}),
...(a.wrap !== 'inline' ? { 'data-wrap': a.wrap } : {}),
...(a.shapeKind !== 'textbox' ? { 'data-shape': a.shapeKind } : {}),
...(a.fillColor ? { 'data-fill': a.fillColor } : {}),
...(a.strokeColor ? { 'data-stroke': a.strokeColor } : {}),
...(a.strokeWidthPt !== 1 ? { 'data-stroke-width': String(a.strokeWidthPt) } : {}),
}), 0];
},
addCommands() {
return {
// Insert a fresh text box after the current top-level block, cursor inside.
insertTextBox:
() =>
({ state, dispatch }) => {
const type = state.schema.nodes.textBox;
const para = state.schema.nodes.paragraph?.createAndFill();
if (!type || !para) return false;
const { $from } = state.selection;
const pos = $from.depth >= 1 ? $from.after(1) : state.selection.from;
if (dispatch) {
const box = type.create({ width: DEFAULT_WIDTH_PX, height: DEFAULT_HEIGHT_PX }, para);
const tr = state.tr.insert(pos, box);
tr.setSelection(TextSelection.create(tr.doc, pos + 2));
dispatch(tr.scrollIntoView());
}
return true;
},
// Update attrs of the selected box or the box enclosing the cursor.
setTextBoxAttrs:
(attrs: Partial<TextBoxAttrs>) =>
({ state, dispatch }) => {
const found = findTextBox(state);
if (!found) return false;
if (dispatch) {
dispatch(state.tr.setNodeMarkup(found.pos, undefined, { ...found.node.attrs, ...attrs }));
}
return true;
},
};
},
addNodeView() {
return ({ node, editor, getPos }) => new TextBoxView(node as PMNode, editor, getPos as () => number);
},
});
// Node view: like ImageView, an axis-aligned wrapper reserves the rotated bounding
// box and a centered rotor carries fill/stroke/rotation plus the resize/rotate
// handles — but the rotor holds an editable contentDOM and auto-grows with its text,
// so a ResizeObserver re-fits the wrapper whenever the rotor's size changes.
class TextBoxView {
dom: HTMLElement;
contentDOM: HTMLElement;
private rotor: HTMLElement;
private badge: HTMLElement;
private node: PMNode;
private editor: Editor;
private getPos: () => number;
private observer: ResizeObserver | null = null;
private resizing = false;
constructor(node: PMNode, editor: Editor, getPos: () => number) {
this.node = node;
this.editor = editor;
this.getPos = getPos;
this.dom = document.createElement('div');
this.dom.className = 'textbox-node';
this.rotor = document.createElement('div');
this.rotor.className = 'textbox-rotor';
// Padding lives on the rotor, so the inset ring around the text is frame
// (click-to-select) area rather than content.
this.rotor.style.padding = `${PADDING_PX.toFixed(2)}px`;
this.contentDOM = document.createElement('div');
this.contentDOM.className = 'textbox-content';
this.rotor.appendChild(this.contentDOM);
for (const cfg of HANDLES) {
const h = document.createElement('span');
h.className = `image-resize-handle image-resize-${cfg.k}`;
h.addEventListener('mousedown', e => this.startResize(e as MouseEvent, cfg));
this.rotor.appendChild(h);
}
const rot = document.createElement('span');
rot.className = 'image-rotate-handle';
rot.addEventListener('mousedown', e => this.startRotate(e as MouseEvent));
this.rotor.appendChild(rot);
this.dom.appendChild(this.rotor);
this.badge = document.createElement('span');
this.badge.className = 'image-size-badge';
this.dom.appendChild(this.badge);
// A mousedown on the frame (border, not the text area) selects the box whole.
this.dom.addEventListener('mousedown', e => this.onFrameMouseDown(e));
// The rotor is out of flow and grows with its content; refit the wrapper to the
// rotated bounding box whenever the rendered size changes (typing, resizing).
this.observer = new ResizeObserver(() => this.fitWrapper());
this.observer.observe(this.rotor);
this.applyAll();
}
private attrs(): TextBoxAttrs {
return this.node.attrs as TextBoxAttrs;
}
private applyAll(): void {
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.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.applyWrap();
this.fitWrapper();
}
// Size the wrapper to the rotor's rotated bounding box so surrounding text
// reserves the right space (same math as ImageView.applyLayout).
private fitWrapper(): void {
const w = this.rotor.offsetWidth;
const h = this.rotor.offsetHeight;
if (!w || !h) return;
const rad = (this.attrs().rotation * Math.PI) / 180;
const bw = Math.abs(w * Math.cos(rad)) + Math.abs(h * Math.sin(rad));
const bh = Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad));
this.dom.style.width = `${bw}px`;
this.dom.style.height = `${bh}px`;
}
// Float per wrap mode, exactly like ImageView.applyWrap. 'inline' and 'topBottom'
// both render in-flow for a block-level box (they differ only in export anchoring).
private applyWrap(): void {
const d = this.dom;
d.style.float = '';
d.style.clear = '';
d.style.margin = '6px 0';
const wrap = this.attrs().wrap;
if (wrap === 'left') {
d.style.float = 'left';
d.style.margin = '0 14px 6px 0';
} else if (wrap === 'right') {
d.style.float = 'right';
d.style.margin = '0 0 6px 14px';
} else if (wrap === 'topBottom') {
d.style.clear = 'both';
}
}
// The resize/rotate handles and the badge — always ours, never ProseMirror's.
private isOwnUi(t: EventTarget | null): boolean {
return (
t instanceof HTMLElement &&
(t.classList.contains('image-resize-handle') ||
t.classList.contains('image-rotate-handle') ||
t === this.badge)
);
}
// Word-like frame hit test: the border plus a few px of the inner padding ring
// select the box; anywhere further inside is text area. Rotation-aware (the point
// is un-rotated into the rotor's own axes) and zoom-aware.
private isFrameHit(e: MouseEvent): boolean {
const r = this.rotor.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const zoom = this.dom.getBoundingClientRect().width / this.dom.offsetWidth || 1;
const rad = (-this.attrs().rotation * Math.PI) / 180;
const dx = (e.clientX - cx) / zoom;
const dy = (e.clientY - cy) / zoom;
const lx = dx * Math.cos(rad) - dy * Math.sin(rad);
const ly = dx * Math.sin(rad) + dy * Math.cos(rad);
const hw = this.rotor.offsetWidth / 2;
const hh = this.rotor.offsetHeight / 2;
const RING = 6;
const inside = Math.abs(lx) <= hw + 2 && Math.abs(ly) <= hh + 2;
const nearEdge = Math.abs(lx) >= hw - RING || Math.abs(ly) >= hh - RING;
return inside && nearEdge;
}
private onFrameMouseDown(e: MouseEvent): void {
if (this.isOwnUi(e.target) || !this.isFrameHit(e)) return;
const pos = this.getPos();
if (typeof pos !== 'number') return;
// No preventDefault: the browser must still be able to start a native drag of
// the (now selected, draggable) box.
const view = this.editor.view;
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)));
}
private showBadge(w: number, h: number): void {
const cm = (px: number) => ((px * 2.54) / 96).toFixed(2);
this.badge.textContent = `Width ${cm(w)} cm × Height ${cm(h)} cm`;
this.badge.style.display = 'block';
}
private commit(attrs: Partial<TextBoxAttrs>): void {
const pos = this.getPos();
if (typeof pos !== 'number') return;
this.editor.view.dispatch(this.editor.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, ...attrs }));
}
// Largest width the box may take: the page text column.
private boxMaxWidth(): number {
const box = this.dom.closest('.tiptap') as HTMLElement | null;
if (!box) return 10000;
const cs = getComputedStyle(box);
const w = box.clientWidth - parseFloat(cs.paddingLeft || '0') - parseFloat(cs.paddingRight || '0');
return Math.max(MIN_SIZE_PX, w || 10000);
}
// Drag math mirrors ImageView.startResize (zoom-aware, deltas un-rotated onto the
// box's own axes); the committed height is a min-height, so content never clips.
private startResize(event: MouseEvent, cfg: typeof HANDLES[number]): void {
event.preventDefault();
event.stopPropagation();
if (!this.editor.isEditable) return;
const startW = this.rotor.offsetWidth;
const startH = this.rotor.offsetHeight;
if (!startW || !startH) return;
const aspect = startW / startH;
const deg = this.attrs().rotation;
const th = (deg * Math.PI) / 180;
const cosT = Math.cos(th);
const sinT = Math.sin(th);
const zoom = this.dom.getBoundingClientRect().width / this.dom.offsetWidth || 1;
const maxW = this.boxMaxWidth();
const maxH = pageContentHeightPx();
const sx = event.clientX;
const sy = event.clientY;
const win = this.dom.ownerDocument.defaultView ?? window;
let lastW = startW;
let lastH = startH;
let moved = false;
this.resizing = true;
const move = (e: MouseEvent): void => {
if (!e.buttons) { finish(); return; }
const dx = (e.clientX - sx) / zoom;
const dy = (e.clientY - sy) / zoom;
const lx = dx * cosT + dy * sinT;
const ly = -dx * sinT + dy * cosT;
if (cfg.aspect) {
let w = clamp(startW + cfg.x * lx, MIN_SIZE_PX, maxW);
let h = w / aspect;
if (h > maxH) { h = maxH; w = h * aspect; }
lastW = Math.round(w);
lastH = Math.round(h);
} else {
if (cfg.x) lastW = Math.round(clamp(startW + cfg.x * lx, MIN_SIZE_PX, maxW));
if (cfg.y) lastH = Math.round(clamp(startH + cfg.y * ly, MIN_SIZE_PX, maxH));
}
moved = true;
this.rotor.style.width = `${lastW}px`;
this.rotor.style.minHeight = `${lastH}px`;
this.fitWrapper();
this.showBadge(lastW, lastH);
};
const finish = (): void => {
win.removeEventListener('mousemove', move);
win.removeEventListener('mouseup', finish);
this.badge.style.display = 'none';
this.resizing = false;
if (moved) this.commit({ width: lastW, height: lastH });
};
win.addEventListener('mousemove', move);
win.addEventListener('mouseup', finish);
}
private startRotate(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
if (!this.editor.isEditable) return;
const rect = this.dom.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const win = this.dom.ownerDocument.defaultView ?? window;
let lastDeg = this.attrs().rotation;
let moved = false;
const move = (e: MouseEvent): void => {
if (!e.buttons) { finish(); return; }
let ang = (Math.atan2(e.clientY - cy, e.clientX - cx) * 180) / Math.PI + 90;
if (e.shiftKey) ang = Math.round(ang / 15) * 15;
lastDeg = ((Math.round(ang) % 360) + 360) % 360;
moved = true;
this.rotor.style.transform = `translate(-50%, -50%) rotate(${lastDeg}deg)`;
this.fitWrapper();
};
const finish = (): void => {
win.removeEventListener('mousemove', move);
win.removeEventListener('mouseup', finish);
if (moved) this.commit({ rotation: lastDeg });
};
win.addEventListener('mousemove', move);
win.addEventListener('mouseup', finish);
}
update(node: PMNode): boolean {
if (node.type !== this.node.type) return false;
this.node = node;
// Never touch contentDOM children — ProseMirror owns them.
if (!this.resizing) this.applyAll();
return true;
}
selectNode(): void {
this.dom.classList.add('textbox-selected');
// Draggable only while node-selected, so selecting text inside the box never
// starts a native drag.
this.dom.draggable = true;
}
deselectNode(): void {
this.dom.classList.remove('textbox-selected');
this.dom.draggable = false;
}
ignoreMutation(m: MutationRecord | { type: 'selection'; target: globalThis.Node }): boolean {
return !this.contentDOM.contains(m.target);
}
// Mouse events on the frame ring/handles are ours (selection, resize, rotate);
// clicks further inside pass through so ProseMirror places the caret and edits.
stopEvent(event: Event): boolean {
if (!event.type.startsWith('mouse')) return false;
if (this.isOwnUi(event.target)) return true;
return this.isFrameHit(event as MouseEvent);
}
destroy(): void {
this.observer?.disconnect();
this.observer = null;
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { Plugin, PluginKey } from '@tiptap/pm/state';
const trailingNodeKey = new PluginKey('trailingNode');
// Block types that can't sit at the document end without trapping the cursor.
const NEEDS_TRAILING_AFTER = new Set(['table']);
const NEEDS_TRAILING_AFTER = new Set(['table', 'textBox']);
export const TrailingNode = Extension.create({
name: 'trailingNode',
+234 -3
View File
@@ -12,6 +12,8 @@ import type {
IRunStylePropertiesOptions, ISpacingProperties, IIndentAttributesProperties,
ILevelsOptions, IFloating, IBorderOptions,
} from 'docx';
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
@@ -51,6 +53,14 @@ const TWIPS_PER_CM = 1440 / 2.54; // 566.929
const cmToTwip = (cm: number) => Math.round(cm * TWIPS_PER_CM);
const ptToTwip = (pt: number) => Math.round(pt * 20);
const pxToTwip = (px: number) => Math.round((px * 1440) / 96); // px @96dpi → twips
const EMU_PER_PX = 9525;
const EMU_PER_PT = 12700;
const EMU_PER_CM = 360000;
// Sentinel wrapping a text box's index in a marker paragraph (same trick as
// export/odt.ts): the docx library can't emit DrawingML shapes, so buildDocx swaps
// the marker for a hand-built <w:drawing><wps:wsp> in a zip post-processing pass.
const TBX = '';
// CSS font-size string ("12pt", "16px", "1.2em") → Word half-points. em is relative
// to the 12pt body. Returns undefined when unparseable.
@@ -273,6 +283,221 @@ function imageRun(node: TiptapNode): ImageRun | null {
});
}
// ---- text boxes / shapes ---------------------------------------------------
// A textBox node collected by blocksToDocx; applyTextBoxesDocx rewrites its marker
// paragraph into DrawingML (wp:inline/wp:anchor > wps:wsp > wps:txbx) after packing.
type TextBoxDocx = {
widthPx: number;
heightPx: number;
rotationDeg: number;
wrap: 'inline' | 'left' | 'right' | 'topBottom';
shapeKind: 'textbox' | 'roundRect' | 'ellipse';
fill: string | null;
stroke: string | null;
strokeWidthPt: number;
content: TiptapNode[];
};
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,
rotationDeg: typeof a.rotation === 'number' ? a.rotation : 0,
wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline',
shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : '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,
content: node.content ?? [],
};
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Run properties for the hand-serialized txbxContent, in CT_RPr schema order
// (rFonts, b, i, strike, color, sz, u, shd, vertAlign). Mirrors runPropsFromMarks.
function txbxRunPropsXml(marks: TiptapNode['marks'] = []): string {
const ts = marks.find((m) => m.type === 'textStyle');
const parts: string[] = [];
const ff = ts?.attrs?.fontFamily;
if (ff) {
const f = escapeXml(String(ff) === SCREEN_FONT ? DOC_FONT : String(ff));
parts.push(`<w:rFonts w:ascii="${f}" w:hAnsi="${f}" w:cs="${f}"/>`);
}
let bold = markPresent(marks, 'bold');
const fw = ts?.attrs?.fontWeight;
if (fw != null) {
const s = String(fw);
if (s === 'normal' || s === '400') bold = false;
else if (s === 'bold' || /^[5-9]\d\d$/.test(s)) bold = true;
}
if (bold) parts.push('<w:b/>');
if (markPresent(marks, 'italic')) parts.push('<w:i/>');
if (markPresent(marks, 'strike')) parts.push('<w:strike/>');
const col = ts?.attrs?.color;
if (col) {
const h = hexColor(String(col));
if (h) parts.push(`<w:color w:val="${h}"/>`);
}
const fs = ts?.attrs?.fontSize;
if (fs) {
const hp = fontSizeToHalfPoints(String(fs));
if (hp) parts.push(`<w:sz w:val="${hp}"/><w:szCs w:val="${hp}"/>`);
}
if (markPresent(marks, 'underline')) parts.push('<w:u w:val="single"/>');
const hl = marks.find((m) => m.type === 'highlight');
if (hl?.attrs?.color) {
const h = hexColor(String(hl.attrs.color));
if (h) parts.push(`<w:shd w:val="clear" w:fill="${h}"/>`);
}
if (markPresent(marks, 'superscript')) parts.push('<w:vertAlign w:val="superscript"/>');
else if (markPresent(marks, 'subscript')) parts.push('<w:vertAlign w:val="subscript"/>');
return parts.length ? `<w:rPr>${parts.join('')}</w:rPr>` : '';
}
// One paragraph/heading of a text box. markerText prefixes a literal list marker
// (in-box lists are flattened, like ODT cell lists once were); images are dropped
// (media/rels can't be minted from a string pass).
function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): string {
const attrs = node.attrs ?? {};
const pPr: string[] = [];
if (node.type === 'heading') {
const lvl = Math.min(3, Math.max(1, Number(attrs.level) || 1));
pPr.push(`<w:pStyle w:val="Heading${lvl}"/>`);
}
if (indentTwip) pPr.push(`<w:ind w:left="${indentTwip}"/>`);
const ta = attrs.textAlign;
const jc = ta === 'center' ? 'center' : ta === 'right' ? 'right' : ta === 'justify' ? 'both' : '';
if (jc) pPr.push(`<w:jc w:val="${jc}"/>`);
let runs = markerText ? `<w:r><w:t xml:space="preserve">${escapeXml(markerText)}</w:t></w:r>` : '';
for (const child of node.content ?? []) {
if (child.type === 'text' && child.text) {
const rPr = txbxRunPropsXml(child.marks);
let inner = '';
child.text.split('\t').forEach((seg, i) => {
if (i > 0) inner += '<w:tab/>';
if (seg) inner += `<w:t xml:space="preserve">${escapeXml(seg)}</w:t>`;
});
runs += `<w:r>${rPr}${inner}</w:r>`;
} else if (child.type === 'hardBreak') {
runs += '<w:r><w:br/></w:r>';
}
}
return `<w:p>${pPr.length ? `<w:pPr>${pPr.join('')}</w:pPr>` : ''}${runs}</w:p>`;
}
// In-box lists flatten to literal-marker paragraphs (real numbering would need
// numbering.xml references, which the post-pack string pass can't mint).
function txbxListXml(node: TiptapNode, depth: number): string {
const ordered = node.type === 'orderedList';
let n = typeof node.attrs?.start === 'number' ? node.attrs.start : 1;
let out = '';
for (const item of node.content ?? []) {
if (item.type !== 'listItem') continue;
let first = true;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
out += txbxListXml(child, depth + 1);
} else if (child.type === 'paragraph' || child.type === 'heading') {
const marker = first ? (ordered ? `${n}. ` : `${BULLET_CHARS[depth % BULLET_CHARS.length]} `) : '';
out += txbxParagraphXml(child, cmToTwip((depth + 1) * LIST_HANGING_CM), marker);
first = false;
}
}
n++;
}
return out;
}
function txbxContentXml(blocks: TiptapNode[]): string {
let out = '';
for (const b of blocks) {
if (b.type === 'paragraph' || b.type === 'heading') out += txbxParagraphXml(b);
else if (b.type === 'bulletList' || b.type === 'orderedList') out += txbxListXml(b, 0);
}
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.
function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
const cx = Math.round(box.widthPx * EMU_PER_PX);
const cy = Math.round(box.heightPx * EMU_PER_PX);
const rot = box.rotationDeg ? ` rot="${Math.round(box.rotationDeg * 60000)}"` : '';
const fill = box.fill
? `<a:solidFill><a:srgbClr val="${hexColor(box.fill) ?? 'FFFFFF'}"/></a:solidFill>`
: '<a:noFill/>';
const ln = box.stroke
? `<a:ln w="${Math.round(box.strokeWidthPt * EMU_PER_PT)}"><a:solidFill><a:srgbClr val="${hexColor(box.stroke) ?? '000000'}"/></a:solidFill></a:ln>`
: '<a:ln><a:noFill/></a:ln>';
const inset = Math.round(TEXTBOX_PADDING_CM * EMU_PER_CM);
// Auto-grow only for plain text boxes, matching the ODT export.
const autofit = box.shapeKind === 'textbox' ? '<a:spAutoFit/>' : '';
const wsp =
`<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>` +
`<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>`;
const graphic =
`<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">` +
`<a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">${wsp}</a:graphicData></a:graphic>`;
const docPr = `<wp:docPr id="${9001 + index}" name="TextBox${index + 1}"/>`;
const extent = `<wp:extent cx="${cx}" cy="${cy}"/>`;
const wpNs = 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"';
if (box.wrap === 'inline') {
return `<w:drawing><wp:inline ${wpNs} distT="0" distB="0" distL="0" distR="0">${extent}${docPr}${graphic}</wp:inline></w:drawing>`;
}
// wrapText names the side TEXT flows on (inverse of the box side), like ODT.
const wrapEl = box.wrap === 'topBottom'
? '<wp:wrapTopAndBottom/>'
: `<wp:wrapSquare wrapText="${box.wrap === 'right' ? 'left' : 'right'}"/>`;
const align = box.wrap === 'right' ? 'right' : 'left';
return (
`<w:drawing><wp:anchor ${wpNs} distT="0" distB="0" distL="114300" distR="114300"` +
` simplePos="0" relativeHeight="${251658240 + index}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="0">` +
`<wp:simplePos x="0" y="0"/>` +
`<wp:positionH relativeFrom="margin"><wp:align>${align}</wp:align></wp:positionH>` +
`<wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>` +
`${extent}${wrapEl}${docPr}${graphic}</wp:anchor></w:drawing>`
);
}
// Post-pack pass: swap each marker paragraph in word/document.xml for its drawing.
// The tempered pattern keeps the match inside one paragraph.
function applyTextBoxesDocx(bytes: Uint8Array, boxes: TextBoxDocx[]): Uint8Array {
if (!boxes.length) return bytes;
const files = unzipSync(bytes);
const docBytes = files['word/document.xml'];
if (!docBytes) return bytes;
let xml = strFromU8(docBytes);
xml = xml.replace(
new RegExp(`<w:p\\b[^>]*?>(?:(?!</w:p>)[\\s\\S])*?${TBX}(\\d+)${TBX}(?:(?!</w:p>)[\\s\\S])*?</w:p>`, 'g'),
(_m, idx: string) => {
const box = boxes[Number(idx)];
return box ? `<w:p><w:r>${textBoxDrawingXml(box, Number(idx))}</w:r></w:p>` : '';
},
);
files['word/document.xml'] = strToU8(xml);
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
return zipSync(out);
}
// ---- paragraphs ------------------------------------------------------------
function alignOf(attrs: TiptapNode['attrs']): (typeof AlignmentType)[keyof typeof AlignmentType] | undefined {
switch (attrs?.textAlign) {
@@ -445,7 +670,7 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
}
// ---- top-level walk --------------------------------------------------------
function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: number): (Paragraph | Table | TableOfContents)[] {
function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: number, textBoxes: TextBoxDocx[]): (Paragraph | Table | TableOfContents)[] {
const out: (Paragraph | Table | TableOfContents)[] = [];
for (const node of content) {
if (node.type === 'paragraph' || node.type === 'heading') {
@@ -456,6 +681,11 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
out.push(tableToDocx(node, contentWidthCm, num));
} else if (node.type === 'image') {
out.push(new Paragraph({ children: inlineToRuns([node]) }));
} else if (node.type === 'textBox') {
// Marker paragraph; applyTextBoxesDocx swaps it for the DrawingML shape.
const i = textBoxes.length;
textBoxes.push(textBoxDocxDescriptor(node));
out.push(new Paragraph({ children: [new TextRun({ text: `${TBX}${i}${TBX}` })] }));
} else if (node.type === 'tableOfContents') {
// A real, recognized TOC field (levels 1–3, hyperlinked); Word/LibreOffice populate
// + link it on field update (features.updateFields does this on open). Title is a
@@ -502,7 +732,8 @@ export async function buildDocx(
const pageHeightCm = landscape ? 21 : 29.7;
const contentWidthCm = pageWidthCm - margins.left - margins.right;
const body = blocksToDocx(docJson.content ?? [], num, contentWidthCm);
const textBoxes: TextBoxDocx[] = [];
const body = blocksToDocx(docJson.content ?? [], num, contentWidthCm, textBoxes);
if (body.length === 0) body.push(new Paragraph({}));
// A TOC field is empty until its field is calculated; ask the reader to update fields
@@ -540,5 +771,5 @@ export async function buildDocx(
});
const blob = await Packer.toBlob(doc);
return new Uint8Array(await blob.arrayBuffer());
return applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes);
}
+162 -9
View File
@@ -4,6 +4,7 @@ import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { DEFAULT_ORDERED_TYPE, orderedTypeDef } from '../utils/orderedListTypes';
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -74,6 +75,12 @@ const FSZ = '\uE007';
// applyToc rewrites the whole marker <text:p> to a <text:table-of-content>. U+E006.
const TOC_SENT = '';
// Sentinel bracketing a hoisted text box's blocks (marker paragraphs TBX S{i} TBX …
// TBX E{i} TBX): replaceTextBoxes hoists the box's blocks to top level so every
// existing pass serializes them; applyTextBoxes wraps the region back into a
// <draw:frame>/<draw:text-box> or <draw:custom-shape>. U+E008.
const TBX = '';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -252,6 +259,58 @@ function replaceImages(node: TiptapNode, images: ImageExport[]): TiptapNode {
return { ...node, content };
}
// One text box / shape, collected by replaceTextBoxes and emitted by applyTextBoxes.
type ShapeKind = 'textbox' | 'roundRect' | 'ellipse';
type TextBoxExport = {
widthCm: number;
heightCm: number;
rotationDeg: number;
wrap: WrapMode;
shapeKind: ShapeKind;
fill: string | null;
stroke: string | null;
strokeWidthPt: number;
};
function textBoxDescriptor(node: TiptapNode): TextBoxExport {
const round3 = (v: number) => Math.round(v * 1000) / 1000;
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),
rotationDeg: typeof a.rotation === 'number' ? a.rotation : 0,
wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline',
shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : '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,
};
}
// Swap each top-level textBox for a pair of marker paragraphs bracketing its child
// blocks, hoisted to top level. The hoisted blocks then ride every existing export
// pass unchanged (custom attrs, list styles, inline sentinels, images); applyTextBoxes
// re-wraps the serialized region into the drawing element. Top-level only by schema.
function replaceTextBoxes(doc: TiptapNode, boxes: TextBoxExport[]): TiptapNode {
if (!doc.content?.length) return doc;
const content: TiptapNode[] = [];
for (const child of doc.content) {
if (child.type === 'textBox') {
const i = boxes.length;
boxes.push(textBoxDescriptor(child));
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${TBX}S${i}${TBX}` }] });
content.push(...(child.content ?? []));
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${TBX}E${i}${TBX}` }] });
continue;
}
content.push(child);
}
return { ...doc, content };
}
// One generated table of contents, collected by replaceTableOfContents and emitted by
// applyToc. Entries are the cached heading→page rows (the node view keeps them current).
type TocEntry = { text: string; level: number; page: number };
@@ -1273,13 +1332,13 @@ function applyInlineSentinels(odtBytes: Uint8Array): Uint8Array {
return rezipOdt(files);
}
// ODF draw:transform for a rotated frame. ODF rotate() is CCW radians (ours is CW
// degrees) about the origin, so the translate re-centres it on the unrotated box.
function imageTransform(img: ImageExport): string {
if (!img.rotationDeg || !img.widthCm || !img.heightCm) return '';
const a = (-img.rotationDeg * Math.PI) / 180;
const cw = img.widthCm / 2;
const ch = img.heightCm / 2;
// ODF draw:transform for a rotated frame/shape. ODF rotate() is CCW radians (ours is
// CW degrees) about the origin, so the translate re-centres it on the unrotated box.
function frameTransform(rotationDeg: number, widthCm: number, heightCm: number): string {
if (!rotationDeg || !widthCm || !heightCm) return '';
const a = (-rotationDeg * Math.PI) / 180;
const cw = widthCm / 2;
const ch = heightCm / 2;
const cos = Math.cos(a);
const sin = Math.sin(a);
const r3 = (v: number) => Math.round(v * 1000) / 1000;
@@ -1288,6 +1347,10 @@ function imageTransform(img: ImageExport): string {
return ` draw:transform="rotate (${a.toFixed(6)}) translate (${tx}cm ${ty}cm)"`;
}
function imageTransform(img: ImageExport): string {
return frameTransform(img.rotationDeg, img.widthCm, img.heightCm);
}
// ODF style:wrap is the side TEXT flows on (inverse of the image side); horizontal-pos
// places the frame on that side. topBottom ⇒ no wrap, centred.
function imageWrapProps(wrap: WrapMode): string {
@@ -1372,6 +1435,92 @@ function applyImages(odtBytes: Uint8Array, images: ImageExport[]): Uint8Array {
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: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 {
const r3 = (v: number) => Math.round(v * 1000) / 1000;
const fill = box.fill
? `draw:fill="solid" draw:fill-color="${normalizeColor(box.fill) ?? '#FFFFFF'}"`
: 'draw:fill="none"';
const stroke = box.stroke
? `draw:stroke="solid" svg:stroke-color="${normalizeColor(box.stroke) ?? '#000000'}" svg:stroke-width="${r3(box.strokeWidthPt)}pt"`
: 'draw:stroke="none"';
const wrap = box.wrap === 'inline'
? ''
: ` ${imageWrapProps(box.wrap)} style:number-wrapped-paragraphs="no-limit"` +
` style:horizontal-rel="paragraph-content" style:vertical-pos="top" style:vertical-rel="paragraph"`;
// auto-grow only for plain text boxes; a custom-shape needs both explicitly
// false, or LibreOffice's shape autofit shrinks it to its text.
const grow = box.shapeKind === 'textbox'
? ' draw:auto-grow-height="true"'
: ' draw:auto-grow-height="false" draw:auto-grow-width="false"';
return (
`<style:style style:name="TbxFr${index + 1}" style:family="graphic">` +
`<style:graphic-properties ${fill} ${stroke} fo:padding="${TEXTBOX_PADDING_CM}cm"` +
`${grow} draw:textarea-vertical-align="top"${wrap}/>` +
`</style:style>`
);
}
// 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.
function textBoxXml(box: TextBoxExport, inner: string, index: number): string {
const n = index + 1;
const anchor = box.wrap === 'inline' ? 'as-char' : 'paragraph';
const transform = frameTransform(box.rotationDeg, box.widthCm, box.heightCm);
const common =
` draw:style-name="TbxFr${n}" text:anchor-type="${anchor}" draw:z-index="${index}"` +
` svg:width="${box.widthCm}cm"`;
if (box.shapeKind === 'textbox') {
// svg:height for consumers without auto-grow; fo:min-height is the real semantic
// (height = minimum, content grows the box) and wins on our own re-import.
return (
`<draw:frame draw:name="TextBox${n}"${common} svg:height="${box.heightCm}cm"${transform}>` +
`<draw:text-box fo:min-height="${box.heightCm}cm">${inner}</draw:text-box></draw:frame>`
);
}
return (
`<draw:custom-shape draw:name="Shape${n}"${common} svg:height="${box.heightCm}cm"${transform}>` +
`${inner}${ENHANCED_GEOMETRY[box.shapeKind]}</draw:custom-shape>`
);
}
// Resolve the text-box marker paragraphs: wrap each S{i}…E{i} region (the box's
// hoisted, fully serialized blocks) into its drawing element inside a fresh anchor
// paragraph, and inject the minted graphic styles.
function applyTextBoxes(odtBytes: Uint8Array, boxes: TextBoxExport[]): Uint8Array {
if (!boxes.length) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
content = content.replace(
new RegExp(`<text:p\\b[^>]*>${TBX}S(\\d+)${TBX}</text:p>([\\s\\S]*?)<text:p\\b[^>]*>${TBX}E\\1${TBX}</text:p>`, 'g'),
(_m, idx: string, inner: string) => {
const i = Number(idx);
const box = boxes[i];
if (!box) return '';
return `<text:p text:style-name="Standard">${textBoxXml(box, inner, i)}</text:p>`;
},
);
content = injectAutomaticStyles(content, boxes.map((b, i) => textBoxGraphicStyle(b, i)).join(''));
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// Minted automatic paragraph style for a TOC entry level: per-level left indent and a
// right tab stop at the text width with a dotted leader (so the page number right-aligns
// with dots, matching the on-screen TOC and Word/LibreOffice).
@@ -1477,9 +1626,12 @@ export type HfExport = {
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null): Promise<Uint8Array> {
// Collect embedded images and swap them for IMG sentinels before serialization;
// applyImages resolves the sentinels and writes the Pictures/ + manifest entries.
// Text boxes are hoisted after replacePageBreaks (so PGB never lands on their
// blocks) and before the inline passes (which then cover the hoisted blocks too).
const images: ImageExport[] = [];
const tocs: TocExport[] = [];
const raw = replaceImages(replaceTabs(replaceHardBreaks(replacePageBreaks(replaceTableOfContents(docJson, tocs)))), images);
const textBoxes: TextBoxExport[] = [];
const raw = replaceImages(replaceTabs(replaceHardBreaks(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes))), images);
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
// Distance from the page edge to the header (top) / footer (bottom). Becomes the
@@ -1596,7 +1748,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const cleaned = collapseRunWhitespace(styledRows);
const withBreaks = applyInlineSentinels(cleaned);
const withImages = applyImages(withBreaks, images);
const withToc = applyToc(withImages, tocs, contentWidthCm);
const withTextBoxes = applyTextBoxes(withImages, textBoxes);
const withToc = applyToc(withTextBoxes, tocs, contentWidthCm);
const withPageBreaks = applyPageBreaks(withToc);
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
const withStyles = rewriteStylesXml(withEmptyFontSizes, language ?? null);
+154 -5
View File
@@ -1,5 +1,5 @@
import { unzipSync, strFromU8 } from 'fflate';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A, PKG_REL, type RunProps } from './docxStyles';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps } from './docxStyles';
import { lengthToPt } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
@@ -19,12 +19,15 @@ type Node = { type: string; attrs?: Record<string, unknown>; content?: Node[]; m
type BlockKind = 'body' | 'list' | 'cell';
type RelInfo = { target: string; external: boolean };
// pendingBlocks: text boxes/shapes found inside runs — block nodes that convertBlocks
// flushes after the anchor paragraph (mirrors import/odt.ts).
type Ctx = {
styles: DocxStyles;
warnings: Set<string>;
files: Record<string, Uint8Array>;
rels: Map<string, RelInfo>;
imageCache: Map<string, string>;
pendingBlocks: Node[];
};
// ---- units & editor defaults to suppress -----------------------------------
@@ -98,7 +101,7 @@ export function importDocx(bytes: Uint8Array): OdtImportResult {
const styles = new DocxStyles(stylesDoc, numberingDoc, themeDoc);
const warnings = new Set<string>();
const ctx: Ctx = { styles, warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map() };
const ctx: Ctx = { styles, warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), pendingBlocks: [] };
const body = docDoc.getElementsByTagNameNS(W, 'body')[0];
if (!body) throw new Error('Not a Word document (no w:body).');
@@ -206,6 +209,22 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
};
const flush = () => { while (stack.length) closeTop(); };
// Emit an anchor paragraph, then any text boxes found inside it. In the body they
// follow the anchor at top level — and an empty anchor (our own export's wrapper
// paragraph) is dropped; elsewhere their blocks are unwrapped in place.
const pushWithPending = (anchor: Node | null) => {
const pending = ctx.pendingBlocks.splice(0);
const anchorIsEmpty = anchor?.type === 'paragraph' && !anchor.content?.length;
if (anchor && !(pending.length && anchorIsEmpty)) out.push(anchor);
if (!pending.length) return;
if (kind === 'body') {
out.push(...pending);
} else {
ctx.warnings.add('Text boxes nested in table cells or other text boxes were flattened');
for (const box of pending) out.push(...(box.content ?? [{ type: 'paragraph' }]));
}
};
for (const el of children) {
if (el.namespaceURI !== W) continue;
if (el.localName === 'p') {
@@ -228,7 +247,7 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
stack[stack.length - 1].list.content!.push({ type: 'listItem', content: [para] });
} else {
flush();
out.push(convertParagraph(el, ctx, kind, boldByDefault));
pushWithPending(convertParagraph(el, ctx, kind, boldByDefault));
}
} else if (el.localName === 'tbl') {
flush();
@@ -251,6 +270,8 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
}
}
flush();
// Boxes anchored inside list items land after the whole list.
pushWithPending(null);
return out;
}
@@ -424,7 +445,31 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
const skipResult = () => fieldMode === 'result' && hfFields; // hide a hf field's cached value
// Route a drawing/pict result: an image is inline; a text box is a block node
// riding ctx.pendingBlocks (dropped in the one-paragraph header/footer schema).
const pushDrawn = (n: Node | null) => {
if (!n) return;
if (n.type !== 'textBox') { out.push(n); return; }
if (hfFields) ctx.warnings.add('Drawings were removed');
else ctx.pendingBlocks.push(n);
};
for (const child of Array.from(r.children)) {
// Word wraps every shape in mc:AlternateContent; use only the mc:Choice branch
// (the mc:Fallback VML duplicates it and would double-import).
if (child.namespaceURI === MC && child.localName === 'AlternateContent') {
const choice = Array.from(child.children).find((c) => c.namespaceURI === MC && c.localName === 'Choice');
const drawing = choice?.getElementsByTagNameNS(W, 'drawing')[0];
if (drawing) pushDrawn(convertDrawing(drawing, ctx));
else {
const pict = Array.from(child.children)
.find((c) => c.namespaceURI === MC && c.localName === 'Fallback')
?.getElementsByTagNameNS(W, 'pict')[0];
if (pict) pushDrawn(convertPict(pict, ctx));
else ctx.warnings.add('Drawings were removed');
}
continue;
}
if (child.namespaceURI !== W) continue;
switch (child.localName) {
case 'fldChar': {
@@ -438,7 +483,8 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu
case 't': if (!skipResult()) pushText(child.textContent ?? '', marks); break;
case 'tab': if (!skipResult()) pushText('\t', marks); break;
case 'br': case 'cr': out.push({ type: 'hardBreak' }); break;
case 'drawing': { const img = convertDrawing(child, ctx); if (img) out.push(img); break; }
case 'drawing': pushDrawn(convertDrawing(child, ctx)); break;
case 'pict': pushDrawn(convertPict(child, ctx)); break;
}
}
};
@@ -538,8 +584,12 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
const anchor = drawing.getElementsByTagNameNS(WP, 'anchor')[0];
const root = drawing.getElementsByTagNameNS(WP, 'inline')[0] ?? anchor;
if (!root) return null;
// A wordprocessingShape (text box / preset shape) has no blip — convert it first.
const wsp = root.getElementsByTagNameNS(WPS, 'wsp')[0];
if (wsp) return convertWpsShape(wsp, root, !!anchor, ctx);
const blip = drawing.getElementsByTagNameNS(A, 'blip')[0];
const embed = blip?.getAttributeNS(R, 'embed');
if (!blip) { ctx.warnings.add('Drawings were removed'); return null; }
const embed = blip.getAttributeNS(R, 'embed');
const rel = embed ? ctx.rels.get(embed) : undefined;
if (!rel || rel.external) { ctx.warnings.add('Some images could not be read and were skipped'); return null; }
const src = loadImageDataUrl(`word/${rel.target.replace(/^\/+/, '')}`, ctx);
@@ -572,6 +622,105 @@ function anchorWrap(anchor: Element): 'left' | 'right' | 'topBottom' {
return align === 'right' ? 'right' : 'left';
}
// ---- text boxes / shapes ------------------------------------------------------
function nsChild(el: Element | null, ns: string, localName: string): Element | null {
if (!el) return null;
for (const c of Array.from(el.children)) if (c.namespaceURI === ns && c.localName === localName) return c;
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.
function setShapeStyleAttrs(attrs: Record<string, unknown>, fill: string | null, stroke: string | null, strokeWidthPt: number | null): void {
if (fill !== '#FFFFFF') attrs.fillColor = fill;
if (stroke !== '#000000') attrs.strokeColor = stroke;
if (stroke && strokeWidthPt != null && Math.abs(strokeWidthPt - 1) > 0.1) {
attrs.strokeWidthPt = Math.round(strokeWidthPt * 100) / 100;
}
}
// A DrawingML <wps:wsp> (text box or preset shape) → a textBox node. Unsupported
// preset geometries (stars, arrows, …) are 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];
if (!kind) { ctx.warnings.add('Unsupported shapes were removed'); return null; }
const attrs: Record<string, unknown> = {};
if (kind !== 'textbox') attrs.shapeKind = kind;
const extent = root.getElementsByTagNameNS(WP, 'extent')[0];
const cx = intAttr(extent, '', 'cx');
const cy = intAttr(extent, '', 'cy');
if (cx) attrs.width = Math.round(emuToPx(cx));
if (cy) attrs.height = Math.round(emuToPx(cy));
const rot = intAttr(nsChild(spPr, A, 'xfrm'), '', 'rot');
if (rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
if (isAnchor) attrs.wrap = anchorWrap(root);
const fillClr = nsChild(nsChild(spPr, A, 'solidFill'), A, 'srgbClr')?.getAttribute('val');
const fill = fillClr ? hexColor(fillClr) ?? null : null;
const ln = nsChild(spPr, A, 'ln');
const lnClr = nsChild(nsChild(ln, A, 'solidFill'), A, 'srgbClr')?.getAttribute('val');
const stroke = ln && !nsChild(ln, A, 'noFill') ? hexColor(lnClr ?? '000000') ?? '#000000' : null;
const lnW = intAttr(ln, '', 'w');
setShapeStyleAttrs(attrs, fill, stroke, lnW != null ? lnW / 12700 : null);
const txbxContent = nsChild(nsChild(wsp, WPS, 'txbx'), W, 'txbxContent');
const blocks = txbxContent ? convertBlocks(Array.from(txbxContent.children), ctx, 'cell') : [];
return { type: 'textBox', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
}
// Legacy VML (w:pict): v:rect/v:oval/v:roundrect/v:shape with a v:textbox. Geometry
// comes from the CSS-ish style string; shapes without text content are dropped.
function convertPict(pict: Element, ctx: Ctx): Node | null {
const shape = Array.from(pict.children).find(
(c) => c.namespaceURI === VML && ['shape', 'rect', 'oval', 'roundrect'].includes(c.localName),
);
const txbxContent = shape
? nsChild(shape.getElementsByTagNameNS(VML, 'textbox')[0] ?? null, W, 'txbxContent')
: null;
if (!shape || !txbxContent) { ctx.warnings.add('Drawings were removed'); return null; }
const attrs: Record<string, unknown> = {};
const kind = shape.localName === 'oval' ? 'ellipse' : shape.localName === 'roundrect' ? 'roundRect' : 'textbox';
if (kind !== 'textbox') attrs.shapeKind = kind;
const style = shape.getAttribute('style') ?? '';
const dimPx = (key: string): number | null => {
const m = new RegExp(`(?:^|;)\\s*${key}:\\s*([\\d.]+)(pt|in|cm|mm|px)?`).exec(style);
if (!m) return null;
const v = parseFloat(m[1]);
const u = m[2] ?? 'pt';
const pt = u === 'pt' ? v : u === 'in' ? v * 72 : u === 'cm' ? (v * 72) / 2.54 : u === 'mm' ? (v * 7.2) / 2.54 : v * 0.75;
return Math.round((pt * 96) / 72);
};
const w = dimPx('width');
const h = dimPx('height');
if (w) attrs.width = w;
if (h) attrs.height = h;
const fillAttr = shape.getAttribute('fillcolor');
const fill = fillAttr ? normalizeColor(fillAttr) ?? null : null;
const stroked = shape.getAttribute('stroked');
const stroke = stroked === 'f' || stroked === 'false'
? null
: normalizeColor(shape.getAttribute('strokecolor') ?? '#000000') ?? '#000000';
const swm = /^([\d.]+)\s*(pt)?$/.exec(shape.getAttribute('strokeweight') ?? '');
setShapeStyleAttrs(attrs, fill, stroke, swm ? parseFloat(swm[1]) : null);
const blocks = convertBlocks(Array.from(txbxContent.children), ctx, 'cell');
return { type: 'textBox', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
}
// ---- tables -----------------------------------------------------------------
function convertTable(tbl: Element, ctx: Ctx): Node | null {
const grid = fc(tbl, 'tblGrid');
+5
View File
@@ -7,6 +7,11 @@ export const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relation
export const WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing';
export const A = 'http://schemas.openxmlformats.org/drawingml/2006/main';
export const PKG_REL = 'http://schemas.openxmlformats.org/package/2006/relationships';
// Text boxes / shapes: DrawingML shape container, markup-compatibility wrapper
// (Word wraps every shape in mc:AlternateContent), and legacy VML.
export const WPS = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape';
export const MC = 'http://schemas.openxmlformats.org/markup-compatibility/2006';
export const VML = 'urn:schemas-microsoft-com:vml';
// A run's resolved character properties; only set keys are present so layers merge cleanly.
export type RunProps = {
+161 -28
View File
@@ -42,12 +42,15 @@ export interface OdtImportResult {
type BlockKind = 'body' | 'list' | 'cell';
// `files` is the full unzipped archive so image converters can read Pictures/
// binaries; imageCache dedupes repeated hrefs into one data-URI.
// binaries; imageCache dedupes repeated hrefs into one data-URI. pendingBlocks is
// the side channel for text boxes/shapes found while converting inline content —
// they are block nodes, so convertBlocks flushes them after the anchor paragraph.
type Ctx = {
resolver: StyleResolver;
warnings: Set<string>;
files: Record<string, Uint8Array>;
imageCache: Map<string, string>;
pendingBlocks: Node[];
};
const MIME_BY_EXT: Record<string, string> = {
@@ -90,6 +93,30 @@ function wrapModeFromOdf(wrapVal: string | undefined, hpos: string | undefined):
return hpos && /right/.test(hpos) ? 'right' : 'left';
}
// draw:transform rotate() is CCW radians; the editor stores CW degrees.
function frameRotationDeg(el: Element): number {
const transform = el.getAttributeNS(NS.draw, 'transform');
const rot = transform && /rotate\s*\(\s*(-?[\d.eE+]+)\s*\)/.exec(transform);
if (!rot) return 0;
return ((Math.round((-parseFloat(rot[1]) * 180) / Math.PI) % 360) + 360) % 360;
}
// Set rotation + wrap attrs shared by images, text boxes and shapes. A non-as-char
// anchor or an explicit style:wrap makes the element floating (free x/y positions
// collapse to the nearest side — the editor's anchor-float model). An explicit
// as-char anchor is always inline: LibreOffice's named Graphics style carries a
// style:wrap that would otherwise float every inherited as-char frame.
function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>, gp: PropMap): void {
const deg = frameRotationDeg(el);
if (deg) attrs.rotation = deg;
const anchor = el.getAttributeNS(NS.text, 'anchor-type');
if (anchor === 'as-char') return;
const wrapVal = gp['style:wrap'];
if (anchor || wrapVal) {
attrs.wrap = wrapModeFromOdf(wrapVal, gp['style:horizontal-pos']);
}
}
// A <draw:frame><draw:image> → an image node. Size comes from the frame's svg
// geometry (cm → px). as-char frames stay inline; paragraph/page-anchored frames
// with a wrap become floating (wrap mode + svg:x/svg:y position).
@@ -110,23 +137,106 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null {
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
const title = frame.getElementsByTagNameNS(NS.svg, 'title')[0]?.textContent;
if (title) attrs.alt = title;
// draw:transform rotate() is CCW radians; the editor stores CW degrees.
const transform = frame.getAttributeNS(NS.draw, 'transform');
const rot = transform && /rotate\s*\(\s*(-?[\d.eE+]+)\s*\)/.exec(transform);
if (rot) {
const deg = ((Math.round((-parseFloat(rot[1]) * 180) / Math.PI) % 360) + 360) % 360;
if (deg) attrs.rotation = deg;
}
// Floating frame → wrap mode (side). (as-char stays inline.)
const anchor = frame.getAttributeNS(NS.text, 'anchor-type');
const gp = ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name'));
const wrapVal = gp['style:wrap'];
if ((anchor && anchor !== 'as-char') || wrapVal) {
attrs.wrap = wrapModeFromOdf(wrapVal, gp['style:horizontal-pos']);
}
applyFrameRotationAndWrap(frame, attrs, ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name')));
return { type: 'image', attrs };
}
// Fill/stroke attrs from a shape's graphic style, suppressing the editor defaults
// (white fill, 1pt black stroke). Absent/none → explicit null (transparent/no border),
// since omitting the attr would re-apply the default.
function shapeStyleAttrs(gp: PropMap, attrs: Record<string, unknown>): void {
const fillColor = gp['draw:fill'] === 'solid' ? normalizeColor(gp['draw:fill-color'] ?? '') ?? null : null;
if (fillColor !== '#FFFFFF') attrs.fillColor = fillColor;
const strokeColor = gp['draw:stroke'] && gp['draw:stroke'] !== 'none'
? normalizeColor(gp['svg:stroke-color'] ?? '#000000') ?? '#000000'
: null;
if (strokeColor !== '#000000') attrs.strokeColor = strokeColor;
if (strokeColor) {
const widthPt = lengthToPt(gp['svg:stroke-width']);
if (widthPt != null && Math.abs(widthPt - 1) > 0.1) {
attrs.strokeWidthPt = Math.round(widthPt * 100) / 100;
}
}
}
// The block content of a text box / shape: its text:* children via the cell path
// (which flattens whatever the box schema can't hold), at least one paragraph.
function textBoxContent(children: Element[], ctx: Ctx): Node[] {
const textChildren = children.filter(c => c.namespaceURI === NS.text);
const blocks = convertBlocks(textChildren, ctx, 'cell');
return blocks.length ? blocks : [{ type: 'paragraph' }];
}
// A <draw:frame><draw:text-box> → a textBox node. The height is the text-box's
// fo:min-height when present (our own export; height = minimum, content grows the
// box), else the frame's computed svg:height (LibreOffice re-saves).
function convertTextBoxFrame(frame: Element, textBoxEl: Element, ctx: Ctx): Node {
const attrs: Record<string, unknown> = {};
const wCm = lengthToCm(frame.getAttributeNS(NS.svg, 'width'));
if (wCm != null) attrs.width = Math.round(cmToPx(wCm));
const hCm = lengthToCm(textBoxEl.getAttributeNS(NS.fo, 'min-height'))
?? lengthToCm(frame.getAttributeNS(NS.svg, 'height'));
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
const gp = ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name'));
applyFrameRotationAndWrap(frame, attrs, gp);
shapeStyleAttrs(gp, attrs);
return { type: 'textBox', attrs, content: textBoxContent(Array.from(textBoxEl.children), ctx) };
}
// 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.
function convertShape(el: Element, ctx: Ctx): Node | null {
let kind: 'textbox' | 'roundRect' | 'ellipse' | 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';
}
if (!kind) {
ctx.warnings.add('Unsupported shapes were removed');
return null;
}
const attrs: Record<string, unknown> = {};
if (kind !== 'textbox') attrs.shapeKind = kind;
const wCm = lengthToCm(el.getAttributeNS(NS.svg, 'width'));
const hCm = lengthToCm(el.getAttributeNS(NS.svg, 'height'));
if (wCm != null) attrs.width = Math.round(cmToPx(wCm));
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
const gp = ctx.resolver.graphicProps(el.getAttributeNS(NS.draw, 'style-name'));
applyFrameRotationAndWrap(el, attrs, gp);
shapeStyleAttrs(gp, attrs);
return { type: 'textBox', attrs, content: textBoxContent(Array.from(el.children), ctx) };
}
// Dispatch any draw:* element: an image stays inline; a text box / shape is a block
// node routed through ctx.pendingBlocks; everything else is dropped with a warning.
function convertDrawElement(e: Element, ctx: Ctx): { inline?: Node; block?: Node } | null {
if (e.localName === 'frame') {
const textBoxEl = Array.from(e.children).find(
c => c.namespaceURI === NS.draw && c.localName === 'text-box',
);
if (textBoxEl) return { block: convertTextBoxFrame(e, textBoxEl, ctx) };
const hasImage = !!e.getElementsByTagNameNS(NS.draw, 'image')[0];
const img = convertFrame(e, ctx);
if (img) return { inline: img };
// A frame with neither image nor text box (OLE object, …) — report the drop;
// an unreadable image already warned inside convertFrame.
if (!hasImage) ctx.warnings.add('Drawings were removed');
return null;
}
if (e.localName === 'rect' || e.localName === 'ellipse' || e.localName === 'custom-shape') {
const shape = convertShape(e, ctx);
return shape ? { block: shape } : null;
}
ctx.warnings.add('Drawings were removed');
return null;
}
// ---- editor defaults to suppress on import -----------------------------------
const BODY_FONT_SIZE_PT = 12;
@@ -174,7 +284,7 @@ export function importOdt(bytes: Uint8Array): OdtImportResult {
const body = contentDoc.getElementsByTagNameNS(NS.office, 'text')[0];
if (!body) throw new Error('Not a text document (no office:text body).');
const ctx: Ctx = { resolver, warnings, files, imageCache: new Map() };
const ctx: Ctx = { resolver, warnings, files, imageCache: new Map(), pendingBlocks: [] };
const blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
@@ -260,13 +370,32 @@ function parseXml(xml: string): Document {
function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
const out: Node[] = [];
// Emit an anchor block, then any text boxes found inside it (block nodes riding
// ctx.pendingBlocks). In the body they follow the anchor at top level — and an
// empty anchor (our own export's wrapper paragraph) is dropped; elsewhere (cells,
// other boxes) their blocks are unwrapped in place.
const pushWithPending = (anchor: Node | null) => {
const pending = ctx.pendingBlocks.splice(0);
const anchorIsEmpty = anchor?.type === 'paragraph' && !anchor.content?.length;
if (anchor && !(pending.length && anchorIsEmpty)) out.push(anchor);
if (!pending.length) return;
if (kind === 'body') {
out.push(...pending);
} else {
ctx.warnings.add('Text boxes nested in table cells or other text boxes were flattened');
for (const box of pending) out.push(...(box.content ?? [{ type: 'paragraph' }]));
}
};
for (const el of elements) {
if (el.namespaceURI === NS.text) {
if (el.localName === 'p' || el.localName === 'h') {
out.push(convertParaLike(el, ctx, kind, boldByDefault));
pushWithPending(convertParaLike(el, ctx, kind, boldByDefault));
} else if (el.localName === 'list') {
const list = convertList(el, ctx, null, 1);
if (list) out.push(list);
pushWithPending(null);
} else if (el.localName === 'section') {
out.push(...convertBlocks(Array.from(el.children), ctx, kind, boldByDefault));
} else if (el.localName === 'table-of-content' && kind === 'body') {
@@ -288,12 +417,14 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef
ctx.warnings.add('Nested tables were flattened to paragraphs');
out.push(...flattenTable(el, ctx));
}
} else if (el.namespaceURI === NS.draw && el.localName === 'frame') {
// A frame at block level (rare) → wrap the inline image in a paragraph.
const img = convertFrame(el, ctx);
if (img) out.push({ type: 'paragraph', content: [img] });
} else if (el.namespaceURI === NS.draw) {
ctx.warnings.add('Drawings were removed');
const conv = convertDrawElement(el, ctx);
// An image at block level (rare) → wrapped in a paragraph.
if (conv?.inline) out.push({ type: 'paragraph', content: [conv.inline] });
else if (conv?.block) {
ctx.pendingBlocks.push(conv.block);
pushWithPending(null);
}
}
}
return out;
@@ -510,13 +641,15 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
continue;
}
}
if (e.namespaceURI === NS.draw && e.localName === 'frame') {
const img = convertFrame(e, ctx);
if (img) out.push(img);
continue;
}
if (e.namespaceURI === NS.draw) {
ctx.warnings.add('Drawings were removed');
// The one-paragraph header/footer schema can't hold images or boxes.
if (hfFields) {
ctx.warnings.add('Drawings were removed');
continue;
}
const conv = convertDrawElement(e, ctx);
if (conv?.inline) out.push(conv.inline);
else if (conv?.block) ctx.pendingBlocks.push(conv.block);
continue;
}
if (e.namespaceURI === NS.office && e.localName === 'annotation') {
+38 -1
View File
@@ -248,7 +248,9 @@
z-index: 30;
}
.paper .tiptap .image-node.image-selected .image-resize-handle,
.paper .tiptap .image-node.image-selected .image-rotate-handle {
.paper .tiptap .image-node.image-selected .image-rotate-handle,
.paper .tiptap .textbox-node.textbox-selected .image-resize-handle,
.paper .tiptap .textbox-node.textbox-selected .image-rotate-handle {
display: block;
}
.paper .tiptap .image-resize-nw { top: -5px; left: -5px; cursor: nwse-resize; }
@@ -304,6 +306,41 @@
z-index: 32;
}
/* Text boxes / shapes (textBox.ts). Same wrapper/rotor split as images: the wrapper
reserves the rotated bounding box, the rotor carries fill/stroke/rotation and the
shared image-* handles; .textbox-content is the editable area. */
.paper .tiptap .textbox-node {
position: relative;
}
.paper .tiptap .textbox-node.textbox-selected {
z-index: 25;
}
.paper .tiptap .textbox-rotor {
position: absolute;
left: 50%;
top: 50%;
box-sizing: border-box;
transform: translate(-50%, -50%);
}
.paper .tiptap .textbox-node.textbox-selected .textbox-rotor {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 1px;
}
/* Grab affordance on the frame edge (outside the text area). */
.paper .tiptap .textbox-rotor {
cursor: move;
}
.paper .tiptap .textbox-content {
cursor: text;
min-height: 1em;
}
.paper .tiptap .textbox-content > :first-child {
margin-top: 0;
}
.paper .tiptap .textbox-content > :last-child {
margin-bottom: 0;
}
/* Tab stops every 1.25cm, matching ODF's default tab interval. */
.paper .tiptap {
tab-size: 1.25cm;
+127
View File
@@ -44,6 +44,11 @@ describe('DOCX export → import round trip', () => {
{ type: 'bulletList', content: [li(para('one')), li(para('two'), { type: 'bulletList', content: [li(para('nested'))] })] },
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [li(para('alpha'))] },
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', alt: 'pic' } }]),
{ type: 'textBox', attrs: { width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 }, content: [
para('box text'),
para([text('bold in box', [{ type: 'bold' }])]),
] },
{ type: 'textBox', attrs: { width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] },
{ type: 'table', content: [
{ type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] },
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1')] },
@@ -118,6 +123,26 @@ describe('DOCX export → import round trip', () => {
expect(img.attrs.src.startsWith('data:image/png')).toBe(true);
});
it('round-trips text boxes: geometry, shape kind, fill/stroke, wrap, rotation', () => {
const boxes = walk(doc, 'textBox');
expect(boxes.length).toBe(2);
const [plain, ellipse] = boxes;
expect(plain.attrs.width).toBe(288);
expect(plain.attrs.height).toBe(96);
// Editor defaults (white fill, 1pt black stroke, no wrap/kind) are suppressed.
expect(plain.attrs.fillColor).toBeUndefined();
expect(plain.attrs.strokeColor).toBeUndefined();
expect(plain.attrs.shapeKind).toBeUndefined();
expect(plain.content!.length).toBe(2);
expect(hasMark(walk(plain, 'text')[1], 'bold')).toBe(true);
expect(ellipse.attrs.shapeKind).toBe('ellipse');
expect(ellipse.attrs.wrap).toBe('right');
expect(ellipse.attrs.rotation).toBe(30);
expect(ellipse.attrs.fillColor).toBe('#FFEE00');
expect(ellipse.attrs.strokeColor).toBe('#FF0000');
expect(ellipse.attrs.strokeWidthPt).toBe(2.25);
});
it('round-trips the table: header shade, merged cell, covered-cell drop, row height', () => {
const table = walk(doc, 'table')[0];
const rows = table.content!;
@@ -299,6 +324,108 @@ describe('DOCX import detects headings by outline level (non-"HeadingN" style id
});
});
describe('DOCX import of foreign text boxes / shapes', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const MC = 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"';
const WPNS = 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"';
const ANS = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"';
const WPSNS = 'xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"';
const VNS = 'xmlns:v="urn:schemas-microsoft-com:vml"';
it('imports a Word-style mc:AlternateContent-wrapped wps text box (Choice only, no VML double-import)', () => {
// Real Word structure: mc:Choice carries the DrawingML shape, mc:Fallback a VML copy.
const documentXml = `<?xml version="1.0"?><w:document ${W} ${MC} ${WPNS} ${ANS} ${WPSNS} ${VNS}><w:body>
<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing>
<wp:anchor distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="2" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="0">
<wp:simplePos x="0" y="0"/>
<wp:positionH relativeFrom="margin"><wp:align>right</wp:align></wp:positionH>
<wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>
<wp:extent cx="1828800" cy="914400"/>
<wp:wrapSquare wrapText="left"/>
<wp:docPr id="7" name="Textfeld 7"/>
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
<wps:wsp><wps:cNvSpPr txBox="1"/><wps:spPr>
<a:xfrm rot="1800000"><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></a:xfrm>
<a:prstGeom prst="roundRect"><a:avLst/></a:prstGeom>
<a:solidFill><a:srgbClr val="CCFFCC"/></a:solidFill>
<a:ln w="28575"><a:solidFill><a:srgbClr val="003300"/></a:solidFill></a:ln>
</wps:spPr>
<wps:txbx><w:txbxContent>
<w:p><w:r><w:t>choice text</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>bold line</w:t></w:r></w:p>
</w:txbxContent></wps:txbx>
<wps:bodyPr/></wps:wsp>
</a:graphicData></a:graphic>
</wp:anchor>
</w:drawing></mc:Choice><mc:Fallback><w:pict>
<v:rect style="width:144pt;height:72pt" fillcolor="#ccffcc"><v:textbox><w:txbxContent><w:p><w:r><w:t>choice text</w:t></w:r></w:p></w:txbxContent></v:textbox></v:rect>
</w:pict></mc:Fallback></mc:AlternateContent></w:r><w:r><w:t>anchor text</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const boxes = walk(result.content as N, 'textBox');
expect(boxes.length).toBe(1); // the VML fallback must not double-import
const b = boxes[0];
expect(b.attrs.shapeKind).toBe('roundRect');
expect(b.attrs.width).toBe(192); // 1828800 EMU
expect(b.attrs.height).toBe(96);
expect(b.attrs.wrap).toBe('right'); // text left ⇒ box right
expect(b.attrs.rotation).toBe(30);
expect(b.attrs.fillColor).toBe('#CCFFCC');
expect(b.attrs.strokeColor).toBe('#003300');
expect(b.attrs.strokeWidthPt).toBe(2.25); // 28575 EMU
expect(walk(b, 'text').map((t) => t.text)).toEqual(['choice text', 'bold line']);
expect(hasMark(walk(b, 'text')[1], 'bold')).toBe(true);
// The anchor paragraph's own text survives, the box follows it at top level.
expect(walk(result.content as N, 'text').some((t) => t.text === 'anchor text')).toBe(true);
expect(result.warnings).toEqual([]);
});
it('imports a legacy VML-only text box (w:pict)', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W} ${VNS}><w:body>
<w:p><w:r><w:pict>
<v:oval style="width:144pt;height:72pt" fillcolor="#ffee00" strokecolor="#ff0000" strokeweight="2.25pt">
<v:textbox><w:txbxContent><w:p><w:r><w:t>vml text</w:t></w:r></w:p></w:txbxContent></v:textbox>
</v:oval>
</w:pict></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const boxes = walk(result.content as N, 'textBox');
expect(boxes.length).toBe(1);
const b = boxes[0];
expect(b.attrs.shapeKind).toBe('ellipse');
expect(b.attrs.width).toBe(192); // 144pt
expect(b.attrs.height).toBe(96);
expect(b.attrs.fillColor).toBe('#FFEE00');
expect(b.attrs.strokeColor).toBe('#FF0000');
expect(b.attrs.strokeWidthPt).toBe(2.25);
expect(walk(b, 'text')[0].text).toBe('vml text');
});
it('drops unsupported shapes / blip-less drawings with accurate warnings', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W} ${WPNS} ${ANS} ${WPSNS}><w:body>
<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>
</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">
<wp:extent cx="914400" cy="914400"/><wp:docPr id="2" name="Chart"/>
<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"/></a:graphic>
</wp:inline></w:drawing></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
expect(walk(result.content as N, 'textBox').length).toBe(0);
expect(result.warnings).toContain('Unsupported shapes were removed');
expect(result.warnings).toContain('Drawings were removed');
// The wrong "images could not be read" warning must NOT appear for shapes.
expect(result.warnings).not.toContain('Some images could not be read and were skipped');
});
});
describe('DOCX table of contents (TOC field) round trip', () => {
const tocDoc = {
type: 'doc',
+32 -3
View File
@@ -30,6 +30,7 @@ const IMGN = (width: number, height: number, alt?: string, rotation?: number, wr
src: PNG, width, height,
...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}),
} });
const TBX = (attrs: N, ...content: N[]): N => ({ type: 'textBox', attrs, content });
const margins = { top: 3, bottom: 2, left: 2.5, right: 1.5 };
@@ -78,6 +79,10 @@ const fixture: N = {
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [240] }, content: [P(null, T('B2'))] },
] },
] },
TBX({ width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 },
P(null, T('box text')), P(null, T('second para'))),
TBX({ width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 },
P(null, T('in ellipse'))),
P(null, T('The end.')),
],
};
@@ -86,6 +91,7 @@ const fixture: N = {
const DEFAULTS: Record<string, unknown> = {
textAlign: 'left', lineHeight: null, spaceBefore: null, spaceAfter: null,
listStyleType: 'decimal', start: 1, rowHeight: null, colspan: 1, rowspan: 1, type: null,
shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1,
};
function normalize(node: N): N {
const out: N = { type: node.type };
@@ -107,12 +113,22 @@ function normalize(node: N): N {
if (k === 'width' || k === 'height') { attrs[k] = Math.round((v as number) / 3) * 3; continue; } // ±unit noise
if (k === 'rotation') { attrs.rotation = 'R'; continue; } // exact angle checked leniently below
if (k === 'wrap') { attrs.wrap = 'W'; continue; } // float survives; exact mode checked leniently
if (k === 'strokeWidthPt') { attrs.strokeWidthPt = Math.round((v as number) * 4) / 4; continue; } // pt↔in noise
if (k === 'fillColor' || k === 'strokeColor') { attrs[k] = typeof v === 'string' ? v.toUpperCase() : v; continue; }
// LO re-parents text-box paragraphs onto its Frame-contents style (margin 0), so
// an explicit spaceAfter 0 comes back where Standard's default was suppressed.
if (k === 'spaceAfter' && v === 0) continue;
attrs[k] = v;
}
if (Object.keys(attrs).length) out.attrs = attrs;
if (node.content?.length) {
const mapped = node.content.map(normalize);
// LibreOffice re-anchors paragraph-anchored (floating) frames to the paragraph
// start; a float's inline position is visually meaningless, so canonicalize it.
const isFloat = (c: N) => c.type === 'image' && c.attrs?.wrap;
const ordered = [...mapped.filter(isFloat), ...mapped.filter((c: N) => !isFloat(c))];
const kids: N[] = [];
for (const c of node.content.map(normalize)) {
for (const c of ordered) {
const prev = kids[kids.length - 1];
if (prev && prev.type === 'text' && c.type === 'text' &&
JSON.stringify(prev.marks ?? null) === JSON.stringify(c.marks ?? null)) prev.text += c.text;
@@ -134,8 +150,10 @@ function firstDiff(a: N, b: N, path = '$'): string | null {
return null;
}
// Generous per-test timeouts: a cold soffice start under a parallel `npm test`
// run easily exceeds vitest's 5s default.
describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () => {
it('survives a `soffice --convert-to odt` re-save of the body document', async () => {
it('survives a `soffice --convert-to odt` re-save of the body document', { timeout: 180000 }, async () => {
const bytes = await buildOdt(fixture, margins, 'landscape');
mkdirSync('/tmp/lo-rt', { recursive: true });
writeFileSync('/tmp/lo-rt/doc.odt', bytes);
@@ -160,9 +178,20 @@ describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () =
check('LO: image rotation survives (~30°)', Math.abs(rot - 30) <= 2 || Math.abs(rot - 330) <= 2, rot);
const floated = imgs.find((i: N) => i.attrs?.wrap && i.attrs.wrap !== 'inline');
check('LO: image text-wrap survives', !!floated, imgs.map((i: N) => i.attrs?.wrap));
// Text boxes: geometry, colors and shape kind must survive the LO re-save.
const boxes = (res.content.content ?? []).filter((n: N) => n.type === 'textBox');
check('LO: both text boxes survive', boxes.length === 2, (res.content.content ?? []).map((n: N) => n.type));
const [plain, ellipse] = boxes;
check('LO: box geometry survives (288×96)', Math.abs((plain?.attrs?.width ?? 0) - 288) <= 3 && Math.abs((plain?.attrs?.height ?? 0) - 96) <= 3, plain?.attrs);
check('LO: box paragraphs survive', plain?.content?.length === 2, plain?.content);
check('LO: ellipse kind + fill survive', ellipse?.attrs?.shapeKind === 'ellipse' && String(ellipse?.attrs?.fillColor).toUpperCase() === '#FFEE00', ellipse?.attrs);
const brot = ellipse?.attrs?.rotation ?? 0;
check('LO: ellipse rotation survives (~30°)', Math.abs(brot - 30) <= 2 || Math.abs(brot - 330) <= 2, brot);
check('LO: ellipse wrap survives', !!ellipse?.attrs?.wrap && ellipse.attrs.wrap !== 'inline', ellipse?.attrs);
});
it('survives a `soffice` re-save of the header/footer geometry', async () => {
it('survives a `soffice` re-save of the header/footer geometry', { timeout: 180000 }, async () => {
const header: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [
T('Bericht ', { type: 'bold' }, { type: 'textStyle', attrs: { color: '#C00000' } }),
T('2026'),
+107
View File
@@ -35,6 +35,7 @@ const IMGN = (width: number, height: number, alt?: string, rotation?: number, wr
src: PNG, width, height,
...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}),
} });
const TBX = (attrs: N, ...content: N[]): N => ({ type: 'textBox', attrs, content });
const margins = { top: 3, bottom: 2, left: 2.5, right: 1.5 };
@@ -73,6 +74,8 @@ const fixture: N = {
P(null, T('rotated: '), IMGN(120, 80, 'Rotated', 30)),
P(null, T('wrapped left '), IMGN(90, 60, 'Float', 0, 'left'), T(' text flows beside it')),
P(null, T('top/bottom '), IMGN(70, 50, 'Banner', 0, 'topBottom')),
TBX({ width: 288, height: 96 }, P(null, T('box para one')), P(null, T('box '), T('bold', { type: 'bold' }))),
TBX({ width: 192, height: 80, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, P(null, T('in ellipse'))),
{ type: 'bulletList', content: [
LI(P(null, T('bullet one'))),
LI(P(null, T('bullet two')), { type: 'orderedList', attrs: { listStyleType: 'upper-roman-paren' }, content: [
@@ -119,6 +122,7 @@ const ORDERED_DEFAULTS: Record<string, unknown> = {
textAlign: 'left', lineHeight: null, spaceBefore: null, spaceAfter: null,
listStyleType: 'decimal', start: 1, rowHeight: null, colspan: 1, rowspan: 1,
type: null, level: undefined, rotation: 0, wrap: 'inline',
shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1,
};
function normalize(node: N): N {
@@ -550,6 +554,109 @@ describe('Leg 4: foreign header/footer → importOdt', () => {
});
});
describe('Leg 6: text boxes / shapes (ODT)', () => {
const boxDoc: N = {
type: 'doc',
content: [
P(null, T('before')),
TBX({ width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 },
P(null, T('plain box')),
P(null, T('with '), T('marks', { type: 'italic' })),
),
TBX({ width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 },
P(null, T('in ellipse')),
),
TBX({ width: 192, height: 80, wrap: 'left', shapeKind: 'roundRect', fillColor: null, strokeColor: null },
P(null, T('transparent round')),
),
P(null, T('after')),
],
};
it('emits frames/shapes with graphic styles and round-trips every attr', async () => {
const bytes = await buildOdt(boxDoc, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
check('emits <draw:frame><draw:text-box>', /<draw:frame [^>]*><draw:text-box/.test(xml));
check('emits <draw:custom-shape> with ellipse geometry', /<draw:custom-shape[\s\S]*?draw:type="ellipse"/.test(xml));
check('emits round-rectangle geometry', xml.includes('draw:type="round-rectangle"'));
check('mints TbxFr graphic styles', xml.includes('style:name="TbxFr1"') && xml.includes('style:name="TbxFr3"'));
check('graphic style carries fill + stroke', xml.includes('draw:fill-color="#FFEE00"') && xml.includes('svg:stroke-color="#FF0000"'));
check('transparent box has fill/stroke none', /draw:fill="none" draw:stroke="none"/.test(xml));
check('no leftover TBX sentinel', !xml.includes(''));
const res = importOdt(bytes);
check('no warnings on own export', res.warnings.length === 0, res.warnings);
const boxes = (res.content.content ?? []).filter((n: N) => n.type === 'textBox');
check('all 3 boxes round-trip', boxes.length === 3, (res.content.content ?? []).map((n: N) => n.type));
const [plain, ellipse, round] = boxes;
check('plain box: size 288×96, defaults suppressed', plain?.attrs?.width === 288 && plain?.attrs?.height === 96 &&
plain?.attrs?.fillColor === undefined && plain?.attrs?.strokeColor === undefined && plain?.attrs?.shapeKind === undefined, plain?.attrs);
check('plain box: both paragraphs + marks survive',
plain?.content?.length === 2 && plain?.content?.[1]?.content?.some((n: N) => n.marks?.some((m: N) => m.type === 'italic')), plain?.content);
check('ellipse: shapeKind + wrap + rotation', ellipse?.attrs?.shapeKind === 'ellipse' && ellipse?.attrs?.wrap === 'right' && ellipse?.attrs?.rotation === 30, ellipse?.attrs);
check('ellipse: fill/stroke/width exact', ellipse?.attrs?.fillColor === '#FFEE00' && ellipse?.attrs?.strokeColor === '#FF0000' && ellipse?.attrs?.strokeWidthPt === 2.25, ellipse?.attrs);
check('roundRect: explicit null fill/stroke survive (transparent, no border)',
round?.attrs?.shapeKind === 'roundRect' && round?.attrs?.fillColor === null && round?.attrs?.strokeColor === null, round?.attrs);
check('document JSON round-trips', firstDiff(normalize(boxDoc), normalize(res.content)) === null,
firstDiff(normalize(boxDoc), normalize(res.content)));
});
});
describe('Leg 7: foreign shapes/text boxes → importOdt', () => {
it('imports text-box frames + preset shapes, flattens boxes in cells, warns on the rest', () => {
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0">
<office:automatic-styles>
<style:style style:name="gr1" style:family="graphic">
<style:graphic-properties draw:fill="solid" draw:fill-color="#ccffcc" draw:stroke="solid" svg:stroke-color="#003300" svg:stroke-width="0.0292in" style:wrap="left" style:horizontal-pos="right"/>
</style:style>
<style:style style:name="gr2" style:family="graphic">
<style:graphic-properties draw:fill="none" draw:stroke="none"/>
</style:style>
</office:automatic-styles>
<office:body><office:text>
<text:p>anchor <draw:frame draw:style-name="gr1" text:anchor-type="paragraph" svg:width="2in" svg:x="1cm" svg:y="2cm"><draw:text-box fo:min-height="1in"><text:p>floating box</text:p><text:p>second</text:p></draw:text-box></draw:frame>text continues</text:p>
<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:line svg:x1="0cm" svg:y1="0cm" svg:x2="5cm" svg:y2="0cm"/></text:p>
<table:table>
<table:table-column/>
<table:table-row><table:table-cell><text:p>cell <draw:frame text:anchor-type="as-char" svg:width="2cm"><draw:text-box><text:p>box in cell</text:p></draw:text-box></draw:frame></text:p></table:table-cell></table:table-row>
</table:table>
</office:text></office:body>
</office:document-content>`;
const foreign = zipSync({
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
'content.xml': [strToU8(contentXml), { level: 6 }],
} as any);
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));
const [floatBox, rect, ellipse] = 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);
check('frame: stroke width 0.0292in → ≈2.1pt', Math.abs((floatBox?.attrs?.strokeWidthPt ?? 0) - 2.1) < 0.05, floatBox?.attrs);
check('frame: both paragraphs kept', floatBox?.content?.length === 2, floatBox?.content);
check('frame: anchor paragraph text kept', c[0]?.content?.map((n: N) => n.text).join('') === 'anchor text continues', c[0]);
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('draw:line dropped with warning', f.warnings.includes('Drawings were removed'), f.warnings);
const table = c.find((n: N) => n.type === 'table');
const cellBlocks = table?.content?.[0]?.content?.[0]?.content ?? [];
check('box in cell flattened into the cell', cellBlocks.some((b: N) => b.content?.some((t: N) => t.text === 'box in cell')), cellBlocks);
check('cell flatten warning reported', f.warnings.includes('Text boxes nested in table cells or other text boxes were flattened'), f.warnings);
});
});
describe('Leg 5: table of contents (text:table-of-content)', () => {
const tocDoc: N = {
type: 'doc',
+10
View File
@@ -39,6 +39,7 @@ const fixture = {
] },
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [li(para('alpha'))] },
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', alt: 'pic' } }]),
{ type: 'textBox', attrs: { width: 288, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('box text')] },
{ type: 'table', content: [
{ type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] },
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1')] },
@@ -132,6 +133,15 @@ describe('buildDocx', () => {
expect(docXml).toContain('wp:anchor'); // floating, not inline
});
it('rewrites the text-box marker into a DrawingML shape (post-pack pass)', () => {
expect(docXml).toContain('<wps:wsp');
expect(docXml).toContain('<wps:txbx><w:txbxContent>');
expect(docXml).toContain('prst="ellipse"');
expect(docXml).toMatch(/a:xfrm rot="1800000"/); // 30° × 60000
expect(docXml).toMatch(/<wp:wrapSquare wrapText="left"\/>/); // box right ⇒ text left
expect(docXml).not.toContain('\uE008'); // marker sentinel fully consumed
});
it('writes page geometry with header/footer distances', () => {
expect(docXml).toMatch(/<w:pgSz[^>]*w:w="11906"[^>]*w:h="16838"/);
expect(docXml).toMatch(/<w:pgMar[^>]*w:header="/);