diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md
index 5570dd3..eb3886f 100644
--- a/docs/architecture/frames.md
+++ b/docs/architecture/frames.md
@@ -4,7 +4,20 @@ Covers `src/lib/editor/extensions/image.ts` and `textBox.ts` — node views, res
handles, wrap modes, frame offsets — and their ODF/DOCX legs.
- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `rotation` (CW degrees). The node view nests an ` ` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'|'through'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below), and `through` — Word's in-front-of / behind-text (`wp:wrapNone` + `behindDoc`), ODF's `style:wrap="run-through"` — leaves the flow entirely: `position: absolute` with no offsets keeps the static position it was anchored at, so it reserves neither width nor height, and `inFront` picks the side of the text it lands on. Read as a side float instead it reserved a near-column-wide picture's whole height, which cost the image fixture a page and ~50 reports. A text box in that mode always sits behind the text — the mode has no toolbar entry yet, so only a file can carry it in. All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** `wrapDist` is the gap the file keeps beside a float, in cm (Word's `distL`/`distR`, ODF's `fo:margin-left`/`-right` on the graphic style) — only the side the text flows on, since the other one is the frame's own offset. A frame that declares none reserves **nothing**, beside it or above/below it: probed, LibreOffice wraps flush against it, and a gap of our own put every figure in a text box 3mm too tall and broke a line a word early. `setImageWrap` gives a frame the file never floated the 0.32cm a word processor's own wrap command writes. **Vertical alignment (as-char):** `vAlign` carries ODF's `style:vertical-pos`/`-rel` pair for an inline frame — `null` (LibreOffice's `top`/`baseline` and Word's only mode) stands the frame on the text baseline, `middle`/`below` centre it on the baseline or hang it below, and `text-top`/`text-middle`/`text-bottom` measure against the character area instead (CSS `vertical-align: text-top`/`text-bottom`; nothing in CSS centres on that area, so `text-middle` uses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share). `from-top` becomes `vAlign: 'offset'` and puts the frame's top `wrapOffsetY` cm below the baseline whatever the relation says — all probed against LibreOffice, whose formula images use `middle`/`text` and `from-top` almost exclusively. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **no text descent below it** (probed); `image.ts` gives such a frame an `image-line` class, since CSS cannot ask whether a line holds real text. Hard breaks are the only line boundaries the model shows, so the run between two of them is what gets tested — which covers the frame-break-caption idiom LibreOffice's own guides are full of; a frame alone on a *soft-wrapped* line is invisible from there. A frame that shares its line with text keeps the baseline and the descent. **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. The **block after the anchor paragraph clears the band** (an `editor.css` `:has` rule): a float only pushes lines that intersect it, so a short block (a heading) would slot into the clearance gap a prior side float opens above the band — both word processors start it below; a page-break spacer and the block holding a band-sharing partner frame are exempt. Two `topBottom` frames set against **opposite ends** of nearby paragraphs (`wrapAlign`, from Word's `positionH` align / ODF `style:horizontal-pos`) share one band and float to their own sides instead: `pairAlignedFrames` (import/odt.ts, both importers) keeps the attr only for such a pair — a lone one reserves the whole band, which is what the wrap means — and scales a pair up to 15% wider than the column down to it, since Word lets the two overlap in the middle and two floats cannot. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota.
-- **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap` — an in-flow box also `clear`s, since a block box is laid *over* a float rather than beside it, and both word processors put it on the next free line), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). The node has its **own group** (`group: 'textBox'`) so only the document admits it (`extensions.ts` widens Document to `'(block | textBox)+'`) — never in table cells, lists, header/footer, or other boxes; imports found there are flattened with a warning. `TextBoxView` mirrors `ImageView` (wrapper reserving the rotated bbox → rotor carrying fill/stroke/rotation → `contentDOM`); the rotor is out of flow and auto-grows, so a ResizeObserver refits the wrapper. **Click model (Word-like):** `stopEvent`/`isFrameHit` claim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the shared `image-*` handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-`draggable` only while node-selected, so PM's native block drag moves it without hijacking text selection. Commands: `insertTextBox()` (after the current top-level block, cursor inside), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`). `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke, `wrapAlign` and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically. **DOCX autofit:** the shape body writes ` `, which is what LibreOffice writes for the same frame — read as `spAutoFit` it gives the frame `fo:min-height="0"` and draws its text detached from the shape, over whatever follows (probed). The cost is the ODF leg's auto-grow: content taller than the declared height is clipped there, DOCX having no `fo:min-height`. **LibreOffice puts a `wrapTopAndBottom` anchor's band above its anchor paragraph** whatever the anchor says — probed against `posOffset` 0/635, `align top`, `relativeFrom` line/column, `allowOverlap`, and against a file LibreOffice exported itself; `wp:inline` is the one form it places right. **Where it sits across the column:** the two modes that leave the box in flow (`inline`, `topBottom`) take `wrapAlign` — `null`/`center`/`right`, drawn as the wrapper's auto margins, and the toolbar drops any `wrapOffset` when one is picked. A side wrap names the side itself, so the buttons hide there. **Spacing:** a box lifted out of an anchor paragraph that held nothing else stands in for it, so it takes that paragraph's resolved `fo:margin-top`/`-bottom` as `spaceBefore`/`spaceAfter` (`ParagraphSpacing` covers `textBox` for that). Space above is padding — it adds to the block above rather than collapsing against it — which means the node is `content-box` and the rotor, centred in the padding box, gets half of it back.
+- **`textBox.ts`** (`TextBox`) — an **inline** text box / basic shape carrying editable block content (`group: 'inline'`, `inline: true`, `content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`) — the frame both formats write, a character in a run that holds paragraphs. It rides a paragraph, so `inline` really is in the line, and a box reaches a table cell or a list item through their paragraphs, as one does in Word. Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). **Wrap** follows `ImageView.applyWrap` exactly: `inline` is an `inline-block` in the line, `left`/`right` are floats, `topBottom` a **full-width float** whose wrapper spans the column while `placeInBand` moves the rotor across it, `through` leaves the flow. Never `display:block` — that splits the paragraph's inline content into anonymous block boxes and ProseMirror drops the following page-break spacer. A paragraph holding a box paginates **atomically** (`pageBreaks.ts`), whichever way the box wraps. `TextBoxView` mirrors `ImageView` (wrapper reserving the rotated bbox → rotor carrying fill/stroke/rotation → `contentDOM`); the rotor is out of flow and auto-grows, so a ResizeObserver refits the wrapper. **Click model (Word-like):** `stopEvent`/`isFrameHit` claim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the shared `image-*` handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-`draggable` only while node-selected, so PM's native drag moves it without hijacking text selection. Commands: `insertTextBox()` (at the caret, cursor inside; refused inside a box), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`), `setTextBoxAlign()`. **Two guards** the schema cannot express live in the extension's plugin: a `filterTransaction` refuses a box inside a box — both word processors refuse it and neither format's writer has a place for one — and an `appendTransaction` grows a selection ending inside a box it did not start in out to the box's own bounds, since deleting such a range dissolves the frame and spills its text into the body. `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke, the alignment and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. **DOCX autofit:** the shape body writes ` `, which is what LibreOffice writes for the same frame — read as `spAutoFit` it gives the frame `fo:min-height="0"` and draws its text detached from the shape, over whatever follows (probed). The cost is the ODF leg's auto-grow: content taller than the declared height is clipped there, DOCX having no `fo:min-height`. **LibreOffice puts a `wrapTopAndBottom` anchor's band above its anchor paragraph** whatever the anchor says — probed against `posOffset` 0/635, `align top`, `relativeFrom` line/column, `allowOverlap`, and against a file LibreOffice exported itself; `wp:inline` is the one form it places right, and a band-wrapped box keeps `wp:anchor` anyway (Word is the reference for `.docx`). **Where it sits across the column:** a band-wrapped box takes `wrapAlign` (`null`/`center`/`right`, drawn by `placeInBand`), and the toolbar drops any `wrapOffset` when one is picked. A box **in the line** is a character and has none of its own: its paragraph aligns it, which is what both formats write, so the same buttons set the paragraph's `textAlign` through `setTextBoxAlign`. A side wrap names the side itself, so the buttons hide there. An as-char frame must name the **baseline pair** explicitly in ODF (`style:vertical-pos="top" style:vertical-rel="baseline"`): the `Frame` parent hangs a frame that names none below the line, where a style-less as-char frame (an image) stands on it — probed, and the measured difference between the two formats until it was written.
+
+## A box in static HTML
+
+A box is inline and its content is blocks, and no `
` can hold those: the browser's
+parser breaks the paragraph open around it. That only bites where the document goes
+through an **HTML string** — `generateHTML`, `editor.getHTML()`. Copy and paste inside
+the editor is unaffected (ProseMirror pastes its own cached slice), and the raster PDF
+path reads the live DOM. The **vector print path** (`export/pdf.ts`) handles it:
+`hoistTextBoxes` gives each box a paragraph of its own before serializing, and
+`buildBodyHtml` drops the two empty paragraphs the split then leaves around it — so the
+box prints as a block between whole paragraphs, which is how it printed as one.
+An **AutoText** entry holding a box keeps the box's text and loses the frame; so does a
+paste from another window.
## Clicking a float
diff --git a/src/App.svelte b/src/App.svelte
index fee0285..6df34f2 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -535,7 +535,18 @@
// recorded, the RECORDING meta keeps the whole file from arriving as this author's
// insertion. Both word processors record what is typed after an open, not the file.
function loadContent(content: Content): void {
- editor?.chain().setMeta(RECORDING, true).setContent(content).run();
+ editor?.chain().setMeta(RECORDING, true).setContent(wrapLooseTextBoxes(content)).run();
+ }
+
+ // A text box used to be a block of its own; it is inline now, so a document written
+ // before that (an autosave, a recent file) would lose every box it holds to the
+ // schema. Give each one the paragraph it now needs.
+ function wrapLooseTextBoxes(content: Content): Content {
+ if (!content || typeof content !== 'object' || Array.isArray(content)) return content;
+ const doc = content as { content?: { type?: string }[] };
+ if (!doc.content?.some(n => n?.type === 'textBox')) return content;
+ return { ...doc, content: doc.content.map(n =>
+ n?.type === 'textBox' ? { type: 'paragraph', content: [n] } : n) } as Content;
}
// Reset every document side-car to its default; the $effects persist these.
diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte
index 1b368b8..39ff0c4 100644
--- a/src/lib/components/Editor.svelte
+++ b/src/lib/components/Editor.svelte
@@ -674,9 +674,9 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// 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; wrapAlign: string | null; shapeKind: ShapeKind; fillColor: string | null;
+ wrap: WrapMode; wrapAlign: string | null; paraAlign: string | null; shapeKind: ShapeKind; fillColor: string | null;
strokeColor: string | null; strokeWidthPt: number; textVertical: boolean;
- }>({ visible: false, top: 0, left: 0, wrap: 'inline', wrapAlign: null, shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1, textVertical: false });
+ }>({ visible: false, top: 0, left: 0, wrap: 'inline', wrapAlign: null, paraAlign: null, shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1, textVertical: false });
function recomputeTextBoxUi() {
const ed = editor;
@@ -702,6 +702,8 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
left: r.left - cRect.left + container!.scrollLeft,
wrap: (a.wrap as WrapMode) || 'inline',
wrapAlign: (a.wrapAlign as string | null) ?? null,
+ // The box's own paragraph places an in-line box, so the toolbar reads it there.
+ paraAlign: (view!.state.doc.resolve(found.pos).parent.attrs?.textAlign as string | null) ?? null,
shapeKind: (a.shapeKind as ShapeKind) || 'textbox',
fillColor: (a.fillColor as string | null) ?? null,
strokeColor: (a.strokeColor as string | null) ?? null,
@@ -1563,6 +1565,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
left={textBoxUi.left}
wrap={textBoxUi.wrap}
wrapAlign={textBoxUi.wrapAlign}
+ paraAlign={textBoxUi.paraAlign}
shapeKind={textBoxUi.shapeKind}
fillColor={textBoxUi.fillColor}
strokeColor={textBoxUi.strokeColor}
diff --git a/src/lib/components/LineNumberLayer.svelte b/src/lib/components/LineNumberLayer.svelte
index 0b78e75..2e53179 100644
--- a/src/lib/components/LineNumberLayer.svelte
+++ b/src/lib/components/LineNumberLayer.svelte
@@ -62,8 +62,9 @@
const range = document.createRange();
range.selectNodeContents(block);
let rects = Array.from(range.getClientRects()).filter((r) => r.height > 0);
- // A frame floated out of the flow (wrapped image, text box) is no text line of its
- // anchor paragraph — its band would otherwise count as an extra line.
+ // A frame is no text line of its paragraph: a floated one's band would count as an
+ // extra line, an in-line one's box as a second line beside the text it shares.
+ // A paragraph left with no rect at all falls back to its own box — one line.
const frames = block.querySelectorAll('.image-node[data-wrap], .textbox-node');
if (frames.length) {
const fr = Array.from(frames, (f) => f.getBoundingClientRect());
@@ -104,21 +105,16 @@
let count = 0;
let page = 1;
// Text blocks count — list items and column lines too, as in both word processors;
- // a table, an image frame or an index is not a numbered line in either. A top-level
- // text box counts as one empty line: in the file it hangs off an empty anchor
- // paragraph, and both word processors count that line (probed in Word).
+ // a table, a frame or an index is not a numbered line in either. A text box rides a
+ // paragraph, and that paragraph's own line is what counts.
const blocks = view.dom.querySelectorAll(
':scope > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10, blockquote),'
- + ' :scope > .textbox-node,'
+ ' :scope > :is(ul, ol) li > p,'
+ ' :scope > .columns-node :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)');
for (const block of Array.from(blocks)) {
if (block.closest('table')) continue;
- const anchor = block.classList.contains('textbox-node');
- const empty = anchor || !block.textContent?.trim();
- const lines = anchor
- ? [{ top: (block.getBoundingClientRect().top - origin) / scale, height: 18 }]
- : lineTops(block, origin, scale);
+ const empty = !block.textContent?.trim();
+ const lines = lineTops(block, origin, scale);
for (const line of lines) {
const linePage = pageAt(line.top);
if (lineNumbering.restart === 'page' && linePage !== page) { page = linePage; count = 0; }
diff --git a/src/lib/components/TextBoxToolbar.svelte b/src/lib/components/TextBoxToolbar.svelte
index 488b3bf..80ca53b 100644
--- a/src/lib/components/TextBoxToolbar.svelte
+++ b/src/lib/components/TextBoxToolbar.svelte
@@ -12,6 +12,7 @@
left,
wrap,
wrapAlign,
+ paraAlign,
shapeKind,
fillColor,
strokeColor,
@@ -23,6 +24,7 @@
left: number;
wrap: WrapMode;
wrapAlign: string | null;
+ paraAlign: string | null;
shapeKind: ShapeKind;
fillColor: string | null;
strokeColor: string | null;
@@ -46,9 +48,12 @@
}
// Where the box sits across the column. A side wrap has no such choice — the wrap
- // names the side — so the buttons show for the two modes that leave the box in flow.
+ // names the side — so the buttons show for the two modes that leave it in flow.
const alignModes = ['left', 'center', 'right'] as const;
const alignable = $derived(wrap === 'inline' || wrap === 'topBottom');
+ // A box in the line is a character: its paragraph places it, and that is what both
+ // formats write. A band-wrapped box has a place of its own across the column.
+ const current = $derived(wrap === 'inline' ? paraAlign ?? 'left' : wrapAlign ?? 'left');
function alignTitle(a: (typeof alignModes)[number]): string {
return a === 'left' ? t().textBox.alignLeft
: a === 'center' ? t().textBox.alignCenter
@@ -57,7 +62,8 @@
// An imported box placed by coordinate keeps that x over any alignment, so picking
// one drops it.
function setAlign(a: (typeof alignModes)[number]) {
- set({ wrapAlign: a === 'left' ? null : a, wrapOffset: null });
+ if (wrap === 'inline') editor?.chain().focus().setTextBoxAlign(a === 'left' ? null : a).run();
+ else set({ wrapAlign: a === 'left' ? null : a, wrapOffset: null });
}
const strokeWidths = [0.5, 1, 2.25];
@@ -123,10 +129,10 @@
{#each alignModes as a}
setAlign(a)}
>
diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts
index 49920da..799edae 100644
--- a/src/lib/editor/extensions.ts
+++ b/src/lib/editor/extensions.ts
@@ -79,13 +79,13 @@ import { loadDocProperties } from '../storage/docProperties';
import { Insertion, Deletion, TrackChanges } from './extensions/trackChanges';
export const extensions = [
- // textBox and columns have their own groups so only the document (not
- // cells/lists/boxes) admits them; the note section is last or nowhere.
+ // columns has its own group so only the document (not cells/lists) admits it; the
+ // note section is last or nowhere. A text box is inline and rides a paragraph.
// Spelled as an alternation, not `… + noteSection?`: with the trailing optional the
// match state after the first block offers noteSection first, and that is the type
// TipTap's clearNodes converts a block into (`contentMatchAt(i).defaultType`).
Document.extend({
- content: '((block | textBox | columns)+ noteSection) | (block | textBox | columns)+',
+ content: '((block | columns)+ noteSection) | (block | columns)+',
}),
Paragraph,
Text,
@@ -142,7 +142,7 @@ export const extensions = [
// Mathematical formula; stores LaTeX, renders native MathML, round-trips to an ODF
// embedded formula object and DOCX OMML. See docs/architecture/formulas.md.
Formula,
- // Block-level text box / basic shape with editable content; round-trips to ODF
+ // Inline text box / basic shape with editable block content; round-trips to ODF
// draw:frame/draw:text-box + draw:custom-shape and DOCX wps:wsp/wps:txbx.
TextBox,
// Multi-column (newspaper) section; round-trips to ODF +
diff --git a/src/lib/editor/extensions/caption.ts b/src/lib/editor/extensions/caption.ts
index 17b1b0b..cdd02bd 100644
--- a/src/lib/editor/extensions/caption.ts
+++ b/src/lib/editor/extensions/caption.ts
@@ -85,9 +85,9 @@ export function captionPlacement(leftCm: number, rightCm: number): CaptionPlacem
function frameSpanCm(view: EditorView, block: PMNode, blockPos: number): { left: number; right: number } | null {
const host = view.dom as HTMLElement;
if (typeof getComputedStyle !== 'function' || !host.offsetWidth) return null;
+ // Every frame is inline — a picture or a text box in the block's own content.
let framePos: number | null = null;
- if (block.attrs.wrap) framePos = blockPos;
- else if (block.inlineContent) {
+ if (block.inlineContent) {
block.descendants((node, pos) => {
if (framePos === null && node.attrs.wrap) framePos = blockPos + 1 + pos;
return framePos === null;
diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts
index 2632f9b..364c1e4 100644
--- a/src/lib/editor/extensions/pageBreaks.ts
+++ b/src/lib/editor/extensions/pageBreaks.ts
@@ -1006,27 +1006,6 @@ export const PageBreaks = Extension.create({
});
continue;
}
- // A text box paginates atomically: a line-split spacer can't render
- // sanely inside a rotated/floated node view.
- if (child.classList.contains('textbox-node')) {
- // Its anchor paragraph's spacing rides the box (textBox.ts): space above
- // as padding, which a page top drops, space below as the margin.
- const boxCs = getComputedStyle(child);
- const spaceAbove = parseFloat(boxCs.getPropertyValue('--space-before')) || 0;
- const dropped = spaceAbove > 0.5 && !inTableCell && parseFloat(boxCs.paddingTop) < 0.5
- ? spaceAbove : 0;
- leaves.push({
- el: child,
- kind: 'atomic',
- naturalTop: naturalTopOf(child),
- naturalHeight: child.offsetHeight + dropped,
- spaceAfter: inTableCell ? 0 : parseFloat(boxCs.marginBottom) || 0,
- spaceAbove,
- inTableCell,
- });
- cumulativeDropped += dropped;
- continue;
- }
// A multi-column section fragment: also atomic for spacer purposes (a
// spacer inside a multicol container rebalances around it), but the
// placement logic defers overflow to columnsFlow.ts via columnsFragment.
@@ -1051,12 +1030,13 @@ export const PageBreaks = Extension.create({
});
continue;
}
- // A paragraph holding a floated image paginates atomically: a line-split
- // spacer beside the float is dropped by ProseMirror. As-character images
- // are ordinary line boxes, so their paragraph breaks between lines.
+ // A paragraph holding a floated frame — a picture or a text box —
+ // paginates atomically: a line-split spacer beside the float is dropped by
+ // ProseMirror. An as-character frame is an ordinary line box, so its
+ // paragraph breaks between lines like any other.
const splittableTag = SPLITTABLE_TAGS.has(tag);
- const floats = Array.from(child.querySelectorAll('.image-node'))
- .filter((img) => !!img.style.float);
+ const floats = Array.from(child.querySelectorAll('.image-node, .textbox-node'))
+ .filter((f) => !!f.style.float);
const hasImage = splittableTag && floats.length > 0;
// Keep lines together (w:keepLines, fo:keep-together): the block moves
// whole. One taller than a page still splits — as it does in Word.
@@ -1773,8 +1753,7 @@ export const PageBreaks = Extension.create({
}));
}
for (const b of pageTopBlocks) {
- // --space-top: a text box draws its own space above from it (textBox.ts).
- decoArray.push(Decoration.node(b.from, b.to, { style: 'padding-top:0;margin-top:0;--space-top:0px' }));
+ decoArray.push(Decoration.node(b.from, b.to, { style: 'padding-top:0;margin-top:0' }));
}
// Each footnote to the foot of its anchor's page; the topmost of a page also
// carries the separator (editor.css draws it above the box).
diff --git a/src/lib/editor/extensions/paragraphSpacing.ts b/src/lib/editor/extensions/paragraphSpacing.ts
index c804350..69b1c56 100644
--- a/src/lib/editor/extensions/paragraphSpacing.ts
+++ b/src/lib/editor/extensions/paragraphSpacing.ts
@@ -24,9 +24,7 @@ export const ParagraphSpacing = Extension.create({
addOptions() {
return {
- // textBox: a box lifted out of its anchor paragraph carries that paragraph's
- // spacing, since it stands in its place (import/odt.ts).
- types: ['paragraph', 'heading', 'textBox'] as string[],
+ types: ['paragraph', 'heading'] as string[],
};
},
diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts
index e510333..599c49a 100644
--- a/src/lib/editor/extensions/textBox.ts
+++ b/src/lib/editor/extensions/textBox.ts
@@ -1,12 +1,13 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
-import type { Node as PMNode } from '@tiptap/pm/model';
+import type { Node as PMNode, Slice } from '@tiptap/pm/model';
import { NodeSelection, TextSelection, Plugin } from '@tiptap/pm/state';
import type { EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { EditorView } from '@tiptap/pm/view';
import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, applyRunThrough, type WrapMode } from './image';
import { SHAPES, shapePath, linePaths, arrowHeadPx, isShapeKind, isLineKind, type ShapeKind } from '../../utils/shapes';
+import { cmToPx } from '../../storage/pageMargins';
// cm attribute value → number, for the frame offsets (px ones use parsePx).
const parseCmAttr = (v: string | null): number | null => {
@@ -59,6 +60,7 @@ declare module '@tiptap/core' {
textBox: {
insertTextBox: () => ReturnType;
setTextBoxAttrs: (attrs: Partial) => ReturnType;
+ setTextBoxAlign: (align: string | null) => ReturnType;
};
}
}
@@ -78,6 +80,26 @@ export function findTextBox(state: EditorState): { pos: number; node: PMNode } |
return null;
}
+// The depth of the text box a position sits in, or -1. The box is inline, so every
+// ancestor from depth 1 has to be asked.
+function boxDepthAt($p: { depth: number; node: (d: number) => PMNode }): number {
+ for (let d = 1; d <= $p.depth; d++) if ($p.node(d).type.name === 'textBox') return d;
+ return -1;
+}
+
+// Whether any text box in the fragment sits inside another. Both word processors refuse
+// a box in a box, and neither format's writer has a place to put one.
+function nestsBox(node: PMNode, inBox = false): boolean {
+ let bad = false;
+ node.forEach(child => {
+ if (bad) return;
+ const isBox = child.type.name === 'textBox';
+ if (isBox && inBox) bad = true;
+ else if (nestsBox(child, inBox || isBox)) bad = true;
+ });
+ return bad;
+}
+
function shapeRadius(kind: ShapeKind): string {
return kind === 'ellipse' ? '50%' : kind === 'roundRect' ? '8px' : '0';
}
@@ -90,9 +112,11 @@ function isDrawnShape(a: TextBoxAttrs): boolean {
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',
+ // An inline node carrying block content, like the frame both formats write: it rides
+ // a paragraph's content, so `inline` really is in the line and a box reaches a cell
+ // or a list item through their paragraphs. A box inside a box is barred by a plugin.
+ group: 'inline',
+ inline: true,
content: '(paragraph | heading | bulletList | orderedList)+',
isolating: true,
defining: true,
@@ -239,24 +263,45 @@ export const TextBox = Node.create({
addCommands() {
return {
- // Insert a fresh text box after the current top-level block, cursor inside.
+ // Insert a fresh text box at the cursor, cursor inside. A box is a character, so
+ // it goes where the caret is — except inside a box, where it would nest: there it
+ // follows the block that holds that box, in a paragraph of its own.
insertTextBox:
() =>
({ state, dispatch }) => {
const type = state.schema.nodes.textBox;
- const para = state.schema.nodes.paragraph?.createAndFill();
- if (!type || !para) return false;
+ const paraType = state.schema.nodes.paragraph;
+ const para = paraType?.createAndFill();
+ if (!type || !paraType || !para) return false;
const { $from } = state.selection;
- const pos = $from.depth >= 1 ? $from.after(1) : state.selection.from;
+ const nested = boxDepthAt($from) >= 0 && $from.depth >= 1;
+ const at = nested ? $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));
+ const tr = state.tr.insert(at, nested ? paraType.create(null, box) : box);
+ // The box's own first text position: one more where a paragraph wraps it.
+ tr.setSelection(TextSelection.create(tr.doc, at + (nested ? 3 : 2)));
dispatch(tr.scrollIntoView());
}
return true;
},
+ // The alignment of the paragraph an in-line box sits in — which is what places it,
+ // and what both formats write. Its own command because the caret is usually inside
+ // the box, where the generic one would align the box's own paragraph instead.
+ setTextBoxAlign:
+ (align: string | null) =>
+ ({ state, dispatch }) => {
+ const found = findTextBox(state);
+ if (!found) return false;
+ const $box = state.doc.resolve(found.pos);
+ if (!$box.parent.type.spec.attrs?.textAlign) return false;
+ if (dispatch) {
+ dispatch(state.tr.setNodeMarkup($box.before(), undefined, { ...$box.parent.attrs, textAlign: align }));
+ }
+ return true;
+ },
+
// Update attrs of the selected box or the box enclosing the cursor.
setTextBoxAttrs:
(attrs: Partial) =>
@@ -280,6 +325,37 @@ export const TextBox = Node.create({
addProseMirrorPlugins() {
return [
new Plugin({
+ // A selection may not end inside a box it did not start in: deleting one
+ // dissolves the frame and spills its text into the body. Grown to the box's
+ // own bounds, the same delete takes the whole box, which is what both word
+ // processors do.
+ appendTransaction: (_trs, _old, state) => {
+ const sel = state.selection;
+ if (!(sel instanceof TextSelection) || sel.empty) return null;
+ const { $from, $to } = sel;
+ const df = boxDepthAt($from);
+ const dt = boxDepthAt($to);
+ if (df === dt && (df < 0 || $from.start(df) === $to.start(df))) return null;
+ const from = df < 0 ? sel.from : $from.before(df);
+ const to = dt < 0 ? sel.to : $to.after(dt);
+ return state.tr.setSelection(TextSelection.create(state.doc, from, to));
+ },
+ // A box in a box has nowhere to go in either format, so it never enters the
+ // document. Only a step that carries one is worth the walk.
+ filterTransaction: tr => {
+ if (!tr.docChanged) return true;
+ const carriesBox = tr.steps.some(step => {
+ const slice = (step as unknown as { slice?: Slice }).slice;
+ if (!slice) return false;
+ let found = false;
+ slice.content.descendants(n => {
+ if (n.type.name === 'textBox') found = true;
+ return !found;
+ });
+ return found;
+ });
+ return !carriesBox || !nestsBox(tr.doc);
+ },
props: {
decorations(state) {
const { from, to } = state.selection;
@@ -510,12 +586,16 @@ class TextBoxView {
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`;
+ // A band-wrapped frame spans the column instead (applyWrap), which is what keeps
+ // text off its sides; only its height is the frame's.
+ const band = this.attrs().wrap === 'topBottom';
+ this.dom.style.width = band ? '100%' : `${bw}px`;
this.dom.style.height = `${bh}px`;
+ if (band) this.placeInBand(this.attrs());
}
- // 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).
+ // Float per wrap mode, exactly like ImageView.applyWrap: an inline node leaves the
+ // line by floating, never by becoming a block box.
private applyWrap(): void {
const d = this.dom;
const a = this.attrs();
@@ -523,36 +603,43 @@ class TextBoxView {
d.style.clear = '';
d.style.position = '';
d.style.zIndex = '';
- d.style.margin = frameMargins('topBottom', a.wrap === 'topBottom' ? a.wrapOffset : null, 0);
+ d.style.display = '';
+ d.style.verticalAlign = '';
+ d.style.margin = '';
+ this.rotor.style.left = '';
if (a.wrap === 'left' || a.wrap === 'right') {
d.style.float = a.wrap;
d.style.margin = frameMargins(a.wrap, a.wrapOffset, this.wrapperWidth(), null, a.wrapDist);
} else if (a.wrap === 'through') {
// Behind the text, which is what a shape with no run-through of its own exports as.
applyRunThrough(d, a.wrapOffset, a.wrapOffsetY, false);
- } else {
- // In flow: the frame is a block box, and CSS lays one over a float rather than
- // beside it (only line boxes shorten), so it takes the next free line instead.
+ } else if (a.wrap === 'topBottom') {
+ // A full-width float, as on an image: text may only flow above and below it, and
+ // a block box on an inline node view splits the paragraph's inline content into
+ // anonymous ones, which loses the following page-break spacer.
+ d.style.float = 'left';
d.style.clear = 'both';
+ d.style.width = '100%';
+ d.style.margin = frameMargins('topBottom', null, 0, a.wrapOffsetY);
+ this.placeInBand(a);
+ } else {
+ // In the line: a box is a character. inline-block keeps ProseMirror's inline view
+ // intact where a block box would not.
+ d.style.display = 'inline-block';
+ d.style.verticalAlign = 'bottom';
}
- // The anchor paragraph's spacing, which a lifted box stands in for: space above as
- // padding so it adds to the block above (editor.css), space below as the margin the
- // frame distance already is.
- const before = this.node.attrs.spaceBefore as number | null;
- const after = this.node.attrs.spaceAfter as number | null;
- // --space-top is the same value the rotor can inherit (--space-before is registered
- // non-inheriting), and it is what pageBreaks.ts zeroes at a page top.
- for (const prop of ['--space-before', '--space-top']) {
- if (before != null) d.style.setProperty(prop, `${before}pt`);
- else d.style.removeProperty(prop);
- }
- if (after != null) d.style.marginBottom = `${after}pt`;
- // Set against the middle or the far end of the column, unless the file placed the
- // box by coordinate.
- if ((a.wrapAlign === 'center' || a.wrapAlign === 'right')
- && a.wrapOffset == null && a.wrap !== 'left' && a.wrap !== 'right') {
- d.style.marginLeft = 'auto';
- if (a.wrapAlign === 'center') d.style.marginRight = 'auto';
+ }
+
+ // Where the frame sits across a band it spans: the wrapper is the whole column, so
+ // this moves the rotor, which CSS centres in it — which is already `center`.
+ private placeInBand(a: TextBoxAttrs): void {
+ const half = (parseFloat(this.rotor.style.width) || 0) / 2;
+ if (typeof a.wrapOffset === 'number') {
+ this.rotor.style.left = `${Math.round(cmToPx(a.wrapOffset)) + half}px`;
+ } else if (a.wrapAlign === 'right') {
+ this.rotor.style.left = `calc(100% - ${half}px)`;
+ } else if (a.wrapAlign !== 'center') {
+ this.rotor.style.left = `${half}px`;
}
}
diff --git a/src/lib/editor/extensions/trailingNode.ts b/src/lib/editor/extensions/trailingNode.ts
index fbc47c4..80fea9b 100644
--- a/src/lib/editor/extensions/trailingNode.ts
+++ b/src/lib/editor/extensions/trailingNode.ts
@@ -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', 'textBox', 'columns']);
+const NEEDS_TRAILING_AFTER = new Set(['table', 'columns']);
export const TrailingNode = Extension.create({
name: 'trailingNode',
diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md
index 9523301..7763e35 100644
--- a/src/lib/export/CLAUDE.md
+++ b/src/lib/export/CLAUDE.md
@@ -31,7 +31,8 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
- **Hard breaks & tabs** (`replaceHardBreaks`/`replaceTabs`/`applyInlineSentinels`): `hardBreak` nodes become `LBR`-sentinel text and tab chars become `TAB`-sentinel text so both survive every odf-kit path (a literal `\t` would collapse to a space), then the sentinels are rewritten to ` `/` ` in content.xml.
- **Images** (`replaceImages`/`applyImages`): odf-kit's native image path rounds dimensions to whole cm and doesn't reach our cell path, so images bypass it. `replaceImages` (a pre-pass) swaps each `image` node for an `IMG`-sentinel text run and collects the bytes (decoded data-URI) + cm geometry (px→cm via `round3`); the sentinel rides every odf-kit path including table cells. `applyImages` (after `applyCellBlocks`) rewrites each sentinel to a ` `, adds the binary `Pictures/` entries to the zip, and appends a `` per picture to `META-INF/manifest.xml`. Integer px ⇄ round3-cm ⇄ px is sub-pixel exact, so size is unchanged. A rotated image also gets a `draw:transform="rotate() translate(...)"` (ODF rotate is CCW radians, ours CW degrees; the translate re-centres it on the unrotated box). A **floating** image (`wrap !== 'inline'`) instead uses `text:anchor-type="paragraph"` + a minted `` carrying `style:wrap` + `style:horizontal-pos` (image-left → `wrap="right" horizontal-pos="left"`; topBottom → `wrap="none" horizontal-pos="center"`; through → `wrap="run-through"` + `style:run-through`) injected into content.xml automatic-styles.
- **Tables:** column widths come from the editor's per-column weights (`tableColumnWidthsCm`, summing exactly to the text width, with `table:align="margins"`). **Table margins:** a table's `marginLeft`/`marginRight` shrink that width and are written by `applyTableMargins` into the table's automatic style (`style:width` + `fo:margin-left`/`-right`; odf-kit names table styles `Table1`, `Table2`, … in document order, matching the descriptor list `exportTable` fills). odf-kit serializes each cell to a single `` of runs; `exportTable` emits all of a cell's content into that paragraph separated by a `SEG` sentinel and records a `CellBlock[]` descriptor, then `applyCellBlocks` splits on `SEG` and rebuilds real ``/``/`` (incl. nested lists) with minted automatic paragraph styles. **Merged cells:** `exportTable` passes each cell's `colspan`/`rowspan` as odf-kit `CellOptions.colSpan`/`rowSpan`; odf-kit emits `table:number-columns/rows-spanned` and auto-generates the `` placeholders (which never match the `applyCellBlocks` cell regex, so `cellBlocks` stays one-per-real-cell aligned). Import already reads spans/covered cells in `convertTable`. **Cell shading:** a cell's `backgroundColor` attr is passed as `CellOptions.backgroundColor` (normalized), which odf-kit mints into the cell style as `fo:background-color`. **Cell borders:** the per-side border attrs (tableCellBorders.ts) are passed as `CellOptions.borderTop/Right/Bottom/Left` (`'none'` or `'pt solid #RRGGBB'`) overriding the table-level default border per side.
-- **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a top-level `textBox` is **hoisted** — `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) swaps it for `TBX`-sentinel marker paragraphs (`S{i}`…`E{i}`, U+E008) bracketing its child blocks at top level, so every existing pass (custom attrs, list styles, inline sentinels, images) serializes them unchanged and document-order-indexed passes stay aligned. `applyTextBoxes` (after `applyImages`) wraps each serialized S…E region into `` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `` + preset `` (`utils/shapes.ts`; see `docs/architecture/frames.md`) inside a fresh anchor paragraph, minting a `TbxFr{n}` graphic style (fill/stroke/padding + the image wrap props; `draw:auto-grow-height="true"` for boxes, both auto-grows explicitly **false** for shapes or LibreOffice's autofit shrinks them to their text). A plain box's style chains to **`style:parent-style-name="Frame"`** — that parent is what makes LibreOffice read it as a Writer text *frame*; without it it is a drawing object whose text loses lists, nested images and borders (probed on a re-save). Frames take `fo:background-color` (written beside `draw:fill`) and `fo:border` — `draw:stroke` is ignored there — and vertical text as `style:writing-mode` in the graphic properties; a *shape* instead needs ` ` (both probed). `rewriteStylesXml` declares a minimal `Frame` base style (zeroed margins/padding/border, or the built-in leaks 2mm margins and a hairline). An **inline box's `wrapAlign`** rides its anchor paragraph's `fo:text-align` (`TbxP{n}`) — an as-char frame has no horizontal-pos, the paragraph centres it, which the import reads back. In DOCX it is the same pair: `applyTextBoxesDocx` rebuilds the marker paragraph, so an as-char box's alignment goes in as its `w:jc` and a band-wrapped one's as the anchor's `wp:align`.
+- **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a box is inline, so it becomes **two** things. `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) recurses over every node's content like `replaceImages` and swaps each box for a `TBX`-sentinel `P{i}` run **where it sits in its paragraph** — which rides every odf-kit path, table cells included — while its own blocks are **staged** at top level between `S{i}`…`E{i}` marker paragraphs (U+E008), so every existing pass serializes them unchanged (custom attrs, list styles, inline sentinels, images) and document-order-indexed passes stay aligned. The staging is only a place to serialize in: where a box sits and where its content is written out are no longer the same spot, which is what lets a box in a cell work with no change to the cell machinery. `applyTextBoxes` (after `applyImages`) cuts each `S…E` region out, keeps the serialized blocks, and writes `` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `` + preset `` (`utils/shapes.ts`; see `docs/architecture/frames.md`) over the `P{i}` sentinel, in whatever `` the box really sits in. It mints a `TbxFr{n}` graphic style (fill/stroke/padding + the image wrap props; `draw:auto-grow-height="true"` for boxes, both auto-grows explicitly **false** for shapes or LibreOffice's autofit shrinks them to their text). A plain box's style chains to **`style:parent-style-name="Frame"`** — that parent is what makes LibreOffice read it as a Writer text *frame*; without it it is a drawing object whose text loses lists, nested images and borders (probed on a re-save). It is also why an **as-char** box's style spells out `style:vertical-pos="top" style:vertical-rel="baseline"`: under that parent a frame naming no vertical position hangs below the line, where a style-less as-char frame (an image) stands on it. Frames take `fo:background-color` (written beside `draw:fill`) and `fo:border` — `draw:stroke` is ignored there — and vertical text as `style:writing-mode` in the graphic properties; a *shape* instead needs ` ` (both probed). `rewriteStylesXml` declares a minimal `Frame` base style (zeroed margins/padding/border, or the built-in leaks 2mm margins and a hairline). An in-line box has **no horizontal position of its own** — the paragraph it sits in aligns it — so nothing is minted for that; a band-wrapped one carries `wrapAlign` in its graphic style. DOCX is the same pair: `inlineToRuns` drops a marker **run** where the box sits, and `applyTextBoxesDocx` swaps only that run, leaving the paragraph and the text beside it alone.
+
- **A text box's own parts, DOCX** (`applyTextBoxesDocx`): the `docx` package can't emit a DrawingML shape, so the box XML is a hand-built string — and its content needs parts the package never hears about. The pass holds the whole unzipped package, so it mints them itself: a picture becomes `word/media/tbx{n}.{ext}` plus a `Relationship` above the package's highest `rId` (Content_Types already declares the raster defaults), a list an ``/`` pair above its highest id, appended **before** the first `` because every abstract definition has to precede them. The numbering ids only exist once the packed file is in hand, so the paragraphs carry a `TXBX_NUM` sentinel that the same pass substitutes.
- **Templates** (`template.ts`): a `.ott`/`.dotx` is the built document with one label changed — ODF's package media type (in `mimetype` **and** in the manifest, the mimetype entry staying first and stored) and OOXML's content type for `/word/document.xml`. `saveAsTemplate` offers both in one picker, so the bytes are only built once the extension is known.
- **Formulas** (`replaceFormulas`/`applyFormulas`): an `MTH`-sentinel run becomes an as-char ` ` plus a `Formula{n}/content.xml` sub-document (MathML root) and its two manifest entries. **No `svg:width`/`svg:height`** — a sized frame makes LibreOffice scale the object instead of typesetting it. DOCX emits `` (or `` when displayed) from the same post-pack pass as the text boxes. See `docs/architecture/formulas.md`.
diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts
index 7bf3aca..f1739e2 100644
--- a/src/lib/export/docx.ts
+++ b/src/lib/export/docx.ts
@@ -173,6 +173,10 @@ function docCitationStyle(node: TiptapNode): CitationStyle {
// Filled from the note section before the body is walked, then read by the anchor —
// module-level for the same reason as docFormulas.
let docNoteIds = new Map();
+// The boxes inlineToRuns met, in document order; applyTextBoxesDocx writes them. A
+// module-level collector like docNoteIds: a box sits in a run, and the run builder is
+// reached from every paragraph path there is.
+let docTextBoxes: TextBoxDocx[] = [];
// Comments, numbered in document order before the runs are built — Word's ids are
// integers and word/comments.xml has to exist before the Document is constructed.
@@ -666,6 +670,11 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
} else if (node.type === 'image') {
const img = imageRun(node);
if (img) out.push(img);
+ } else if (node.type === 'textBox') {
+ // A marker run; applyTextBoxesDocx swaps it for the DrawingML shape, which the
+ // `docx` package has no API for.
+ docTextBoxes.push(textBoxDocxDescriptor(node));
+ out.push(new TextRun({ text: `${TBX}${docTextBoxes.length - 1}${TBX}` }));
} else if (node.type === 'pageNumber') {
out.push(new TextRun({ children: [PageNumber.CURRENT], ...runPropsFromMarks(node.marks) }));
} else if (node.type === 'pageCount') {
@@ -1303,16 +1312,13 @@ function applyTextBoxesDocx(bytes: Uint8Array, boxes: TextBoxDocx[]): Uint8Array
let nextBm = 8000;
const parts: TxbxParts = { media: [], nums: [], links: [], nextRid: () => `rId${++nextRid}`, nextBookmarkId: () => ++nextBm };
+ // Only the marker run is rebuilt — the paragraph around it keeps its own properties
+ // and whatever text stands beside the box. Tempered so a match can't span two runs.
xml = xml.replace(
- new RegExp(`]*?>(?:(?! )[\\s\\S])*?${TBX}(\\d+)${TBX}(?:(?!)[\\s\\S])*?`, 'g'),
+ new RegExp(`]*?>(?:(?! )[\\s\\S])*?${TBX}(\\d+)${TBX}(?:(?!)[\\s\\S])*?`, 'g'),
(_m, idx: string) => {
const box = boxes[Number(idx)];
- if (!box) return '';
- // The marker paragraph is rebuilt, so an as-char box's alignment — which is the
- // paragraph's, a frame in the line having none of its own — is written here.
- const pPr = box.wrap === 'inline' && box.alignH
- ? ` ` : '';
- return `${pPr}${textBoxDrawingXml(box, Number(idx), parts)} `;
+ return box ? `${textBoxDrawingXml(box, Number(idx), parts)} ` : '';
},
);
@@ -2256,7 +2262,7 @@ function indexFieldParagraphs(node: TiptapNode, kind: IndexKind, maxLevel: numbe
}
// ---- top-level walk --------------------------------------------------------
-function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: number, textBoxes: TextBoxDocx[]): (Paragraph | Table | TableOfContents)[] {
+function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: number): (Paragraph | Table | TableOfContents)[] {
const out: (Paragraph | Table | TableOfContents)[] = [];
for (const node of content) {
if (node.type === 'paragraph' || node.type === 'heading') {
@@ -2267,11 +2273,6 @@ 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 field (TOC/INDEX/BIBLIOGRAPHY) whose result is the editor's cached rows.
// A list of figures/tables is the same field over a caption label (\c) instead of
@@ -2306,7 +2307,7 @@ function startsSection(node: TiptapNode): boolean {
return node.type === 'columns' && node.content?.[0]?.attrs?.sectionBreak === true;
}
-function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: number, textBoxes: TextBoxDocx[]): BodyGroup[] {
+function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: number): BodyGroup[] {
const groups: BodyGroup[] = [];
let section = 0;
let plain: TiptapNode[] = [];
@@ -2315,7 +2316,7 @@ function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: numbe
let cols: { count: number; gapCm: number; blocks: TiptapNode[] } | null = null;
const flushPlain = () => {
if (!plain.length) return;
- groups.push({ section, columns: null, children: blocksToDocx(plain, num, contentWidthCm, textBoxes) });
+ groups.push({ section, columns: null, children: blocksToDocx(plain, num, contentWidthCm) });
plain = [];
};
const flushCols = () => {
@@ -2323,7 +2324,7 @@ function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: numbe
groups.push({
section,
columns: { count: cols.count, gapCm: cols.gapCm },
- children: blocksToDocx(mergeJoinedParagraphsJson(cols.blocks), num, contentWidthCm, textBoxes),
+ children: blocksToDocx(mergeJoinedParagraphsJson(cols.blocks), num, contentWidthCm),
});
cols = null;
};
@@ -2555,13 +2556,13 @@ export async function buildDocx(
const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation);
const contentWidthCm = pageWidthCm - margins.left - margins.right;
- const textBoxes: TextBoxDocx[] = [];
// The note section holds the notes in anchor order (notes.ts), which is the order
// Word numbers them in; each class counts from 1 because each has its own part.
// Numbered before the body is walked, so an anchor already knows its id.
const noteBlocks = (docJson.content ?? []).filter((n) => n.type === 'noteSection')
.flatMap((n) => n.content ?? []);
docNoteIds = new Map();
+ docTextBoxes = [];
docComments = [];
docCommentIds = new Map();
docRevisionIds = new Map();
@@ -2582,7 +2583,7 @@ export async function buildDocx(
};
}
const body = (docJson.content ?? []).filter((n) => n.type !== 'noteSection');
- const groups = bodyGroups(body, num, contentWidthCm, textBoxes);
+ const groups = bodyGroups(body, num, contentWidthCm);
// The index fields carry the editor's rows as their cached result; still ask the
// reader to update fields on open, so a capable one repaginates + hyperlinks them.
@@ -2730,7 +2731,7 @@ export async function buildDocx(
...num.styleLinks().map((l) => numberingStyleXml(l.name)),
]);
const linked = applyListStylesDocx(styled, num.styleLinks());
- const packed = applyFormulasDocx(applyTextBoxesDocx(linked, textBoxes), docFormulas);
+ const packed = applyFormulasDocx(applyTextBoxesDocx(linked, docTextBoxes), docFormulas);
const cited = applyBibliographyDocx(applyPlaceholdersDocx(applyRubyDocx(packed, docRubies), docPlaceholders), docSources, docCitationStyle(docJson));
const withNotes = docNoteIds.size ? applyNoteMarksDocx(applyNotePrDocx(cited, notesSettings)) : cited;
const withResolved = applyCommentsResolvedDocx(withNotes);
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index 55345ee..f1394f1 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -996,9 +996,6 @@ type TextBoxExport = {
fill: string | null;
stroke: string | null;
strokeWidthPt: number;
- // The anchor paragraph's own spacing in pt, which the box stands in for.
- spaceBeforePt: number;
- spaceAfterPt: number;
};
function textBoxDescriptor(node: TiptapNode): TextBoxExport {
@@ -1023,29 +1020,41 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport {
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,
- spaceBeforePt: typeof a.spaceBefore === 'number' ? round3(a.spaceBefore) : 0,
- spaceAfterPt: typeof a.spaceAfter === 'number' ? round3(a.spaceAfter) : 0,
};
}
-// Swap each top-level textBox for a pair of marker paragraphs bracketing its child blocks,
-// hoisted to top level so they ride every existing export pass unchanged (custom attrs,
-// list styles, inline sentinels, images); applyTextBoxes re-wraps the serialized region.
+// A text box becomes two things: a P{i} sentinel where it sits in its paragraph's run —
+// which rides every odf-kit path, cells included, as the image sentinel does — and a
+// staging region at top level, its own blocks bracketed by S{i}…E{i} marker paragraphs so
+// they ride every existing export pass unchanged (custom attrs, list styles, inline
+// sentinels, images). applyTextBoxes lifts the serialized region back out and sets the
+// frame at the sentinel. The staging is only a place to serialize in: where a box really
+// sits and where its content is written out are no longer the same spot.
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;
+ const staged: TiptapNode[] = [];
+ const walk = (node: TiptapNode): TiptapNode => {
+ if (!node.content?.length) return node;
+ const content: TiptapNode[] = [];
+ for (const child of node.content) {
+ if (child.type === 'textBox') {
+ const i = boxes.length;
+ boxes.push(textBoxDescriptor(child));
+ content.push({ type: 'text', text: `${TBX}P${i}${TBX}` });
+ staged.push({ type: 'paragraph', content: [{ type: 'text', text: `${TBX}S${i}${TBX}` }] });
+ staged.push(...(child.content ?? []).map(walk));
+ staged.push({ type: 'paragraph', content: [{ type: 'text', text: `${TBX}E${i}${TBX}` }] });
+ continue;
+ }
+ content.push(walk(child));
}
- content.push(child);
- }
- return { ...doc, content };
+ return { ...node, content };
+ };
+ const out = walk(doc);
+ if (!staged.length) return out;
+ // Ahead of the note section, which is the document's last block or nowhere.
+ const body = out.content ?? [];
+ const cut = body.length && body[body.length - 1].type === 'noteSection' ? body.length - 1 : body.length;
+ return { ...out, content: [...body.slice(0, cut), ...staged, ...body.slice(cut)] };
}
// One footnote/endnote, collected by replaceNotes and emitted by applyNotes.
@@ -4284,10 +4293,12 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
: (box.stroke
? `draw:stroke="solid" svg:stroke-color="${normalizeColor(box.stroke) ?? '#000000'}" svg:stroke-width="${r3(box.strokeWidthPt)}pt"`
: 'draw:stroke="none"');
- // An as-char frame keeps only its horizontal-pos: that is what centres a figure
- // frame, and the anchor paragraph this export mints carries no alignment.
+ // An as-char frame has no horizontal position of its own: the paragraph it sits in
+ // places it, which is where its alignment lives too. It does need the baseline pair
+ // spelled out — the `Frame` parent hangs a frame that names none below the line,
+ // where a style-less as-char frame (an image) stands on it (probed).
const wrap = box.wrap === 'inline'
- ? (box.wrapAlign ? ` style:horizontal-pos="${box.wrapAlign}" style:horizontal-rel="paragraph-content"` : '')
+ ? ' style:vertical-pos="top" style:vertical-rel="baseline"'
: ` ${imageWrapProps(box.wrap, box.wrapOffsetCm, box.wrapAlign, box.wrapDistCm, 'left')} style:number-wrapped-paragraphs="no-limit"` +
` style:horizontal-rel="paragraph-content"` +
` style:vertical-pos="${box.wrapOffsetYCm != null ? 'from-top' : 'top'}" style:vertical-rel="paragraph"`;
@@ -4318,22 +4329,6 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
);
}
-// The anchor paragraph carries the spacing the box stands in for (import/odt.ts hands a
-// lifted box its anchor's margins) — and, for an inline box, its alignment: an as-char
-// frame has no horizontal-pos of its own, the paragraph centres it (how LibreOffice
-// does it, and what the import reads back). Nothing to mint when it has neither.
-function textBoxAnchorStyle(box: TextBoxExport, index: number): string {
- const align = box.wrap === 'inline' && box.wrapAlign
- ? ` fo:text-align="${box.wrapAlign === 'right' ? 'end' : 'center'}"`
- : '';
- if (!box.spaceBeforePt && !box.spaceAfterPt && !align) return '';
- return (
- `` +
- ` ` +
- ` `
- );
-}
-
// The drawing element wrapping a box's serialized blocks: a /
// for plain text boxes (height = fo:min-height, so it grows with content like the
// editor), or a with the preset geometry of `utils/shapes.ts`.
@@ -4382,9 +4377,9 @@ function textBoxXml(box: TextBoxExport, inner: string, index: number): string {
const ODF_ARROW = 'Arrow';
const ODF_ARROW_MARKER = ` `;
-// 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.
+// Resolve the text boxes in two steps: cut each S{i}…E{i} staging region out and keep the
+// serialized blocks it holds, then write the drawing element over the P{i} sentinel — in
+// whatever the box really sits in, the way applyImages places a picture.
function applyTextBoxes(odtBytes: Uint8Array, boxes: TextBoxExport[]): Uint8Array {
if (!boxes.length) return odtBytes;
const files = unzipSync(odtBytes);
@@ -4392,20 +4387,20 @@ function applyTextBoxes(odtBytes: Uint8Array, boxes: TextBoxExport[]): Uint8Arra
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
+ const inners = new Map();
content = content.replace(
new RegExp(`]*>${TBX}S(\\d+)${TBX} ([\\s\\S]*?)]*>${TBX}E\\1${TBX} `, 'g'),
(_m, idx: string, inner: string) => {
- const i = Number(idx);
- const box = boxes[i];
- if (!box) return '';
- const style = textBoxAnchorStyle(box, i) ? `TbxP${i + 1}` : 'Standard';
- return `${textBoxXml(box, inner, i)} `;
+ inners.set(Number(idx), inner);
+ return '';
},
);
- content = injectAutomaticStyles(
- content,
- boxes.map((b, i) => textBoxGraphicStyle(b, i) + textBoxAnchorStyle(b, i)).join(''),
- );
+ content = content.replace(new RegExp(`${TBX}P(\\d+)${TBX}`, 'g'), (_m, idx: string) => {
+ const i = Number(idx);
+ const box = boxes[i];
+ return box ? textBoxXml(box, inners.get(i) ?? '', i) : '';
+ });
+ content = injectAutomaticStyles(content, boxes.map((b, i) => textBoxGraphicStyle(b, i)).join(''));
files['content.xml'] = strToU8(content);
// An arrow head is referenced by name, so its one definition goes where LibreOffice
// keeps its own — office:styles in styles.xml — and only when a line asks for it.
diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts
index d3e6e5b..742fffd 100644
--- a/src/lib/export/pdf.ts
+++ b/src/lib/export/pdf.ts
@@ -383,11 +383,37 @@ function weightsFromRow(row: Json): (number | null)[] {
return w;
}
+// A text box is an inline node carrying blocks, and no can hold those: the parser
+// breaks the paragraph open around it, so the text after the box would print as a
+// paragraph of its own. Give each box a paragraph of its own first — the split then
+// leaves the box a block between two whole paragraphs, which is how it prints.
+export function hoistTextBoxes(json: Json): Json {
+ const walk = (node: Json): Json => {
+ if (!node?.content?.length) return node;
+ const out: Json[] = [];
+ for (const child of node.content) {
+ const boxes = (child.content ?? []).filter((n: Json) => n.type === 'textBox');
+ if (!boxes.length) { out.push(walk(child)); continue; }
+ out.push({ ...child, content: child.content!.filter((n: Json) => n.type !== 'textBox') });
+ for (const b of boxes) out.push({ type: 'paragraph', content: [b] });
+ }
+ return { ...node, content: out };
+ };
+ return walk(json);
+}
+
// JSON → clean HTML, re-inserting the percentage
s the table node view
// builds at runtime but generateHTML omits (matches the editor's column widths).
-function buildBodyHtml(json: Json): string {
+export function buildBodyHtml(json: Json): string {
const host = document.createElement('div');
- host.innerHTML = generateHTML(json, extensions);
+ host.innerHTML = generateHTML(hoistTextBoxes(json), extensions);
+ // The two empty paragraphs the split above leaves around each box. A blank line the
+ // reader wrote sits one further out, so immediate adjacency picks only the artifacts.
+ for (const box of Array.from(host.querySelectorAll('div[data-textbox]'))) {
+ for (const p of [box.previousElementSibling, box.nextElementSibling]) {
+ if (p?.tagName === 'P' && !p.firstChild && !p.attributes.length) p.remove();
+ }
+ }
const percents: number[][] = [];
(function walk(n: Json) {
if (!n) return;
diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md
index d1fe4de..1819536 100644
--- a/src/lib/import/CLAUDE.md
+++ b/src/lib/import/CLAUDE.md
@@ -22,7 +22,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **`style:contextual-spacing`** drops a paragraph's own spacing towards a neighbour of the same style (LibreOffice's `List` styles carry it; Word's `w:contextualSpacing` is the same rule). The editor has no such mode, so the suppressed side becomes an explicit 0 — and in a list, where each item wraps its own paragraph, the neighbour is the adjacent *item's*.
- **Default suppression** keeps round trips (editor → LibreOffice → editor) from accreting explicit attrs: values matching the editor's defaults (12pt body, `HEADING_STYLE_OVERRIDES` sizes/margins, **0 paragraph spacing**, list margin 0, Times New Roman/Liberation Serif, `#000000` text) are imported as `null`/no mark, with small tolerances for producer unit rounding (pt↔cm↔twips). This is why the editor's defaults must equal Word's/LibreOffice's (below) — an editor-only default is indistinguishable from a failed style resolution and silently lands in every imported document.
- **A heading is bold by default only where the bold built-in still renders it.** `blockDefaults` suppresses a run mark the block's style supplies, and the editor's `Heading` parent supplies bold — but a file that declares its own heading style re-parents it to `Standard` (`collectStyleSheet`), so nothing supplies bold there and an explicit `w:b` / `fo:font-weight` has to stay a mark or the heading renders regular. Gated on the style's own `w:basedOn` / `style:parent-style-name` (`styleBasedOn`, `styleParent`), which is exactly what decides whether the built-in's parent survives.
-- **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`) — unless the list style is a **named** one (`StyleResolver.isNamedListStyle`): then the outermost list gets `listStyleName` (display name decoded), no per-level attrs are derived, and `collectStyleSheet`/`listStyleFromOdf` put the definition into `sheet.list`, tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`), and `draw:polygon`/`polyline`/`path`/`connector` — or a preset no gallery covers but whose `draw:enhanced-path` is a plain outline — → a box carrying that outline as `shapePath` (`convertFreeform`, `docs/architecture/frames.md`; a geometry built from modifier formulas still reads as "Unsupported shapes were removed"). A box is a block node but its frame sits inside a `text:p`, so converted boxes ride `Ctx.pendingBlocks` and `convertBlocks` flushes them after the anchor paragraph (dropping our own export's empty anchor); in cells/other boxes their blocks are unwrapped in place with a warning. The DOCX importer mirrors all of this — its drawing root is the `w:drawing`'s **direct** `wp:inline`/`wp:anchor` child, since a box holding a picture nests a second drawing and a subtree search reads *its* root instead, dropping the box and the caption in it (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import).
+- **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`) — unless the list style is a **named** one (`StyleResolver.isNamedListStyle`): then the outermost list gets `listStyleName` (display name decoded), no per-level attrs are derived, and `collectStyleSheet`/`listStyleFromOdf` put the definition into `sheet.list`, tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`), and `draw:polygon`/`polyline`/`path`/`connector` — or a preset no gallery covers but whose `draw:enhanced-path` is a plain outline — → a box carrying that outline as `shapePath` (`convertFreeform`, `docs/architecture/frames.md`; a geometry built from modifier formulas still reads as "Unsupported shapes were removed"). A box is an inline node, so it stays exactly where the frame sits — in the paragraph's own content, beside the runs around it, cells and list items included; only a box **inside another box** is unwrapped in place, with a warning. The DOCX importer mirrors all of this — its drawing root is the `w:drawing`'s **direct** `wp:inline`/`wp:anchor` child, since a box holding a picture nests a second drawing and a subtree search reads *its* root instead, dropping the box and the caption in it (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import).
- **Image formats (`import/imageFormats.ts`, shared by both importers):** ` ` only renders a fixed set of formats, so `imageDataUrl(bytes, path)` resolves the mime by extension **then** magic-number (mislabelled/extensionless entries still work) and returns `null` for anything unrenderable — the importer then skips it with a specific "format the browser can't display" warning instead of a broken ` `. A frame's multiple `` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and ``-wrapped frames are unwrapped (the hyperlink dropped). **Non-web formats we can decode client-side** (currently **TIFF**) are converted to PNG up front by `convertUnsupportedImages(bytes)` — an async pass `applyImport` runs before the synchronous import, passing a `path → PNG data-URI` map (`ConvertedImages`) into `importOdt`/`importDocx` (default empty, so tests stay sync). The decoder (`utif2`) is a **dynamic `import()`** → its own lazy chunk, loaded only when a convertible image is actually present, so ordinary docs pay nothing. **EMF** goes through the same lazy pre-pass into an SVG picture (`import/emf.ts`, below). **SVM/WMF** still have no decoder (SVM is StarOffice-proprietary, WMF a different 16-bit record format) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB). A **CMYK** JPEG loses its ICC profile (`stripCmykIccProfile`): Chromium colour-manages through it, LibreOffice and Word convert naively, and the file's own colours are the ones every word processor shows (measured on the contract fixture's logo: blue (0,80,131) → (0,77,183) against LibreOffice's (0,32,183), black (57,53,54) → (11,11,18) against (0,0,0)).
- **EMF (`import/emf.ts`)**: `emfToSvg(bytes)` replays the record stream into SVG — window/viewport plus world transform mapped per point, a DC stack (`SAVEDC`/`RESTOREDC`), pens/brushes/fonts (incl. the stock objects), the 16- and 32-bit poly records, path brackets, `RECTANGLE`/`ELLIPSE`, `EXTTEXTOUT` (its per-character advances become `textLength`, so labels keep their width) and `STRETCHDIBITS` (the DIB re-wrapped as a .bmp data-URI). **Text is stored as raw code units**, so a symbol font leaves lone surrogates behind — they must be stripped or `encodeURIComponent` throws and the whole picture is lost. Unknown records are skipped; a file that yields nothing returns null and keeps the placeholder.
- **Charts (`import/chart.ts`, DOCX)**: `word/charts/chartN.xml` is parsed and **drawn as an SVG picture** of the frame's own size — series values from the `numCache` the producer left, colours from the named theme accent (`themeAccents`), title/axis titles/gridlines, and the value axis' own `c:scaling` bounds where the file fixes them. Bar (incl. stacked and `barDir="bar"`), line, area, scatter and pie; anything else keeps the placeholder. Read-only — the editor has no chart object, so a re-export carries the picture, not the chart. **ODF** charts go through the same drawing (`odfChartDataUrl`, `convertChartFrame`): `` in the `draw:object`'s sub-document, series values read out of its own `local-table` by cell range, colours from the series' `draw:fill-color`, bounds from the y-axis style's `chart:maximum`/`-minimum`.
@@ -33,7 +33,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **Page geometry is the *body's* master page**, not the one named `Standard`: the importer counts the blocks each `style:master-page-name` governs and re-reads geometry (and the app's own header/footer) from the winner. A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one.
- **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame.
- **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text.
-- **A hoisted text box keeps what its anchor paragraph gave it**: an as-char figure frame is centred by the paragraph it sits in, so `wrapAlign` carries that (or the frame's own `style:horizontal-pos`) onto the box. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default.
+- **An as-char text box is placed by its paragraph**, which is where its alignment stays — the box keeps only the `style:horizontal-pos` a band-wrapped frame declares of its own. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default.
- **A cell's number format** is on its *style* in ODF (`style:data-style-name` → a `number:number-style`/`percentage-style`/`currency-style`/`date-style`, read for its family, decimals and grouping) and on the field in DOCX (the `\#` picture, or `\@` for a date, which is not part of the formula and is cut out of it). Only a formula cell keeps one, and the file's own currency symbol and date order give way to the document language's.
- **Vertical text in a box** is the style's writing mode in ODF (a frame's graphic properties — where LibreOffice writes it — or a drawing shape's paragraph properties; both top-to-bottom modes) and `w:bodyPr vert` in DOCX (`vert`/`eaVert`/`mongolianVert`/`wordArtVert`; `vert270` reads bottom-to-top, which the browser cannot lay out, so it is not one). VML says the same as `layout-flow:vertical` on the textbox.
- **Ruby**: `text:ruby` and `w:ruby` are one atom here (`ruby.ts`), so only the two texts are read — the base out of `text:ruby-base` / `w:rubyBase`, the reading out of `text:ruby-text` / `w:rt`. A file's own alignment and position are dropped; both products' defaults are what the exports write.
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
index 98f08a8..b6adc42 100644
--- a/src/lib/import/docx.ts
+++ b/src/lib/import/docx.ts
@@ -26,7 +26,7 @@ import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSett
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
import { clampPageStart, DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
import { citationStyleFromDocx, type CitationStyle } from '../utils/citationStyle';
-import { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, type OdtImportResult } from './odt';
+import { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, unnestBoxes, type OdtImportResult } from './odt';
import { chartDataUrl } from './chart';
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
@@ -50,8 +50,6 @@ type Node = { type: string; attrs?: Record; 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;
// Word styleId → registry name, and the ids blocks actually reference.
@@ -64,7 +62,6 @@ type Ctx = {
rels: Map;
imageCache: Map;
convertedImages: ConvertedImages;
- pendingBlocks: Node[];
listCounters: Map>; // numId → ilvl → last number used
usedListStyles: Map; // numId → the named numbering style it links to
// Text width (cm) of the file's page setup; a table's margins are relative to it.
@@ -194,7 +191,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const sectPr = fc(body, 'sectPr');
const contentWidthCm = sectionContentWidthCm(sectPr);
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
- const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), usedListStyles: new Map(), contentWidthCm, leftMarginCm, pageRtl: sectPrRtl(sectPr), hyphenate: docAutoHyphenation(files), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), bibSources: docxSources(files), citationStyle: docxCitationStyle(files), notes: [], noteParts: {
+ const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, listCounters: new Map(), usedListStyles: new Map(), contentWidthCm, leftMarginCm, pageRtl: sectPrRtl(sectPr), hyphenate: docAutoHyphenation(files), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), bibSources: docxSources(files), citationStyle: docxCitationStyle(files), notes: [], noteParts: {
footnote: noteParts(files, 'footnotes', 'footnote'),
endnote: noteParts(files, 'endnotes', 'endnote'),
} };
@@ -519,30 +516,6 @@ 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') {
- // A box lifted out of its anchor paragraph keeps the alignment that paragraph gave
- // it: an as-char frame is set against the middle of the column by the paragraph it
- // sits in, not by anything of its own.
- const align = anchor?.attrs?.textAlign;
- if (align === 'center' || align === 'right')
- for (const b of pending)
- if (b.type === 'textBox' && b.attrs && b.attrs.wrapOffset == null && !b.attrs.wrapAlign)
- b.attrs.wrapAlign = align;
- 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 (trackBookmark(el, ctx) || trackComment(el, ctx)) continue;
@@ -597,7 +570,7 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
flush();
const { blocks, trailingBreak } = splitParaAtPageBreaks(convertParagraph(el, ctx, kind, boldByDefault), kind);
if (breakPending) { applyBreakBefore(blocks[0]); breakPending = false; }
- for (const b of blocks) pushWithPending(b);
+ out.push(...blocks);
breakPending = trailingBreak;
}
} else if (el.localName === 'tbl') {
@@ -633,8 +606,6 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
}
}
flush();
- // Boxes anchored inside list items land after the whole list.
- pushWithPending(null);
return out;
}
@@ -1436,9 +1407,9 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
// date/time field (replaced by its live node).
const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime || !!fieldShown);
- // Route a drawing/pict result: an image is inline, a text box a block node riding
- // ctx.pendingBlocks. The one-paragraph header/footer zone takes as-char images only —
- // boxes and floating page-sized drawings (backgrounds, watermarks) are dropped there.
+ // Route a drawing/pict result: both a picture and a text box are inline and stay
+ // where the drawing sits. The one-paragraph header/footer zone takes as-char images
+ // only — boxes and floating page-sized drawings (backgrounds, watermarks) go.
const pushDrawn = (n: Node | null, floating: boolean) => {
if (!n) return;
if (hfFields) {
@@ -1446,8 +1417,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
ctx.warnings.add('Drawings were removed');
return;
}
- if (n.type !== 'textBox') { out.push(n); return; }
- ctx.pendingBlocks.push(n);
+ out.push(n);
};
// Set when this run's w:footnoteReference declares a custom mark: the run's own
@@ -2154,7 +2124,7 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct
if (['vert', 'eaVert', 'mongolianVert', 'wordArtVert'].includes(vert)) attrs.textVertical = true;
const txbxContent = nsChild(nsChild(wsp, WPS, 'txbx'), W, 'txbxContent');
- const blocks = txbxContent ? convertBlocks(Array.from(txbxContent.children), ctx, 'cell') : [];
+ const blocks = txbxContent ? unnestBoxes(convertBlocks(Array.from(txbxContent.children), ctx, 'cell'), ctx) : [];
return { type: 'textBox', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
}
@@ -2231,7 +2201,7 @@ function convertPict(pict: Element, ctx: Ctx): Node | null {
const swm = /^([\d.]+)\s*(pt)?$/.exec(shape.getAttribute('strokeweight') ?? '');
setShapeStyleAttrs(attrs, fill, stroke, swm ? parseFloat(swm[1]) : null);
- const blocks = txbxContent ? convertBlocks(Array.from(txbxContent.children), ctx, 'cell') : [];
+ const blocks = txbxContent ? unnestBoxes(convertBlocks(Array.from(txbxContent.children), ctx, 'cell'), ctx) : [];
return { type: 'textBox', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
}
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
index c378f6d..c40dba4 100644
--- a/src/lib/import/odt.ts
+++ b/src/lib/import/odt.ts
@@ -124,8 +124,7 @@ 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. pendingBlocks is the side channel
-// for text boxes found in inline content — convertBlocks flushes them after the anchor.
+// imageCache dedupes repeated hrefs into one data-URI.
type Ctx = {
resolver: StyleResolver;
// ODF style name → display name, and the names blocks actually reference: the
@@ -140,7 +139,6 @@ type Ctx = {
files: Record;
imageCache: Map;
convertedImages: ConvertedImages;
- pendingBlocks: Node[];
// Text width (cm) of the file's page setup; a table's margins are relative to it.
contentWidthCm: number;
// The page's own direction: a block declaring the same one is inheriting, not
@@ -320,16 +318,18 @@ export function applyUniformRunFont(attrs: Record, content: { t
// text the offset can only be drawn as the lines standing above the frame.
export function sinkOffsetFrames(content: { type: string; text?: string; attrs?: Record }[]): void {
const sinks = (n: { type: string; attrs?: Record }) =>
- n.type === 'image' && n.attrs?.wrap === 'topBottom' && (n.attrs.wrapOffsetY as number) > 0;
+ (n.type === 'image' || n.type === 'textBox')
+ && n.attrs?.wrap === 'topBottom' && (n.attrs.wrapOffsetY as number) > 0;
if (!content.some(sinks) || !content.some(n => n.type === 'text' && n.text?.trim())) return;
const frames = content.filter(sinks);
for (const f of frames) content.splice(content.indexOf(f), 1);
content.push(...frames);
}
-// Two top-and-bottom frames set against opposite ends of nearby paragraphs share one
+// Two top-and-bottom pictures set against opposite ends of nearby paragraphs share one
// band and sit side by side, as they do in LibreOffice and Word. Only such a pair keeps
-// its wrapAlign: a lone frame reserves the whole band, which is what the wrap means.
+// its wrapAlign: a lone picture reserves the whole band, which is what the wrap means.
+// Text boxes are left out — a box's place across its band is one the editor offers.
export function pairAlignedFrames(blocks: { content?: { type: string; attrs?: Record }[] }[], columnPx: number): void {
const found: { attrs: Record; at: number }[] = [];
blocks.forEach((b, i) => {
@@ -440,10 +440,25 @@ function shapeStyleAttrs(gp: PropMap, attrs: Record, defaultSol
// (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');
+ const blocks = unnestBoxes(convertBlocks(textChildren, ctx, 'cell'), ctx);
return blocks.length ? blocks : [{ type: 'paragraph' }];
}
+// A box inside a box has nowhere to go — both word processors refuse one and the editor
+// bars it — so its blocks take its place in the outer box. Bottom-up, so one pass does.
+export function unnestBoxes(blocks: Node[], ctx: { warnings: Set }): Node[] {
+ const out: Node[] = [];
+ for (const block of blocks) {
+ const boxes = (block.content ?? []).filter(n => n.type === 'textBox');
+ if (!boxes.length) { out.push(block); continue; }
+ ctx.warnings.add('Text boxes nested in other text boxes were flattened');
+ block.content = block.content!.filter(n => n.type !== 'textBox');
+ if (block.content.length) out.push(block);
+ for (const b of boxes) out.push(...(b.content ?? [{ type: 'paragraph' }]));
+ }
+ return out;
+}
+
// A → 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).
@@ -639,7 +654,7 @@ function convertLine(el: Element, ctx: Ctx): Node | null {
}
// 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.
+// node; everything else is dropped with a warning.
function convertDrawElement(e: Element, ctx: Ctx): { inline?: Node; block?: Node } | null {
// The watermark is not a drawing: it rides the page decoration instead
// (storage/pageDecor.ts), so it must not also arrive as a shape in the header.
@@ -758,7 +773,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
const contentWidthCm = geo
? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right
: pageDimsCm('A4', 'portrait').w - 2 * 2.12;
- const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), usedListStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false };
+ const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), usedListStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm)));
@@ -1082,33 +1097,6 @@ function hiddenParagraph(el: Element, ctx: Ctx): boolean {
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 export's wrapper) is dropped; in cells/boxes they unwrap 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') {
- // A box lifted out of its anchor paragraph keeps the alignment that paragraph gave
- // it: an as-char figure frame is set against the middle of the column by the
- // paragraph it sits in, not by anything of its own.
- const align = anchor?.attrs?.textAlign;
- if (align === 'center' || align === 'right') {
- for (const b of pending) {
- if (b.type === 'textBox' && b.attrs && b.attrs.wrapOffset == null && !b.attrs.wrapAlign) {
- b.attrs.wrapAlign = align;
- }
- }
- }
- 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' }]));
- }
- };
-
// A page-anchored frame is out of the text flow: it rides a paragraph of its own,
// which collapses to nothing (editor.css). In a cell there is no page corner to place
// it from, so it stays the ordinary floating frame the wrap rules made of it.
@@ -1128,7 +1116,8 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef
if (el.namespaceURI === NS.text) {
if (el.localName === 'p' || el.localName === 'h') {
if (hiddenParagraph(el, ctx)) continue;
- pushWithPending(hoistPageFrames(convertParaLike(el, ctx, kind, boldByDefault)));
+ const block = hoistPageFrames(convertParaLike(el, ctx, kind, boldByDefault));
+ if (block) out.push(block);
} else if (el.localName === 'list') {
// A list wrapping only headings is ODF outline (chapter) numbering, not a real
// list — unwrap it to plain headings instead of empty nested list levels.
@@ -1139,7 +1128,6 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef
const list = convertList(el, ctx, null, 1);
if (list) out.push(list);
}
- pushWithPending(null);
} else if (el.localName === 'section') {
const inner = convertBlocks(Array.from(el.children), ctx, kind, boldByDefault);
const cols = ctx.resolver.sectionColumns(el.getAttributeNS(NS.text, 'style-name'));
@@ -1167,12 +1155,10 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef
}
} else if (el.namespaceURI === NS.draw) {
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);
- }
+ // A frame at block level (rare — ODF allows one straight under office:text)
+ // → wrapped in a paragraph, which is where an inline node has to live.
+ const frame = conv?.inline ?? conv?.block;
+ if (frame) out.push({ type: 'paragraph', content: [frame] });
}
}
// A block's own "break after" becomes the next block's break before — the same page
@@ -1666,21 +1652,6 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
: defaults;
const content = convertInline(el, ctx, baseTextProps, runDefaults, false);
- // A box lifted out of a paragraph that held nothing else replaces it in the flow, so
- // it takes that paragraph's own space above and below — resolved, not the direct half:
- // the box carries no style name to inherit the rest from.
- if (kind === 'body' && !content.length && ctx.pendingBlocks.length) {
- const mt = snapPt(lengthToPt(paraProps['fo:margin-top']) ?? 0);
- const mb = snapPt(lengthToPt(paraProps['fo:margin-bottom']) ?? 0);
- for (const b of ctx.pendingBlocks) {
- // A floating box is out of the flow and placed by its own offsets instead.
- const wrap = b.attrs?.wrap;
- if (b.type !== 'textBox' || wrap === 'left' || wrap === 'right') continue;
- if (mt) b.attrs!.spaceBefore = mt;
- if (mb) b.attrs!.spaceAfter = mb;
- }
- }
-
// The paragraph style's own font size is the block's line-height floor on every
// line, not only on an empty one; carry it as a block attr (mirrors the docx
// paragraph-mark size), or text smaller than the style keeps the taller strut.
@@ -2285,8 +2256,9 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
} else if (conv) ctx.warnings.add('Drawings were removed');
continue;
}
+ // A box is inline like a picture, so it stays exactly where the frame sits.
if (conv?.inline) out.push(conv.inline);
- else if (conv?.block) ctx.pendingBlocks.push(conv.block);
+ else if (conv?.block) out.push(conv.block);
continue;
}
if (e.namespaceURI === NS.office && (e.localName === 'annotation' || e.localName === 'annotation-end')) {
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 05cb238..b2dc111 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -488,11 +488,6 @@
shared image-* handles; .textbox-content is the editable area. */
.paper .tiptap .textbox-node {
position: relative;
- /* The anchor paragraph's space above, as padding so it adds to the block above
- rather than collapsing against it (textBox.ts, storage/spacingModel.ts). The
- inline height is the frame's, so the padding has to sit outside it. */
- box-sizing: content-box;
- padding-top: var(--space-before);
}
.paper .tiptap .textbox-node.textbox-selected,
.paper .tiptap .textbox-node.textbox-active {
@@ -501,9 +496,7 @@
.paper .tiptap .textbox-rotor {
position: absolute;
left: 50%;
- /* Centred in the padding box, so half the space above has to come back to keep the
- frame itself below it — the *applied* space, which a page top drops. */
- top: calc(50% + var(--space-top, 0px) / 2);
+ top: 50%;
box-sizing: border-box;
transform: translate(-50%, -50%);
}
diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts
index 3e63450..fbf0b28 100644
--- a/tests/docx-roundtrip.test.ts
+++ b/tests/docx-roundtrip.test.ts
@@ -58,11 +58,11 @@ describe('DOCX export → import round trip', () => {
li(para('ml two')),
] },
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', wrapOffsetY: 2.5, alt: 'pic' } }]),
- { type: 'textBox', attrs: { width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 }, content: [
+ para([{ 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', wrapOffset: 6, wrapOffsetY: 1.5, shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] },
+ ] }]),
+ para([{ type: 'textBox', attrs: { width: 192, height: 96, wrap: 'right', wrapOffset: 6, wrapOffsetY: 1.5, shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] }]),
{ type: 'columns', attrs: { count: 2, gapCm: 0.8 }, content: [para('newspaper one'), para('newspaper two')] },
{ type: 'table', content: [
{ type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] },
@@ -1173,14 +1173,14 @@ describe('DOCX text box: a list inside it is a real list, a picture a real pictu
const doc: N = { type: 'doc', content: [
// A body list first, so the box's numbering has to find free ids beside it.
{ type: 'bulletList', content: [li(para('body item'))] },
- { type: 'textBox', attrs: { width: 300, height: 200 }, content: [
+ para([{ type: 'textBox', attrs: { width: 300, height: 200 }, content: [
para('box text'),
{ type: 'bulletList', content: [
li(para('one')),
li(para('two'), { type: 'orderedList', attrs: { start: 3 }, content: [li(para('a')), li(para('b'))] }),
] },
para([{ type: 'image', attrs: { src: PNG, width: 64, height: 64, alt: 'dot' } }]),
- ] },
+ ] }]),
] };
it('mints numbering, media and relationships of its own', async () => {
@@ -1216,7 +1216,7 @@ describe('DOCX text box: a list inside it is a real list, a picture a real pictu
it('round-trips the whole box', async () => {
const back = importDocx(await buildDocx(doc, { top: 2, bottom: 2, left: 2, right: 2 } as any, 'portrait'));
expect(back.warnings).toEqual([]);
- const box = (back.content.content ?? []).find((n: N) => n.type === 'textBox')!;
+ const box = walk(back.content as N, 'textBox')[0];
const list = box.content!.find((n: N) => n.type === 'bulletList')!;
expect(list.content![1].content![1].type).toBe('orderedList');
expect(list.content![1].content![1].attrs.start).toBe(3);
diff --git a/tests/fuzzDoc.ts b/tests/fuzzDoc.ts
index f310070..bd2f308 100644
--- a/tests/fuzzDoc.ts
+++ b/tests/fuzzDoc.ts
@@ -232,7 +232,8 @@ function textBox(r: Rng): N {
if (attrs.wrap === 'topBottom' && maybe(r, 0.3)) attrs.wrapOffsetY = 1.5;
}
const kids = Array.from({ length: int(r, 1, 2) }, () => paragraph(r));
- return { type: 'textBox', attrs, content: kids };
+ // A box is inline, so it rides a paragraph of its own.
+ return { type: 'paragraph', content: [{ type: 'textBox', attrs, content: kids }] };
}
// A generated index. Both importers return an empty entry cache (the node view refills
diff --git a/tests/kitchenSink.ts b/tests/kitchenSink.ts
index 41de093..b8b75a4 100644
--- a/tests/kitchenSink.ts
+++ b/tests/kitchenSink.ts
@@ -136,17 +136,17 @@ export function kitchenSinkDoc(): N {
t('Bild über dem Text.')]),
h(1, 'Textrahmen und Formen'),
- { type: 'textBox', attrs: { width: 240, height: 96, fillColor: '#FFE0A0', strokeColor: '#0070C0', strokeWidthPt: 2 }, content: [
+ p([{ type: 'textBox', attrs: { width: 240, height: 96, fillColor: '#FFE0A0', strokeColor: '#0070C0', strokeWidthPt: 2 }, content: [
p([t('Ein Rahmen mit Liste und Bild:')]),
{ type: 'bulletList', content: [li(p([t('Punkt im Rahmen')]))] },
p([{ type: 'image', attrs: { src: PNG, width: 48, height: 24 } }]),
- ] },
- { type: 'textBox', attrs: { width: 192, height: 96, shapeKind: 'roundRect', fillColor: '#DDEEFF', wrap: 'right', wrapOffset: 1.5 }, content: [
+ ] }]),
+ p([{ type: 'textBox', attrs: { width: 192, height: 96, shapeKind: 'roundRect', fillColor: '#DDEEFF', wrap: 'right', wrapOffset: 1.5 }, content: [
p([t('Form mit Text')]),
- ] },
- { type: 'textBox', attrs: { width: 96, height: 144, textVertical: true }, content: [
+ ] }]),
+ p([{ type: 'textBox', attrs: { width: 96, height: 144, textVertical: true }, content: [
p([t('senkrecht')]),
- ] },
+ ] }]),
h(1, 'Formeln'),
p([t('Inline: '), { type: 'formula', attrs: { latex: 'x^{2}+1', display: false } }, t(' im Satz.')]),
diff --git a/tests/lo-roundtrip.test.ts b/tests/lo-roundtrip.test.ts
index d62fc4e..d13e522 100644
--- a/tests/lo-roundtrip.test.ts
+++ b/tests/lo-roundtrip.test.ts
@@ -30,6 +30,13 @@ const IMGN = (width: number, height: number, alt?: string, rotation?: number, wr
...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}),
} });
const TBX = (attrs: N, ...content: N[]): N => ({ type: 'textBox', attrs, content });
+// A box is inline, so it rides a paragraph of its own.
+const PBX = (attrs: N, ...content: N[]): N => ({ type: 'paragraph', content: [TBX(attrs, ...content)] });
+const boxesIn = (n: N, out: N[] = []): N[] => {
+ if (n?.type === 'textBox') out.push(n);
+ for (const c of n?.content ?? []) boxesIn(c, out);
+ return out;
+};
const margins = { top: 3, bottom: 2, left: 2.5, right: 1.5 };
@@ -91,11 +98,11 @@ const fixture: N = {
] },
// A list and a nested image survive only in a Writer text frame — a drawing-object
// text box flattens both, so this guards the Frame-parent discriminator.
- TBX({ width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 },
+ PBX({ width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 },
P(null, T('box text')),
{ type: 'bulletList', content: [LI(P(null, T('box bullet')))] },
P(null, T('second para'), IMGN(40, 20))),
- TBX({ width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 },
+ PBX({ 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.')),
],
@@ -199,7 +206,7 @@ describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () =
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');
+ const boxes = boxesIn(res.content);
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);
diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts
index 7d8e92b..58917dc 100644
--- a/tests/roundtrip.test.ts
+++ b/tests/roundtrip.test.ts
@@ -48,6 +48,14 @@ const IMGN = (width: number, height: number, alt?: string, rotation?: number, wr
...(wrapOffsetY ? { wrapOffsetY } : {}),
} });
const TBX = (attrs: N, ...content: N[]): N => ({ type: 'textBox', attrs, content });
+// A box is inline, so it rides a paragraph; most fixtures give it one of its own.
+const PBX = (attrs: N, ...content: N[]): N => P(null, TBX(attrs, ...content));
+// Every text box in a document, at whatever depth.
+const boxesIn = (n: N, out: N[] = []): N[] => {
+ if (n?.type === 'textBox') out.push(n);
+ for (const c of n?.content ?? []) boxesIn(c, out);
+ return out;
+};
const COLS = (attrs: N, ...content: N[]): N => ({ type: 'columns', attrs, content });
const margins = { top: 3, bottom: 2, left: 2.5, right: 1.5 };
@@ -96,8 +104,8 @@ const fixture: N = {
P(null, T('rotated: '), IMGN(120, 80, 'Rotated', 30)),
P(null, T('wrapped left '), IMGN(90, 60, 'Float', 0, 'left', 2.5), 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', wrapOffset: 6, wrapOffsetY: 1.5, shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, P(null, T('in ellipse'))),
+ PBX({ width: 288, height: 96 }, P(null, T('box para one')), P(null, T('box '), T('bold', { type: 'bold' }))),
+ PBX({ width: 192, height: 80, wrap: 'right', wrapOffset: 6, wrapOffsetY: 1.5, shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, P(null, T('in ellipse'))),
COLS({ count: 2, gapCm: 0.5 }, P(null, T('newspaper column text one')), P(null, T('newspaper column text two'))),
{ type: 'bulletList', content: [
LI(P(null, T('bullet one'))),
@@ -1167,19 +1175,16 @@ describe('Leg 6: text boxes / shapes (ODT)', () => {
type: 'doc',
content: [
P(null, T('before')),
- TBX({ width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 },
+ PBX({ 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 },
+ PBX({ 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 },
+ PBX({ width: 192, height: 80, wrap: 'left', shapeKind: 'roundRect', fillColor: null, strokeColor: null },
P(null, T('transparent round')),
),
- TBX({ width: 192, height: 96, spaceBefore: 6, spaceAfter: 8 },
- P(null, T('spaced box')),
- ),
P(null, T('after')),
],
};
@@ -1197,13 +1202,10 @@ describe('Leg 6: text boxes / shapes (ODT)', () => {
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 4 boxes round-trip', boxes.length === 4, (res.content.content ?? []).map((n: N) => n.type));
+ const boxes = boxesIn(res.content);
+ check('all 3 boxes round-trip', boxes.length === 3, (res.content.content ?? []).map((n: N) => n.type));
- const [plain, ellipse, round, spaced] = boxes;
- check('anchor paragraph carries the box spacing',
- xml.includes('style:name="TbxP4"') && xml.includes('fo:margin-top="6pt" fo:margin-bottom="8pt"'), xml.slice(0, 0));
- check('box spacing round-trips', spaced?.attrs?.spaceBefore === 6 && spaced?.attrs?.spaceAfter === 8, spaced?.attrs);
+ 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',
@@ -1249,16 +1251,17 @@ describe('Leg 7: foreign shapes/text boxes → importOdt', () => {
const f = importOdt(foreign);
const c = f.content.content!;
- const boxes = c.filter((n: N) => n.type === 'textBox');
- check('5 supported shapes imported', boxes.length === 5, c.map((n: N) => n.type));
+ const boxes = boxesIn(f.content);
+ check('6 supported shapes imported', boxes.length === 6, c.map((n: N) => n.type));
- const [floatBox, rect, ellipse, star, line] = boxes;
+ const [floatBox, rect, ellipse, star, line, inCell] = 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('frame: the text around it is kept, the box between the two runs',
+ c[0]?.content?.map((n: N) => n.text ?? `[${n.type}]`).join('') === 'anchor [textBox]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);
@@ -1268,10 +1271,9 @@ describe('Leg 7: foreign shapes/text boxes → importOdt', () => {
check('draw:line: a plain line, no heads declared', line?.attrs?.shapeKind === 'line', line?.attrs);
check('draw:line: 5cm wide and flat', line?.attrs?.width === 189 && line?.attrs?.height === 0, line?.attrs);
- 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);
+ // A box reaches a cell through the cell's paragraph, as it does in Word.
+ check('box in cell kept as a box', inCell?.content?.[0]?.content?.[0]?.text === 'box in cell', inCell);
+ check('no flatten warning', !f.warnings.some(w => /nested in table cells/.test(w)), f.warnings);
});
});
@@ -2318,12 +2320,11 @@ describe('Leg 23: recorded revisions', () => {
describe('Leg 24: preset shapes beyond rect / round-rect / ellipse', () => {
const shape = (kind: string, text: string): N =>
- TBX({ width: 200, height: 120, shapeKind: kind }, P(null, T(text)));
+ PBX({ width: 200, height: 120, shapeKind: kind }, P(null, T(text)));
const doc: N = { type: 'doc', content: [
shape('triangle', 'tri'), shape('star5', 'star'), shape('rightArrow', 'arrow'),
] };
- const kinds = (back: N) => (back.content.content ?? [])
- .filter((n: N) => n.type === 'textBox').map((n: N) => n.attrs?.shapeKind).join(',');
+ const kinds = (back: N) => boxesIn(back.content).map((n: N) => n.attrs?.shapeKind).join(',');
it('ODT: each preset gets its draw:type, its own path and its text area', async () => {
const bytes = await buildOdt(doc, margins, 'portrait');
@@ -2544,8 +2545,8 @@ describe('Leg 29: lines and arrows (ODT + DOCX)', () => {
attrs: { width: 200, height: 60, shapeKind: kind, strokeColor: '#000000', ...(flipV ? { flipV: true } : {}) },
content: [P(null)],
});
- const doc: N = { type: 'doc', content: [P(null, T('above')), line('lineArrow', true), P(null, T('below'))] };
- const boxOf = (res: N): N => (res.content.content ?? []).find((n: N) => n.type === 'textBox');
+ const doc: N = { type: 'doc', content: [P(null, T('above')), P(null, line('lineArrow', true)), P(null, T('below'))] };
+ const boxOf = (res: N): N => boxesIn(res.content)[0];
it('ODT: a line is two endpoints, not a frame', async () => {
const bytes = await buildOdt(doc, margins);
@@ -2622,13 +2623,13 @@ describe('Leg 28: a cell formula (ODT + DOCX)', () => {
// captioned figure, and what a caption that stays with its picture would build.
describe('Leg 30: a figure frame (picture + caption in one box)', () => {
const SEQ: N = { type: 'sequenceField', attrs: { category: 'figure', format: '1', number: 1 } };
- const frame = TBX(
+ const frame = PBX(
{ width: 240, height: 190, wrap: 'topBottom', wrapAlign: 'center', fillColor: null, strokeColor: null },
P(null, IMGN(240, 160, 'pic')),
P({ styleName: 'Caption' }, T('Figure '), SEQ, T(': framed')),
);
const doc: N = { type: 'doc', content: [P(null, T('body')), frame] };
- const boxOf = (res: N): N => (res.content.content ?? []).find((n: N) => n.type === 'textBox');
+ const boxOf = (res: N): N => boxesIn(res.content)[0];
const shapeOf = (box: N): string =>
(box?.content ?? []).map((b: N) => (b.content ?? []).map((c: N) => c.type).join('+')).join('|');
@@ -2741,25 +2742,49 @@ describe('Leg 32: fold marks (ODT + DOCX)', () => {
});
describe('Leg 33: where a box sits across the column (ODT + DOCX)', () => {
- const box = (wrap: string, wrapAlign: string | null): N =>
- TBX({ width: 200, height: 90, wrap, ...(wrapAlign ? { wrapAlign } : {}) }, P(null, T('Kasten')));
- const doc = (wrap: string, wrapAlign: string | null): N =>
- ({ type: 'doc', content: [P(null, T('above')), box(wrap, wrapAlign), P(null, T('below'))] });
- const alignOf = (res: N): unknown =>
- (res.content.content ?? []).find((n: N) => n.type === 'textBox')?.attrs?.wrapAlign ?? null;
+ // A box in the line is a character: the paragraph it sits in aligns it, and that is
+ // what both formats write (fo:text-align / w:jc). A band-wrapped box has a place of
+ // its own across the column instead, which is what wrapAlign is.
+ const inlineDoc = (align: string | null): N => ({
+ type: 'doc',
+ content: [
+ P(null, T('above')),
+ P(align ? { textAlign: align } : null, TBX({ width: 200, height: 90 }, P(null, T('Kasten')))),
+ P(null, T('below')),
+ ],
+ });
+ const bandDoc = (wrapAlign: string | null): N => ({
+ type: 'doc',
+ content: [
+ P(null, T('above')),
+ PBX({ width: 200, height: 90, wrap: 'topBottom', ...(wrapAlign ? { wrapAlign } : {}) }, P(null, T('Kasten'))),
+ P(null, T('below')),
+ ],
+ });
+ const alignOf = (res: N): unknown => boxesIn(res.content)[0]?.attrs?.wrapAlign ?? null;
+ const paraAlignOf = (res: N): unknown =>
+ (res.content.content ?? []).find((n: N) => boxesIn(n).length)?.attrs?.textAlign ?? null;
- for (const [wrap, align] of [['inline', 'center'], ['inline', 'right'], ['topBottom', 'center'], ['topBottom', 'right'], ['topBottom', null]] as const) {
- it(`ODT: ${wrap} ${align ?? 'left'} comes back`, async () => {
- const bytes = await buildOdt(doc(wrap, align), margins);
+ for (const align of ['center', 'right', null] as const) {
+ it(`ODT: an in-line box takes its paragraph's ${align ?? 'left'} alignment`, async () => {
+ const bytes = await buildOdt(inlineDoc(align), margins);
+ check('the alignment survives', paraAlignOf(importOdt(bytes)) === align, paraAlignOf(importOdt(bytes)));
+ });
+
+ it(`DOCX: an in-line box takes its paragraph's ${align ?? 'left'} alignment`, async () => {
+ const bytes = await buildDocx(inlineDoc(align), margins);
+ check('the alignment survives', paraAlignOf(importDocx(bytes)) === align, paraAlignOf(importDocx(bytes)));
+ });
+
+ it(`ODT: a band-wrapped box keeps ${align ?? 'left'}`, async () => {
+ const bytes = await buildOdt(bandDoc(align), margins);
check('the alignment survives', alignOf(importOdt(bytes)) === align, alignOf(importOdt(bytes)));
});
- it(`DOCX: ${wrap} ${align ?? 'left'} comes back`, async () => {
- const bytes = await buildDocx(doc(wrap, align), margins);
+ it(`DOCX: a band-wrapped box keeps ${align ?? 'left'}`, async () => {
+ const bytes = await buildDocx(bandDoc(align), margins);
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
- if (wrap === 'topBottom') {
- check('the band position is written', xml.includes(`${align ?? 'left'} `), //.exec(xml)?.[0]);
- }
+ check('the band position is written', xml.includes(`${align ?? 'left'} `), //.exec(xml)?.[0]);
check('the alignment survives', alignOf(importDocx(bytes)) === align, alignOf(importDocx(bytes)));
});
}
@@ -2768,7 +2793,7 @@ describe('Leg 33: where a box sits across the column (ODT + DOCX)', () => {
describe('Leg 34: a box keeps the height it declares (DOCX)', () => {
const doc: N = {
type: 'doc',
- content: [P(null, T('above')), TBX({ width: 200, height: 90, wrap: 'topBottom' }, P(null, T('inside'))), P(null, T('below'))],
+ content: [P(null, T('above')), PBX({ width: 200, height: 90, wrap: 'topBottom' }, P(null, T('inside'))), P(null, T('below'))],
};
it('the shape body does not autofit to its text', async () => {
@@ -2781,3 +2806,91 @@ describe('Leg 34: a box keeps the height it declares (DOCX)', () => {
check('the declared height still rides the extent', xml.includes('cy="857250"'), 'wp:extent');
});
});
+
+// A box is a character now, so what has to survive is its place in the run and the
+// places the old block model could not reach at all.
+describe('Leg 35: an in-line box keeps its place in the paragraph (ODT + DOCX)', () => {
+ const doc: N = {
+ type: 'doc',
+ content: [P(null, T('vor '), TBX({ width: 160, height: 60 }, P(null, T('Kasten'))), T(' nach'))],
+ };
+ // The paragraph's content types, so a box that slipped out of the run shows up.
+ const shape = (res: N): string =>
+ ((res.content.content ?? [])[0]?.content ?? []).map((n: N) => n.type).join('+');
+
+ it('ODT: an as-char frame between the two runs', async () => {
+ const bytes = await buildOdt(doc, margins);
+ const xml = strFromU8(unzipSync(bytes)['content.xml']);
+ check('one paragraph holds text, frame and text', /]*>vor nach<\/text:p>/.test(xml),
+ /]*>vor [\s\S]{0,120}/.exec(xml)?.[0]);
+ check('anchored as-char', xml.includes('text:anchor-type="as-char"'), 'anchor');
+ // The Frame parent hangs a frame naming no vertical position below the line.
+ check('standing on the baseline', xml.includes('style:vertical-pos="top" style:vertical-rel="baseline"'), 'TbxFr1');
+ check('it comes back between the two runs', shape(importOdt(bytes)) === 'text+textBox+text', shape(importOdt(bytes)));
+ });
+
+ it('DOCX: a wp:inline drawing between the two runs', async () => {
+ const bytes = await buildDocx(doc, margins);
+ const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
+ check('inline, not anchored', xml.includes(' {
+ const doc: N = {
+ type: 'doc',
+ content: [
+ P(null, T('vor der Tabelle')),
+ { type: 'table', content: [ROW(
+ CELL([6], P(null, T('zelle '), TBX({ width: 120, height: 50 }, P(null, T('im Kasten'))))),
+ CELL([6], P(null, T('b'))),
+ )] },
+ ],
+ };
+ const inCell = (res: N): N => {
+ const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
+ return boxesIn(table)[0];
+ };
+
+ it('ODT: the frame rides the cell paragraph', async () => {
+ const bytes = await buildOdt(doc, margins);
+ const xml = strFromU8(unzipSync(bytes)['content.xml']);
+ check('the frame is inside the cell', //.test(xml), 'cell');
+ const res = importOdt(bytes);
+ check('no warnings', res.warnings.length === 0, res.warnings);
+ check('the box survives with its text', inCell(res)?.content?.[0]?.content?.[0]?.text === 'im Kasten', inCell(res));
+ });
+
+ it('DOCX: the drawing rides the cell paragraph', async () => {
+ const bytes = await buildDocx(doc, margins);
+ const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
+ check('the drawing is inside the cell', /[\s\S]*?/.test(xml), 'w:tc');
+ const res = importDocx(bytes);
+ check('the box survives with its text', inCell(res)?.content?.[0]?.content?.[0]?.text === 'im Kasten', inCell(res));
+ });
+});
+
+// The editor bars a box inside a box (both word processors do), so a file carrying one
+// has to arrive without it — a rejected load would drop the whole document.
+describe('Leg 37: a box inside a box is unwrapped on import (ODT)', () => {
+ it('keeps its blocks in the outer box, with a warning', () => {
+ const contentXml = `
+
+
+ aussen innen
+
+ `;
+ 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 boxes = boxesIn(f.content);
+ check('only the outer box survives', boxes.length === 1, boxes.map((b: N) => b.attrs));
+ check('the inner box\'s text is kept in it',
+ JSON.stringify(boxes[0]?.content).includes('innen'), boxes[0]?.content);
+ check('and it is reported', f.warnings.some((w: string) => /nested in other text boxes/.test(w)), f.warnings);
+ });
+});
diff --git a/tests/unit/docx-export.test.ts b/tests/unit/docx-export.test.ts
index 4176ec7..a6b7156 100644
--- a/tests/unit/docx-export.test.ts
+++ b/tests/unit/docx-export.test.ts
@@ -42,7 +42,7 @@ const fixture = {
li(para('legal'), { type: 'orderedList', content: [li(para('legal sub'))] }),
] },
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')] },
+ para([{ 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')] },
diff --git a/tests/unit/freeform-shape.test.ts b/tests/unit/freeform-shape.test.ts
index 9595f9c..966bd4b 100644
--- a/tests/unit/freeform-shape.test.ts
+++ b/tests/unit/freeform-shape.test.ts
@@ -38,7 +38,10 @@ const docx = (body: string): Uint8Array => zipSync({
+ `${body} `),
});
-const shape = (r: N): N => ((r.content as N).content ?? []).find((n: N) => n.type === 'textBox');
+const shape = (r: N): N => {
+ const find = (n: N): N => n?.type === 'textBox' ? n : (n?.content ?? []).map(find).find(Boolean);
+ return find((r.content as N));
+};
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
describe('a freeform drawing', () => {
@@ -80,11 +83,11 @@ describe('a freeform drawing', () => {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'before' }] },
- {
+ { type: 'paragraph', content: [{
type: 'textBox',
attrs: { width: 200, height: 150, shapePath: 'M 0 0 C 25 0 75 100 100 50 L 0 100 Z' },
content: [{ type: 'paragraph' }],
- },
+ }] },
],
};
const bytes = await buildOdt(doc, margins, 'portrait');
@@ -99,11 +102,11 @@ describe('a freeform drawing', () => {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'before' }] },
- {
+ { type: 'paragraph', content: [{
type: 'textBox',
attrs: { width: 200, height: 100, shapePath: 'M 0 0 L 100 50 L 0 100 Z' },
content: [{ type: 'paragraph' }],
- },
+ }] },
],
};
const bytes = await buildDocx(doc, margins, 'portrait');
diff --git a/tests/unit/print-text-box.test.ts b/tests/unit/print-text-box.test.ts
new file mode 100644
index 0000000..64faef0
--- /dev/null
+++ b/tests/unit/print-text-box.test.ts
@@ -0,0 +1,42 @@
+// The vector print path turns the document into an HTML string and the reader parses it
+// back. A text box is an inline node carrying blocks, and no can hold those — the
+// parser breaks the paragraph open around it — so the box gets a paragraph of its own
+// first and the artifacts of that split are cleaned out.
+import { describe, it, expect } from 'vitest';
+import { buildBodyHtml } from '../../src/lib/export/pdf';
+
+type N = any;
+const T = (text: string): N => ({ type: 'text', text });
+const P = (...content: N[]): N => ({ type: 'paragraph', ...(content.length ? { content } : {}) });
+const BOX = (): N => ({ type: 'textBox', attrs: { width: 160, height: 60 }, content: [P(T('KASTEN'))] });
+
+// The blocks the reader's parser makes of that string.
+function shape(html: string): string {
+ const doc = new DOMParser().parseFromString(`
${html}`, 'text/html');
+ return Array.from(doc.body.children)
+ .map((el) => {
+ const tag = (el as HTMLElement).dataset.textbox !== undefined ? 'BOX' : el.tagName;
+ const text = el.textContent?.replace(/\s+/g, ' ').trim();
+ return text ? `${tag}(${text})` : tag;
+ })
+ .join(' ');
+}
+
+describe('a text box in the printed HTML', () => {
+ it('stands between whole paragraphs, with no split artifacts', () => {
+ const doc: N = { type: 'doc', content: [P(T('vor '), BOX(), T(' nach')), P(T('ende'))] };
+ expect(shape(buildBodyHtml(doc))).toBe('P(vor nach) BOX(KASTEN) P(ende)');
+ });
+
+ it("keeps a blank line the reader wrote next to it", () => {
+ const doc: N = { type: 'doc', content: [P(), P(T('mit '), BOX()), P()] };
+ // Each empty paragraph keeps its own line (a the pass re-adds); only the two
+ // the split invented are gone.
+ expect(shape(buildBodyHtml(doc))).toBe('P P(mit) BOX(KASTEN) P');
+ });
+
+ it('leaves a document without boxes untouched', () => {
+ const doc: N = { type: 'doc', content: [P(T('eins')), P(T('zwei'))] };
+ expect(shape(buildBodyHtml(doc))).toBe('P(eins) P(zwei)');
+ });
+});
diff --git a/tests/unit/text-box-guards.test.ts b/tests/unit/text-box-guards.test.ts
new file mode 100644
index 0000000..0a730a0
--- /dev/null
+++ b/tests/unit/text-box-guards.test.ts
@@ -0,0 +1,134 @@
+// A text box is an inline node with block content, so two things the schema alone
+// cannot say have to be enforced: a box never nests in another, and a selection never
+// ends inside a box it did not start in (deleting one would dissolve the frame and
+// spill its text into the body).
+import { describe, it, expect } from 'vitest';
+import { Editor } from '@tiptap/core';
+import Document from '@tiptap/extension-document';
+import Paragraph from '@tiptap/extension-paragraph';
+import Text from '@tiptap/extension-text';
+import Heading from '@tiptap/extension-heading';
+import { BulletList } from '../../src/lib/editor/extensions/bulletList';
+import { OrderedList } from '../../src/lib/editor/extensions/orderedList';
+import ListItem from '@tiptap/extension-list-item';
+import { TextBox } from '../../src/lib/editor/extensions/textBox';
+
+type N = any;
+
+// jsdom has none, and the node view refits its wrapper through one.
+(globalThis as N).ResizeObserver ??= class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+};
+
+const box = (text: string): N => ({
+ type: 'textBox',
+ attrs: { width: 160, height: 60 },
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
+});
+
+function makeEditor(...content: N[]) {
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ return new Editor({
+ element: el,
+ extensions: [Document, Paragraph, Text, Heading, BulletList, OrderedList, ListItem, TextBox],
+ content: { type: 'doc', content },
+ });
+}
+
+const boxCount = (ed: Editor): number => {
+ let n = 0;
+ ed.state.doc.descendants((node) => { if (node.type.name === 'textBox') n += 1; });
+ return n;
+};
+// The depth the caret sits at inside a box, 0 when it is not in one.
+const boxDepth = (ed: Editor): number => {
+ const $p = ed.state.selection.$from;
+ for (let d = 1; d <= $p.depth; d++) if ($p.node(d).type.name === 'textBox') return d;
+ return 0;
+};
+const boxPos = (ed: Editor): number => {
+ let pos = -1;
+ ed.state.doc.descendants((node, p) => { if (pos < 0 && node.type.name === 'textBox') pos = p; });
+ return pos;
+};
+
+const doc = (): N[] => [
+ { type: 'paragraph', content: [{ type: 'text', text: 'vor AAA ' }, box('KASTEN'), { type: 'text', text: ' nach BBB' }] },
+];
+
+describe('a text box in the line', () => {
+ it('lives in the paragraph, not beside it', () => {
+ const ed = makeEditor(...doc());
+ const para = ed.state.doc.child(0);
+ expect(para.type.name).toBe('paragraph');
+ expect(para.content.child(1).type.name).toBe('textBox');
+ expect(ed.state.schema.nodes.textBox.isInline).toBe(true);
+ ed.destroy();
+ });
+
+ it('refuses a box inside a box', () => {
+ const ed = makeEditor(...doc());
+ const s = ed.state.schema;
+ const nested = s.nodes.textBox.create({ width: 60, height: 30 }, s.nodes.paragraph.create(null, s.text('INNEN')));
+ // Position 2 inside the box's own first paragraph.
+ ed.view.dispatch(ed.state.tr.insert(boxPos(ed) + 2, nested));
+ expect(boxCount(ed)).toBe(1);
+ ed.destroy();
+ });
+
+ it('grows a selection that reaches into the box to the whole box', () => {
+ const ed = makeEditor(...doc());
+ const pos = boxPos(ed);
+ ed.commands.setTextSelection({ from: pos - 4, to: pos + 4 });
+ const sel = ed.state.selection;
+ expect(sel.from).toBe(pos - 4);
+ expect(sel.to).toBe(pos + ed.state.doc.nodeAt(pos)!.nodeSize);
+ ed.destroy();
+ });
+
+ it('deletes such a selection whole instead of spilling the box text', () => {
+ const ed = makeEditor(...doc());
+ const pos = boxPos(ed);
+ ed.commands.setTextSelection({ from: pos - 4, to: pos + 4 });
+ ed.commands.deleteSelection();
+ expect(boxCount(ed)).toBe(0);
+ expect(ed.state.doc.textBetween(0, ed.state.doc.content.size)).toBe('vor nach BBB');
+ ed.destroy();
+ });
+
+ it('leaves a selection inside one box alone', () => {
+ const ed = makeEditor(...doc());
+ const pos = boxPos(ed);
+ ed.commands.setTextSelection({ from: pos + 2, to: pos + 5 });
+ expect(ed.state.selection.from).toBe(pos + 2);
+ expect(ed.state.selection.to).toBe(pos + 5);
+ ed.destroy();
+ });
+});
+
+describe('inserting a text box', () => {
+ it('puts it at the caret, cursor inside', () => {
+ const ed = makeEditor({ type: 'paragraph', content: [{ type: 'text', text: 'abcd' }] });
+ ed.commands.setTextSelection(3);
+ ed.commands.insertTextBox();
+ const para = ed.state.doc.child(0);
+ expect(para.content.child(1).type.name).toBe('textBox');
+ expect(ed.state.selection.$from.parent.type.name).toBe('paragraph');
+ expect(boxDepth(ed)).toBeGreaterThan(0);
+ ed.destroy();
+ });
+
+ it('puts a second one after the block when the caret is inside one', () => {
+ const ed = makeEditor(...doc());
+ ed.commands.setTextSelection(boxPos(ed) + 2);
+ ed.commands.insertTextBox();
+ expect(boxCount(ed)).toBe(2);
+ // The new box rides a paragraph of its own, next to the one holding the first.
+ expect(ed.state.doc.childCount).toBe(2);
+ expect(ed.state.doc.child(1).content.child(0).type.name).toBe('textBox');
+ ed.destroy();
+ });
+});
diff --git a/tests/unit/vertical-text-box.test.ts b/tests/unit/vertical-text-box.test.ts
index 26a9d33..7c10e47 100644
--- a/tests/unit/vertical-text-box.test.ts
+++ b/tests/unit/vertical-text-box.test.ts
@@ -17,11 +17,15 @@ const box = (attrs: N): N => ({
});
const doc: N = {
type: 'doc',
- content: [{ type: 'paragraph', content: [{ type: 'text', text: 'davor' }] }, box({ textVertical: true })],
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'davor' }] },
+ { type: 'paragraph', content: [box({ textVertical: true })] }],
};
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
-const boxOf = (r: N): N => ((r.content as N).content ?? []).find((n: N) => n.type === 'textBox');
+const boxOf = (r: N): N => {
+ const find = (n: N): N => n?.type === 'textBox' ? n : (n?.content ?? []).map(find).find(Boolean);
+ return find((r.content as N));
+};
describe('a text box with vertical text', () => {
it('round-trips through ODF', async () => {
@@ -32,7 +36,7 @@ describe('a text box with vertical text', () => {
});
it('keeps the writing mode of a shape in its paragraph properties', async () => {
- const shaped: N = { ...doc, content: [doc.content[0], box({ textVertical: true, shapeKind: 'roundRect' })] };
+ const shaped: N = { ...doc, content: [doc.content[0], { type: 'paragraph', content: [box({ textVertical: true, shapeKind: 'roundRect' })] }] };
const xml = strFromU8(unzipSync(await buildOdt(shaped, margins, 'portrait'))['content.xml']);
expect(xml).toContain(' ');
expect(boxOf(importOdt(await buildOdt(shaped, margins, 'portrait')))?.attrs.textVertical).toBe(true);
@@ -46,7 +50,7 @@ describe('a text box with vertical text', () => {
});
it('leaves a horizontal box alone', async () => {
- const flat: N = { ...doc, content: [doc.content[0], box({})] };
+ const flat: N = { ...doc, content: [doc.content[0], { type: 'paragraph', content: [box({})] }] };
const odt = strFromU8(unzipSync(await buildOdt(flat, margins, 'portrait'))['content.xml']);
expect(odt).not.toContain('writing-mode="tb-rl"');
const docx = strFromU8(unzipSync(await buildDocx(flat, margins, 'portrait'))['word/document.xml']);