Files
edentext/tests/paragraph-box.test.ts

121 lines
5.9 KiB
TypeScript

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
});
});