Files
edentext/tests/unit/docx-export.test.ts
Steffen Becker bd38c955ae test: every fixture picture is a PNG a browser accepts
The 1x1 fixture carried an IDAT from a different image: its CRC fails and the
zlib stream is truncated. Chromium and WebKit render it anyway, Firefox reports
"Image corrupt or truncated", and the same bytes sat in eleven fixtures.
2026-09-06 19:07:24 +02:00

165 lines
7.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeAll } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildDocx } from '../../src/lib/export/docx';
import { HEADER_SHADE } from '../../src/lib/editor/extensions/tableHeaderRow';
// 1×1 transparent PNG.
const PNG =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNwaDgAAAKEAYEml6crAAAAAElFTkSuQmCC';
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)] });
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 },
),
para([text('a\tb')]),
para([text('line1'), { type: 'hardBreak' }, text('line2')]),
{ type: 'bulletList', attrs: { indent: 0, bulletChar: '❖' }, content: [
li(para('one')),
li(para('two'), { type: 'bulletList', content: [li(para('nested'))] }),
] },
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [li(para('alpha'))] },
{ type: 'orderedList', attrs: { listStyleType: 'multilevel' }, content: [
li(para('legal'), { type: 'orderedList', content: [li(para('legal sub'))] }),
] },
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', alt: 'pic' } }]),
para([{ type: 'textBox', attrs: { width: 288, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('box text')] }]),
{ 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')] },
{ type: 'tableRow', content: [cell('2')] },
] },
],
} as any;
const hf = {
header: { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [text('My header')] }] },
footer: { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'center' }, content: [text('Page '), { type: 'pageNumber' }, text(' of '), { type: 'pageCount' }] }] },
pageCount: 1,
} as any;
describe('buildDocx', () => {
let files: Record<string, Uint8Array>;
let bytes: Uint8Array;
let docXml: string;
let names: string[];
beforeAll(async () => {
bytes = await buildDocx(fixture, { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }, 'portrait', hf, { language: 'en', country: 'US' });
files = unzipSync(bytes);
names = Object.keys(files);
docXml = strFromU8(files['word/document.xml']);
});
it('produces a valid zip (PK signature) with the core OOXML parts', () => {
expect(bytes[0]).toBe(0x50);
expect(bytes[1]).toBe(0x4b);
expect(names).toContain('[Content_Types].xml');
expect(names).toContain('word/document.xml');
expect(names).toContain('word/numbering.xml');
expect(names.some((n) => /^word\/media\/.*\.png$/.test(n))).toBe(true);
expect(names.some((n) => /^word\/header\d*\.xml$/.test(n))).toBe(true);
expect(names.some((n) => /^word\/footer\d*\.xml$/.test(n))).toBe(true);
});
it('emits well-formed XML in every part (Word rejects malformed XML)', () => {
const parser = new DOMParser();
for (const name of names.filter((n) => n.endsWith('.xml'))) {
const doc = parser.parseFromString(strFromU8(files[name]), 'application/xml');
expect(doc.getElementsByTagName('parsererror').length, `parse error in ${name}`).toBe(0);
}
});
it('emits headings, alignment, spacing, indent and line spacing', () => {
expect(docXml).toContain('w:pStyle w:val="Heading1"');
expect(docXml).toContain('w:jc w:val="center"');
expect(docXml).toContain('w:line="360"'); // 1.5 × 240
expect(docXml).toMatch(/w:ind[^>]*w:left="567"/); // 1cm
});
it('emits run formatting: bold, color, highlight shading, hyperlink', () => {
expect(docXml).toContain('<w:b/>');
expect(docXml).toContain('w:color w:val="FF0000"');
expect(docXml).toMatch(/w:shd[^>]*w:fill="00FF00"/); // highlight
expect(docXml).toContain('<w:hyperlink');
});
it('emits tabs and hard breaks as elements', () => {
expect(docXml).toContain('<w:tab/>');
expect(docXml).toContain('<w:br/>');
});
it('emits numbered/bulleted paragraphs and a numbering definition', () => {
expect(docXml).toContain('<w:numPr>');
expect(docXml).toContain('<w:numId');
const numXml = strFromU8(files['word/numbering.xml']);
expect(numXml).toContain('w:val="bullet"');
expect(numXml).toContain('w:val="lowerLetter"');
// Custom bullet char → level 0 lvlText; nested default level keeps the cycle (◦)
expect(numXml).toMatch(/w:lvlText[^>]*w:val="❖"/);
expect(numXml).toMatch(/w:lvlText[^>]*w:val="◦"/);
// Multilevel list → chained lvlText below level 0
expect(numXml).toMatch(/w:lvlText[^>]*w:val="%1\.%2\."/);
});
it('emits a fixed-layout table with merged cells and shading', () => {
expect(docXml).toContain('<w:tbl>');
expect(docXml).toContain('<w:tblGrid>');
expect(docXml).toContain('w:vMerge'); // rowspan
expect(docXml).toMatch(/w:shd[^>]*w:fill="F2F2F2"/); // header shade
expect(docXml).toMatch(/w:shd[^>]*w:fill="FFFF00"/); // custom cell fill
expect(docXml).toMatch(/w:trHeight[^>]*w:val="600"/); // 40px → 600 twips
});
it('bakes bold onto header-row cells', () => {
// The header cells carry no bold mark; bold must be baked into their runs.
const tbl = docXml.slice(docXml.indexOf('<w:tbl>'));
expect(tbl).toContain('<w:b/>');
});
it('embeds the image as a floating (wrapped) drawing', () => {
expect(docXml).toContain('<w:drawing>');
expect(docXml).toContain('wp:anchor'); // floating, not inline
});
it('rewrites the text-box marker into a DrawingML shape (post-pack pass)', () => {
expect(docXml).toContain('<wps:wsp');
expect(docXml).toContain('<wps:txbx><w:txbxContent>');
expect(docXml).toContain('prst="ellipse"');
expect(docXml).toMatch(/a:xfrm rot="1800000"/); // 30° × 60000
expect(docXml).toMatch(/<wp:wrapSquare wrapText="left"\/>/); // box right ⇒ text left
expect(docXml).not.toContain('\uE008'); // marker sentinel fully consumed
});
it('writes page geometry with header/footer distances', () => {
expect(docXml).toMatch(/<w:pgSz[^>]*w:w="11906"[^>]*w:h="16838"/);
expect(docXml).toMatch(/<w:pgMar[^>]*w:header="/);
});
it('puts page-number / page-count fields in the footer', () => {
const footerName = names.find((n) => /^word\/footer\d*\.xml$/.test(n))!;
const footerXml = strFromU8(files[footerName]);
expect(footerXml).toContain('PAGE');
expect(footerXml).toContain('NUMPAGES');
});
});