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:num="2"/); expect(documentXml).toMatch(/]*w:space="454"/); // 0.8cm in twips expect(documentXml.match(//g)!.length).toBe(2); // Word wants the page geometry repeated per sectPr. expect(documentXml.match(/ { 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(/ 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(' { 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:firstRow="true"/); expect(xml).toMatch(/]*w:lastRow="true"/); expect(xml).toMatch(/]*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:type="first"/); expect(documentXml).toMatch(/]*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: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(' { 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:instr="[^"]*DATE[^"]*dd\.MM\.yyyy/); expect(xml).toMatch(/]*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(/]*>[^<]*]*>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 = ` Foreign Title First Wingdings diamond Courier hollow Legal one Legal one-one AB C `; const stylesXml = ` `; const numberingXml = ` `; 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 = ` `; 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 = ` Inherits justify from Standard Direct center wins Heading based on Standard `; const stylesXml = ` `; 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 = ` Body text `; const stylesXml = ` `; const themeXml = ` `; 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 = ` Body text Heading text `; const themeXml = ` `; 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 = ` Localised heading `; const stylesXml = ` `; 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 = ` right 0 choice text bold line choice text anchor text `; 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 = ` vml text `; 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 = ` `; 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 = ` intro text col one col two outro text `; 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 = ` kept marker text tail `; 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 = ` all of it flows in columns `; 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 = ` before table in table after table `; 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"

"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) => `${inner}`; const documentXml = ` ${run('Muster')}${run('')}${run('')}${run('Arbeitsvertrag')} `; 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) => `${t}`; const gap = (t: string) => `${t}`; // 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 = ` ${item(5, 'One')}${gap('body a')}${item(5, 'Two')}${gap('body b')}${item(5, 'Three')}${item(6, 'Other')} `; const numberingXml = ` `; 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 = ''; const build = (type: string | null) => { const t = type ? `` : ''; const documentXml = ` section one ${geom} section two ${t}${geom} `; 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:styleId="Merksatz"[\s\S]*?<\/w:style>/)?.[0] ?? ''; expect(merk).toContain(''); expect(merk).toContain(''); expect(documentXml).toContain(''); expect(documentXml).toContain(''); 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:styleId="Signal"/); expect(stylesXml).toMatch(/w:styleId="Signal"[\s\S]*?/); expect(documentXml).toContain(''); expect(documentXml).toContain(''); 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(``); 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); }); });