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); + }); +});