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
<table:table-header-rows>. 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.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:11 +02:00
1 parent 8b006d22a8
commit 7e037c03a8
6 files changed
+205 -33

No files matched your search

+8
View File
@@ -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.
+39 -13
View File
@@ -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
? `<a:ln w="${Math.round(box.strokeWidthPt * EMU_PER_PT)}"><a:solidFill><a:srgbClr val="${hexColor(box.stroke) ?? '000000'}"/></a:solidFill>${ends}</a:ln>`
: '<a:ln><a:noFill/></a:ln>';
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' ? '<wp:wrapNone/>'
: box.wrap === 'topBottom' ? '<wp:wrapTopAndBottom/>'
: `<wp:wrapSquare wrapText="${box.wrap === 'right' ? 'left' : 'right'}"/>`;
// 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;
};
+11
View File
@@ -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
`<table:table-header-rows>` 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
+55 -12
View File
@@ -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<string>): 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<IndexKind, RegExp | null> = {
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 <table:table-header-rows>: 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;
+8 -8
View File
@@ -215,21 +215,21 @@ export function isSvgDataUrl(src: unknown): src is string {
export async function svgToPngDataUrl(src: string, widthPx: number, heightPx: number): Promise<string | null> {
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<void>((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;
+84
View File
@@ -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('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>');
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(/<w:sectPr[\s\S]*?<\/w:sectPr>/g) ?? [];
expect(sects).toHaveLength(2);
for (const s of sects) expect(s).toMatch(/<w:headerReference/);
});
it('exports a document holding an SVG without hanging where no canvas can draw', async () => {
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);
});
});