From 7e037c03a8f96ec710d5cd17aa4e562253241815 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 11:06:48 +0200 Subject: [PATCH] fix: a document keeps its look across the formats, not only its words A header row is w:tblHeader on the row, the same thing ODF spells with . An index's leader and tab stop ride its first row, the level styles are a family per kind, and the stop is written even with no page number running to it. A box carries its own ring (w:bodyPr/@lIns) and where it sits across the column when behind the text. A section past the first now names its zones even when blank: a sectPr without a headerReference is Word's Link to Previous, which put a chapter's running head on the pages a blank section was meant for. --- src/lib/export/CLAUDE.md | 8 ++++ src/lib/export/docx.ts | 52 +++++++++++++++------ src/lib/import/CLAUDE.md | 11 +++++ src/lib/import/docx.ts | 67 ++++++++++++++++++++++----- src/lib/import/imageFormats.ts | 16 +++---- tests/docx-roundtrip.test.ts | 84 ++++++++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 33 deletions(-) diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 6be89db..6fb419d 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -59,6 +59,14 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow - **`normalizeColor`** — coerces colors to `#RRGGBB` (ODF requirement; rejects/normalizes `rgb()` and short hex). - **Schema conformance** (guarded by `tests/schema-validation.test.ts`; LibreOffice forgives all of this, Word's strict reader does not): `applyOdfVersion` stamps every ODF part **1.3** — the version LibreOffice writes, and the first with `style:header-first` — over odf-kit's 1.2. `draw:image` carries the `xlink:type/show/actuate` trio (`xlink:type` is mandatory beside `xlink:href`); `text:time-value` is an xsd dateTime, never a `PT…S` duration (the ODT importer still reads the legacy duration); `index-entry-link-start/-end` only in TOC entry templates; `text:dont-balance-text-columns` on `style:section-properties`. DOCX: `orderDocxSettings` (last pass) re-sorts `w:settings` children into the fixed CT_Settings sequence the prepend-passes scramble; `w14:paraId` in comments.xml requires `mc:Ignorable="w14"` on the root. `tests/package-lint.test.ts` guards the invariants the schemas cannot express (unique style ids, no dangling style/num/rel references, balanced ranges, manifest completeness). +- **A section past the first spells its zones out, blank ones included.** A `w:sectPr` + naming no `w:headerReference` is Word's "Link to Previous" and repeats the section above + it — measured: a chapter's running head landed on the pages a blank section was meant + for. Both zones write an empty part rather than none (`spellOut`, `mkHeaders`). +- **A box spanning the text column keeps where it sits across it**, banded or behind the + text alike; only a side wrap has its side dictated by the wrap itself. +- **An index row's tab stop is written even with no page number running to it** — it is + where the row's leader is kept, and a stop no tab reaches draws nothing. - **A heading writes its own `w:outlineLvl`** beside its `w:pStyle`: the style carries the look, the level says it is a heading at all, and a document setting its chapters in a style of its own leaves the importer nothing else to go by. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 512803e..d527cd0 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -853,10 +853,14 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | if (wrap === 'through') { // Word's in-front-of / behind-text: no wrap at all, and behindDoc names which side // of the text the frame lands on. It overlaps by definition, so allowOverlap holds. + // Where it sits across the text column is its own, as it is for a wrapped frame: + // a full-width figure behind the text is centred, not flush left. + const through = alignH === 'right' ? HorizontalPositionAlign.RIGHT + : alignH === 'center' ? HorizontalPositionAlign.CENTER : HorizontalPositionAlign.LEFT; return { horizontalPosition: offsetCm != null ? { relative: HorizontalPositionRelativeFrom.MARGIN, offset: Math.round(offsetCm * 360000) } - : { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT }, + : { relative: HorizontalPositionRelativeFrom.MARGIN, align: through }, verticalPosition, wrap: { type: TextWrappingType.NONE }, behindDocument: !inFront, @@ -868,9 +872,12 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | // may overlap vertically — that is what puts them side by side. const end = alignH === 'right' ? HorizontalPositionAlign.RIGHT : alignH === 'left' ? HorizontalPositionAlign.LEFT : null; + // Centred is a place in the band, not an end of it: it shares with nobody, so it + // keeps the overlap off that a side-by-side pair needs. + const mid = alignH === 'center' ? HorizontalPositionAlign.CENTER : null; return { - horizontalPosition: end - ? { relative: HorizontalPositionRelativeFrom.MARGIN, align: end } + horizontalPosition: end || mid + ? { relative: HorizontalPositionRelativeFrom.MARGIN, align: (end ?? mid)! } : offsetCm != null ? { relative: HorizontalPositionRelativeFrom.MARGIN, offset: Math.round(offsetCm * 360000) } : { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT }, @@ -949,6 +956,7 @@ type TextBoxDocx = { fill: string | null; stroke: string | null; strokeWidthPt: number; + paddingCm: number; content: TiptapNode[]; }; @@ -971,6 +979,8 @@ function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx { 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, + // The attr is only set where the box disagrees with the editor's own ring. + paddingCm: typeof a.paddingCm === 'number' && a.paddingCm >= 0 ? a.paddingCm : TEXTBOX_PADDING_CM, content: node.content ?? [], }; } @@ -1315,7 +1325,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): s const ln = box.stroke ? `${ends}` : ''; - const inset = Math.round(TEXTBOX_PADDING_CM * EMU_PER_CM); + const inset = Math.round(box.paddingCm * EMU_PER_CM); // The box keeps the height it declares — LibreOffice's own DOCX export writes this for // the same frame, and read as spAutoFit it lays the text out detached from the shape // (probed: the text lands in the body, over whatever follows). @@ -1352,9 +1362,10 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): s const wrapEl = box.wrap === 'through' ? '' : box.wrap === 'topBottom' ? '' : ``; - // A side wrap names the side itself; only a band-wrapped box has a place to choose. + // A side wrap names the side itself; a box spanning the column — banded or behind the + // text — has a place across it to choose, and a figure usually sits in the middle. const align = box.wrap === 'right' ? 'right' - : box.wrap === 'topBottom' ? box.alignH ?? 'left' : 'left'; + : box.wrap === 'topBottom' || box.wrap === 'through' ? box.alignH ?? 'left' : 'left'; const emu = (cm: number) => Math.round(cm * 360000); // The x rides topBottom too: it moves the frame within its full-width band. const posH = box.offsetCm != null @@ -2339,7 +2350,10 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering): } return new TableRow({ height: typeof rh === 'number' && rh > 0 ? { value: pxToTwip(rh), rule: HeightRule.ATLEAST } : undefined, - ...(repeatHeader && rowIndex === 0 ? { tableHeader: true } : {}), + // The flag marks a header row, whether the table asked for the repeat or only the + // row's own cells say they head it — ODF spells both with one element. + ...((repeatHeader && rowIndex === 0) || (row.content ?? []).some((c) => c.type === 'tableHeader') + ? { tableHeader: true } : {}), children: cells, }); }); @@ -2376,7 +2390,9 @@ function indexFieldParagraphs(node: TiptapNode, kind: IndexKind, maxLevel: numbe const a = node.attrs ?? {}; const noPages = a.pageNumbers === false; const instr = - kind === 'alphabetical' ? 'INDEX \\c "1" \\e "\t"' + // `\n` is a TOC switch that INDEX has no counterpart for; Word ignores the unknown + // one and regenerates its rows, and this side reads it back. + kind === 'alphabetical' ? `INDEX \\c "1" \\e "\t"${noPages ? ' \\n' : ''}` : kind === 'bibliography' ? 'BIBLIOGRAPHY' // `\n` over the whole range: Word's switch takes levels, the editor's index is // all-or-nothing. @@ -2409,7 +2425,9 @@ function indexFieldParagraphs(node: TiptapNode, kind: IndexKind, maxLevel: numbe return entries.map((e, i) => new Paragraph({ style: own(e.level) ? docxStyleId(own(e.level)!) : undefined, indent: own(e.level) || e.level === 1 ? undefined : { left: cmToTwip(0.5 * (e.level - 1)) }, - tabStops: noPage ? undefined : [{ type: TabStopType.RIGHT, position: cmToTwip(tabCm), ...(leader ? { leader } : {}) }], + // Written even with no page number running to it: it is where the row's leader is + // kept, and a stop no tab reaches changes nothing on the page. + tabStops: [{ type: TabStopType.RIGHT, position: cmToTwip(tabCm), ...(leader ? { leader } : {}) }], children: [ ...(i === 0 ? open : []), ...e.text.split('\n').map((part, li) => new TextRun(li ? { text: part, break: 1 } : { text: part })), @@ -2826,23 +2844,31 @@ export async function buildDocx( // needs one — empty where the zone has no text — for the post-passes to inject // into; a variant without its own part would blank the decor on those pages. const decorated = Boolean(decor.watermark?.text) || foldMarks; + // A section past the first says what it has, blank included: a zone naming no part + // of its own is Word's "Link to Previous" and repeats the section above it — which + // put a chapter's running head on the pages a blank one was meant for. + const spellOut = decorated || i > 0; const h: { default?: Header; first?: Header; even?: Header } = {}; if (d) h.default = new Header({ children: [paragraphToDocx(d)] }); - else if (decorated) h.default = new Header({ children: [new Paragraph({})] }); + else if (spellOut) h.default = new Header({ children: [new Paragraph({})] }); if (f) h.first = new Header({ children: [paragraphToDocx(f)] }); - else if (s.differentFirstPage && decorated) h.first = new Header({ children: [new Paragraph({})] }); + else if (s.differentFirstPage && spellOut) h.first = new Header({ children: [new Paragraph({})] }); if (e) h.even = new Header({ children: [paragraphToDocx(e)] }); - else if (s.differentOddEven && decorated) h.even = new Header({ children: [new Paragraph({})] }); + else if (s.differentOddEven && spellOut) h.even = new Header({ children: [new Paragraph({})] }); return Object.keys(h).length ? h : undefined; }; const mkFooters = (i: number) => { const s = setAt(i); const d = para(s.footer), f = s.differentFirstPage ? para(s.footerFirst) : null, e = s.differentOddEven ? para(s.footerEven) : null; - if (!d && !f && !e) return undefined; + if (!d && !f && !e && i === 0) return undefined; const fo: { default?: Footer; first?: Footer; even?: Footer } = {}; + // Spelled out past the first section, for the reason the headers are. if (d) fo.default = new Footer({ children: [paragraphToDocx(d)] }); + else if (i > 0) fo.default = new Footer({ children: [new Paragraph({})] }); if (f) fo.first = new Footer({ children: [paragraphToDocx(f)] }); + else if (s.differentFirstPage && i > 0) fo.first = new Footer({ children: [new Paragraph({})] }); if (e) fo.even = new Footer({ children: [paragraphToDocx(e)] }); + else if (s.differentOddEven && i > 0) fo.even = new Footer({ children: [new Paragraph({})] }); return fo; }; diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 08dd789..04bf21d 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -108,6 +108,17 @@ Body text with no resolved font falls back to the *document's own theme minor fo on the page layout governing the body (`HfSet.pageNumberFormat`), which is how a roman front matter precedes a decimal body; Word's is `w:pgNumType w:fmt` per section, and the document's own comes from the **first** section, as the paper and the margins already do. +- **A header row is `w:tblHeader` on the row**, which says what ODF's + `` says: its cells become `tableHeader`, and the export writes + the flag for a row whose cells are header cells even where the table asks for no repeat. +- **An index's look rides its first row.** The field opens on that row, so the leader and + the right tab stop the page number runs to are read off the very paragraph the node is + built from; a stop at the text width names no position of its own and is dropped, as on + the ODF side. The level styles are a family per index kind (`INDEX_LEVEL_STYLES`) — + `Contents n` for a contents, `Illustration Index n`, `Table index n`, `Index n` — since + Word regenerates each kind's rows from its own. +- **A text box's ring is `w:bodyPr/@lIns`** (EMU), suppressed against the editor's own + 0.15cm as the ODF side suppresses `fo:padding`. - **Chapter numbering** — `text:outline-style` on the ODF side, the heading styles' `w:numPr` on Word's — becomes `StyleSheet.outline`; see `src/lib/styles/CLAUDE.md`. A numbered heading is therefore **never a list item** (`paragraphNum` returns null for diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 1882f66..180657d 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -9,6 +9,7 @@ import { DEFAULT_OUTLINE_LEVEL, MAX_OUTLINE_LEVELS, type OutlineNumbering } from import { MAX_LIST_LEVELS, type ListLevelStyle, type ListStyle } from '../styles/listStyles'; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { fitInlineImage, framePx } from '../editor/extensions/image'; +import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; import { formatTabStops } from '../editor/extensions/tabStops'; import type { CapsMode, LineStyle } from '../editor/extensions/textEffects'; import { builtinTableStyles, parseTableLook, resolveTableCell, tableLookAttr } from '../styles/tableStyles'; @@ -352,6 +353,19 @@ function documentLanguage(stylesDoc: Document | null, warnings: Set): Do } // ---- block conversion (paragraphs, lists, tables) -------------------------- +// An index row runs its page number out to a right tab stop, and the dots in between are +// that stop's leader. The field opens on its own first row, so the look sits on the very +// paragraph the node is built from. +function tocRowTab(el: Element, ctx: Ctx): { leader?: string; tabPosCm?: number } { + const tabs = fc(fc(el, 'pPr'), 'tabs'); + const stop = tabs ? readTabStops(tabs).find((t) => t.align === 'right') : undefined; + if (!stop) return {}; + // A stop at the text width is where a row's number lands anyway, so it names no + // position of its own — the ODF side suppresses it the same way. + const own = stop.pos > 0 && Math.abs(stop.pos - ctx.contentWidthCm) > 0.05; + return { ...(stop.leader ? { leader: stop.leader } : {}), ...(own ? { tabPosCm: stop.pos } : {}) }; +} + // A TOC is a `TOC` field spanning several paragraphs, each entry a nested PAGEREF field, // so field depth is tracked (across one convertBlocks call) to match the TOC's own end. type TocFieldState = { fieldDepth: number; tocDepth: number; instr: string[] }; @@ -546,11 +560,12 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef // The field carries no heading of its own — Word's sits in a separate paragraph. const instr = instrTextOf(el); const index = tocIndexKind(instr); - // maxLevel only where the field has levels — the ODF side sets none for the - // alphabetical index or the bibliography either. - const levels = index === 'alphabetical' || index === 'bibliography' ? null : tocMaxLevel(instr); - const levelStyles = levels == null ? null : tocLevelStyles(ctx, levels); + // A bibliography has no levels; every other index does, the alphabetical one's + // being its own three, as the ODF side reads them off its entry templates. + const levels = index === 'bibliography' ? null : tocMaxLevel(instr); + const levelStyles = levels == null ? null : tocLevelStyles(ctx, levels, index); out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', index, ...tocPageNumbers(instr), + ...tocRowTab(el, ctx), ...(levels == null ? {} : { maxLevel: levels }), ...(levelStyles ? { levelStyles } : {}), ...(index === 'bibliography' ? { citationStyle: ctx.citationStyle } : {}) } }); @@ -606,12 +621,13 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef const content = fc(el, 'sdtContent'); const instr = content ? instrTextOf(content) : ''; const maxLevel = tocMaxLevel(instr); - const levelStyles = tocLevelStyles(ctx, maxLevel); + const sdtKind = tocIndexKind(instr); + const levelStyles = tocLevelStyles(ctx, maxLevel, sdtKind); out.push({ type: 'tableOfContents', attrs: { entries: [], title: tocHeading(content) ?? '', maxLevel, - index: tocIndexKind(instr), + index: sdtKind, ...tocPageNumbers(instr), ...(levelStyles ? { levelStyles } : {}), } }); @@ -828,11 +844,24 @@ function registryName(id: string, wordName: string, isDefault: boolean): string // The paragraph styles a TOC field regenerates its rows from, by level. Naming them // gives the index the file's own indent and spacing instead of the editor's fallback. -function tocLevelStyles(ctx: Ctx, maxLevel: number): (string | null)[] | null { +// Each kind of index has its own family of level styles, LibreOffice's names throughout +// (registryName maps Word's localized `toc n` onto them). Word regenerates its rows from +// the styles, so the family the index belongs to is what says which ones are its own. +const INDEX_LEVEL_STYLES: Record = { + toc: /^Contents (10|[1-9])$/, + figures: /^Illustration Index (10|[1-9])$/, + tables: /^Table index (10|[1-9])$/i, + alphabetical: /^Index (10|[1-9])$/, + bibliography: null, +}; + +function tocLevelStyles(ctx: Ctx, maxLevel: number, kind: IndexKind = 'toc'): (string | null)[] | null { + const pattern = INDEX_LEVEL_STYLES[kind]; + if (!pattern) return null; const out: (string | null)[] = []; for (const id of ctx.styles.namedParagraphStyles().keys()) { const name = ctx.styleNames.get(id) ?? ''; - const level = Number(/^Contents (10|[1-9])$/.exec(name)?.[1]); + const level = Number(pattern.exec(name)?.[1]); if (!level || level > maxLevel) continue; ctx.usedStyles.add(id); out[level - 1] = name; @@ -2180,8 +2209,8 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct // the far end that an image reads as one half of a side-by-side pair. const align = root.getElementsByTagNameNS(WP, 'positionH')[0] ?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim(); - if (wrap === 'topBottom' && offsetCm == null && (align === 'center' || align === 'right')) - attrs.wrapAlign = align; + if ((wrap === 'topBottom' || wrap === 'through') && offsetCm == null + && (align === 'center' || align === 'right')) attrs.wrapAlign = align; } const fillClr = nsChild(nsChild(spPr, A, 'solidFill'), A, 'srgbClr')?.getAttribute('val'); @@ -2201,8 +2230,18 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct // Vertical text: every one of Word's top-to-bottom flows, the editor having the one // direction the browser lays out (`vert270` reads bottom-to-top and is not one). - const vert = nsChild(wsp, WPS, 'bodyPr')?.getAttribute('vert') ?? 'horz'; + const bodyPr = nsChild(wsp, WPS, 'bodyPr'); + const vert = bodyPr?.getAttribute('vert') ?? 'horz'; if (['vert', 'eaVert', 'mongolianVert', 'wordArtVert'].includes(vert)) attrs.textVertical = true; + // The ring inside the box (w:lIns, EMU). Word's own default is 0.25cm on the sides; + // only a value the editor's own ring does not already draw is worth the attr. + const lIns = bodyPr?.getAttribute('lIns'); + if (lIns != null) { + const padCm = Number(lIns) / 360000; + if (Number.isFinite(padCm) && padCm >= 0 && Math.abs(padCm - TEXTBOX_PADDING_CM) > 0.01) { + attrs.paddingCm = Math.round(padCm * 1000) / 1000; + } + } const txbxContent = nsChild(nsChild(wsp, WPS, 'txbx'), W, 'txbxContent'); const blocks = txbxContent ? unnestBoxes(convertBlocks(Array.from(txbxContent.children), ctx, 'cell'), ctx) : []; @@ -2508,6 +2547,10 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { ?? Math.max(1, ...trs.map((tr) => fcAll(tr, 'tc').reduce((n, tc) => n + (intAttr(fc(fc(tc, 'tcPr'), 'gridSpan'), W, 'val') ?? 1), 0))); for (let ri = 0; ri < trs.length; ri++) { const tr = trs[ri]; + // w:tblHeader says the same as ODF's : this row heads the + // table. Its cells are header cells, which is what shades and bolds them. + const trHdr = fc(fc(tr, 'trPr'), 'tblHeader'); + const isHeaderRow = !!trHdr && onOff(trHdr); const cells: Node[] = []; let col = 0; for (const tc of fcAll(tr, 'tc')) { @@ -2580,7 +2623,7 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { const { formula, format } = cellFormulaOf(tc); if (formula) attrs.formula = formula; if (formula && format) attrs.cellFormat = format; - const cell: Node = { type: 'tableCell', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] }; + const cell: Node = { type: isHeaderRow ? 'tableHeader' : 'tableCell', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] }; cells.push(cell); for (let c = col; c < col + colspan; c++) pending[c] = vMerge === 'restart' ? cell : null; col += colspan; diff --git a/src/lib/import/imageFormats.ts b/src/lib/import/imageFormats.ts index 5329e02..9484ce7 100644 --- a/src/lib/import/imageFormats.ts +++ b/src/lib/import/imageFormats.ts @@ -215,21 +215,21 @@ export function isSvgDataUrl(src: unknown): src is string { export async function svgToPngDataUrl(src: string, widthPx: number, heightPx: number): Promise { if (typeof document === 'undefined' || typeof Image === 'undefined') return null; - const w = Math.max(1, Math.round(widthPx * SVG_RASTER_SCALE)); - const h = Math.max(1, Math.round(heightPx * SVG_RASTER_SCALE)); try { + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(widthPx * SVG_RASTER_SCALE)); + canvas.height = Math.max(1, Math.round(heightPx * SVG_RASTER_SCALE)); + // Probed before the image is loaded, not after: jsdom has a document and an Image + // whose onload never fires for a data URI, and awaiting that one hangs the caller. + const ctx = canvas.getContext('2d'); + if (!ctx) return null; const img = new Image(); await new Promise((ok, fail) => { img.onload = () => ok(); img.onerror = () => fail(new Error('svg')); img.src = src; }); - const canvas = document.createElement('canvas'); - canvas.width = w; - canvas.height = h; - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - ctx.drawImage(img, 0, 0, w, h); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); return canvas.toDataURL('image/png'); } catch { return null; diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 4f6086c..6dd4cf8 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -1417,3 +1417,87 @@ describe('numbered headings under a document stylesheet', () => { expect(named!.attrs.level).toBe(1); }); }); + +// What a document keeps across the formats besides its words: the look of an index, the +// header a section blanks, a box's own ring, a header row. +describe('the look a document carries', () => { + const SVG = 'data:image/svg+xml;base64,' + btoa(''); + + it('keeps a header row a header row', async () => { + const doc = { type: 'doc', content: [{ type: 'table', attrs: { repeatHeader: true }, content: [ + { type: 'tableRow', content: [headerCell('Name'), headerCell('Qty')] }, + { type: 'tableRow', content: [cell('Widget'), cell('1')] }, + ] }] } as any; + const back = importDocx(await buildDocx(doc)).content as N; + expect(walk(back, 'tableHeader')).toHaveLength(2); + expect(back.content![0].attrs.repeatHeader).toBe(true); + }); + + it('marks a header row even where the table asks for no repeat', async () => { + const doc = { type: 'doc', content: [{ type: 'table', content: [ + { type: 'tableRow', content: [headerCell('H')] }, + { type: 'tableRow', content: [cell('b')] }, + ] }] } as any; + expect(walk(importDocx(await buildDocx(doc)).content as N, 'tableHeader')).toHaveLength(1); + }); + + it("keeps a text box's own padding and where it sits behind the text", async () => { + const doc = { type: 'doc', content: [para([ + { type: 'textBox', attrs: { width: 300, height: 100, wrap: 'through', wrapAlign: 'center', paddingCm: 0 }, content: [para('in the box')] }, + ])] } as any; + const box = walk(importDocx(await buildDocx(doc)).content as N, 'textBox')[0]; + expect(box.attrs.paddingCm).toBe(0); + expect(box.attrs.wrapAlign).toBe('center'); + }); + + it("keeps an index's leader, tab position and per-level styles", async () => { + const sheet = builtinStyleSheet(); + for (const n of [1, 2]) sheet.paragraph[`Illustration Index ${n}`] = { name: `Illustration Index ${n}`, parent: 'Standard', next: 'Standard', para: {}, text: {} }; + const doc = { type: 'doc', content: [{ type: 'tableOfContents', attrs: { + index: 'figures', title: '', leader: '.', tabPosCm: 12, maxLevel: 10, + levelStyles: ['Illustration Index 1', 'Illustration Index 2'], + entries: [{ text: 'Illustration 1: One', level: 1, page: 2 }], + } }] } as any; + const toc = walk(importDocx(await buildDocx(doc, undefined, undefined, undefined, undefined, undefined, sheet)).content as N, 'tableOfContents')[0]; + expect(toc.attrs.leader).toBe('.'); + expect(toc.attrs.tabPosCm).toBe(12); + expect(toc.attrs.levelStyles?.[0]).toBe('Illustration Index 1'); + }); + + it('keeps an index of text alone free of page numbers', async () => { + const doc = { type: 'doc', content: [{ type: 'tableOfContents', attrs: { + index: 'alphabetical', title: '', pageNumbers: false, leader: '.', + entries: [{ text: 'Car to X', level: 1, page: 1 }], + } }] } as any; + const toc = walk(importDocx(await buildDocx(doc)).content as N, 'tableOfContents')[0]; + expect(toc.attrs.pageNumbers).toBe(false); + expect(toc.attrs.leader).toBe('.'); + }); + + it('blanks a later section instead of repeating the header above it', async () => { + const doc = { type: 'doc', content: [ + para('first section'), + para('second section', { sectionBreak: true }), + ] } as any; + const hfTwo = { + header: { type: 'doc', content: [{ type: 'paragraph', content: [text('Running head')] }] }, + footer: null, pageCount: 1, + sections: [ + { header: { type: 'doc', content: [{ type: 'paragraph', content: [text('Running head')] }] }, footer: null }, + { header: null, footer: null }, // the blank one + ], + } as any; + const xml = strFromU8(unzipSync(await buildDocx(doc, undefined, undefined, hfTwo))['word/document.xml']); + // Every section names its own header part; one that named none would inherit. + const sects = xml.match(//g) ?? []; + expect(sects).toHaveLength(2); + for (const s of sects) expect(s).toMatch(/ { + const doc = { type: 'doc', content: [para([{ type: 'image', attrs: { src: SVG, width: 8, height: 8 } }])] } as any; + // jsdom has an Image whose onload never fires; the rasterizer must not await it. + const bytes = await buildDocx(doc); + expect(bytes.length).toBeGreaterThan(0); + }); +});