Files
edentext/tests/docx-roundtrip.test.ts
T
Steffen Becker 5968b6a04b fix: a frame keeps its place whatever kind of frame it is
Text boxes and shapes had no offsets at all, and for images the wrap side
overrode the x: a file that names the side its text flows on lost the
coordinate, our own export included. Side and place are read separately now,
and one helper turns both into the float's margins for every frame kind.
2026-08-08 10:17:21 +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 ?? null).toBe(null); // Courier 'o' = default ◦ at level 2
});
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);
});
});