feat: textbox and shapes
This commit is contained in:
1 parent
6731134d06
commit
0aa9399f93
19 files changed
+1856
-57
No files matched your search
+11
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')) -
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// 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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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="/);
|
||||
|
||||
Reference in new issue
Block a user