diff --git a/CHANGELOG.md b/CHANGELOG.md
index 091cac3..24f3cf9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -80,6 +80,7 @@
- Bookmarks and cross-references: name a selection, then insert a reference to it (its text, its number or its page); the reference follows the text it points at. Round-trips to ODF `text:bookmark-start`/`text:bookmark-ref` and Word's `Bookmark` + `REF`/`PAGEREF` fields
- Manual page break (Ctrl+Enter); round-trips to ODF `fo:break-before`
- Text boxes and basic shapes — rectangle, rounded rectangle, ellipse, triangles, diamond, pentagon, hexagon, five-point star, trapezoid, parallelogram and the four block arrows: editable block content, fill and border colors, border width, resize/rotate handles, text wrap (inline / left / right / top-bottom) like images; a shape gallery in the floating toolbar and in the ribbon's Shape tab. Each outline is one polygon in `utils/shapes.ts` that the editor draws as SVG and the ODF export scales into its `draw:enhanced-path`, so the shape on screen is the shape in the file; Word gets the preset's name and draws its own. Round-trips to ODF `draw:frame`/`draw:text-box` + `draw:custom-shape` and DOCX DrawingML `wps:wsp`/`wps:txbx` (imports Word's `mc:AlternateContent` and legacy VML text boxes too)
+- A text box carries its content into Word intact: a list inside one is a real Word list with its own numbering definition (nesting, marker symbols and start values included) and a picture inside one is a real picture, its bytes and relationship minted beside the package's own. Both were flattened to literal `•`/`1.` markers and dropped, respectively
**Page & layout**
- Page margins (cm) and page orientation (portrait / landscape)
@@ -132,7 +133,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
**Content an imported document loses**
- Charts are **drawn** from the file (`import/chart.ts`: DrawingML `chartN.xml` and ODF `chart:chart`), but as a picture, not a chart object — a re-export carries the drawing and the numbers behind it are no longer editable. The same holds for an **EMF** metafile (`import/emf.ts`): it is drawn, but as the SVG picture it was rebuilt into, and only from the record set a plot consists of — a hatched brush, a clipping region or a rotated bitmap is skipped. **WMF/SVM** metafiles and OLE objects still keep their box and a placeholder label, and export writes that back out: WMF is a different (16-bit) record format, SVM is StarOffice-proprietary, and an OLE object cannot be rendered without its application
- Shapes with no text area of their own: lines, arrows as a *line* rather than a block arrow, connectors and freeform curves — they are two endpoints, not a box, and ODF gives each its own element (`draw:line`, `draw:polyline`) rather than a `draw:custom-shape` preset. Rotated shape text (`w:bodyPr vert`) too. All dropped on import with a warning
-- Text boxes in the DOCX export: lists inside a box are flattened to literal-marker paragraphs (`•` / `1.`) and images inside a box are dropped — the box XML is injected by a post-pack string pass (`export/docx.ts` `applyTextBoxesDocx`) that mints no numbering.xml or media/rels entries. The ODT export has neither limitation
**Missing while writing**
- One colour for every tracked change, where both word processors give each author their own — and no reviewing pane listing the revisions the way the comments pane lists comments
diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md
index 8120c81..c884595 100644
--- a/src/lib/export/CLAUDE.md
+++ b/src/lib/export/CLAUDE.md
@@ -22,7 +22,8 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
- **Hard breaks & tabs** (`replaceHardBreaks`/`replaceTabs`/`applyInlineSentinels`): `hardBreak` nodes become `LBR`-sentinel text and tab chars become `TAB`-sentinel text so both survive every odf-kit path (a literal `\t` would collapse to a space), then the sentinels are rewritten to ``/`` in content.xml.
- **Images** (`replaceImages`/`applyImages`): odf-kit's native image path rounds dimensions to whole cm and doesn't reach our cell path, so images bypass it. `replaceImages` (a pre-pass) swaps each `image` node for an `IMG`-sentinel text run and collects the bytes (decoded data-URI) + cm geometry (px→cm via `round3`); the sentinel rides every odf-kit path including table cells. `applyImages` (after `applyCellBlocks`) rewrites each sentinel to a ``, adds the binary `Pictures/` entries to the zip, and appends a `` per picture to `META-INF/manifest.xml`. Integer px ⇄ round3-cm ⇄ px is sub-pixel exact, so size is unchanged. A rotated image also gets a `draw:transform="rotate() translate(...)"` (ODF rotate is CCW radians, ours CW degrees; the translate re-centres it on the unrotated box). A **floating** image (`wrap !== 'inline'`) instead uses `text:anchor-type="paragraph"` + a minted `` carrying `style:wrap` + `style:horizontal-pos` (image-left → `wrap="right" horizontal-pos="left"`; topBottom → `wrap="none" horizontal-pos="center"`) injected into content.xml automatic-styles.
- **Tables:** column widths come from the editor's per-column weights (`tableColumnWidthsCm`, summing exactly to the text width, with `table:align="margins"`). **Table margins:** a table's `marginLeft`/`marginRight` shrink that width and are written by `applyTableMargins` into the table's automatic style (`style:width` + `fo:margin-left`/`-right`; odf-kit names table styles `Table1`, `Table2`, … in document order, matching the descriptor list `exportTable` fills). odf-kit serializes each cell to a single `` of runs; `exportTable` emits all of a cell's content into that paragraph separated by a `SEG` sentinel and records a `CellBlock[]` descriptor, then `applyCellBlocks` splits on `SEG` and rebuilds real ``/``/`` (incl. nested lists) with minted automatic paragraph styles. **Merged cells:** `exportTable` passes each cell's `colspan`/`rowspan` as odf-kit `CellOptions.colSpan`/`rowSpan`; odf-kit emits `table:number-columns/rows-spanned` and auto-generates the `` placeholders (which never match the `applyCellBlocks` cell regex, so `cellBlocks` stays one-per-real-cell aligned). Import already reads spans/covered cells in `convertTable`. **Cell shading:** a cell's `backgroundColor` attr is passed as `CellOptions.backgroundColor` (normalized), which odf-kit mints into the cell style as `fo:background-color`. **Cell borders:** the per-side border attrs (tableCellBorders.ts) are passed as `CellOptions.borderTop/Right/Bottom/Left` (`'none'` or `'pt solid #RRGGBB'`) overriding the table-level default border per side.
-- **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a top-level `textBox` is **hoisted** — `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) swaps it for `TBX`-sentinel marker paragraphs (`S{i}`…`E{i}`, U+E008) bracketing its child blocks at top level, so every existing pass (custom attrs, list styles, inline sentinels, images) serializes them unchanged and document-order-indexed passes stay aligned. `applyTextBoxes` (after `applyImages`) wraps each serialized S…E region into `` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `` + preset `` (roundRect/ellipse, static LibreOffice-style paths) 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).
+- **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a top-level `textBox` is **hoisted** — `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) swaps it for `TBX`-sentinel marker paragraphs (`S{i}`…`E{i}`, U+E008) bracketing its child blocks at top level, so every existing pass (custom attrs, list styles, inline sentinels, images) serializes them unchanged and document-order-indexed passes stay aligned. `applyTextBoxes` (after `applyImages`) wraps each serialized S…E region into `` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `` + preset `` (`utils/shapes.ts`; see `docs/architecture/frames.md`) inside a fresh anchor paragraph, minting a `TbxFr{n}` graphic style (fill/stroke/padding + the image wrap props; `draw:auto-grow-height="true"` for boxes, both auto-grows explicitly **false** for shapes or LibreOffice's autofit shrinks them to their text).
+- **A text box's own parts, DOCX** (`applyTextBoxesDocx`): the `docx` package can't emit a DrawingML shape, so the box XML is a hand-built string — and its content needs parts the package never hears about. The pass holds the whole unzipped package, so it mints them itself: a picture becomes `word/media/tbx{n}.{ext}` plus a `Relationship` above the package's highest `rId` (Content_Types already declares the raster defaults), a list an ``/`` pair above its highest id, appended **before** the first `` because every abstract definition has to precede them. The numbering ids only exist once the packed file is in hand, so the paragraphs carry a `TXBX_NUM` sentinel that the same pass substitutes.
- **Formulas** (`replaceFormulas`/`applyFormulas`): an `MTH`-sentinel run becomes an as-char `` plus a `Formula{n}/content.xml` sub-document (MathML root) and its two manifest entries. **No `svg:width`/`svg:height`** — a sized frame makes LibreOffice scale the object instead of typesetting it. DOCX emits `` (or `` when displayed) from the same post-pack pass as the text boxes. See `docs/architecture/formulas.md`.
- **Right-to-left pages**: ODF gets `style:writing-mode="rl-tb"` on the page layout (`rewriteStylesXml`), DOCX a `` in every `w:sectPr` from a post-pack pass (`applyBidiDocx`) — the package exposes only the paragraph-level flag. A **paragraph's** own direction (`textDirection.ts`) rides that same flag in DOCX and the `ParaStyle`/PBX-spec pipe in ODF, so it reaches cells and list items as well as top-level blocks.
- **Mirror margins** (`applyMirrorMarginsDocx`, `export/docx.ts`): the `docx` package writes only the per-section `w:pgMar`, so Word's document-level `w:mirrorMargins` goes into `word/settings.xml` in the same post-pack pass; ODF's flag rides `rewriteStylesXml`. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel.
@@ -52,4 +53,5 @@ U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
`BMS`/`BME` U+E013/U+E014 (a bookmark's range), `XRF` U+E015 (cross-references),
`CHP` U+E016 (the chapter field), `FNT` U+E017 (footnotes/endnotes),
`CMS`/`CME` U+E018/U+E019 (a comment's range), `SEQ` U+E01A (a caption's number),
-`TCI` U+E01B (a tracked insertion's range), `TCD` U+E01C (where a tracked deletion was).
+`TCI` U+E01B (a tracked insertion's range), `TCD` U+E01C (where a tracked deletion was),
+`TXBX_NUM` U+E01D (DOCX only: a numbering id a box's list has yet to be given).
diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts
index ca2bea4..83a541e 100644
--- a/src/lib/export/docx.ts
+++ b/src/lib/export/docx.ts
@@ -150,6 +150,14 @@ function collectComments(node: TiptapNode): void {
// export/odt.ts): the docx library can't emit DrawingML shapes, so buildDocx swaps
// the marker for a hand-built in a zip post-processing pass.
const TBX = '';
+// Wraps the index of a numbering definition minted for a box's list; the ids only
+// become known once the packed numbering.xml is in hand.
+const TXBX_NUM = '';
+
+const WP_NS = 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"';
+const A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main';
+const PIC_NS = 'http://schemas.openxmlformats.org/drawingml/2006/picture';
+const R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
// CSS font-size string ("12pt", "16px", "1.2em") → Word half-points. em is relative
// to the 12pt body. Returns undefined when unparseable.
@@ -692,12 +700,45 @@ function txbxRunPropsXml(marks: TiptapNode['marks'] = []): string {
return parts.length ? `${parts.join('')}` : '';
}
-// One paragraph/heading of a text box. markerText prefixes a literal list marker
-// (in-box lists are flattened, like ODT cell lists once were); images are dropped
-// (media/rels can't be minted from a string pass).
-function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): string {
+// The package parts a box's content needs beyond word/document.xml: a picture is a
+// media entry plus a relationship, a list a numbering definition. applyTextBoxesDocx
+// runs after packing, so it holds the whole zip and can mint all three.
+type TxbxParts = {
+ media: { path: string; bytes: Uint8Array; rid: string; type: string }[];
+ nums: { id: number; levels: string[] }[];
+ nextRid: () => string;
+};
+
+// One picture inside a box, as an as-char drawing.
+function txbxImageXml(node: TiptapNode, parts: TxbxParts): string {
+ const src = node.attrs?.src;
+ if (typeof src !== 'string') return '';
+ const decoded = decodeDataUri(src);
+ if (!decoded) return '';
+ const n = parts.media.length + 1;
+ const ext = decoded.type === 'jpg' ? 'jpeg' : decoded.type;
+ const rid = parts.nextRid();
+ parts.media.push({ path: `word/media/tbx${n}.${ext}`, bytes: decoded.bytes, rid, type: ext });
+ const cx = Math.round((typeof node.attrs?.width === 'number' && node.attrs.width > 0 ? node.attrs.width : 200) * EMU_PER_PX);
+ const cy = Math.round((typeof node.attrs?.height === 'number' && node.attrs.height > 0 ? node.attrs.height : 150) * EMU_PER_PX);
+ const alt = escapeXml(typeof node.attrs?.alt === 'string' ? node.attrs.alt : '');
+ return (
+ `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ ``
+ );
+}
+
+// One paragraph/heading of a text box. `numPr` makes it a list item of one of the
+// numbering definitions minted for the box.
+function txbxParagraphXml(node: TiptapNode, parts: TxbxParts, indentTwip = 0, numPr = ''): string {
const attrs = node.attrs ?? {};
- const pPr: string[] = [];
+ const pPr: string[] = [numPr];
if (node.type === 'heading') {
const lvl = Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(attrs.level) || 1));
pPr.push(``);
@@ -706,9 +747,12 @@ function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): st
const ta = attrs.textAlign;
const jc = ta === 'center' ? 'center' : ta === 'right' ? 'right' : ta === 'justify' ? 'both' : '';
if (jc) pPr.push(``);
- let runs = markerText ? `${escapeXml(markerText)}` : '';
+ let runs = '';
for (const child of node.content ?? []) {
- if (child.type === 'text' && child.text) {
+ if (child.type === 'image') {
+ const drawing = txbxImageXml(child, parts);
+ if (drawing) runs += `${drawing}`;
+ } else if (child.type === 'text' && child.text) {
const rPr = txbxRunPropsXml(child.marks);
let inner = '';
child.text.split('\t').forEach((seg, i) => {
@@ -723,40 +767,67 @@ function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): st
return `${pPr.length ? `${pPr.join('')}` : ''}${runs}`;
}
-// In-box lists flatten to literal-marker paragraphs (real numbering would need
-// numbering.xml references, which the post-pack string pass can't mint).
-// `mlPrefix`: parent chain ("1.2.") when inside a multilevel list, else null.
-function txbxListXml(node: TiptapNode, depth: number, mlPrefix: string | null = null, cycle: OrderedCycle = ROOT_ORDERED_CYCLE): string {
+// One of a numbering definition minted for a box — the same shape
+// Numbering.ensureLevel hands the package for a body list.
+function txbxLevelXml(node: TiptapNode, depth: number, chained: boolean, cycle: OrderedCycle): string {
const ordered = node.type === 'orderedList';
const attr = node.attrs?.listStyleType as string | null | undefined;
- const chained = ordered && (attr === 'multilevel' || (mlPrefix !== null && !attr));
const def = effectiveOrderedDefAt(attr === 'multilevel' ? 'decimal' : attr, cycle);
+ const fmt = ordered
+ ? (chained ? 'decimal' : String(ORDERED_FORMAT[def.numFormat] ?? 'decimal'))
+ : 'bullet';
+ const text = !ordered
+ ? bulletCharOf(node, depth)
+ : chained
+ ? Array.from({ length: depth + 1 }, (_, i) => `%${i + 1}.`).join('')
+ : `%${depth + 1}${def.numSuffix}`;
+ const start = ordered && typeof node.attrs?.start === 'number' ? node.attrs.start : 1;
+ const jc = node.attrs?.markerAlign === 'right' ? 'right' : 'left';
+ return (
+ `` +
+ `` +
+ ``
+ );
+}
+
+// A list inside a box, as a real Word list: one numbering definition per top-level
+// list, each nesting level's format filled in when first met — as the body's
+// Numbering registry does, only written straight to XML.
+function txbxListXml(
+ node: TiptapNode, depth: number, parts: TxbxParts,
+ num: TxbxParts['nums'][number] | null = null, chainRoot = false, cycle: OrderedCycle = ROOT_ORDERED_CYCLE,
+): string {
+ const ordered = node.type === 'orderedList';
+ const attr = node.attrs?.listStyleType as string | null | undefined;
+ const chained = ordered && (attr === 'multilevel' || (chainRoot && !attr));
const cChild = childCycle(cycle, attr, ordered);
- let n = typeof node.attrs?.start === 'number' ? node.attrs.start : 1;
+ const def = num ?? { id: 0, levels: [] as string[] };
+ if (!num) {
+ def.id = parts.nums.length;
+ parts.nums.push(def);
+ }
+ if (!def.levels[depth]) def.levels[depth] = txbxLevelXml(node, depth, chained, cycle);
+ const numPr = ``;
let out = '';
for (const item of node.content ?? []) {
if (item.type !== 'listItem') continue;
- let first = true;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
- out += txbxListXml(child, depth + 1, chained ? `${mlPrefix ?? ''}${n}.` : null, cChild);
+ out += txbxListXml(child, depth + 1, parts, def, chained, cChild);
} else if (child.type === 'paragraph' || child.type === 'heading') {
- const ordinal = chained ? `${mlPrefix ?? ''}${n}. ` : `${formatOrdinal(n, def.numFormat)}${def.numSuffix} `;
- const marker = first ? (ordered ? ordinal : `${bulletCharOf(node, depth)} `) : '';
- out += txbxParagraphXml(child, cmToTwip((depth + 1) * LIST_HANGING_CM), marker);
- first = false;
+ out += txbxParagraphXml(child, parts, 0, numPr);
}
}
- n++;
}
return out;
}
-function txbxContentXml(blocks: TiptapNode[]): string {
+function txbxContentXml(blocks: TiptapNode[], parts: TxbxParts): string {
let out = '';
for (const b of blocks) {
- if (b.type === 'paragraph' || b.type === 'heading') out += txbxParagraphXml(b);
- else if (b.type === 'bulletList' || b.type === 'orderedList') out += txbxListXml(b, 0);
+ if (b.type === 'paragraph' || b.type === 'heading') out += txbxParagraphXml(b, parts);
+ else if (b.type === 'bulletList' || b.type === 'orderedList') out += txbxListXml(b, 0, parts);
}
return out || '';
}
@@ -764,7 +835,7 @@ function txbxContentXml(blocks: TiptapNode[]): string {
// The full for one text box. Namespaces are declared inline on the
// wp/a/wps elements so the output never depends on what the library declares on
// the document root.
-function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
+function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): string {
const cx = Math.round(box.widthPx * EMU_PER_PX);
const cy = Math.round(box.heightPx * EMU_PER_PX);
const rot = box.rotationDeg ? ` rot="${Math.round(box.rotationDeg * 60000)}"` : '';
@@ -782,7 +853,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
`` +
`` +
`${fill}${ln}` +
- `${txbxContentXml(box.content)}` +
+ `${txbxContentXml(box.content, parts)}` +
`${autofit}` +
``;
const graphic =
@@ -790,9 +861,8 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
`${wsp}`;
const docPr = ``;
const extent = ``;
- const wpNs = 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"';
if (box.wrap === 'inline') {
- return `${extent}${docPr}${graphic}`;
+ return `${extent}${docPr}${graphic}`;
}
// wrapText names the side TEXT flows on (inverse of the box side), like ODT.
const wrapEl = box.wrap === 'topBottom'
@@ -804,7 +874,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
? `${emu(box.offsetCm)}`
: `${align}`;
return (
- `` +
`` +
`${posH}` +
@@ -813,21 +883,63 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
);
}
+// The highest number already used by `attr` in `xml`, so a part we add gets a free one.
+function maxIdIn(xml: string, attr: RegExp): number {
+ let max = 0;
+ for (const m of xml.matchAll(attr)) max = Math.max(max, parseInt(m[1], 10) || 0);
+ return max;
+}
+
// Post-pack pass: swap each marker paragraph in word/document.xml for its drawing.
-// The tempered pattern keeps the match inside one paragraph.
+// The tempered pattern keeps the match inside one paragraph. A box's pictures and
+// lists need parts of their own — the pass holds the whole zip, so it appends the
+// media entries, their relationships and the numbering definitions here.
function applyTextBoxesDocx(bytes: Uint8Array, boxes: TextBoxDocx[]): Uint8Array {
if (!boxes.length) return bytes;
const files = unzipSync(bytes);
const docBytes = files['word/document.xml'];
if (!docBytes) return bytes;
let xml = strFromU8(docBytes);
+
+ const relsPath = 'word/_rels/document.xml.rels';
+ const rels = files[relsPath] ? strFromU8(files[relsPath]) : '';
+ let nextRid = maxIdIn(rels, /Id="rId(\d+)"/g);
+ const parts: TxbxParts = { media: [], nums: [], nextRid: () => `rId${++nextRid}` };
+
xml = xml.replace(
new RegExp(`]*?>(?:(?!)[\\s\\S])*?${TBX}(\\d+)${TBX}(?:(?!)[\\s\\S])*?`, 'g'),
(_m, idx: string) => {
const box = boxes[Number(idx)];
- return box ? `${textBoxDrawingXml(box, Number(idx))}` : '';
+ return box ? `${textBoxDrawingXml(box, Number(idx), parts)}` : '';
},
);
+
+ const numPath = 'word/numbering.xml';
+ const numXml = files[numPath] ? strFromU8(files[numPath]) : '';
+ if (parts.nums.length && numXml) {
+ // Both id spaces are the package's, so one free number above its highest serves
+ // as the abstract id and the concrete one alike.
+ const base = Math.max(maxIdIn(numXml, /w:abstractNumId="(\d+)"/g), maxIdIn(numXml, /w:numId="(\d+)"/g)) + 1;
+ const abstracts = parts.nums.map((n, i) =>
+ `` +
+ `${n.levels.filter(Boolean).join('')}`).join('');
+ const concretes = parts.nums.map((_n, i) =>
+ ``).join('');
+ // w:abstractNum must precede every w:num, so both go where the first w:num is.
+ const at = numXml.indexOf('', `${abstracts}${concretes}`)
+ : numXml.slice(0, at) + abstracts + concretes + numXml.slice(at));
+ xml = xml.replace(new RegExp(`${TXBX_NUM}(\\d+)${TXBX_NUM}`, 'g'), (_m, i: string) => String(base + Number(i)));
+ }
+
+ if (parts.media.length && rels) {
+ for (const m of parts.media) files[m.path] = m.bytes as Uint8Array;
+ files[relsPath] = strToU8(rels.replace('', parts.media.map((m) =>
+ ``).join('') +
+ ''));
+ }
+
files['word/document.xml'] = strToU8(xml);
const out: Record = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts
index 75f7a35..86eedbe 100644
--- a/tests/docx-roundtrip.test.ts
+++ b/tests/docx-roundtrip.test.ts
@@ -1146,3 +1146,61 @@ describe('DOCX table margins (dragged outer edges)', () => {
expect(table.attrs?.marginLeft ?? 0).toBe(0);
});
});
+
+describe('DOCX text box: a list inside it is a real list, a picture a real picture', () => {
+ 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('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 () => {
+ const files = unzipSync(await buildDocx(doc, { top: 2, bottom: 2, left: 2, right: 2 } as any, 'portrait'));
+ const xml = strFromU8(files['word/document.xml']);
+ const numbering = strFromU8(files['word/numbering.xml']);
+ const rels = strFromU8(files['word/_rels/document.xml.rels']);
+
+ const inBox = xml.slice(xml.indexOf(''));
+ const numIds = [...inBox.matchAll(//g)].map((m) => m[1]);
+ expect(numIds.length).toBe(4);
+ expect(new Set(numIds).size).toBe(1);
+ // The body's own list keeps its definition; the box's is a second one.
+ const bodyNumId = //.exec(xml)![1];
+ expect(numIds[0]).not.toBe(bodyNumId);
+ for (const id of new Set([...numIds, bodyNumId])) {
+ expect(numbering).toContain(``);
+ }
+ // Two levels: the bullet and the ordered list nested under it, starting at 3.
+ const added = numbering.slice(numbering.lastIndexOf(`');
+ expect(added).toContain('');
+ expect(added).toContain('');
+ // No literal markers left in the runs.
+ expect(inBox).not.toContain('• ');
+
+ expect(Object.keys(files)).toContain('word/media/tbx1.png');
+ const rid = / {
+ 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 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);
+ const img = walk(box, 'image')[0];
+ expect(img.attrs.width).toBe(64);
+ expect(img.attrs.alt).toBe('dot');
+ expect(String(img.attrs.src).startsWith('data:image/png;base64,')).toBe(true);
+ });
+});