Files
edentext/tests/docx-roundtrip.test.ts
T
Steffen Becker 2a8f0675bc fix: Word's hollow bullet is the letter it is
A Courier New "o" was mapped to ◦ for looks. LibreOffice and Word both
draw the glyph the numbering names, so the nested lists of the gravity
summary now read like theirs.
2026-08-11 21:22:13 +00:00

1113 lines
59 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { zipSync, strToU8, unzipSync, strFromU8 } from 'fflate';
import { buildDocx } from '../src/lib/export/docx';
import { importDocx } from '../src/lib/import/docx';
import { builtinStyleSheet } from '../src/lib/styles/styleSheet';
import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow';
type N = { type: string; attrs?: any; content?: N[]; marks?: any[]; text?: string };
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
const text = (t: string, marks?: any[]) => ({ type: 'text', text: t, ...(marks ? { marks } : {}) });
const para = (content: any, attrs: any = {}) => ({ type: 'paragraph', attrs, content: Array.isArray(content) ? content : [text(content)] });
const heading = (level: number, t: string, attrs: any = {}) => ({ type: 'heading', attrs: { level, ...attrs }, content: [text(t)] });
const li = (...content: any[]) => ({ type: 'listItem', content });
const cell = (t: string, attrs: any = {}) => ({ type: 'tableCell', attrs, content: [para(t)] });
const headerCell = (t: string, attrs: any = {}) => ({ type: 'tableHeader', attrs: { backgroundColor: HEADER_SHADE, ...attrs }, content: [para(t)] });
function walk(node: N, type: string, out: N[] = []): N[] {
if (node.type === type) out.push(node);
for (const c of node.content ?? []) walk(c, type, out);
return out;
}
const hasMark = (n: N, type: string) => (n.marks ?? []).some((m: any) => m.type === type);
const markAttrs = (n: N, type: string) => (n.marks ?? []).find((m: any) => m.type === type)?.attrs;
describe('DOCX export → import round trip', () => {
const fixture = {
type: 'doc',
content: [
heading(1, 'Title', { textAlign: 'center' }),
para([
text('plain '),
text('bold', [{ type: 'bold' }]),
text(' '),
text('red', [{ type: 'textStyle', attrs: { color: '#FF0000' } }]),
text(' '),
text('hi', [{ type: 'highlight', attrs: { color: '#00FF00' } }]),
text(' '),
text('site', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
], { spaceBefore: 6, spaceAfter: 6, lineHeight: '1.5', indent: 1 }),
// Character effects: letter case, line shapes, a freely raised run.
para([
text('caps ', [{ type: 'textStyle', attrs: { caps: 'uppercase' } }]),
text('petite ', [{ type: 'textStyle', attrs: { caps: 'smallCaps' } }]),
text('dotted ', [{ type: 'underline', attrs: { lineStyle: 'dotted', lineColor: '#FF0000' } }]),
text('crossed ', [{ type: 'strike', attrs: { lineStyle: 'double' } }]),
text('raised', [{ type: 'textStyle', attrs: { fontSize: '14pt', textPosition: 3 } }]),
]),
{ type: 'paragraph', attrs: { fontSize: '22pt', textAlign: 'center' } }, // empty sized line
para([text('a\tb')]),
para([text('line1'), { type: 'hardBreak' }, text('line2')]),
{ type: 'bulletList', attrs: { bulletChar: '❖' }, content: [li(para('one')), li(para('two'), { type: 'bulletList', attrs: { bulletChar: '➢' }, content: [li(para('nested'))] })] },
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [li(para('alpha'))] },
{ type: 'orderedList', content: [li(para('cycle top'), { type: 'orderedList', content: [li(para('cycle sub'))] })] },
{ type: 'orderedList', attrs: { listStyleType: 'multilevel' }, content: [
li(para('ml one'), { type: 'orderedList', content: [li(para('ml one-one'))] }),
li(para('ml two')),
] },
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', wrapOffsetY: 2.5, alt: 'pic' } }]),
{ type: 'textBox', attrs: { width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 }, content: [
para('box text'),
para([text('bold in box', [{ type: 'bold' }])]),
] },
{ type: 'textBox', attrs: { width: 192, height: 96, wrap: 'right', wrapOffset: 6, wrapOffsetY: 1.5, shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] },
{ type: 'columns', attrs: { count: 2, gapCm: 0.8 }, content: [para('newspaper one'), para('newspaper two')] },
{ type: 'table', content: [
{ type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] },
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1', { borderTop: 'none', borderRight: '2.25pt solid #FF0000' })] },
{ type: 'tableRow', content: [cell('2')] },
] },
heading(4, 'Fourth'),
heading(5, 'Fifth'),
],
} as any;
const hf = {
header: { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [text('My header')] }] },
footer: { type: 'doc', content: [{ type: 'paragraph', content: [text('Page '), { type: 'pageNumber' }, text(' of '), { type: 'pageCount' }] }] },
pageCount: 1,
} as any;
let doc: N;
let result: any;
let documentXml = '';
it('imports without throwing', async () => {
const bytes = await buildDocx(fixture, { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }, 'portrait', hf, { language: 'en', country: 'US' });
documentXml = strFromU8(unzipSync(bytes)['word/document.xml']);
result = importDocx(bytes);
doc = result.content;
expect(doc.type).toBe('doc');
});
it('emits the columns region as its own continuous section with w:cols', () => {
// Three sections: before / columns / after (each earlier sectPr rides an empty
// paragraph's pPr; the last is the body-final one).
expect(documentXml.match(/<w:sectPr/g)!.length).toBe(3);
expect(documentXml).toMatch(/<w:cols[^>]*w:num="2"/);
expect(documentXml).toMatch(/<w:cols[^>]*w:space="454"/); // 0.8cm in twips
expect(documentXml.match(/<w:type w:val="continuous"\/>/g)!.length).toBe(2);
// Word wants the page geometry repeated per sectPr.
expect(documentXml.match(/<w:pgSz/g)!.length).toBe(3);
expect(documentXml.match(/<w:pgMar/g)!.length).toBe(3);
// The body-final sectPr (the one the importer reads) still references the header.
const finalSect = documentXml.slice(documentXml.lastIndexOf('<w:sectPr'));
expect(finalSect).toContain('<w:headerReference');
});
it('coalesces adjacent equal-attr fragments (columnsFlow page splits) into one section', async () => {
const fragmented = {
type: 'doc',
content: [
para('lead'),
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [para('frag one a'), para('frag one b')] },
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [para('frag two')] },
{ type: 'columns', attrs: { count: 3, gapCm: 0.5 }, content: [para('other section')] },
para('tail'),
],
} as any;
const bytes = await buildDocx(fragmented);
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
expect(xml.match(/<w:sectPr/g)!.length).toBe(4); // lead / merged 2-col / 3-col / tail
const res = importDocx(bytes).content as N;
const cols = walk(res, 'columns');
expect(cols.map((c) => c.content!.length)).toEqual([3, 1]);
expect(cols[0].attrs.count).toBe(2);
expect(cols[1].attrs.count).toBe(3);
});
it('round-trips a named table style (w:tblStyle + a name-only definition)', async () => {
const cell = (t: string, bg?: string, region?: string): N => ({
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1, colwidth: null, ...(bg ? { backgroundColor: bg } : {}), ...(region ? { region } : {}) },
content: [para(t)],
});
const doc = {
type: 'doc',
content: [{
type: 'table',
attrs: { tableStyle: 'Box List Blue' },
content: [
{ type: 'tableRow', content: [cell('Kopf', '#4A7EBB', 'headerRow')] },
{ type: 'tableRow', content: [cell('Zeile')] },
],
}],
} as any;
const bytes = await buildDocx(doc);
const files = unzipSync(bytes);
const xml = strFromU8(files['word/document.xml']);
const stylesXml = strFromU8(files['word/styles.xml']);
expect(xml).toContain('<w:tblStyle w:val="BoxListBlue"');
expect(stylesXml).toContain('w:type="table"');
// The header region's white bold is presentational in the editor, baked here.
expect(xml).toContain('<w:color w:val="FFFFFF"');
const res = importDocx(bytes).content as N;
const table = walk(res, 'table')[0];
expect(table.attrs.tableStyle).toBe('Box List Blue');
expect(table.content![0].content![0].attrs.backgroundColor).toBe('#4A7EBB');
});
it('round-trips the table style options as w:tblLook', async () => {
const cell = (t: string): N => ({
type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: null }, content: [para(t)],
});
const doc = {
type: 'doc',
content: [{
type: 'table',
attrs: { tableStyle: 'Box List Blue', tableLook: 'lastRow headerRow' },
content: [
{ type: 'tableRow', content: [cell('Kopf')] },
{ type: 'tableRow', content: [cell('Summe')] },
],
}],
} as any;
const bytes = await buildDocx(doc);
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
// Word inverts the band flags, so both are switched off here.
expect(xml).toMatch(/<w:tblLook[^>]*w:firstRow="true"/);
expect(xml).toMatch(/<w:tblLook[^>]*w:lastRow="true"/);
expect(xml).toMatch(/<w:tblLook[^>]*w:noHBand="true"/);
const table = walk(importDocx(bytes).content as N, 'table')[0];
expect(table.attrs.tableLook).toBe('lastRow headerRow');
});
it('re-merges a page-boundary line-split paragraph (joinPrev) on export', async () => {
const split = {
type: 'doc',
content: [
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [para('first half ')] },
{ type: 'columns', attrs: { count: 2, gapCm: 0.5 }, content: [
{ type: 'paragraph', attrs: { joinPrev: true }, content: [text('second half.')] },
para('tail'),
] },
],
} as any;
const bytes = await buildDocx(split);
const res = importDocx(bytes).content as N;
const cols = walk(res, 'columns');
expect(cols.length).toBe(1);
expect(cols[0].content!.length).toBe(2);
expect(walk(cols[0].content![0], 'text').map((t) => t.text).join('')).toBe('first half second half.');
});
it('round-trips the columns section (count + gap + content, marker dropped)', () => {
const cols = walk(doc, 'columns');
expect(cols.length).toBe(1);
expect(cols[0].attrs.count).toBe(2);
expect(cols[0].attrs.gapCm).toBe(0.8);
const texts = walk(cols[0], 'text').map((t) => t.text);
expect(texts).toEqual(['newspaper one', 'newspaper two']);
// The empty section-break marker paragraphs must not leak into the document.
const paras = walk(doc, 'paragraph');
expect(paras.filter((p) => !p.content && !p.attrs?.fontSize).length).toBe(0);
});
it('round-trips headings + paragraph block attrs', () => {
const h = doc.content![0];
expect(h.type).toBe('heading');
expect(h.attrs.level).toBe(1);
expect(h.attrs.textAlign).toBe('center');
const p = doc.content![1];
expect(p.attrs.spaceBefore).toBe(6);
expect(p.attrs.spaceAfter).toBe(6);
expect(p.attrs.lineHeight).toBe('1.5');
expect(p.attrs.indent).toBe(1);
});
it('round-trips heading levels 4 and 5', () => {
const heads = walk(doc, 'heading').filter((h) => h.attrs.level >= 4);
expect(heads.map((h) => h.attrs.level)).toEqual([4, 5]);
// Their style sizes are the level defaults, so no explicit size mark survives.
expect(heads.every((h) => !(h.content![0].marks ?? []).length)).toBe(true);
});
it('round-trips an empty line\'s font size (paragraph-mark size)', () => {
const empty = doc.content!.find((n) => n.type === 'paragraph' && !n.content && n.attrs?.fontSize);
expect(empty).toBeTruthy();
expect(empty!.attrs.fontSize).toBe('22pt');
});
it('round-trips run marks (bold, color, highlight, link)', () => {
const texts = walk(doc, 'text');
expect(hasMark(texts.find((t) => t.text === 'bold')!, 'bold')).toBe(true);
expect(markAttrs(texts.find((t) => t.text === 'red')!, 'textStyle').color).toBe('#FF0000');
expect(markAttrs(texts.find((t) => t.text === 'hi')!, 'highlight').color).toBe('#00FF00');
expect(markAttrs(texts.find((t) => t.text === 'site')!, 'link').href).toBe('https://example.com');
});
it('round-trips tabs and hard breaks', () => {
expect(walk(doc, 'text').some((t) => t.text!.includes('\t'))).toBe(true);
expect(walk(doc, 'hardBreak').length).toBeGreaterThan(0);
});
it('reconstructs nested bullet lists and an ordered list type', () => {
const bullets = walk(doc, 'bulletList');
const top = bullets.find((b) => b.content!.length === 2)!;
expect(top).toBeTruthy();
// second item carries a nested bulletList with the "nested" paragraph
const nested = top.content![1].content!.find((c) => c.type === 'bulletList')!;
expect(nested).toBeTruthy();
expect(walk(nested, 'text')[0].text).toBe('nested');
// Custom bullet chars survive via w:lvlText
expect(top.attrs?.bulletChar).toBe('❖');
expect(nested.attrs?.bulletChar).toBe('➢');
const ol = walk(doc, 'orderedList')[0];
expect(ol.attrs.listStyleType).toBe('lower-alpha');
});
it('round-trips depth-default and multilevel numbering', () => {
const ols = walk(doc, 'orderedList');
// Attr-less nesting: exports 1. / a. and re-imports as null (cycle suppression).
const cycleTop = ols.find((o) => walk(o, 'text').some((t) => t.text === 'cycle top'))!;
expect(cycleTop.attrs?.listStyleType ?? null).toBe(null);
const cycleSub = walk(cycleTop, 'orderedList').find((o) => o !== cycleTop)!;
expect(cycleSub.attrs?.listStyleType ?? null).toBe(null);
// Multilevel: "%1.%2." chain lvlText → attr on the top list only.
const mlTop = ols.find((o) => walk(o, 'text').some((t) => t.text === 'ml one'))!;
expect(mlTop.attrs?.listStyleType).toBe('multilevel');
const mlSub = walk(mlTop, 'orderedList').find((o) => o !== mlTop)!;
expect(mlSub.attrs?.listStyleType ?? null).toBe(null);
});
it('round-trips the character effects (case, line shapes, raised run)', () => {
const runs = walk(doc, 'text');
const of = (t: string) => runs.find((r: N) => r.text === t)!.marks!;
const attrs = (t: string, type: string) => of(t).find((m: N) => m.type === type)!.attrs!;
expect(attrs('caps ', 'textStyle').caps).toBe('uppercase');
expect(attrs('petite ', 'textStyle').caps).toBe('smallCaps');
expect(attrs('dotted ', 'underline')).toMatchObject({ lineStyle: 'dotted', lineColor: '#FF0000' });
expect(attrs('crossed ', 'strike').lineStyle).toBe('double');
expect(attrs('raised', 'textStyle').textPosition).toBe(3);
});
it('round-trips the image (size + floating wrap)', () => {
const img = walk(doc, 'image')[0];
expect(img.attrs.width).toBe(100);
expect(img.attrs.height).toBe(80);
expect(img.attrs.wrap).toBe('left');
expect(img.attrs.wrapOffsetY).toBe(2.5); // positionV posOffset, paragraph-relative
expect(img.attrs.src.startsWith('data:image/png')).toBe(true);
});
it('round-trips text boxes: geometry, shape kind, fill/stroke, wrap, rotation', () => {
const boxes = walk(doc, 'textBox');
expect(boxes.length).toBe(2);
const [plain, ellipse] = boxes;
expect(plain.attrs.width).toBe(288);
expect(plain.attrs.height).toBe(96);
// Editor defaults (white fill, 1pt black stroke, no wrap/kind) are suppressed.
expect(plain.attrs.fillColor).toBeUndefined();
expect(plain.attrs.strokeColor).toBeUndefined();
expect(plain.attrs.shapeKind).toBeUndefined();
expect(plain.content!.length).toBe(2);
expect(hasMark(walk(plain, 'text')[1], 'bold')).toBe(true);
expect(ellipse.attrs.shapeKind).toBe('ellipse');
expect(ellipse.attrs.wrap).toBe('right');
expect(ellipse.attrs.wrapOffset).toBeCloseTo(6, 1);
expect(ellipse.attrs.wrapOffsetY).toBeCloseTo(1.5, 1);
expect(ellipse.attrs.rotation).toBe(30);
expect(ellipse.attrs.fillColor).toBe('#FFEE00');
expect(ellipse.attrs.strokeColor).toBe('#FF0000');
expect(ellipse.attrs.strokeWidthPt).toBe(2.25);
});
it('round-trips the table: header shade, merged cell, covered-cell drop, row height', () => {
const table = walk(doc, 'table')[0];
const rows = table.content!;
expect(rows.length).toBe(3);
// header cells keep the shade and are NOT over-marked bold (CSS renders them)
const headerCells = rows[0].content!;
expect(headerCells[0].attrs.backgroundColor).toBe(HEADER_SHADE);
expect(hasMark(walk(headerCells[0], 'text')[0], 'bold')).toBe(false);
// rowspan reconstructed from vMerge; the covered cell in row 3 is dropped
const widget = rows[1].content![0];
expect(widget.attrs.rowspan).toBe(2);
expect(widget.attrs.backgroundColor).toBe('#FFFF00');
expect(rows[2].content!.length).toBe(1);
expect(rows[1].attrs.rowHeight).toBe(40);
// per-side borders (w:tcBorders): hidden top + custom red right; defaults stay null
const qty = rows[1].content![1];
expect(qty.attrs.borderTop).toBe('none');
expect(qty.attrs.borderRight).toBe('2.25pt solid #FF0000');
expect(qty.attrs.borderBottom ?? null).toBe(null);
expect(widget.attrs.borderTop ?? null).toBe(null);
});
it('round-trips page geometry + header/footer with page fields', () => {
expect(result.orientation).toBe('portrait');
expect(result.margins.top).toBeCloseTo(2.54, 1);
expect(result.margins.left).toBeCloseTo(2.12, 1);
expect(walk(result.header, 'text')[0].text).toBe('My header');
expect(result.header.content[0].attrs.textAlign).toBe('right');
expect(walk(result.footer, 'pageNumber').length).toBe(1);
expect(walk(result.footer, 'pageCount').length).toBe(1);
});
});
describe('DOCX different first page (w:titlePg)', () => {
const fixture: N = { type: 'doc', content: [para('Body')] };
// Arial 10pt on the runs and the page-field atoms, so the field digits keep the font.
const arial = [{ type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '10pt' } }];
const hf = {
header: { type: 'doc', content: [para('Default header')] },
footer: { type: 'doc', content: [para([text('Seite ', arial), { type: 'pageNumber', marks: arial }, text(' von ', arial), { type: 'pageCount', marks: arial }])] },
headerFirst: { type: 'doc', content: [para('Cover header')] },
footerFirst: { type: 'doc', content: [para('Stand 2025')] },
differentFirstPage: true,
pageCount: 3,
} as any;
it('emits w:titlePg + first-type header/footer refs and round-trips the variants', async () => {
const bytes = await buildDocx(fixture, undefined, 'portrait', hf, { language: 'de', country: 'DE' });
const documentXml = strFromU8(unzipSync(bytes)['word/document.xml']);
expect(documentXml).toContain('<w:titlePg');
expect(documentXml).toMatch(/<w:headerReference[^>]*w:type="first"/);
expect(documentXml).toMatch(/<w:footerReference[^>]*w:type="first"/);
const res = importDocx(bytes);
expect(res.differentFirstPage).toBe(true);
expect(walk(res.header, 'text')[0].text).toBe('Default header');
expect(walk(res.footer, 'pageNumber').length).toBe(1);
expect(walk(res.headerFirst, 'text')[0].text).toBe('Cover header');
expect(walk(res.footerFirst, 'text')[0].text).toBe('Stand 2025');
// The page-field atom keeps the run's font (Arial 10pt) so its digits match the text.
const pn = walk(res.footer, 'pageNumber')[0];
expect(markAttrs(pn, 'textStyle')).toMatchObject({ fontFamily: 'Arial', fontSize: '10pt' });
});
it('keeps trailing blank lines (empty footer paragraphs) as hardBreaks', async () => {
const br = (): N => ({ type: 'hardBreak' });
const withBlanks = {
...hf,
footerFirst: { type: 'doc', content: [para([text('Stand 2025'), br(), br()])] },
} as any;
const bytes = await buildDocx(fixture, undefined, 'portrait', withBlanks, { language: 'de', country: 'DE' });
const res = importDocx(bytes);
const inline = res.footerFirst.content[0].content as N[];
expect(inline.filter((n) => n.type === 'hardBreak').length).toBe(2);
expect(inline[inline.length - 1].type).toBe('hardBreak');
});
it('round-trips an inline image in the default footer and the first-page header', async () => {
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const image = (w: number, h: number): N => ({ type: 'image', attrs: { src: PNG, alt: 'Logo', width: w, height: h, wrap: 'inline' } });
const withImgs = {
...hf,
footer: { type: 'doc', content: [para([text('Logo '), image(120, 48)])] },
headerFirst: { type: 'doc', content: [para([image(200, 60)])] },
} as any;
const res = importDocx(await buildDocx(fixture, undefined, 'portrait', withImgs, { language: 'de', country: 'DE' }));
const fi = walk(res.footer, 'image');
const hi = walk(res.headerFirst, 'image');
expect(fi.length).toBe(1);
expect(hi.length).toBe(1);
expect(String(fi[0].attrs.src)).toMatch(/^data:image\//);
expect(Math.abs(fi[0].attrs.width - 120)).toBeLessThanOrEqual(2);
expect(Math.abs(fi[0].attrs.height - 48)).toBeLessThanOrEqual(2);
});
it('round-trips odd/even page variants (w:evenAndOddHeaders + even refs)', async () => {
const withEven = {
...hf,
headerEven: { type: 'doc', content: [para('Even header')] },
footerEven: { type: 'doc', content: [para('Even footer')] },
differentOddEven: true,
} as any;
const bytes = await buildDocx(fixture, undefined, 'portrait', withEven, { language: 'de', country: 'DE' });
const files = unzipSync(bytes);
expect(strFromU8(files['word/settings.xml'])).toContain('evenAndOddHeaders');
expect(strFromU8(files['word/document.xml'])).toMatch(/<w:headerReference[^>]*w:type="even"/);
const res = importDocx(bytes);
expect(res.differentOddEven).toBe(true);
expect(walk(res.headerEven, 'text')[0].text).toBe('Even header');
expect(walk(res.footerEven, 'text')[0].text).toBe('Even footer');
// default + first still intact.
expect(walk(res.header, 'text')[0].text).toBe('Default header');
expect(walk(res.headerFirst, 'text')[0].text).toBe('Cover header');
});
it('omits titlePg and first-page zones when the flag is off', async () => {
const bytes = await buildDocx(fixture, undefined, 'portrait', { ...hf, differentFirstPage: false }, { language: 'de', country: 'DE' });
const documentXml = strFromU8(unzipSync(bytes)['word/document.xml']);
expect(documentXml).not.toContain('<w:titlePg');
const res = importDocx(bytes);
expect(res.differentFirstPage).toBe(false);
expect(res.headerFirst).toBeNull();
expect(res.footerFirst).toBeNull();
});
});
describe('DOCX date/time fields', () => {
const dtf = (kind: string, format: string, fixed: boolean, value: string): N =>
({ type: 'dateTimeField', attrs: { kind, format, fixed, value } });
const doc: N = { type: 'doc', content: [
para([text('Auto '), dtf('date', 'dmy_dots', false, '2026-07-08T00:00:00')]),
para([text('Fixed '), dtf('date', 'mdy_long', true, '2026-07-08T14:30:00')]),
para([text('Time '), dtf('time', 'hm24', false, '2026-07-08T14:30:00')]),
] };
it('exports an auto field as a DATE fldSimple and a fixed field as text', async () => {
const bytes = await buildDocx(doc, undefined, 'portrait', undefined, { language: 'de', country: 'DE' });
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
expect(xml).toMatch(/<w:fldSimple[^>]*w:instr="[^"]*DATE[^"]*dd\.MM\.yyyy/);
expect(xml).toMatch(/<w:fldSimple[^>]*w:instr="[^"]*TIME[^"]*HH:mm/);
// The fixed field has no fldSimple wrapper — just a plain run rendered in the
// document language (German month name here).
expect(xml).toContain('Juli 8, 2026');
expect(xml).not.toMatch(/<w:fldSimple[^>]*>[^<]*<w:r><w:t[^>]*>Juli/);
});
it('re-imports the auto DATE/TIME fields as live dateTimeField nodes', async () => {
const bytes = await buildDocx(doc, undefined, 'portrait', undefined, { language: 'de', country: 'DE' });
const res = importDocx(bytes).content as N;
const fields = walk(res, 'dateTimeField');
expect(fields.map((f: N) => f.attrs.format).sort()).toEqual(['dmy_dots', 'hm24']);
expect(fields.every((f: N) => f.attrs.fixed === false)).toBe(true);
});
});
describe('DOCX import of a foreign Word document', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Foreign Title</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>First</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="2"/></w:numPr></w:pPr><w:r><w:t>Wingdings diamond</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="2"/></w:numPr></w:pPr><w:r><w:t>Courier hollow</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="3"/></w:numPr></w:pPr><w:r><w:t>Legal one</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="3"/></w:numPr></w:pPr><w:r><w:t>Legal one-one</w:t></w:r></w:p>
<w:tbl><w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc><w:tc><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Times New Roman"/><w:sz w:val="24"/></w:rPr></w:rPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="480" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="40"/><w:rFonts w:ascii="Arial"/></w:rPr></w:style>
</w:styles>`;
const numberingXml = `<?xml version="1.0"?><w:numbering ${W}>
<w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum>
<w:abstractNum w:abstractNumId="1">
<w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/><w:lvlText w:val="&#xF076;"/><w:rPr><w:rFonts w:ascii="Wingdings" w:hAnsi="Wingdings"/></w:rPr></w:lvl>
<w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:lvlText w:val="o"/><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr></w:lvl>
</w:abstractNum>
<w:abstractNum w:abstractNumId="2">
<w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/></w:lvl>
<w:lvl w:ilvl="1"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1.%2."/></w:lvl>
</w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
<w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>
<w:num w:numId="3"><w:abstractNumId w:val="2"/></w:num>
</w:numbering>`;
const bytes = zipSync({
'word/document.xml': strToU8(documentXml),
'word/styles.xml': strToU8(stylesXml),
'word/numbering.xml': strToU8(numberingXml),
});
const result = importDocx(bytes);
const doc = result.content as N;
it('resolves a heading via its style chain (style-only run props)', () => {
const h = doc.content![0];
expect(h.type).toBe('heading');
expect(h.attrs.level).toBe(1);
const t = walk(h, 'text')[0];
expect(hasMark(t, 'bold')).toBe(false); // heading bold is presentational
// The style's own formatting stays in the style registry, not on the block.
expect(t.marks ?? []).toEqual([]);
expect(h.attrs.spaceBefore).toBeUndefined();
const style = result.styles.paragraph['Heading 1'];
expect(style.text.fontSizePt).toBe(20); // w:sz 40
expect(style.para.spaceBefore).toBe(24); // 480 twips
expect(style.para.spaceAfter).toBe(6); // 120 twips
});
it('maps Word symbol-font bullets and suppresses the default cycle', () => {
const bullets = walk(doc, 'bulletList');
const top = bullets.find((b) => walk(b, 'text').some((t) => t.text === 'Wingdings diamond'))!;
expect(top).toBeTruthy();
expect(top.attrs?.bulletChar).toBe('❖'); // Wingdings U+F076
const nested = walk(top, 'bulletList').find((b) => b !== top)!;
expect(walk(nested, 'text')[0].text).toBe('Courier hollow');
expect(nested.attrs?.bulletChar).toBe('o'); // Word's hollow bullet is the letter, as LO draws it
});
it("imports Word's legal numbering (%1.%2.) as a multilevel list", () => {
const ols = walk(doc, 'orderedList');
const top = ols.find((o) => walk(o, 'text').some((t) => t.text === 'Legal one'))!;
expect(top.attrs?.listStyleType).toBe('multilevel');
const sub = walk(top, 'orderedList').find((o) => o !== top)!;
expect(sub.attrs?.listStyleType ?? null).toBe(null);
});
it('reconstructs a numbered list and a vertically-merged table', () => {
expect(walk(doc, 'orderedList').filter((o) => walk(o, 'text').some((t) => t.text === 'First')).length).toBe(1);
const rows = walk(doc, 'table')[0].content!;
expect(rows[0].content![0].attrs.rowspan).toBe(2); // vMerge restart → rowspan
expect(rows[1].content!.length).toBe(1); // covered cell dropped
expect(result.margins.top).toBeCloseTo(2.54, 1);
});
});
describe('DOCX import: empty line keeps its paragraph-mark font size', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
// An empty paragraph whose only formatting is the paragraph mark (w:pPr/w:rPr) — 44
// half-points = 22pt, like a title's blank spacer lines. And a plain empty paragraph.
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:pPr><w:jc w:val="center"/><w:rPr><w:sz w:val="44"/></w:rPr></w:pPr></w:p>
<w:p><w:pPr><w:rPr><w:sz w:val="24"/></w:rPr></w:pPr></w:p>
<w:p/>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml) });
const doc = importDocx(bytes).content as N;
it('carries the 22pt paragraph-mark size onto the empty line', () => {
const p = doc.content![0];
expect(p.type).toBe('paragraph');
expect(p.content).toBeUndefined(); // still empty
expect(p.attrs.fontSize).toBe('22pt');
expect(p.attrs.textAlign).toBe('center');
});
it('suppresses a paragraph-mark size equal to the 12pt body default', () => {
expect(doc.content![1].attrs?.fontSize ?? null).toBeNull();
expect(doc.content![2].attrs?.fontSize ?? null).toBeNull();
});
});
describe('DOCX import: alignment inherited from a paragraph style', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
// The default paragraph style (Standard) sets justify; body paragraphs and a heading
// based on it inherit it. A direct w:jc still wins over the style.
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>Inherits justify from Standard</w:t></w:r></w:p>
<w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r><w:t>Direct center wins</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Heading based on Standard</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
<w:style w:type="paragraph" w:default="1" w:styleId="Standard"><w:name w:val="Normal"/><w:pPr><w:jc w:val="both"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:basedOn w:val="Standard"/><w:pPr><w:outlineLvl w:val="0"/></w:pPr></w:style>
</w:styles>`;
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/styles.xml': strToU8(stylesXml) });
const res = importDocx(bytes);
const doc = res.content as N;
it('keeps the default style\'s justify in the style, not on the block', () => {
expect(doc.content![0].attrs?.textAlign).toBeUndefined();
expect(res.styles.paragraph['Standard'].para.textAlign).toBe('justify');
});
it('lets a direct w:jc override the style', () => {
expect(doc.content![1].attrs.textAlign).toBe('center');
});
it('leaves a heading based on the default style free of direct alignment', () => {
expect(doc.content![2].type).toBe('heading');
expect(doc.content![2].attrs.textAlign).toBeUndefined();
// It inherits justify through Heading 1 → Standard in the registry instead.
expect(res.styles.paragraph['Heading 1'].parent).toBe('Standard');
});
});
describe('DOCX import resolves theme fonts (Word default = Calibri)', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const A = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"';
// No explicit w:ascii anywhere — the font lives only in the theme, like a real Word doc.
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>Body text</w:t></w:r></w:p>
</w:body></w:document>`;
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi"/></w:rPr></w:rPrDefault></w:docDefaults>
</w:styles>`;
const themeXml = `<?xml version="1.0"?><a:theme ${A}><a:themeElements><a:fontScheme>
<a:majorFont><a:latin typeface="Calibri Light"/></a:majorFont>
<a:minorFont><a:latin typeface="Calibri"/></a:minorFont>
</a:fontScheme></a:themeElements></a:theme>`;
const bytes = zipSync({
'word/document.xml': strToU8(documentXml),
'word/styles.xml': strToU8(stylesXml),
'word/theme/theme1.xml': strToU8(themeXml),
});
const doc = importDocx(bytes).content as N;
it('tags body text with the theme body font instead of the editor default', () => {
const t = walk(doc, 'text')[0];
expect(t.text).toBe('Body text');
expect(markAttrs(t, 'textStyle')?.fontFamily).toBe('Calibri');
});
});
describe('DOCX import falls back to the theme font when nothing references it', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const A = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"';
// No w:rFonts anywhere (not even a theme ref): Word uses the theme minor font as the
// implicit body default and the major one for headings — the file's fonts, not ours.
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>Body text</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Heading text</w:t></w:r></w:p>
</w:body></w:document>`;
const themeXml = `<?xml version="1.0"?><a:theme ${A}><a:themeElements><a:fontScheme>
<a:majorFont><a:latin typeface="Calibri Light"/></a:majorFont>
<a:minorFont><a:latin typeface="Calibri"/></a:minorFont>
</a:fontScheme></a:themeElements></a:theme>`;
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/theme/theme1.xml': strToU8(themeXml) });
const doc = importDocx(bytes).content as N;
it('applies the theme minor font to body text with no font of its own', () => {
const t = walk(doc.content![0], 'text')[0];
expect(t.text).toBe('Body text');
expect(markAttrs(t, 'textStyle')?.fontFamily).toBe('Calibri');
});
it('applies the theme major font to a heading with no font of its own', () => {
const h = doc.content![1];
expect(h.type).toBe('heading');
expect(markAttrs(walk(h, 'text')[0], 'textStyle')?.fontFamily).toBe('Calibri Light');
});
});
describe('DOCX import detects headings by outline level (non-"HeadingN" style ids)', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
// A heading style whose id is NOT "Heading1" — only its w:outlineLvl marks it a heading.
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:pPr><w:pStyle w:val="Titel1"/></w:pPr><w:r><w:t>Localised heading</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
<w:style w:type="paragraph" w:styleId="Standard"><w:name w:val="Standard"/></w:style>
<w:style w:type="paragraph" w:styleId="Titel1"><w:basedOn w:val="Standard"/><w:pPr><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
</w:styles>`;
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/styles.xml': strToU8(stylesXml) });
const doc = importDocx(bytes).content as N;
it('maps an outline-level-0 paragraph to heading 1', () => {
const h = doc.content![0];
expect(h.type).toBe('heading');
expect(h.attrs.level).toBe(1);
expect(walk(h, 'text')[0].text).toBe('Localised heading');
});
});
describe('DOCX import of foreign text boxes / shapes', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const MC = 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"';
const WPNS = 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"';
const ANS = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"';
const WPSNS = 'xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"';
const VNS = 'xmlns:v="urn:schemas-microsoft-com:vml"';
it('imports a Word-style mc:AlternateContent-wrapped wps text box (Choice only, no VML double-import)', () => {
// Real Word structure: mc:Choice carries the DrawingML shape, mc:Fallback a VML copy.
const documentXml = `<?xml version="1.0"?><w:document ${W} ${MC} ${WPNS} ${ANS} ${WPSNS} ${VNS}><w:body>
<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing>
<wp:anchor distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="2" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="0">
<wp:simplePos x="0" y="0"/>
<wp:positionH relativeFrom="margin"><wp:align>right</wp:align></wp:positionH>
<wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>
<wp:extent cx="1828800" cy="914400"/>
<wp:wrapSquare wrapText="left"/>
<wp:docPr id="7" name="Textfeld 7"/>
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
<wps:wsp><wps:cNvSpPr txBox="1"/><wps:spPr>
<a:xfrm rot="1800000"><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></a:xfrm>
<a:prstGeom prst="roundRect"><a:avLst/></a:prstGeom>
<a:solidFill><a:srgbClr val="CCFFCC"/></a:solidFill>
<a:ln w="28575"><a:solidFill><a:srgbClr val="003300"/></a:solidFill></a:ln>
</wps:spPr>
<wps:txbx><w:txbxContent>
<w:p><w:r><w:t>choice text</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>bold line</w:t></w:r></w:p>
</w:txbxContent></wps:txbx>
<wps:bodyPr/></wps:wsp>
</a:graphicData></a:graphic>
</wp:anchor>
</w:drawing></mc:Choice><mc:Fallback><w:pict>
<v:rect style="width:144pt;height:72pt" fillcolor="#ccffcc"><v:textbox><w:txbxContent><w:p><w:r><w:t>choice text</w:t></w:r></w:p></w:txbxContent></v:textbox></v:rect>
</w:pict></mc:Fallback></mc:AlternateContent></w:r><w:r><w:t>anchor text</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const boxes = walk(result.content as N, 'textBox');
expect(boxes.length).toBe(1); // the VML fallback must not double-import
const b = boxes[0];
expect(b.attrs.shapeKind).toBe('roundRect');
expect(b.attrs.width).toBe(192); // 1828800 EMU
expect(b.attrs.height).toBe(96);
expect(b.attrs.wrap).toBe('right'); // text left ⇒ box right
expect(b.attrs.rotation).toBe(30);
expect(b.attrs.fillColor).toBe('#CCFFCC');
expect(b.attrs.strokeColor).toBe('#003300');
expect(b.attrs.strokeWidthPt).toBe(2.25); // 28575 EMU
expect(walk(b, 'text').map((t) => t.text)).toEqual(['choice text', 'bold line']);
expect(hasMark(walk(b, 'text')[1], 'bold')).toBe(true);
// The anchor paragraph's own text survives, the box follows it at top level.
expect(walk(result.content as N, 'text').some((t) => t.text === 'anchor text')).toBe(true);
expect(result.warnings).toEqual([]);
});
it('imports a legacy VML-only text box (w:pict)', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W} ${VNS}><w:body>
<w:p><w:r><w:pict>
<v:oval style="width:144pt;height:72pt" fillcolor="#ffee00" strokecolor="#ff0000" strokeweight="2.25pt">
<v:textbox><w:txbxContent><w:p><w:r><w:t>vml text</w:t></w:r></w:p></w:txbxContent></v:textbox>
</v:oval>
</w:pict></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const boxes = walk(result.content as N, 'textBox');
expect(boxes.length).toBe(1);
const b = boxes[0];
expect(b.attrs.shapeKind).toBe('ellipse');
expect(b.attrs.width).toBe(192); // 144pt
expect(b.attrs.height).toBe(96);
expect(b.attrs.fillColor).toBe('#FFEE00');
expect(b.attrs.strokeColor).toBe('#FF0000');
expect(b.attrs.strokeWidthPt).toBe(2.25);
expect(walk(b, 'text')[0].text).toBe('vml text');
});
it('drops unsupported shapes, and keeps a chart as a placeholder of its size', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W} ${WPNS} ${ANS} ${WPSNS}><w:body>
<w:p><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">
<wp:extent cx="914400" cy="914400"/><wp:docPr id="1" name="Star"/>
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
<wps:wsp><wps:spPr><a:prstGeom prst="star5"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
</a:graphicData></a:graphic>
</wp:inline></w:drawing></w:r></w:p>
<w:p><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">
<wp:extent cx="914400" cy="914400"/><wp:docPr id="2" name="Chart"/>
<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"/></a:graphic>
</wp:inline></w:drawing></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
expect(walk(result.content as N, 'textBox').length).toBe(0);
expect(result.warnings).toContain('Unsupported shapes were removed');
expect(result.warnings).toContain('Charts and other drawings were replaced by a placeholder');
// The chart keeps the box it reserves (914400 EMU = 96px), so pagination holds.
const ph = walk(result.content as N, 'image');
expect(ph.length).toBe(1);
expect(ph[0].attrs.width).toBe(96);
expect(ph[0].attrs.height).toBe(96);
// The wrong "images could not be read" warning must NOT appear for shapes.
expect(result.warnings).not.toContain('Some images could not be read and were skipped');
});
});
describe('DOCX table of contents (TOC field) round trip', () => {
const tocDoc = {
type: 'doc',
content: [
{ type: 'tableOfContents', attrs: { entries: [
{ text: 'Alpha', level: 1, page: 1 },
{ text: 'Beta', level: 2, page: 2 },
] } },
heading(1, 'Alpha'),
para('body of alpha'),
heading(2, 'Beta'),
],
};
it('exports a TOC field (updateFields) and re-imports it as a tableOfContents node', async () => {
const bytes = await buildDocx(tocDoc as any);
const files = unzipSync(bytes);
const docXml = strFromU8(files['word/document.xml']);
expect(/\bTOC\b/.test(docXml)).toBe(true);
const settings = files['word/settings.xml'];
expect(settings ? strFromU8(settings).includes('updateFields') : false).toBe(true);
const res = importDocx(bytes).content as N;
const tocs = walk(res, 'tableOfContents');
expect(tocs.length).toBe(1);
// Headings survive alongside the TOC.
const headings = walk(res, 'heading');
expect(headings.map(h => walk(h, 'text')[0]?.text)).toEqual(['Alpha', 'Beta']);
});
});
describe('DOCX import of foreign multi-column sections', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
it('wraps a mid-body continuous section with w:cols and drops the empty marker paragraph', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>intro text</w:t></w:r></w:p>
<w:p><w:pPr><w:sectPr><w:type w:val="continuous"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr></w:pPr></w:p>
<w:p><w:r><w:t>col one</w:t></w:r></w:p>
<w:p><w:r><w:t>col two</w:t></w:r></w:p>
<w:p><w:pPr><w:sectPr><w:type w:val="continuous"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/><w:cols w:num="2" w:space="708"/></w:sectPr></w:pPr></w:p>
<w:p><w:r><w:t>outro text</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const doc = result.content as N;
const cols = walk(doc, 'columns');
expect(cols.length).toBe(1);
expect(cols[0].attrs.count).toBe(2);
expect(cols[0].attrs.gapCm).toBe(1.25); // 708 twips
expect(walk(cols[0], 'text').map((t) => t.text)).toEqual(['col one', 'col two']);
// A sectPr paragraph closes the group before it: intro (single-col) / columns /
// trailing outro on the body-final sectPr. Empty markers are dropped.
const top = doc.content!.map((n) => n.type);
expect(top).toEqual(['paragraph', 'columns', 'paragraph']);
expect(walk(doc.content![0], 'text')[0].text).toBe('intro text');
expect(walk(doc.content![2], 'text')[0].text).toBe('outro text');
});
it('keeps a text-bearing sectPr paragraph and clamps >3 columns', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>kept marker text</w:t></w:r><w:pPr><w:sectPr><w:type w:val="continuous"/><w:cols w:num="4" w:space="200"/></w:sectPr></w:pPr></w:p>
<w:p><w:r><w:t>tail</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const doc = result.content as N;
const cols = walk(doc, 'columns');
expect(cols.length).toBe(1);
expect(cols[0].attrs.count).toBe(3);
expect(walk(cols[0], 'text')[0].text).toBe('kept marker text');
expect(result.warnings).toContain('Sections with more than 3 columns were reduced to 3 columns');
});
it('wraps a whole-document multi-column file (cols on the body-final sectPr only)', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>all of it</w:t></w:r></w:p>
<w:p><w:r><w:t>flows in columns</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:cols w:num="3" w:space="425"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const doc = result.content as N;
const cols = walk(doc, 'columns');
expect(cols.length).toBe(1);
expect(cols[0].attrs.count).toBe(3);
expect(cols[0].attrs.gapCm).toBe(0.75); // 425 twips
expect(walk(cols[0], 'text').map((t) => t.text)).toEqual(['all of it', 'flows in columns']);
});
it('splits a columned section around a table with a warning', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>before table</w:t></w:r></w:p>
<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr><w:tc><w:p><w:r><w:t>in table</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:t>after table</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:cols w:num="2" w:space="283"/></w:sectPr>
</w:body></w:document>`;
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
const doc = result.content as N;
expect(doc.content!.map((n) => n.type)).toEqual(['columns', 'table', 'columns']);
expect(result.warnings).toContain('Tables and text boxes inside a multi-column layout were moved out of the columns');
});
});
describe('DOCX import: hardBreak carries its run font (empty-line height)', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
// "Muster" <br/> <br/> "Arbeitsvertrag", every run at 36pt (sz=72): the blank line
// between the two breaks must keep the 36pt height, like Word/LibreOffice.
const run = (inner: string) => `<w:r><w:rPr><w:sz w:val="72"/></w:rPr>${inner}</w:r>`;
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:pPr><w:rPr><w:sz w:val="72"/></w:rPr></w:pPr>
${run('<w:t>Muster</w:t>')}${run('<w:br/>')}${run('<w:br/>')}${run('<w:t>Arbeitsvertrag</w:t>')}
</w:p>
</w:body></w:document>`;
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml) });
it('tags both hardBreaks with the run font size', () => {
const doc = importDocx(bytes).content as N;
const brs = walk(doc.content![0], 'hardBreak');
expect(brs.length).toBe(2);
for (const br of brs) expect(markAttrs(br, 'textStyle')?.fontSize).toBe('36pt');
});
it('keeps the break font size through a DOCX export round trip', async () => {
const doc = importDocx(bytes).content as N;
const round = importDocx(await buildDocx(doc as never)).content as N;
const brs = walk(round.content![0], 'hardBreak');
expect(brs.length).toBe(2);
for (const br of brs) expect(markAttrs(br, 'textStyle')?.fontSize).toBe('36pt');
});
});
describe('DOCX import: a list continued across paragraphs keeps counting (same numId)', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const item = (numId: number, t: string) =>
`<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="${numId}"/></w:numPr></w:pPr><w:r><w:t>${t}</w:t></w:r></w:p>`;
const gap = (t: string) => `<w:p><w:r><w:t>${t}</w:t></w:r></w:p>`;
// numId 5 (upperRoman) split by body paragraphs; numId 6 shares the format but is its own
// counter, so it must restart — Word continues per numId, not per glyph style.
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
${item(5, 'One')}${gap('body a')}${item(5, 'Two')}${gap('body b')}${item(5, 'Three')}${item(6, 'Other')}
</w:body></w:document>`;
const numberingXml = `<?xml version="1.0"?><w:numbering ${W}>
<w:abstractNum w:abstractNumId="5"><w:lvl w:ilvl="0"><w:numFmt w:val="upperRoman"/><w:lvlText w:val="%1."/></w:lvl></w:abstractNum>
<w:num w:numId="5"><w:abstractNumId w:val="5"/></w:num>
<w:num w:numId="6"><w:abstractNumId w:val="5"/></w:num>
</w:numbering>`;
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/numbering.xml': strToU8(numberingXml) });
const doc = importDocx(bytes).content as N;
it('emits separate ordered lists whose start values continue the numId', () => {
const ols = doc.content!.filter((n) => n.type === 'orderedList');
expect(ols.length).toBe(4);
expect(ols.map((o) => o.attrs?.start)).toEqual([undefined, 2, 3, undefined]);
expect(ols.every((o) => o.attrs?.listStyleType === 'upper-roman')).toBe(true);
expect(walk(ols[3], 'text')[0].text).toBe('Other'); // separate numId → restarts at 1
});
});
describe('DOCX import: a mid-body section break starts a new page', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const geom = '<w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/>';
const build = (type: string | null) => {
const t = type ? `<w:type w:val="${type}"/>` : '';
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>section one</w:t></w:r></w:p>
<w:p><w:pPr><w:sectPr>${geom}</w:sectPr></w:pPr></w:p>
<w:p><w:r><w:t>section two</w:t></w:r></w:p>
<w:sectPr>${t}${geom}</w:sectPr>
</w:body></w:document>`;
return importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) })).content as N;
};
it("marks the next section's first block with breakBefore for a default (nextPage) break", () => {
const top = build(null).content!;
expect(top.map((n) => n.type)).toEqual(['paragraph', 'paragraph']);
expect(walk(top[1], 'text')[0].text).toBe('section two');
expect(top[0].attrs?.breakBefore).toBeFalsy();
expect(top[1].attrs?.breakBefore).toBe('page');
});
it('does not break for a continuous section', () => {
const top = build('continuous').content!;
expect(top[1].attrs?.breakBefore).toBeFalsy();
});
it('keeps the section-break page break through a DOCX export round trip', async () => {
const round = importDocx(await buildDocx(build(null) as never)).content as N;
const two = round.content!.find((n) => walk(n, 'text').some((t) => t.text === 'section two'))!;
expect(two.attrs?.breakBefore).toBe('page');
});
});
describe('DOCX named paragraph styles', () => {
const sheet = builtinStyleSheet();
sheet.paragraph['Merksatz'] = {
name: 'Merksatz', parent: 'Quotations', next: 'Standard',
para: { spaceBefore: 8 }, text: { bold: true, color: '#0000AA' },
};
const fixture = {
type: 'doc',
content: [
heading(1, 'Kapitel'),
{ type: 'paragraph', attrs: { styleName: 'Merksatz' }, content: [text('gemerkt')] },
{ type: 'paragraph', attrs: { styleName: 'Merksatz', spaceAfter: 20 }, content: [text('mit Abstand')] },
{ type: 'paragraph', content: [text('normal')] },
],
} as any;
it('writes real Word styles and round-trips the assignment', async () => {
const bytes = await buildDocx(fixture, undefined, undefined, undefined, undefined, undefined, sheet);
const files = unzipSync(bytes);
const stylesXml = strFromU8(files['word/styles.xml']);
const documentXml = strFromU8(files['word/document.xml']);
const merk = stylesXml.match(/<w:style [^>]*w:styleId="Merksatz"[\s\S]*?<\/w:style>/)?.[0] ?? '';
expect(merk).toContain('<w:basedOn w:val="Quotations"/>');
expect(merk).toContain('<w:color w:val="0000AA"/>');
expect(documentXml).toContain('<w:pStyle w:val="Merksatz"/>');
expect(documentXml).toContain('<w:pStyle w:val="Heading1"/>');
const res = importDocx(bytes);
const blocks = (res.content as N).content!;
expect(blocks[1].attrs.styleName).toBe('Merksatz');
expect(blocks[1].content![0].marks ?? []).toEqual([]); // style formatting stays in the style
expect(blocks[2].attrs.spaceAfter).toBe(20); // hard formatting stays direct
expect(blocks[0].type).toBe('heading');
const style = res.styles.paragraph['Merksatz'];
expect(style.parent).toBe('Quotations');
expect(style.text.bold).toBe(true);
expect(style.text.color).toBe('#0000AA');
expect(style.para.spaceBefore).toBe(8);
});
});
describe('DOCX named character styles', () => {
const sheet = builtinStyleSheet();
sheet.character['Signal'] = {
name: 'Signal', parent: null, next: null, para: {}, text: { bold: true, color: '#CC0000' },
};
const CS = (t: string, name: string, ...extra: any[]) =>
({ type: 'text', text: t, marks: [{ type: 'charStyle', attrs: { name } }, ...extra] });
it('writes w:rStyle runs and round-trips them', async () => {
const fixture = { type: 'doc', content: [{ type: 'paragraph', content: [
text('plain '), CS('emphasised', 'Emphasis'), text(' and '), CS('signal', 'Signal', { type: 'italic' }),
] }] } as any;
const bytes = await buildDocx(fixture, undefined, undefined, undefined, undefined, undefined, sheet);
const files = unzipSync(bytes);
const stylesXml = strFromU8(files['word/styles.xml']);
const documentXml = strFromU8(files['word/document.xml']);
expect(stylesXml).toMatch(/<w:style w:type="character" [^>]*w:styleId="Signal"/);
expect(stylesXml).toMatch(/w:styleId="Signal"[\s\S]*?<w:color w:val="CC0000"\/>/);
expect(documentXml).toContain('<w:rStyle w:val="Emphasis"/>');
expect(documentXml).toContain('<w:rStyle w:val="Signal"/>');
const res = importDocx(bytes);
const runs = (res.content as N).content![0].content!;
const charOf = (r: N) => (r.marks ?? []).find((m: N) => m.type === 'charStyle')?.attrs?.name;
expect(runs[0].marks).toBeUndefined();
expect(charOf(runs[1])).toBe('Emphasis');
expect((runs[1].marks ?? []).every((m: N) => m.type === 'charStyle')).toBe(true);
const signal = runs.find((r: N) => charOf(r) === 'Signal')!;
expect((signal.marks ?? []).some((m: N) => m.type === 'italic')).toBe(true);
expect(res.styles.character['Signal'].text).toEqual({ bold: true, color: '#CC0000' });
});
});
describe('DOCX table margins (dragged outer edges)', () => {
// Default Word geometry here: 21 - 2.12 - 2.12 = 16.76cm of text width, so the
// table is 16.76 - 2 - 3 = 11.76cm wide and indented 2cm.
const fixture = {
type: 'doc',
content: [
{ type: 'table', attrs: { marginLeft: 2, marginRight: 3 }, content: [
{ type: 'tableRow', content: [cell('A', { colwidth: [200] }), cell('B', { colwidth: [100] })] },
] },
],
} as any;
it('writes w:tblInd + the narrower grid and round-trips the margins', async () => {
const bytes = await buildDocx(fixture, { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }, 'portrait');
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
expect(xml).toContain(`<w:tblInd w:type="dxa" w:w="${Math.round((2 / 2.54) * 1440)}"/>`);
const res = await importDocx(bytes);
const table = walk(res.content as N, 'table')[0];
expect(table.attrs.marginLeft).toBeCloseTo(2, 1);
expect(table.attrs.marginRight).toBeCloseTo(3, 1);
const weights = (table.content?.[0].content ?? []).map((c: N) => c.attrs.colwidth[0]);
expect(weights[0] / weights[1]).toBeCloseTo(2, 2);
});
});