textBox becomes an inline node carrying block content, like the frame both formats write, so "in line with text" really is in the line and a box reaches a table cell or a list item through their paragraphs. Both exports write it where it sits (an as-char draw:frame / a wp:inline drawing) and both imports leave it there, which drops the hoisting machinery on either side. Two guards the schema cannot express: no box inside a box, and a selection reaching into a box grows to the box's bounds rather than dissolving it.
1374 lines
73 KiB
TypeScript
1374 lines
73 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' } }]),
|
||
para([{ type: 'textBox', attrs: { width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 }, content: [
|
||
para('box text'),
|
||
para([text('bold in box', [{ type: 'bold' }])]),
|
||
] }]),
|
||
para([{ 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"');
|
||
expect(stylesXml, 'the imported style is a direct w:styles child').not.toContain('<undefined');
|
||
// The header region's white bold is presentational in the editor, baked here.
|
||
expect(xml).toContain('<w:color w:val="FFFFFF"');
|
||
// The style paints every border via the cells; a black table-level default would
|
||
// show in Word, whose table borders beat a cell's "none" (only "nil" loses).
|
||
const tblPr = /<w:tblPr>[\s\S]*?<\/w:tblPr>/.exec(xml)![0];
|
||
expect(tblPr).not.toContain('w:val="single"');
|
||
|
||
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('defines Title and Heading1-6 exactly once, chain intact, defaults kept', async () => {
|
||
// The table forces a spliced table style — which used to replace the library's
|
||
// whole factory set, w:docDefaults (default font/size/language) included.
|
||
const doc = { type: 'doc', content: [heading(1, 'Kapitel'), para('Text'), {
|
||
type: 'table', attrs: { tableStyle: 'Box List Blue' },
|
||
content: [{ type: 'tableRow', content: [cell('Zelle')] }],
|
||
}] } as any;
|
||
const files = unzipSync(await buildDocx(doc));
|
||
const stylesXml = strFromU8(files['word/styles.xml']);
|
||
const ids = [...stylesXml.matchAll(/w:styleId="([^"]+)"/g)].map((m) => m[1]);
|
||
// A second definition under the same id makes Word and LO drop the basedOn chain.
|
||
expect(ids.length).toBe(new Set(ids).size);
|
||
expect(stylesXml).toMatch(/w:styleId="Heading1"><w:name w:val="Heading 1"\/><w:basedOn w:val="Heading"\/>/);
|
||
expect(stylesXml).toContain('<w:docDefaults>');
|
||
expect(stylesXml).toContain('w:styleId="BoxListBlue"');
|
||
});
|
||
|
||
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=""/><w:rPr><w:rFonts w:ascii="Wingdings" w:hAnsi="Wingdings"/></w:rPr></w:lvl>
|
||
<w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:lvlText w:val="o"/><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr></w:lvl>
|
||
</w:abstractNum>
|
||
<w:abstractNum w:abstractNumId="2">
|
||
<w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/></w:lvl>
|
||
<w:lvl w:ilvl="1"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1.%2."/></w:lvl>
|
||
</w:abstractNum>
|
||
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
|
||
<w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>
|
||
<w:num w:numId="3"><w:abstractNumId w:val="2"/></w:num>
|
||
</w:numbering>`;
|
||
|
||
const bytes = zipSync({
|
||
'word/document.xml': strToU8(documentXml),
|
||
'word/styles.xml': strToU8(stylesXml),
|
||
'word/numbering.xml': strToU8(numberingXml),
|
||
});
|
||
const result = importDocx(bytes);
|
||
const doc = result.content as N;
|
||
|
||
it('resolves a heading via its style chain (style-only run props)', () => {
|
||
const h = doc.content![0];
|
||
expect(h.type).toBe('heading');
|
||
expect(h.attrs.level).toBe(1);
|
||
const t = walk(h, 'text')[0];
|
||
expect(hasMark(t, 'bold')).toBe(false); // heading bold is presentational
|
||
// The style's own formatting stays in the style registry, not on the block.
|
||
expect(t.marks ?? []).toEqual([]);
|
||
expect(h.attrs.spaceBefore).toBeUndefined();
|
||
const style = result.styles.paragraph['Heading 1'];
|
||
expect(style.text.fontSizePt).toBe(20); // w:sz 40
|
||
expect(style.para.spaceBefore).toBe(24); // 480 twips
|
||
expect(style.para.spaceAfter).toBe(6); // 120 twips
|
||
});
|
||
|
||
it('maps Word symbol-font bullets and suppresses the default cycle', () => {
|
||
const bullets = walk(doc, 'bulletList');
|
||
const top = bullets.find((b) => walk(b, 'text').some((t) => t.text === 'Wingdings diamond'))!;
|
||
expect(top).toBeTruthy();
|
||
expect(top.attrs?.bulletChar).toBe('❖'); // Wingdings U+F076
|
||
const nested = walk(top, 'bulletList').find((b) => b !== top)!;
|
||
expect(walk(nested, 'text')[0].text).toBe('Courier hollow');
|
||
expect(nested.attrs?.bulletChar).toBe('o'); // Word's hollow bullet is the letter, as LO draws it
|
||
});
|
||
|
||
it("imports Word's legal numbering (%1.%2.) as a multilevel list", () => {
|
||
const ols = walk(doc, 'orderedList');
|
||
const top = ols.find((o) => walk(o, 'text').some((t) => t.text === 'Legal one'))!;
|
||
expect(top.attrs?.listStyleType).toBe('multilevel');
|
||
const sub = walk(top, 'orderedList').find((o) => o !== top)!;
|
||
expect(sub.attrs?.listStyleType ?? null).toBe(null);
|
||
});
|
||
|
||
it('reconstructs a numbered list and a vertically-merged table', () => {
|
||
expect(walk(doc, 'orderedList').filter((o) => walk(o, 'text').some((t) => t.text === 'First')).length).toBe(1);
|
||
const rows = walk(doc, 'table')[0].content!;
|
||
expect(rows[0].content![0].attrs.rowspan).toBe(2); // vMerge restart → rowspan
|
||
expect(rows[1].content!.length).toBe(1); // covered cell dropped
|
||
expect(result.margins.top).toBeCloseTo(2.54, 1);
|
||
});
|
||
});
|
||
|
||
describe('DOCX import: empty line keeps its paragraph-mark font size', () => {
|
||
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
|
||
// An empty paragraph whose only formatting is the paragraph mark (w:pPr/w:rPr) — 44
|
||
// half-points = 22pt, like a title's blank spacer lines. And a plain empty paragraph.
|
||
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||
<w:p><w:pPr><w:jc w:val="center"/><w:rPr><w:sz w:val="44"/></w:rPr></w:pPr></w:p>
|
||
<w:p><w:pPr><w:rPr><w:sz w:val="24"/></w:rPr></w:pPr></w:p>
|
||
<w:p/>
|
||
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
|
||
</w:body></w:document>`;
|
||
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml) });
|
||
const doc = importDocx(bytes).content as N;
|
||
|
||
it('carries the 22pt paragraph-mark size onto the empty line', () => {
|
||
const p = doc.content![0];
|
||
expect(p.type).toBe('paragraph');
|
||
expect(p.content).toBeUndefined(); // still empty
|
||
expect(p.attrs.fontSize).toBe('22pt');
|
||
expect(p.attrs.textAlign).toBe('center');
|
||
});
|
||
|
||
it('suppresses a paragraph-mark size equal to the 12pt body default', () => {
|
||
expect(doc.content![1].attrs?.fontSize ?? null).toBeNull();
|
||
expect(doc.content![2].attrs?.fontSize ?? null).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('DOCX import: alignment inherited from a paragraph style', () => {
|
||
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
|
||
// The default paragraph style (Standard) sets justify; body paragraphs and a heading
|
||
// based on it inherit it. A direct w:jc still wins over the style.
|
||
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||
<w:p><w:r><w:t>Inherits justify from Standard</w:t></w:r></w:p>
|
||
<w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r><w:t>Direct center wins</w:t></w:r></w:p>
|
||
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Heading based on Standard</w:t></w:r></w:p>
|
||
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
|
||
</w:body></w:document>`;
|
||
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
|
||
<w:style w:type="paragraph" w:default="1" w:styleId="Standard"><w:name w:val="Normal"/><w:pPr><w:jc w:val="both"/></w:pPr></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Heading1"><w:basedOn w:val="Standard"/><w:pPr><w:outlineLvl w:val="0"/></w:pPr></w:style>
|
||
</w:styles>`;
|
||
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/styles.xml': strToU8(stylesXml) });
|
||
const res = importDocx(bytes);
|
||
const doc = res.content as N;
|
||
|
||
it('keeps the default style\'s justify in the style, not on the block', () => {
|
||
expect(doc.content![0].attrs?.textAlign).toBeUndefined();
|
||
expect(res.styles.paragraph['Standard'].para.textAlign).toBe('justify');
|
||
});
|
||
it('lets a direct w:jc override the style', () => {
|
||
expect(doc.content![1].attrs.textAlign).toBe('center');
|
||
});
|
||
it('leaves a heading based on the default style free of direct alignment', () => {
|
||
expect(doc.content![2].type).toBe('heading');
|
||
expect(doc.content![2].attrs.textAlign).toBeUndefined();
|
||
// It inherits justify through Heading 1 → Standard in the registry instead.
|
||
expect(res.styles.paragraph['Heading 1'].parent).toBe('Standard');
|
||
});
|
||
});
|
||
|
||
describe('DOCX import resolves theme fonts (Word default = Calibri)', () => {
|
||
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
|
||
const A = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"';
|
||
// No explicit w:ascii anywhere — the font lives only in the theme, like a real Word doc.
|
||
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||
<w:p><w:r><w:t>Body text</w:t></w:r></w:p>
|
||
</w:body></w:document>`;
|
||
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
|
||
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi"/></w:rPr></w:rPrDefault></w:docDefaults>
|
||
</w:styles>`;
|
||
const themeXml = `<?xml version="1.0"?><a:theme ${A}><a:themeElements><a:fontScheme>
|
||
<a:majorFont><a:latin typeface="Calibri Light"/></a:majorFont>
|
||
<a:minorFont><a:latin typeface="Calibri"/></a:minorFont>
|
||
</a:fontScheme></a:themeElements></a:theme>`;
|
||
|
||
const bytes = zipSync({
|
||
'word/document.xml': strToU8(documentXml),
|
||
'word/styles.xml': strToU8(stylesXml),
|
||
'word/theme/theme1.xml': strToU8(themeXml),
|
||
});
|
||
const doc = importDocx(bytes).content as N;
|
||
|
||
it('tags body text with the theme body font instead of the editor default', () => {
|
||
const t = walk(doc, 'text')[0];
|
||
expect(t.text).toBe('Body text');
|
||
expect(markAttrs(t, 'textStyle')?.fontFamily).toBe('Calibri');
|
||
});
|
||
});
|
||
|
||
describe('DOCX import falls back to the theme font when nothing references it', () => {
|
||
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
|
||
const A = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"';
|
||
// No w:rFonts anywhere (not even a theme ref): Word uses the theme minor font as the
|
||
// implicit body default and the major one for headings — the file's fonts, not ours.
|
||
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||
<w:p><w:r><w:t>Body text</w:t></w:r></w:p>
|
||
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Heading text</w:t></w:r></w:p>
|
||
</w:body></w:document>`;
|
||
const themeXml = `<?xml version="1.0"?><a:theme ${A}><a:themeElements><a:fontScheme>
|
||
<a:majorFont><a:latin typeface="Calibri Light"/></a:majorFont>
|
||
<a:minorFont><a:latin typeface="Calibri"/></a:minorFont>
|
||
</a:fontScheme></a:themeElements></a:theme>`;
|
||
|
||
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/theme/theme1.xml': strToU8(themeXml) });
|
||
const doc = importDocx(bytes).content as N;
|
||
|
||
it('applies the theme minor font to body text with no font of its own', () => {
|
||
const t = walk(doc.content![0], 'text')[0];
|
||
expect(t.text).toBe('Body text');
|
||
expect(markAttrs(t, 'textStyle')?.fontFamily).toBe('Calibri');
|
||
});
|
||
it('applies the theme major font to a heading with no font of its own', () => {
|
||
const h = doc.content![1];
|
||
expect(h.type).toBe('heading');
|
||
expect(markAttrs(walk(h, 'text')[0], 'textStyle')?.fontFamily).toBe('Calibri Light');
|
||
});
|
||
});
|
||
|
||
describe('DOCX import detects headings by outline level (non-"HeadingN" style ids)', () => {
|
||
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
|
||
// A heading style whose id is NOT "Heading1" — only its w:outlineLvl marks it a heading.
|
||
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
|
||
<w:p><w:pPr><w:pStyle w:val="Titel1"/></w:pPr><w:r><w:t>Localised heading</w:t></w:r></w:p>
|
||
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
|
||
</w:body></w:document>`;
|
||
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
|
||
<w:style w:type="paragraph" w:styleId="Standard"><w:name w:val="Standard"/></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Titel1"><w:basedOn w:val="Standard"/><w:pPr><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
|
||
</w:styles>`;
|
||
const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/styles.xml': strToU8(stylesXml) });
|
||
const doc = importDocx(bytes).content as N;
|
||
|
||
it('maps an outline-level-0 paragraph to heading 1', () => {
|
||
const h = doc.content![0];
|
||
expect(h.type).toBe('heading');
|
||
expect(h.attrs.level).toBe(1);
|
||
expect(walk(h, 'text')[0].text).toBe('Localised heading');
|
||
});
|
||
});
|
||
|
||
describe('DOCX import of foreign text boxes / shapes', () => {
|
||
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
|
||
const MC = 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"';
|
||
const WPNS = 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"';
|
||
const ANS = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"';
|
||
const WPSNS = 'xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"';
|
||
const VNS = 'xmlns:v="urn:schemas-microsoft-com:vml"';
|
||
|
||
it('imports a Word-style mc:AlternateContent-wrapped wps text box (Choice only, no VML double-import)', () => {
|
||
// Real Word structure: mc:Choice carries the DrawingML shape, mc:Fallback a VML copy.
|
||
const documentXml = `<?xml version="1.0"?><w:document ${W} ${MC} ${WPNS} ${ANS} ${WPSNS} ${VNS}><w:body>
|
||
<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing>
|
||
<wp:anchor distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="2" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="0">
|
||
<wp:simplePos x="0" y="0"/>
|
||
<wp:positionH relativeFrom="margin"><wp:align>right</wp:align></wp:positionH>
|
||
<wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>
|
||
<wp:extent cx="1828800" cy="914400"/>
|
||
<wp:wrapSquare wrapText="left"/>
|
||
<wp:docPr id="7" name="Textfeld 7"/>
|
||
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
|
||
<wps:wsp><wps:cNvSpPr txBox="1"/><wps:spPr>
|
||
<a:xfrm rot="1800000"><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></a:xfrm>
|
||
<a:prstGeom prst="roundRect"><a:avLst/></a:prstGeom>
|
||
<a:solidFill><a:srgbClr val="CCFFCC"/></a:solidFill>
|
||
<a:ln w="28575"><a:solidFill><a:srgbClr val="003300"/></a:solidFill></a:ln>
|
||
</wps:spPr>
|
||
<wps:txbx><w:txbxContent>
|
||
<w:p><w:r><w:t>choice text</w:t></w:r></w:p>
|
||
<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>bold line</w:t></w:r></w:p>
|
||
</w:txbxContent></wps:txbx>
|
||
<wps:bodyPr/></wps:wsp>
|
||
</a:graphicData></a:graphic>
|
||
</wp:anchor>
|
||
</w:drawing></mc:Choice><mc:Fallback><w:pict>
|
||
<v:rect style="width:144pt;height:72pt" fillcolor="#ccffcc"><v:textbox><w:txbxContent><w:p><w:r><w:t>choice text</w:t></w:r></w:p></w:txbxContent></v:textbox></v:rect>
|
||
</w:pict></mc:Fallback></mc:AlternateContent></w:r><w:r><w:t>anchor text</w:t></w:r></w:p>
|
||
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
|
||
</w:body></w:document>`;
|
||
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
|
||
const boxes = walk(result.content as N, 'textBox');
|
||
expect(boxes.length).toBe(1); // the VML fallback must not double-import
|
||
const b = boxes[0];
|
||
expect(b.attrs.shapeKind).toBe('roundRect');
|
||
expect(b.attrs.width).toBe(192); // 1828800 EMU
|
||
expect(b.attrs.height).toBe(96);
|
||
expect(b.attrs.wrap).toBe('right'); // text left ⇒ box right
|
||
expect(b.attrs.rotation).toBe(30);
|
||
expect(b.attrs.fillColor).toBe('#CCFFCC');
|
||
expect(b.attrs.strokeColor).toBe('#003300');
|
||
expect(b.attrs.strokeWidthPt).toBe(2.25); // 28575 EMU
|
||
expect(walk(b, 'text').map((t) => t.text)).toEqual(['choice text', 'bold line']);
|
||
expect(hasMark(walk(b, 'text')[1], 'bold')).toBe(true);
|
||
// The anchor paragraph's own text survives, the box follows it at top level.
|
||
expect(walk(result.content as N, 'text').some((t) => t.text === 'anchor text')).toBe(true);
|
||
expect(result.warnings).toEqual([]);
|
||
});
|
||
|
||
it('imports a legacy VML-only text box (w:pict)', () => {
|
||
const documentXml = `<?xml version="1.0"?><w:document ${W} ${VNS}><w:body>
|
||
<w:p><w:r><w:pict>
|
||
<v:oval style="width:144pt;height:72pt" fillcolor="#ffee00" strokecolor="#ff0000" strokeweight="2.25pt">
|
||
<v:textbox><w:txbxContent><w:p><w:r><w:t>vml text</w:t></w:r></w:p></w:txbxContent></v:textbox>
|
||
</v:oval>
|
||
</w:pict></w:r></w:p>
|
||
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>
|
||
</w:body></w:document>`;
|
||
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
|
||
const boxes = walk(result.content as N, 'textBox');
|
||
expect(boxes.length).toBe(1);
|
||
const b = boxes[0];
|
||
expect(b.attrs.shapeKind).toBe('ellipse');
|
||
expect(b.attrs.width).toBe(192); // 144pt
|
||
expect(b.attrs.height).toBe(96);
|
||
expect(b.attrs.fillColor).toBe('#FFEE00');
|
||
expect(b.attrs.strokeColor).toBe('#FF0000');
|
||
expect(b.attrs.strokeWidthPt).toBe(2.25);
|
||
expect(walk(b, 'text')[0].text).toBe('vml text');
|
||
});
|
||
|
||
it('drops unsupported shapes, and keeps a chart as a placeholder of its size', () => {
|
||
const documentXml = `<?xml version="1.0"?><w:document ${W} ${WPNS} ${ANS} ${WPSNS}><w:body>
|
||
<w:p><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">
|
||
<wp:extent cx="914400" cy="914400"/><wp:docPr id="1" name="Star"/>
|
||
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
|
||
<wps:wsp><wps:spPr><a:prstGeom prst="smileyFace"><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);
|
||
});
|
||
|
||
// Word 2010 and earlier measure w:tblInd to the cell's text, so LibreOffice draws such
|
||
// a table a left cell margin outside the body text; Word 2013 (mode 15) does not.
|
||
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||
const indentedTable = (mode: number | null) => {
|
||
const full = Math.round((16.76 / 2.54) * 1440);
|
||
return zipSync({
|
||
'word/document.xml': strToU8(
|
||
`<?xml version="1.0"?><w:document xmlns:w="${W}"><w:body><w:tbl>` +
|
||
`<w:tblPr><w:tblInd w:type="dxa" w:w="0"/></w:tblPr>` +
|
||
`<w:tblGrid><w:gridCol w:w="${full}"/></w:tblGrid>` +
|
||
`<w:tr><w:tc><w:p><w:r><w:t>A</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="1202" w:right="1202"/></w:sectPr>` +
|
||
`</w:body></w:document>`,
|
||
),
|
||
...(mode == null ? {} : {
|
||
'word/settings.xml': strToU8(
|
||
`<?xml version="1.0"?><w:settings xmlns:w="${W}"><w:compat>` +
|
||
`<w:compatSetting w:name="compatibilityMode" w:uri="x" w:val="${mode}"/></w:compat></w:settings>`,
|
||
),
|
||
}),
|
||
});
|
||
};
|
||
|
||
it('hangs an older file’s table its left cell margin into the page margin', () => {
|
||
for (const mode of [null, 14]) {
|
||
const table = walk(importDocx(indentedTable(mode)).content as N, 'table')[0];
|
||
expect(table.attrs.marginLeft).toBeCloseTo(-0.19, 2);
|
||
expect(table.attrs.marginRight).toBeCloseTo(0.19, 2);
|
||
}
|
||
});
|
||
|
||
it('leaves a Word 2013 file’s table flush with the text area', () => {
|
||
const table = walk(importDocx(indentedTable(15)).content as N, 'table')[0];
|
||
expect(table.attrs?.marginLeft ?? 0).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('DOCX text box: a list inside it is a real list, a picture a real picture', () => {
|
||
const doc: N = { type: 'doc', content: [
|
||
// A body list first, so the box's numbering has to find free ids beside it.
|
||
{ type: 'bulletList', content: [li(para('body item'))] },
|
||
para([{ type: 'textBox', attrs: { width: 300, height: 200 }, content: [
|
||
para('box text'),
|
||
{ type: 'bulletList', content: [
|
||
li(para('one')),
|
||
li(para('two'), { type: 'orderedList', attrs: { start: 3 }, content: [li(para('a')), li(para('b'))] }),
|
||
] },
|
||
para([{ type: 'image', attrs: { src: PNG, width: 64, height: 64, alt: 'dot' } }]),
|
||
] }]),
|
||
] };
|
||
|
||
it('mints numbering, media and relationships of its own', async () => {
|
||
const files = unzipSync(await buildDocx(doc, { top: 2, bottom: 2, left: 2, right: 2 } as any, 'portrait'));
|
||
const xml = strFromU8(files['word/document.xml']);
|
||
const numbering = strFromU8(files['word/numbering.xml']);
|
||
const rels = strFromU8(files['word/_rels/document.xml.rels']);
|
||
|
||
const inBox = xml.slice(xml.indexOf('<w:txbxContent>'));
|
||
const numIds = [...inBox.matchAll(/<w:numId w:val="(\d+)"\/>/g)].map((m) => m[1]);
|
||
expect(numIds.length).toBe(4);
|
||
expect(new Set(numIds).size).toBe(1);
|
||
// The body's own list keeps its definition; the box's is a second one.
|
||
const bodyNumId = /<w:numId w:val="(\d+)"\/>/.exec(xml)![1];
|
||
expect(numIds[0]).not.toBe(bodyNumId);
|
||
for (const id of new Set([...numIds, bodyNumId])) {
|
||
expect(numbering).toContain(`<w:num w:numId="${id}">`);
|
||
}
|
||
// Two levels: the bullet and the ordered list nested under it, starting at 3.
|
||
const added = numbering.slice(numbering.lastIndexOf(`<w:abstractNum w:abstractNumId="${numIds[0]}"`));
|
||
expect(added).toContain('<w:numFmt w:val="bullet"/>');
|
||
expect(added).toContain('<w:numFmt w:val="lowerLetter"/>');
|
||
expect(added).toContain('<w:start w:val="3"/>');
|
||
// No literal markers left in the runs.
|
||
expect(inBox).not.toContain('<w:t xml:space="preserve">• ');
|
||
|
||
expect(Object.keys(files)).toContain('word/media/tbx1.png');
|
||
const rid = /<a:blip r:embed="(rId\d+)"/.exec(inBox)![1];
|
||
expect(rels).toContain(`<Relationship Id="${rid}" `);
|
||
expect(rels).toContain('Target="media/tbx1.png"');
|
||
});
|
||
|
||
it('round-trips the whole box', async () => {
|
||
const back = importDocx(await buildDocx(doc, { top: 2, bottom: 2, left: 2, right: 2 } as any, 'portrait'));
|
||
expect(back.warnings).toEqual([]);
|
||
const box = walk(back.content as N, 'textBox')[0];
|
||
const list = box.content!.find((n: N) => n.type === 'bulletList')!;
|
||
expect(list.content![1].content![1].type).toBe('orderedList');
|
||
expect(list.content![1].content![1].attrs.start).toBe(3);
|
||
const img = walk(box, 'image')[0];
|
||
expect(img.attrs.width).toBe(64);
|
||
expect(img.attrs.alt).toBe('dot');
|
||
expect(String(img.attrs.src).startsWith('data:image/png;base64,')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('DOCX named list styles', () => {
|
||
const sheet = builtinStyleSheet();
|
||
sheet.list['Prüfliste'] = {
|
||
name: 'Prüfliste',
|
||
levels: [
|
||
{ kind: 'number', numType: 'upper-roman-paren', markerAlign: 'right', indentCm: 0.5 },
|
||
{ kind: 'bullet', bulletChar: '✓' },
|
||
{ kind: 'number', numType: 'lower-alpha' },
|
||
],
|
||
};
|
||
const fixture = {
|
||
type: 'doc',
|
||
content: [
|
||
{ type: 'orderedList', attrs: { listStyleName: 'Prüfliste' }, content: [
|
||
li(para('one'), { type: 'bulletList', content: [li(para('sub'))] }),
|
||
li(para('two')),
|
||
] },
|
||
para('between'),
|
||
// A second list in the same style: its own instance, so it restarts at 1.
|
||
{ type: 'orderedList', attrs: { listStyleName: 'Prüfliste' }, content: [li(para('restart'))] },
|
||
{ type: 'bulletList', attrs: { listStyleName: 'Diamond Bullets' }, content: [li(para('dash'))] },
|
||
],
|
||
};
|
||
|
||
it('links the shared abstract via w:styleLink and the numbering style', async () => {
|
||
const bytes = await buildDocx(fixture as any, undefined, undefined, undefined, undefined, undefined, sheet);
|
||
const files = unzipSync(bytes);
|
||
const numbering = strFromU8(files['word/numbering.xml']);
|
||
const styles = strFromU8(files['word/styles.xml']);
|
||
|
||
const abstract = numbering.match(/<w:abstractNum [^>]*>(?:(?!<\/w:abstractNum>)[\s\S])*w:styleLink w:val="Prfliste"[\s\S]*?<\/w:abstractNum>/)?.[0] ?? '';
|
||
expect(abstract, 'the style abstract carries w:styleLink').toBeTruthy();
|
||
expect(abstract).toContain('<w:numFmt w:val="upperRoman"/>');
|
||
expect(abstract).toContain('<w:lvlText w:val="%1)"/>');
|
||
expect(abstract).toContain('<w:lvlJc w:val="right"/>');
|
||
expect(abstract).toContain('<w:lvlText w:val="✓"/>');
|
||
const absId = /w:abstractNumId="(\d+)"/.exec(abstract)![1];
|
||
// Two lists in one style → two concrete nums over the one abstract (each restarts).
|
||
const nums = numbering.match(new RegExp(`<w:num w:numId="\\d+"[^>]*>\\s*<w:abstractNumId w:val="${absId}"/>`, 'g')) ?? [];
|
||
expect(nums.length, 'one instance per list').toBe(2);
|
||
const style = styles.match(/<w:style w:type="numbering" w:styleId="Prfliste">[\s\S]*?<\/w:style>/)?.[0] ?? '';
|
||
expect(style).toContain('<w:name w:val="Prüfliste"/>');
|
||
expect(/<w:numId w:val="[1-9]\d*"\/>/.test(style), 'placeholder numId resolved').toBe(true);
|
||
expect(styles).toContain('w:styleId="DiamondBullets"');
|
||
// fromXmlString's nameless wrapper would serialize as <undefined> — Word refuses that.
|
||
expect(styles).not.toContain('<undefined');
|
||
});
|
||
|
||
it('round-trips the assignment and the definition', async () => {
|
||
const bytes = await buildDocx(fixture as any, undefined, undefined, undefined, undefined, undefined, sheet);
|
||
const result = importDocx(bytes);
|
||
const doc = result.content as N;
|
||
const lists = (doc.content ?? []).filter((n) => n.type === 'orderedList' || n.type === 'bulletList');
|
||
expect(lists[0]?.attrs?.listStyleName).toBe('Prüfliste');
|
||
expect(lists[0]?.attrs?.listStyleType).toBeUndefined();
|
||
expect(lists[0]?.attrs?.indent).toBeUndefined();
|
||
const nested = walk(lists[0], 'bulletList')[0];
|
||
expect(nested?.attrs ?? null, 'nested list stays attr-free').toBeNull();
|
||
expect(lists[1]?.attrs?.listStyleName).toBe('Prüfliste');
|
||
expect(lists[2]?.attrs?.listStyleName).toBe('Diamond Bullets');
|
||
const imported = result.styles.list['Prüfliste'];
|
||
expect(imported?.levels[0]).toMatchObject({ kind: 'number', numType: 'upper-roman-paren', markerAlign: 'right', indentCm: 0.5 });
|
||
expect(imported?.levels[1]).toMatchObject({ kind: 'bullet', bulletChar: '✓' });
|
||
expect(imported?.levels[2]).toMatchObject({ kind: 'number', numType: 'lower-alpha' });
|
||
});
|
||
|
||
it('the style level decides a depth\'s kind — a species-mismatched nest keeps the shared abstract', async () => {
|
||
sheet.list['Tab Test'] = { name: 'Tab Test', levels: [
|
||
{ kind: 'bullet', bulletChar: '–' },
|
||
...Array.from({ length: 8 }, () => ({ kind: 'number' as const, numType: 'decimal' as const })),
|
||
] };
|
||
// A Tab-nested tree is bullet lists all the way down; the style numbers depth 2+.
|
||
const doc = { type: 'doc', content: [
|
||
{ type: 'bulletList', attrs: { listStyleName: 'Tab Test' }, content: [
|
||
li(para('one'), { type: 'bulletList', content: [li(para('two'))] }),
|
||
] },
|
||
] };
|
||
const bytes = await buildDocx(doc as any, undefined, undefined, undefined, undefined, undefined, sheet);
|
||
const numbering = strFromU8(unzipSync(bytes)['word/numbering.xml']);
|
||
const abstract = numbering.match(/<w:abstractNum [^>]*>(?:(?!<\/w:abstractNum>)[\s\S])*w:styleLink w:val="TabTest"[\s\S]*?<\/w:abstractNum>/)?.[0] ?? '';
|
||
expect(abstract, 'the style abstract carries w:styleLink').toBeTruthy();
|
||
// The nested <ul> did not fork away from the style reference: exactly one num over
|
||
// the style abstract, and no third abstract beyond the library's default bullet.
|
||
const absId = /w:abstractNumId="(\d+)"/.exec(abstract)![1];
|
||
const nums = numbering.match(new RegExp(`<w:num w:numId="\\d+"[^>]*>\\s*<w:abstractNumId w:val="${absId}"/>`, 'g')) ?? [];
|
||
expect(nums.length, 'one instance for the one list').toBe(1);
|
||
expect((numbering.match(/<w:abstractNum /g) ?? []).length, 'no forked abstract').toBe(2);
|
||
const result = importDocx(bytes);
|
||
const top = (result.content as N).content!.find((n) => n.type === 'bulletList');
|
||
expect(top?.attrs?.listStyleName).toBe('Tab Test');
|
||
const nested = top?.content?.[0]?.content?.[1];
|
||
expect(nested?.type, 'the nested list comes back numbered').toBe('orderedList');
|
||
expect(nested?.attrs ?? null).toBeNull();
|
||
delete sheet.list['Tab Test'];
|
||
});
|
||
|
||
it('an overridden list keeps a private, fully resolved numbering and drops the name', async () => {
|
||
const doc = { type: 'doc', content: [
|
||
{ type: 'orderedList', attrs: { listStyleName: 'Outline A.I.1', listStyleType: 'lower-roman' }, content: [li(para('broken out'))] },
|
||
] };
|
||
const bytes = await buildDocx(doc as any, undefined, undefined, undefined, undefined, undefined, sheet);
|
||
const numbering = strFromU8(unzipSync(bytes)['word/numbering.xml']);
|
||
expect(numbering).not.toContain('w:styleLink');
|
||
expect(numbering).toContain('<w:numFmt w:val="lowerRoman"/>');
|
||
const list = (importDocx(bytes).content as N).content!.find((n) => n.type === 'orderedList');
|
||
expect(list?.attrs?.listStyleType).toBe('lower-roman');
|
||
expect(list?.attrs?.listStyleName).toBeUndefined();
|
||
});
|
||
|
||
it('follows w:numStyleLink in a foreign file (a linked list is numbers, not bullets)', () => {
|
||
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:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>linked one</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>linked two</w:t></w:r></w:p>
|
||
</w:body></w:document>`;
|
||
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
|
||
<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||
<w:style w:type="numbering" w:styleId="MyNum"><w:name w:val="My Numbering"/>
|
||
<w:pPr><w:numPr><w:numId w:val="2"/></w:numPr></w:pPr></w:style>
|
||
</w:styles>`;
|
||
// Word's indirection: the document's abstract 1 carries only w:numStyleLink; the
|
||
// style's own numbering (num 2 → abstract 0, carrying w:styleLink) has the levels.
|
||
const numberingXml = `<?xml version="1.0"?><w:numbering ${W}>
|
||
<w:abstractNum w:abstractNumId="0"><w:multiLevelType w:val="multilevel"/><w:styleLink w:val="MyNum"/>
|
||
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="upperLetter"/><w:lvlText w:val="%1)"/><w:pPr><w:ind w:left="1134" w:hanging="360"/></w:pPr></w:lvl>
|
||
</w:abstractNum>
|
||
<w:abstractNum w:abstractNumId="1"><w:numStyleLink w:val="MyNum"/></w:abstractNum>
|
||
<w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num>
|
||
<w:num w:numId="2"><w:abstractNumId w:val="0"/></w:num>
|
||
</w:numbering>`;
|
||
const result = importDocx(zipSync({
|
||
'word/document.xml': strToU8(documentXml),
|
||
'word/styles.xml': strToU8(stylesXml),
|
||
'word/numbering.xml': strToU8(numberingXml),
|
||
}));
|
||
const doc = result.content as N;
|
||
const list = doc.content!.find((n) => n.type === 'orderedList');
|
||
expect(list, 'the linked list resolves to numbers, not the bullet fallback').toBeTruthy();
|
||
expect(list!.attrs?.listStyleName).toBe('My Numbering');
|
||
const imported = result.styles.list['My Numbering'];
|
||
expect(imported?.levels[0]).toMatchObject({ kind: 'number', numType: 'upper-alpha-paren' });
|
||
expect(imported?.levels[0]?.indentCm).toBeCloseTo(0.73, 2); // 1134 twips = 2cm → +0.73
|
||
});
|
||
});
|