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