feat: a text box sits in the line of text

textBox becomes an inline node carrying block content, like the frame both
formats write, so "in line with text" really is in the line and a box reaches
a table cell or a list item through their paragraphs. Both exports write it
where it sits (an as-char draw:frame / a wp:inline drawing) and both imports
leave it there, which drops the hoisting machinery on either side.

Two guards the schema cannot express: no box inside a box, and a selection
reaching into a box grows to the box's bounds rather than dissolving it.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:01 +02:00
1 parent 21999afbc6
commit 060dcd7380
29 files changed
+700 -345

No files matched your search

+14 -1
View File
@@ -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 `<img>` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'|'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 **nLine truncated
- **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap` — 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 `<a:noAutofit/>`, 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 `<a:noAutofit/>`, 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 `<p>` 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
+12 -1
View File
@@ -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.
+5 -2
View File
@@ -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}
+7 -11
View File
@@ -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; }
+10 -4
View File
@@ -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}
<button
class="tb-btn"
class:active={(wrapAlign ?? 'left') === a}
class:active={current === a}
title={alignTitle(a)}
aria-label={alignTitle(a)}
aria-pressed={(wrapAlign ?? 'left') === a}
aria-pressed={current === a}
onclick={() => setAlign(a)}
>
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
+4 -4
View File
@@ -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 <text:section> +
+2 -2
View File
@@ -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;
+7 -28
View File
@@ -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<HTMLElement>('.image-node'))
.filter((img) => !!img.style.float);
const floats = Array.from(child.querySelectorAll<HTMLElement>('.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).
@@ -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[],
};
},
+122 -35
View File
@@ -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<TextBoxAttrs>) => 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<TextBoxAttrs>) =>
@@ -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`;
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { Plugin, PluginKey } from '@tiptap/pm/state';
const trailingNodeKey = new PluginKey('trailingNode');
// Block types that can't sit at the document end without trapping the cursor.
const NEEDS_TRAILING_AFTER = new Set(['table', 'textBox', 'columns']);
const NEEDS_TRAILING_AFTER = new Set(['table', 'columns']);
export const TrailingNode = Extension.create({
name: 'trailingNode',
+2 -1
View File
@@ -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 `<text:line-break/>`/`<text:tab/>` 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 `<draw:frame text:anchor-type="as-char" svg:width/height><draw:image xlink:href="Pictures/imageN.png"/></draw:frame>`, adds the binary `Pictures/` entries to the zip, and appends a `<manifest:file-entry>` 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(<rad>) 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 `<style:style style:family="graphic">` 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 `<text:p>` 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 `<text:h>`/`<text:p>`/`<text:list>` (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 `<table:covered-table-cell>` 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 `'<W>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 `<draw:frame><draw:text-box fo:min-height>` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `<draw:custom-shape>` + preset `<draw:enhanced-geometry>` (`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 `<style:paragraph-properties style:writing-mode="tb-rl"/>` (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 `<draw:frame><draw:text-box fo:min-height>` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `<draw:custom-shape>` + preset `<draw:enhanced-geometry>` (`utils/shapes.ts`; see `docs/architecture/frames.md`) over the `P{i}` sentinel, in whatever `<text:p>` 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 `<style:paragraph-properties style:writing-mode="tb-rl"/>` (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 `<w:abstractNum>`/`<w:num>` pair above its highest id, appended **before** the first `<w:num>` 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 `<draw:frame><draw:object xlink:href="./Formula{n}"/>` 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 `<m:oMath>` (or `<m:oMathPara>` when displayed) from the same post-pack pass as the text boxes. See `docs/architecture/formulas.md`.
+20 -19
View File
@@ -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<string, { id: number; kind: NoteKind; label: string | null }>();
// 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(`<w:p\\b[^>]*?>(?:(?!</w:p>)[\\s\\S])*?${TBX}(\\d+)${TBX}(?:(?!</w:p>)[\\s\\S])*?</w:p>`, 'g'),
new RegExp(`<w:r\\b[^>]*?>(?:(?!</w:r>)[\\s\\S])*?${TBX}(\\d+)${TBX}(?:(?!</w:r>)[\\s\\S])*?</w:r>`, '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
? `<w:pPr><w:jc w:val="${box.alignH}"/></w:pPr>` : '';
return `<w:p>${pPr}<w:r>${textBoxDrawingXml(box, Number(idx), parts)}</w:r></w:p>`;
return box ? `<w:r>${textBoxDrawingXml(box, Number(idx), parts)}</w:r>` : '';
},
);
@@ -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);
+47 -52
View File
@@ -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 (
`<style:style style:name="TbxP${index + 1}" style:family="paragraph" style:parent-style-name="Standard">` +
`<style:paragraph-properties fo:margin-top="${box.spaceBeforePt}pt" fo:margin-bottom="${box.spaceAfterPt}pt"${align}/>` +
`</style:style>`
);
}
// The drawing element wrapping a box's serialized blocks: a <draw:frame>/<draw:text-box>
// for plain text boxes (height = fo:min-height, so it grows with content like the
// editor), or a <draw:custom-shape> with 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 = `<draw:marker draw:name="${ODF_ARROW}" svg:viewBox="0 0 20 30" svg:d="m10 0-10 30h20z"/>`;
// 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 <text:p> 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<number, string>();
content = content.replace(
new RegExp(`<text:p\\b[^>]*>${TBX}S(\\d+)${TBX}</text:p>([\\s\\S]*?)<text:p\\b[^>]*>${TBX}E\\1${TBX}</text:p>`, 'g'),
(_m, idx: string, inner: string) => {
const i = Number(idx);
const box = boxes[i];
if (!box) return '';
const style = textBoxAnchorStyle(box, i) ? `TbxP${i + 1}` : 'Standard';
return `<text:p text:style-name="${style}">${textBoxXml(box, inner, i)}</text:p>`;
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.
+28 -2
View File
@@ -383,11 +383,37 @@ function weightsFromRow(row: Json): (number | null)[] {
return w;
}
// A text box is an inline node carrying blocks, and no <p> 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 <colgroup>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;
+2 -2
View File
@@ -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):** `<img>` 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 `<img>`. A frame's multiple `<draw:image>` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and `<draw:a>`-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`): `<chart:chart>` 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.
+9 -39
View File
@@ -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<string, unknown>; content?: Node[]; m
type BlockKind = 'body' | 'list' | 'cell';
type RelInfo = { target: string; external: boolean };
// pendingBlocks: text boxes/shapes found inside runs — block nodes that convertBlocks
// flushes after the anchor paragraph (mirrors import/odt.ts).
type Ctx = {
styles: DocxStyles;
// Word styleId → registry name, and the ids blocks actually reference.
@@ -64,7 +62,6 @@ type Ctx = {
rels: Map<string, RelInfo>;
imageCache: Map<string, string>;
convertedImages: ConvertedImages;
pendingBlocks: Node[];
listCounters: Map<number, Map<number, number>>; // numId → ilvl → last number used
usedListStyles: Map<number, string>; // 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' }] };
}
+32 -60
View File
@@ -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<string, Uint8Array>;
imageCache: Map<string, string>;
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<string, unknown>, 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<string, unknown> }[]): void {
const sinks = (n: { type: string; attrs?: Record<string, unknown> }) =>
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<string, unknown> }[] }[], columnPx: number): void {
const found: { attrs: Record<string, unknown>; at: number }[] = [];
blocks.forEach((b, i) => {
@@ -440,10 +440,25 @@ function shapeStyleAttrs(gp: PropMap, attrs: Record<string, unknown>, 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<string> }): 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 <draw:frame><draw:text-box> → a textBox node. The height is the text-box's
// fo:min-height when present (our own export; height = minimum, content grows the
// box), else the frame's computed svg:height (LibreOffice re-saves).
@@ -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')) {
+1 -8
View File
@@ -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%);
}
+6 -6
View File
@@ -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);
+2 -1
View File
@@ -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
+6 -6
View File
@@ -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.')]),
+10 -3
View File
@@ -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);
+157 -44
View File
@@ -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(`<wp:align>${align ?? 'left'}</wp:align>`), /<wp:positionH[\s\S]*?<\/wp:positionH>/.exec(xml)?.[0]);
}
check('the band position is written', xml.includes(`<wp:align>${align ?? 'left'}</wp:align>`), /<wp:positionH[\s\S]*?<\/wp:positionH>/.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', /<text:p[^>]*>vor <draw:frame[\s\S]*?<\/draw:frame> nach<\/text:p>/.test(xml),
/<text:p[^>]*>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('<wp:inline') && !xml.includes('<wp:anchor'), 'wp');
check('the surrounding runs are kept', xml.includes('vor ') && xml.includes(' nach'), 'runs');
check('it comes back between the two runs', shape(importDocx(bytes)) === 'text+textBox+text', shape(importDocx(bytes)));
});
});
describe('Leg 36: a box in a table cell (ODT + DOCX)', () => {
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', /<table:table-cell[\s\S]*?<draw:frame[\s\S]*?<\/table:table-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', /<w:tc>[\s\S]*?<wps:wsp[\s\S]*?<\/w:tc>/.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 = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0">
<office:body><office:text>
<text:p><draw:frame text:anchor-type="as-char" svg:width="8cm"><draw:text-box><text:p>aussen</text:p><text:p><draw:frame text:anchor-type="as-char" svg:width="3cm"><draw:text-box><text:p>innen</text:p></draw:text-box></draw:frame></text:p></draw:text-box></draw:frame></text:p>
</office:text></office:body>
</office:document-content>`;
const foreign = zipSync({
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
'content.xml': [strToU8(contentXml), { level: 6 }],
} as any);
const f = importOdt(foreign);
const 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);
});
});
+1 -1
View File
@@ -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')] },
+8 -5
View File
@@ -38,7 +38,10 @@ const docx = (body: string): Uint8Array => zipSync({
+ `<w:body>${body}</w:body></w:document>`),
});
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');
+42
View File
@@ -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 <p> 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(`<body>${html}</body>`, '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 <br> 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)');
});
});
+134
View File
@@ -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();
});
});
+8 -4
View File
@@ -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('<style:paragraph-properties style:writing-mode="tb-rl"/>');
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']);