import { describe, it, expect } from 'vitest'; import { existsSync, readFileSync } from 'node:fs'; import { buildOdt } from '../src/lib/export/odt'; import { importOdt } from '../src/lib/import/odt'; import { buildDocx } from '../src/lib/export/docx'; import { importDocx } from '../src/lib/import/docx'; import { DEFAULT_MARGINS } from '../src/lib/storage/pageMargins'; import { hfIsEmpty } from '../src/lib/storage/headerFooter'; type N = { type: string; attrs?: any; content?: N[] }; const bandedBody: N = { type: 'doc', content: [ { type: 'paragraph', attrs: { backgroundColor: '#CCFFFF', borderTop: '2pt solid #FF0000', borderRight: '1pt solid #00B050', borderBottom: '4pt solid #0066CC', borderLeft: '1pt solid #00B050' }, content: [{ type: 'text', text: 'banded body' }] }, { type: 'paragraph', content: [{ type: 'text', text: 'plain' }] }, ], }; const bandedHeader: N = { type: 'doc', content: [ { type: 'paragraph', attrs: { backgroundColor: '#CCFFFF', borderBottom: '3pt solid #0066CC', textAlign: 'right' }, content: [{ type: 'text', text: 'Letterhead' }] }, ], }; function firstBanded(doc: N): N | undefined { return doc.content?.find((n) => n.type === 'paragraph' && !!n.content?.length); } describe('paragraph background + borders round trip', () => { it('ODF round-trips body shading + all four borders and a header rule', async () => { const odt = await buildOdt(bandedBody as any, DEFAULT_MARGINS, 'portrait', { header: bandedHeader as any, footer: null, pageCount: 1, } as any); const back: any = importOdt(odt); const bp = firstBanded(back.content)!; expect(bp.attrs.backgroundColor?.toUpperCase()).toBe('#CCFFFF'); expect(bp.attrs.borderTop).toMatch(/2pt solid #FF0000/i); expect(bp.attrs.borderRight).toMatch(/1pt solid #00B050/i); expect(bp.attrs.borderBottom).toMatch(/4pt solid #0066CC/i); expect(bp.attrs.borderLeft).toMatch(/1pt solid #00B050/i); const hp = back.header.content[0]; expect(hp.attrs.backgroundColor?.toUpperCase()).toBe('#CCFFFF'); expect(hp.attrs.borderBottom).toMatch(/3pt solid #0066CC/i); expect(hp.attrs.textAlign).toBe('right'); }); it('DOCX round-trips body shading + borders (w:shd/w:pBdr) and a header rule', async () => { const bytes = await buildDocx(bandedBody as any, DEFAULT_MARGINS, 'portrait', { header: bandedHeader as any, footer: null, pageCount: 1, } as any); const back: any = importDocx(bytes); const bp = firstBanded(back.content)!; expect(bp.attrs.backgroundColor?.toUpperCase()).toBe('#CCFFFF'); expect(bp.attrs.borderTop).toMatch(/2pt solid #FF0000/i); expect(bp.attrs.borderBottom).toMatch(/4pt solid #0066CC/i); expect(bp.attrs.borderLeft).toMatch(/1pt solid #00B050/i); const hp = back.header.content[0]; expect(hp.attrs.backgroundColor?.toUpperCase()).toBe('#CCFFFF'); expect(hp.attrs.borderBottom).toMatch(/3pt solid #0066CC/i); }); it('treats a rule-only header/footer paragraph as non-empty (so it renders/exports)', () => { const ruleOnly: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { borderTop: '4pt solid #0066CC' } }] }; expect(hfIsEmpty(ruleOnly as any)).toBe(false); expect(hfIsEmpty({ type: 'doc', content: [{ type: 'paragraph' }] } as any)).toBe(true); }); it('round-trips an empty bottom-rule paragraph (the --- autocorrect output)', async () => { const doc: N = { type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'above' }] }, { type: 'paragraph', attrs: { borderBottom: '0.5pt solid #000000' } }, { type: 'paragraph', content: [{ type: 'text', text: 'below' }] }, ]}; const findRule = (d: any) => d.content.find((n: any) => n.type === 'paragraph' && !n.content?.length && n.attrs?.borderBottom); const odtBack: any = importOdt(await buildOdt(doc as any, DEFAULT_MARGINS, 'portrait')); const docxBack: any = importDocx(await buildDocx(doc as any, DEFAULT_MARGINS, 'portrait')); for (const back of [odtBack.content, docxBack.content]) { const rule = findRule(back); expect(rule, 'empty bottom-rule paragraph survives').toBeTruthy(); expect(rule.attrs.borderBottom).toMatch(/pt solid #000000/i); } }); it('drops background/borders that are absent (no accretion)', async () => { const plain: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] }; const odt = await buildOdt(plain as any, DEFAULT_MARGINS, 'portrait'); const back: any = importOdt(odt); const p = firstBanded(back.content)!; expect(p.attrs?.backgroundColor ?? null).toBeNull(); expect(p.attrs?.borderBottom ?? null).toBeNull(); }); }); // Real LibreOffice letterhead: two header paragraphs (name = shading, address = shading + // bottom rule) collapse to one paragraph with shading + the bottom rule; the footer is a // single empty paragraph carrying only a top rule. Local-only fixture — skips in CI. const FIXTURE = 'debug/bewerbung.net_anschreibenvorlage-openoffice.odt'; describe.skipIf(!existsSync(FIXTURE))('colored header/footer from a real ODT', () => { it('merges the header band + rule and keeps the footer rule line', () => { const res: any = importOdt(new Uint8Array(readFileSync(FIXTURE))); const hp = res.header.content[0]; expect(hp.attrs.backgroundColor?.toUpperCase()).toBe('#CCFFFF'); expect(hp.attrs.borderBottom).toMatch(/pt solid #0066CC/i); const fp = res.footer.content[0]; expect(fp.attrs.borderTop).toMatch(/pt solid #0066CC/i); }); // dynamic-spacing="true" ⇒ the header→body gap is not reserved as body margin: top = // page margin 0.801 + header min-height 2.2 ≈ 3.0cm (not 0.801 + 2.2 + 2.099 spacing). it('does not reserve the dynamic header/footer spacing in the body margin', () => { const res: any = importOdt(new Uint8Array(readFileSync(FIXTURE))); expect(res.margins.top).toBeCloseTo(3.0, 1); expect(res.margins.bottom).toBeCloseTo(2.22, 1); // 1.199 page + 1.021 footer height }); });