test: set up basic tests
This commit is contained in:
1 parent
453602a440
commit
fd2683a01b
9 files changed
+1783
-3
No files matched your search
Generated
+978
-1
File diff suppressed because it is too large.
Load diff
+9
-2
@@ -13,15 +13,22 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:lo": "vitest run tests/lo-roundtrip.test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"dictionary-en": "^4.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"svelte": "^5.55.9",
|
||||
"svelte-check": "^4.7.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14"
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.23.6",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// LibreOffice round-trip leg: editor JSON -> buildOdt -> `soffice --convert-to odt`
|
||||
// re-save -> importOdt -> compare. Requires LibreOffice (soffice) on PATH; the whole
|
||||
// suite is skipped when it is absent (so plain `npm test` / CI stay green).
|
||||
// jsdom (vitest `environment`) supplies the global DOMParser.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { buildOdt } from '../src/lib/export/odt';
|
||||
import { importOdt } from '../src/lib/import/odt';
|
||||
|
||||
type N = any;
|
||||
|
||||
function hasSoffice(): boolean {
|
||||
try { execSync('command -v soffice', { stdio: 'ignore' }); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
const SOFFICE = hasSoffice();
|
||||
|
||||
function check(label: string, cond: boolean, detail?: unknown) {
|
||||
expect.soft(cond, detail !== undefined ? `${label} — ${JSON.stringify(detail)}` : label).toBe(true);
|
||||
}
|
||||
|
||||
const T = (text: string, ...marks: N[]): N => ({ type: 'text', text, ...(marks.length ? { marks } : {}) });
|
||||
const P = (attrs: N | null, ...content: N[]): N => ({ type: 'paragraph', ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) });
|
||||
const LI = (...content: N[]): N => ({ type: 'listItem', content });
|
||||
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
const IMGN = (width: number, height: number, alt?: string, rotation?: number, wrap?: string): N =>
|
||||
({ type: 'image', attrs: {
|
||||
src: PNG, width, height,
|
||||
...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}),
|
||||
} });
|
||||
|
||||
const margins = { top: 3, bottom: 2, left: 2.5, right: 1.5 };
|
||||
|
||||
const fixture: N = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 1, textAlign: 'center' }, content: [T('LO Round Trip')] },
|
||||
P(null,
|
||||
T('Plain '),
|
||||
T('bold', { type: 'bold' }),
|
||||
T(' italic', { type: 'italic' }),
|
||||
T(' colored', { type: 'textStyle', attrs: { color: '#C00000' } }),
|
||||
T(' marked', { type: 'highlight', attrs: { color: '#FFFF00' } }),
|
||||
T(' arial14', { type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '14pt' } }),
|
||||
),
|
||||
P({ textAlign: 'justify', lineHeight: '1.5', spaceBefore: 12, spaceAfter: 18 }, T('spaced')),
|
||||
P({ indent: 2.5 }, T('indented')),
|
||||
P(null, T('line one'), { type: 'hardBreak' }, T('line two')),
|
||||
P(null, T('logo: '), IMGN(100, 50, 'Logo')),
|
||||
P(null, T('rotated: '), IMGN(120, 80, 'Rotated', 30)),
|
||||
P(null, T('wrapped '), IMGN(90, 60, 'Float', 0, 'left'), T(' text beside it')),
|
||||
{ type: 'orderedList', attrs: { listStyleType: 'upper-roman-paren' }, content: [
|
||||
LI(P(null, T('first'))),
|
||||
LI(P({ textAlign: 'center' }, T('centered'))),
|
||||
] },
|
||||
{ type: 'bulletList', attrs: { indent: 2.5 }, content: [
|
||||
LI(P(null, T('shifted a'))),
|
||||
LI(P(null, T('shifted b'))),
|
||||
] },
|
||||
{ type: 'bulletList', content: [
|
||||
LI(P(null, T('bullet')), { type: 'orderedList', attrs: { listStyleType: 'lower-alpha-paren' }, content: [
|
||||
LI(P(null, T('nested alpha'))),
|
||||
] }),
|
||||
] },
|
||||
{ type: 'table', content: [
|
||||
{ type: 'tableRow', attrs: { rowHeight: 60 }, content: [
|
||||
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [120] }, content: [
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [T('Cell head')] },
|
||||
P(null, T('cell para')),
|
||||
{ type: 'bulletList', content: [LI(P(null, T('cell bullet')))] },
|
||||
] },
|
||||
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [240] }, content: [P(null, T('B1'), IMGN(80, 40))] },
|
||||
] },
|
||||
{ type: 'tableRow', content: [
|
||||
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [120] }, content: [P(null, T('A2'))] },
|
||||
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [240] }, content: [P(null, T('B2'))] },
|
||||
] },
|
||||
] },
|
||||
P(null, T('The end.')),
|
||||
],
|
||||
};
|
||||
|
||||
// --- same normalization as roundtrip.test.ts, with LibreOffice unit-noise tolerances ---
|
||||
const DEFAULTS: Record<string, unknown> = {
|
||||
textAlign: 'left', lineHeight: null, spaceBefore: null, spaceAfter: null,
|
||||
listStyleType: 'decimal', start: 1, rowHeight: null, colspan: 1, rowspan: 1, type: null,
|
||||
};
|
||||
function normalize(node: N): N {
|
||||
const out: N = { type: node.type };
|
||||
if (node.text != null) out.text = node.text;
|
||||
if (node.marks?.length) {
|
||||
out.marks = node.marks.map((m: N) => {
|
||||
const mm: N = { type: m.type };
|
||||
const attrs = Object.fromEntries(Object.entries(m.attrs ?? {}).filter(([, v]) => v != null));
|
||||
if (Object.keys(attrs).length) mm.attrs = attrs;
|
||||
return mm;
|
||||
}).sort((a: N, b: N) => a.type.localeCompare(b.type));
|
||||
}
|
||||
const attrs: N = {};
|
||||
for (const [k, v] of Object.entries(node.attrs ?? {})) {
|
||||
if (v == null || (k in DEFAULTS && DEFAULTS[k] === v)) continue;
|
||||
if (k === 'colwidth') { attrs.colwidth = 'CW'; continue; }
|
||||
if (k === 'rowHeight') { attrs.rowHeight = Math.round((v as number) / 3) * 3; continue; } // ±3px unit noise
|
||||
if (k === 'src') { attrs.src = 'IMG'; continue; } // LibreOffice may re-encode / rename the picture
|
||||
if (k === 'width' || k === 'height') { attrs[k] = Math.round((v as number) / 3) * 3; continue; } // ±unit noise
|
||||
if (k === 'rotation') { attrs.rotation = 'R'; continue; } // exact angle checked leniently below
|
||||
if (k === 'wrap') { attrs.wrap = 'W'; continue; } // float survives; exact mode checked leniently
|
||||
attrs[k] = v;
|
||||
}
|
||||
if (Object.keys(attrs).length) out.attrs = attrs;
|
||||
if (node.content?.length) {
|
||||
const kids: N[] = [];
|
||||
for (const c of node.content.map(normalize)) {
|
||||
const prev = kids[kids.length - 1];
|
||||
if (prev && prev.type === 'text' && c.type === 'text' &&
|
||||
JSON.stringify(prev.marks ?? null) === JSON.stringify(c.marks ?? null)) prev.text += c.text;
|
||||
else kids.push(c);
|
||||
}
|
||||
out.content = kids;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function firstDiff(a: N, b: N, path = '$'): string | null {
|
||||
if (typeof a !== typeof b) return `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`;
|
||||
if (typeof a !== 'object' || a === null || b === null) {
|
||||
return Object.is(a, b) ? null : `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`;
|
||||
}
|
||||
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
||||
const d = firstDiff(a[k], b[k], `${path}.${k}`);
|
||||
if (d) return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () => {
|
||||
it('survives a `soffice --convert-to odt` re-save of the body document', async () => {
|
||||
const bytes = await buildOdt(fixture, margins, 'landscape');
|
||||
mkdirSync('/tmp/lo-rt', { recursive: true });
|
||||
writeFileSync('/tmp/lo-rt/doc.odt', bytes);
|
||||
|
||||
execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/out /tmp/lo-rt/doc.odt', { stdio: 'pipe', timeout: 120000 });
|
||||
const resaved = new Uint8Array(readFileSync('/tmp/lo-rt/out/doc.odt'));
|
||||
|
||||
const res = importOdt(resaved);
|
||||
check('LO: no warnings', res.warnings.length === 0, res.warnings);
|
||||
check('LO: orientation', res.orientation === 'landscape', res.orientation);
|
||||
const m = res.margins!;
|
||||
check('LO: margins', !!m && Math.abs(m.top - 3) < 0.03 && Math.abs(m.bottom - 2) < 0.03 &&
|
||||
Math.abs(m.left - 2.5) < 0.03 && Math.abs(m.right - 1.5) < 0.03, m);
|
||||
|
||||
const diff = firstDiff(normalize(fixture), normalize(res.content));
|
||||
check('LO: document JSON round-trips', diff === null, diff);
|
||||
|
||||
// Rotation survives LibreOffice. Allow its 360-complement in case LO flips the sign.
|
||||
const imgs: N[] = [];
|
||||
(function walk(n: N) { if (n.type === 'image') imgs.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
|
||||
const rot = imgs.find((i: N) => i.attrs?.rotation)?.attrs?.rotation ?? 0;
|
||||
check('LO: image rotation survives (~30°)', Math.abs(rot - 30) <= 2 || Math.abs(rot - 330) <= 2, rot);
|
||||
const floated = imgs.find((i: N) => i.attrs?.wrap && i.attrs.wrap !== 'inline');
|
||||
check('LO: image text-wrap survives', !!floated, imgs.map((i: N) => i.attrs?.wrap));
|
||||
});
|
||||
|
||||
it('survives a `soffice` re-save of the header/footer geometry', async () => {
|
||||
const header: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [
|
||||
T('Bericht ', { type: 'bold' }, { type: 'textStyle', attrs: { color: '#C00000' } }),
|
||||
T('2026'),
|
||||
] }] };
|
||||
const footer: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'center' }, content: [
|
||||
T('Seite '), { type: 'pageNumber' }, T(' von '), { type: 'pageCount' },
|
||||
] }] };
|
||||
const hfMargins = { top: 2.54, bottom: 2.54, left: 2, right: 2 };
|
||||
// Non-default edge distances must survive LibreOffice (they become the ODF page
|
||||
// margin; min-height fills the rest so the body stays at 2.54cm).
|
||||
const hfBytes = await buildOdt(fixture, hfMargins, 'portrait', { header, footer, pageCount: 4, headerDistanceCm: 0.8, footerDistanceCm: 1.6 });
|
||||
mkdirSync('/tmp/lo-rt', { recursive: true });
|
||||
writeFileSync('/tmp/lo-rt/hf.odt', hfBytes);
|
||||
execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/hfout /tmp/lo-rt/hf.odt', { stdio: 'pipe', timeout: 120000 });
|
||||
const hfResaved = new Uint8Array(readFileSync('/tmp/lo-rt/hfout/hf.odt'));
|
||||
|
||||
// Diagnostic: how LibreOffice rewrote the header/footer geometry.
|
||||
const loStyles = strFromU8(unzipSync(hfResaved)['styles.xml']);
|
||||
const geo = loStyles.match(/<style:(header|footer)-style>[\s\S]*?<\/style:\1-style>/g);
|
||||
console.log(' [LO header/footer geometry]', geo?.join(' ') ?? '(none)');
|
||||
|
||||
const hfRes = importOdt(hfResaved);
|
||||
check('LO hf: header text + bold/color', !!hfRes.header && JSON.stringify(hfRes.header).includes('Bericht'), hfRes.header);
|
||||
check('LO hf: header right-aligned', hfRes.header?.content?.[0]?.attrs?.textAlign === 'right', hfRes.header?.content?.[0]?.attrs);
|
||||
const fPara = hfRes.footer?.content?.[0];
|
||||
check('LO hf: footer page-number field survives', fPara?.content?.some((n: N) => n.type === 'pageNumber'), fPara);
|
||||
check('LO hf: footer page-count field survives', fPara?.content?.some((n: N) => n.type === 'pageCount'), fPara);
|
||||
check('LO hf: footer centered', fPara?.attrs?.textAlign === 'center', fPara?.attrs);
|
||||
// The geometry trap: body margins must come back ≈ the originals (2.54cm).
|
||||
const hm = hfRes.margins!;
|
||||
check('LO hf: body top margin reconstructed ≈2.54', Math.abs((hm?.top ?? 0) - 2.54) < 0.1, hm);
|
||||
check('LO hf: body bottom margin reconstructed ≈2.54', Math.abs((hm?.bottom ?? 0) - 2.54) < 0.1, hm);
|
||||
// The configured edge distances survive LibreOffice.
|
||||
check('LO hf: header distance ≈0.8cm', Math.abs((hfRes.headerDistanceCm ?? 0) - 0.8) < 0.1, hfRes.headerDistanceCm);
|
||||
check('LO hf: footer distance ≈1.6cm', Math.abs((hfRes.footerDistanceCm ?? 0) - 1.6) < 0.1, hfRes.footerDistanceCm);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
// Round-trip verification: editor JSON -> buildOdt() -> importOdt() -> compare,
|
||||
// plus a hand-crafted LibreOffice/Word-style .odt exercising the style resolver.
|
||||
// jsdom (vitest `environment`) supplies the global DOMParser.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { zipSync, strToU8 } from 'fflate';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { buildOdt } from '../src/lib/export/odt';
|
||||
import { importOdt } from '../src/lib/import/odt';
|
||||
import { hfExtensions } from '../src/lib/editor/hfExtensions';
|
||||
|
||||
type N = any;
|
||||
|
||||
// `expect.soft` so every assertion in a leg reports rather than bailing on the first.
|
||||
function check(label: string, cond: boolean, detail?: unknown) {
|
||||
expect.soft(cond, detail !== undefined ? `${label} — ${JSON.stringify(detail)}` : label).toBe(true);
|
||||
}
|
||||
|
||||
// ---------- helpers to build editor-shaped JSON ----------
|
||||
const T = (text: string, ...marks: N[]): N => ({ type: 'text', text, ...(marks.length ? { marks } : {}) });
|
||||
const P = (attrs: N | null, ...content: N[]): N => ({ type: 'paragraph', ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) });
|
||||
const H = (attrs: N, ...content: N[]): N => ({ type: 'heading', attrs, ...(content.length ? { content } : {}) });
|
||||
const LI = (...content: N[]): N => ({ type: 'listItem', content });
|
||||
const CELL = (colwidth: number[] | null, ...content: N[]): N =>
|
||||
({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth }, content });
|
||||
|
||||
// A tiny valid PNG; only its bytes matter for the round-trip (no image decoding).
|
||||
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
const IMGN = (width: number, height: number, alt?: string, rotation?: number, wrap?: string): N =>
|
||||
({ type: 'image', attrs: {
|
||||
src: PNG, width, height,
|
||||
...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}),
|
||||
} });
|
||||
|
||||
const margins = { top: 3, bottom: 2, left: 2.5, right: 1.5 };
|
||||
|
||||
const fixture: N = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
H({ level: 1, textAlign: 'center' }, T('Invoice Report 2026')),
|
||||
P(null,
|
||||
T('Plain '),
|
||||
T('bold', { type: 'bold' }),
|
||||
T(' italic', { type: 'italic' }),
|
||||
T(' under', { type: 'underline' }),
|
||||
T(' struck', { type: 'strike' }),
|
||||
T(' sup', { type: 'superscript' }),
|
||||
T(' sub', { type: 'subscript' }),
|
||||
),
|
||||
P(null,
|
||||
T('arial 14 ', { type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '14pt' } }),
|
||||
T('red', { type: 'textStyle', attrs: { color: '#C00000' } }),
|
||||
T(' marked', { type: 'highlight', attrs: { color: '#FFFF00' } }),
|
||||
),
|
||||
P({ textAlign: 'justify', lineHeight: '1.5', spaceBefore: 12, spaceAfter: 18 }, T('spaced and justified paragraph')),
|
||||
P({ indent: 2.5 }, T('indented paragraph')),
|
||||
P(null, T('first line'), { type: 'hardBreak' }, T('second line')),
|
||||
P(null, T('before\ttab\tafter')),
|
||||
P(null, T('logo: '), IMGN(100, 50, 'Logo')),
|
||||
P(null, T('rotated: '), IMGN(120, 80, 'Rotated', 30)),
|
||||
P(null, T('wrapped left '), IMGN(90, 60, 'Float', 0, 'left'), T(' text flows beside it')),
|
||||
P(null, T('top/bottom '), IMGN(70, 50, 'Banner', 0, 'topBottom')),
|
||||
{ type: 'bulletList', content: [
|
||||
LI(P(null, T('bullet one'))),
|
||||
LI(P(null, T('bullet two')), { type: 'orderedList', attrs: { listStyleType: 'upper-roman-paren' }, content: [
|
||||
LI(P(null, T('nested i'))),
|
||||
LI(P(null, T('nested ii'))),
|
||||
] }),
|
||||
] },
|
||||
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [
|
||||
LI(P({ textAlign: 'center' }, T('centered item'))),
|
||||
LI(P(null, T('plain item'))),
|
||||
] },
|
||||
{ type: 'bulletList', attrs: { indent: 2.5 }, content: [
|
||||
LI(P(null, T('shifted bullet a'))),
|
||||
LI(P(null, T('shifted bullet b'))),
|
||||
] },
|
||||
H({ level: 2 }, T('Un', { type: 'textStyle', attrs: { fontWeight: 'normal' } }), T('bolded')),
|
||||
{ type: 'table', content: [
|
||||
{ type: 'tableRow', attrs: { rowHeight: 60 }, content: [
|
||||
CELL([120],
|
||||
H({ level: 3 }, T('Cell head')),
|
||||
P(null, T('cell para')),
|
||||
{ type: 'bulletList', content: [
|
||||
LI(P(null, T('cell bullet'))),
|
||||
LI(P(null, T('with nested')), { type: 'orderedList', attrs: { listStyleType: 'decimal-paren' }, content: [
|
||||
LI(P(null, T('cell nested 1'))),
|
||||
] }),
|
||||
] },
|
||||
),
|
||||
CELL([240], P(null, IMGN(80, 40))),
|
||||
] },
|
||||
{ type: 'tableRow', content: [
|
||||
CELL([120], P(null, T('A2'))),
|
||||
CELL([240], P(null, T('B2'))),
|
||||
] },
|
||||
] },
|
||||
P(null, T('The end.')),
|
||||
],
|
||||
};
|
||||
|
||||
// ---------- normalization for comparison ----------
|
||||
const ORDERED_DEFAULTS: Record<string, unknown> = {
|
||||
textAlign: 'left', lineHeight: null, spaceBefore: null, spaceAfter: null,
|
||||
listStyleType: 'decimal', start: 1, rowHeight: null, colspan: 1, rowspan: 1,
|
||||
type: null, level: undefined, rotation: 0, wrap: 'inline',
|
||||
};
|
||||
|
||||
function normalize(node: N): N {
|
||||
const out: N = { type: node.type };
|
||||
if (node.text != null) out.text = node.text;
|
||||
if (node.marks?.length) {
|
||||
out.marks = node.marks
|
||||
.map((m: N) => {
|
||||
const mm: N = { type: m.type };
|
||||
const attrs = Object.fromEntries(Object.entries(m.attrs ?? {}).filter(([, v]) => v != null));
|
||||
if (Object.keys(attrs).length) mm.attrs = attrs;
|
||||
return mm;
|
||||
})
|
||||
.sort((a: N, b: N) => a.type.localeCompare(b.type));
|
||||
}
|
||||
const attrs: N = {};
|
||||
for (const [k, v] of Object.entries(node.attrs ?? {})) {
|
||||
if (v == null) continue;
|
||||
if (k in ORDERED_DEFAULTS && ORDERED_DEFAULTS[k] === v) continue;
|
||||
if (k === 'colwidth') { attrs.colwidth = 'CW'; continue; } // ratios compared separately
|
||||
attrs[k] = v;
|
||||
}
|
||||
if (node.attrs?.level != null) attrs.level = node.attrs.level;
|
||||
if (Object.keys(attrs).length) out.attrs = attrs;
|
||||
if (node.content?.length) {
|
||||
// merge adjacent identical text nodes so run-splitting differences don't matter
|
||||
const kids: N[] = [];
|
||||
for (const c of node.content.map(normalize)) {
|
||||
const prev = kids[kids.length - 1];
|
||||
if (prev && prev.type === 'text' && c.type === 'text' &&
|
||||
JSON.stringify(prev.marks ?? null) === JSON.stringify(c.marks ?? null)) {
|
||||
prev.text += c.text;
|
||||
} else kids.push(c);
|
||||
}
|
||||
out.content = kids;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function firstDiff(a: N, b: N, path = '$'): string | null {
|
||||
if (typeof a !== typeof b) return `${path}: type ${typeof a} vs ${typeof b}`;
|
||||
if (typeof a !== 'object' || a === null || b === null) {
|
||||
return Object.is(a, b) ? null : `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`;
|
||||
}
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
for (const k of keys) {
|
||||
const d = firstDiff(a[k], b[k], `${path}.${k}`);
|
||||
if (d) return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectColwidths(node: N, acc: number[][] = []): number[][] {
|
||||
if (Array.isArray(node.attrs?.colwidth)) acc.push(node.attrs.colwidth);
|
||||
for (const c of node.content ?? []) collectColwidths(c, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
function collectImages(node: N, acc: N[] = []): N[] {
|
||||
if (node.type === 'image') acc.push(node);
|
||||
for (const c of node.content ?? []) collectImages(c, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
describe('Leg 1: editor → buildOdt → importOdt', () => {
|
||||
it('round-trips the full fixture (margins, orientation, marks, images, lists, table)', async () => {
|
||||
const bytes = await buildOdt(fixture, margins, 'landscape');
|
||||
const res = importOdt(bytes);
|
||||
|
||||
check('no warnings on own export', res.warnings.length === 0, res.warnings);
|
||||
check('orientation round-trips', res.orientation === 'landscape', res.orientation);
|
||||
const m = res.margins!;
|
||||
check('margins round-trip', !!m && Math.abs(m.top - 3) < 0.02 && Math.abs(m.bottom - 2) < 0.02 &&
|
||||
Math.abs(m.left - 2.5) < 0.02 && Math.abs(m.right - 1.5) < 0.02, m);
|
||||
|
||||
const diff = firstDiff(normalize(fixture), normalize(res.content));
|
||||
check('document JSON round-trips', diff === null, diff);
|
||||
|
||||
// Images: src bytes (data-URI) and px size must round-trip exactly (body + cell).
|
||||
const logoPara = (res.content.content ?? []).find((n: N) => n.content?.some((c: N) => c.type === 'image'));
|
||||
const bodyImg = logoPara?.content?.find((c: N) => c.type === 'image');
|
||||
check('image src round-trips (data-URI bytes)', bodyImg?.attrs?.src === PNG, bodyImg?.attrs?.src?.slice(0, 40));
|
||||
check('image size round-trips (100×50 px)', bodyImg?.attrs?.width === 100 && bodyImg?.attrs?.height === 50, bodyImg?.attrs);
|
||||
check('image alt round-trips', bodyImg?.attrs?.alt === 'Logo', bodyImg?.attrs);
|
||||
const cellImg = collectImages(res.content).find((i: N) => i.attrs?.width === 80);
|
||||
check('image in table cell round-trips (80×40 px)', cellImg?.attrs?.height === 40 && cellImg?.attrs?.src === PNG, cellImg?.attrs);
|
||||
const rotImg = collectImages(res.content).find((i: N) => i.attrs?.rotation);
|
||||
check('image rotation round-trips (30°, 120×80)', rotImg?.attrs?.rotation === 30 && rotImg?.attrs?.width === 120 && rotImg?.attrs?.height === 80, rotImg?.attrs);
|
||||
const wrapImg = collectImages(res.content).find((i: N) => i.attrs?.wrap === 'left');
|
||||
check('image wrap=left round-trips', wrapImg?.attrs?.width === 90 && wrapImg?.attrs?.height === 60, wrapImg?.attrs);
|
||||
const tbImg = collectImages(res.content).find((i: N) => i.attrs?.wrap === 'topBottom');
|
||||
check('image wrap=topBottom round-trips', !!tbImg, tbImg?.attrs);
|
||||
|
||||
const indented = (res.content.content ?? []).find((n: N) => n.content?.[0]?.text === 'indented paragraph');
|
||||
check('indent → fo:margin-left round-trips (2.5cm)',
|
||||
indented?.attrs?.indent != null && Math.abs(indented.attrs.indent - 2.5) < 0.02, indented?.attrs);
|
||||
|
||||
const shiftedList = (res.content.content ?? []).find(
|
||||
(n: N) => n.type === 'bulletList' && n.content?.[0]?.content?.[0]?.content?.[0]?.text === 'shifted bullet a');
|
||||
check('whole-list indent round-trips (2.5cm)',
|
||||
shiftedList?.attrs?.indent != null && Math.abs(shiftedList.attrs.indent - 2.5) < 0.02, shiftedList?.attrs);
|
||||
|
||||
const cwIn = collectColwidths(fixture);
|
||||
const cwOut = collectColwidths(res.content);
|
||||
check('colwidth count matches', cwIn.length === cwOut.length, { in: cwIn.length, out: cwOut.length });
|
||||
// per-cell weights are slices of one column set: compare cell-width fractions
|
||||
const fracIn = cwIn.flat().map((w, _, all) => w / all.reduce((a, b) => a + b, 0));
|
||||
const fracOut = cwOut.flat().map((w, _, all) => w / all.reduce((a, b) => a + b, 0));
|
||||
check('column ratios round-trip', fracIn.length === fracOut.length &&
|
||||
fracIn.every((f, i) => Math.abs(f - fracOut[i]) < 0.02), { fracIn, fracOut });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
|
||||
it('resolves named/automatic styles, repeated cells, lists, and reports degradations', () => {
|
||||
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
|
||||
<office:font-face-decls>
|
||||
<style:font-face style:name="F1" svg:font-family="'Courier New', monospace"/>
|
||||
</office:font-face-decls>
|
||||
<office:styles>
|
||||
<style:default-style style:family="paragraph">
|
||||
<style:text-properties fo:font-size="12pt" fo:font-family="Liberation Serif"/>
|
||||
</style:default-style>
|
||||
<style:style style:name="Standard" style:family="paragraph"/>
|
||||
<style:style style:name="Mono" style:family="paragraph" style:parent-style-name="Standard">
|
||||
<style:text-properties style:font-name="F1" fo:font-size="10pt"/>
|
||||
</style:style>
|
||||
</office:styles>
|
||||
<office:automatic-styles>
|
||||
<style:page-layout style:name="pm1">
|
||||
<style:page-layout-properties fo:page-width="21.59cm" fo:page-height="27.94cm" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in"/>
|
||||
</style:page-layout>
|
||||
</office:automatic-styles>
|
||||
<office:master-styles>
|
||||
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
|
||||
</office:master-styles>
|
||||
</office:document-styles>`;
|
||||
|
||||
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0">
|
||||
<office:automatic-styles>
|
||||
<style:style style:name="P1" style:family="paragraph" style:parent-style-name="Standard">
|
||||
<style:paragraph-properties fo:text-align="end" fo:margin-top="0.25in" fo:line-height="200%"/>
|
||||
</style:style>
|
||||
<style:style style:name="T1" style:family="text"><style:text-properties fo:font-weight="700"/></style:style>
|
||||
<style:style style:name="T2" style:family="text"><style:text-properties fo:color="#ff0000" fo:background-color="#ffff00"/></style:style>
|
||||
<text:list-style style:name="L1">
|
||||
<text:list-level-style-number text:level="1" style:num-format="i" style:num-suffix=")"/>
|
||||
<text:list-level-style-bullet text:level="2" text:bullet-char="•"/>
|
||||
</text:list-style>
|
||||
<style:style style:name="co1" style:family="table-column"><style:table-column-properties style:column-width="5cm"/></style:style>
|
||||
<style:style style:name="co2" style:family="table-column"><style:table-column-properties style:column-width="10cm"/></style:style>
|
||||
<style:style style:name="ro1" style:family="table-row"><style:table-row-properties style:min-row-height="2cm"/></style:style>
|
||||
</office:automatic-styles>
|
||||
<office:body><office:text>
|
||||
<text:p>plain default text</text:p>
|
||||
<text:p text:style-name="P1"><text:span text:style-name="T1">bold700</text:span> gap<text:s text:c="3"/>tab<text:tab/>end</text:p>
|
||||
<text:p><text:span text:style-name="T2">colored</text:span><text:line-break/><text:a xlink:href="https://x.example">a link</text:a></text:p>
|
||||
<text:p text:style-name="Mono">mono text</text:p>
|
||||
<text:p>img:<draw:frame><draw:image xlink:href="Pictures/x.png"/></draw:frame><text:note text:note-class="footnote" text:id="ftn1"><text:note-citation>1</text:note-citation><text:note-body><text:p>note body</text:p></text:note-body></text:note></text:p>
|
||||
<text:list text:style-name="L1">
|
||||
<text:list-item text:start-value="3"><text:p>roman three</text:p>
|
||||
<text:list><text:list-item><text:p>sub bullet</text:p></text:list-item></text:list>
|
||||
</text:list-item>
|
||||
</text:list>
|
||||
<table:table>
|
||||
<table:table-column table:style-name="co1"/>
|
||||
<table:table-column table:style-name="co2" table:number-columns-repeated="2"/>
|
||||
<table:table-header-rows>
|
||||
<table:table-row><table:table-cell><text:p>h1</text:p></table:table-cell><table:table-cell table:number-columns-repeated="2"><text:p>hx</text:p></table:table-cell></table:table-row>
|
||||
</table:table-header-rows>
|
||||
<table:table-row table:style-name="ro1">
|
||||
<table:table-cell table:number-columns-spanned="2"><text:p>spanned</text:p></table:table-cell>
|
||||
<table:covered-table-cell/>
|
||||
<table:table-cell><text:p>c3</text:p></table:table-cell>
|
||||
</table:table-row>
|
||||
</table:table>
|
||||
</office:text></office:body>
|
||||
</office:document-content>`;
|
||||
|
||||
// Real picture bytes so the embedded <draw:image> resolves on import.
|
||||
const pngBytes = Uint8Array.from(atob(PNG.split(',')[1]), c => c.charCodeAt(0));
|
||||
const foreign = zipSync({
|
||||
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
|
||||
'content.xml': [strToU8(contentXml), { level: 6 }],
|
||||
'styles.xml': [strToU8(stylesXml), { level: 6 }],
|
||||
'Pictures/x.png': [pngBytes, { level: 6 }],
|
||||
} as any);
|
||||
|
||||
const f = importOdt(foreign);
|
||||
const c = f.content.content!;
|
||||
|
||||
check('foreign: margins 1in → 2.54cm', Math.abs(f.margins!.top - 2.54) < 0.01, f.margins);
|
||||
check('foreign: Letter stays portrait', f.orientation === 'portrait');
|
||||
|
||||
check('foreign: plain run has no marks', c[0].content![0].marks === undefined, c[0]);
|
||||
|
||||
const p1 = c[1];
|
||||
check('foreign: P1 align end → right', p1.attrs?.textAlign === 'right', p1.attrs);
|
||||
check('foreign: P1 0.25in → spaceBefore 18', p1.attrs?.spaceBefore === 18, p1.attrs);
|
||||
check('foreign: P1 200% → lineHeight 2', p1.attrs?.lineHeight === '2', p1.attrs);
|
||||
check('foreign: weight 700 → bold mark', p1.content![0].marks?.some((m: N) => m.type === 'bold'), p1.content![0]);
|
||||
check('foreign: text:s ×3 + tab expanded', p1.content!.map((n: N) => n.text).join('') === 'bold700 gap tab\tend', p1.content);
|
||||
|
||||
const p2 = c[2];
|
||||
check('foreign: lowercase color → #FF0000', p2.content![0].marks?.some((m: N) => m.type === 'textStyle' && m.attrs?.color === '#FF0000'), p2.content![0]);
|
||||
check('foreign: highlight #FFFF00', p2.content![0].marks?.some((m: N) => m.type === 'highlight' && m.attrs?.color === '#FFFF00'), p2.content![0]);
|
||||
check('foreign: line-break → hardBreak', p2.content!.some((n: N) => n.type === 'hardBreak'));
|
||||
check('foreign: link flattened to text', p2.content!.some((n: N) => n.text === 'a link'));
|
||||
|
||||
const mono = c[3];
|
||||
check('foreign: font-face resolves Courier New', mono.content![0].marks?.some((m: N) => m.type === 'textStyle' && m.attrs?.fontFamily === 'Courier New' && m.attrs?.fontSize === '10pt'), mono.content![0]);
|
||||
|
||||
check('foreign: image imported, footnote dropped, text kept',
|
||||
c[4].content!.length === 2 && c[4].content![0].text === 'img:' &&
|
||||
c[4].content![1].type === 'image' && c[4].content![1].attrs?.src?.startsWith('data:image/png;base64,'), c[4]);
|
||||
|
||||
const list = c[5];
|
||||
check('foreign: list → lower-roman-paren, start 3', list.type === 'orderedList' && list.attrs?.listStyleType === 'lower-roman-paren' && list.attrs?.start === 3, list.attrs);
|
||||
check('foreign: level-2 def → nested bulletList', list.content![0].content![1]?.type === 'bulletList', list.content![0]);
|
||||
|
||||
const table = c[6];
|
||||
check('foreign: table present', table.type === 'table');
|
||||
const [hdr, row] = table.content!;
|
||||
check('foreign: header row → tableHeader ×3', hdr.content!.length === 3 && hdr.content!.every((cell: N) => cell.type === 'tableHeader'), hdr.content!.map((x: N) => x.type));
|
||||
check('foreign: repeated cell expanded', hdr.content![1].content![0].content![0].text === 'hx' && hdr.content![2].content![0].content![0].text === 'hx');
|
||||
check('foreign: colspan 2 + covered skipped', row.content!.length === 2 && row.content![0].attrs?.colspan === 2, row.content!.map((x: N) => x.attrs));
|
||||
check('foreign: col weights 5/10/10cm', JSON.stringify(hdr.content!.map((x: N) => x.attrs.colwidth)) === JSON.stringify([[500], [1000], [1000]]), hdr.content!.map((x: N) => x.attrs.colwidth));
|
||||
check('foreign: spanned cell colwidth [500,1000]', JSON.stringify(row.content![0].attrs?.colwidth) === JSON.stringify([500, 1000]), row.content![0].attrs);
|
||||
check('foreign: min-row-height 2cm → 76px', row.attrs?.rowHeight === 76, row.attrs);
|
||||
|
||||
const expectWarn = ['Hyperlinks were converted to plain text', 'Footnotes and endnotes were removed'];
|
||||
check('foreign: warnings reported', expectWarn.every(w => f.warnings.includes(w)), f.warnings);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 3: header/footer → buildOdt → importOdt', () => {
|
||||
it('round-trips header/footer content, fields, and the Word-style geometry mapping', async () => {
|
||||
const header: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [
|
||||
{ type: 'text', text: 'Bericht ', marks: [{ type: 'bold' }, { type: 'textStyle', attrs: { color: '#C00000' } }] },
|
||||
{ type: 'text', text: '2026' },
|
||||
{ type: 'hardBreak' },
|
||||
{ type: 'text', text: 'Zweite Zeile' },
|
||||
] }] };
|
||||
const footer: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'center' }, content: [
|
||||
{ type: 'text', text: 'Seite ' },
|
||||
{ type: 'pageNumber' },
|
||||
{ type: 'text', text: ' von ' },
|
||||
{ type: 'pageCount' },
|
||||
] }] };
|
||||
// Non-default edge distances (header 0.8cm from top, footer 1.6cm from bottom).
|
||||
const hfDist = { headerDistanceCm: 0.8, footerDistanceCm: 1.6 };
|
||||
const hfBytes = await buildOdt(fixture, margins, 'landscape', { header, footer, pageCount: 9, ...hfDist });
|
||||
const hfRes = importOdt(hfBytes);
|
||||
|
||||
check('hf: no warnings', hfRes.warnings.length === 0, hfRes.warnings);
|
||||
check('hf: header round-trips', firstDiff(normalize(header), normalize(hfRes.header)) === null,
|
||||
firstDiff(normalize(header), normalize(hfRes.header)));
|
||||
check('hf: footer round-trips', firstDiff(normalize(footer), normalize(hfRes.footer)) === null,
|
||||
firstDiff(normalize(footer), normalize(hfRes.footer)));
|
||||
// The body margins survive the Word-style header/footer geometry mapping.
|
||||
const hm = hfRes.margins!;
|
||||
check('hf: body margins preserved through geometry mapping',
|
||||
!!hm && Math.abs(hm.top - 3) < 0.05 && Math.abs(hm.bottom - 2) < 0.05 &&
|
||||
Math.abs(hm.left - 2.5) < 0.02 && Math.abs(hm.right - 1.5) < 0.02, hm);
|
||||
// The configured edge distances round-trip (they become the ODF page margin).
|
||||
check('hf: header distance round-trips (0.8cm)', Math.abs((hfRes.headerDistanceCm ?? 0) - 0.8) < 0.02, hfRes.headerDistanceCm);
|
||||
check('hf: footer distance round-trips (1.6cm)', Math.abs((hfRes.footerDistanceCm ?? 0) - 1.6) < 0.02, hfRes.footerDistanceCm);
|
||||
check('hf: body still round-trips alongside header/footer',
|
||||
firstDiff(normalize(fixture), normalize(hfRes.content)) === null);
|
||||
|
||||
// Imported header/footer must be valid in the header/footer editor schema.
|
||||
const hfSchema = getSchema(hfExtensions());
|
||||
let hfSchemaOk = true;
|
||||
for (const z of [hfRes.header, hfRes.footer]) {
|
||||
if (!z) continue;
|
||||
try { PMNode.fromJSON(hfSchema, z).check(); } catch { hfSchemaOk = false; }
|
||||
}
|
||||
check('hf: header/footer valid in hf schema', hfSchemaOk);
|
||||
|
||||
// Empty zones must not be exported (no master-page header/footer).
|
||||
const emptyHf = await buildOdt(fixture, margins, 'portrait', { header: null, footer: null, pageCount: 1 });
|
||||
const emptyRes = importOdt(emptyHf);
|
||||
check('hf: empty zones not exported', emptyRes.header === null && emptyRes.footer === null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 4: foreign header/footer → importOdt', () => {
|
||||
it('parses page-number/count fields, reconstructs body margins, warns on per-page variants', () => {
|
||||
const fStyles = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
|
||||
<office:styles>
|
||||
<style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style>
|
||||
<style:style style:name="Standard" style:family="paragraph"/>
|
||||
<style:style style:name="Header" style:family="paragraph" style:parent-style-name="Standard"><style:paragraph-properties fo:text-align="center"/></style:style>
|
||||
<style:style style:name="Footer" style:family="paragraph" style:parent-style-name="Standard"/>
|
||||
</office:styles>
|
||||
<office:automatic-styles>
|
||||
<style:page-layout style:name="pm1">
|
||||
<style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="1.5cm" fo:margin-bottom="1.5cm" fo:margin-left="2cm" fo:margin-right="2cm"/>
|
||||
<style:header-style><style:header-footer-properties svg:height="0.8cm" fo:margin-bottom="0.3cm"/></style:header-style>
|
||||
<style:footer-style><style:header-footer-properties svg:height="0.6cm" fo:margin-top="0.3cm"/></style:footer-style>
|
||||
</style:page-layout>
|
||||
</office:automatic-styles>
|
||||
<office:master-styles>
|
||||
<style:master-page style:name="Standard" style:page-layout-name="pm1">
|
||||
<style:header><text:p text:style-name="Header"><text:span text:style-name="X">Doc</text:span> — Page <text:page-number text:select-page="current">1</text:page-number> of <text:page-count>5</text:page-count></text:p></style:header>
|
||||
<style:header-first><text:p text:style-name="Header">Cover</text:p></style:header-first>
|
||||
<style:footer><text:p text:style-name="Footer">Confidential</text:p></style:footer>
|
||||
</style:master-page>
|
||||
</office:master-styles>
|
||||
</office:document-styles>`;
|
||||
const fContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text><text:p>Body</text:p></office:text></office:body></office:document-content>`;
|
||||
const foreignHf = zipSync({
|
||||
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
|
||||
'content.xml': [strToU8(fContent), { level: 6 }],
|
||||
'styles.xml': [strToU8(fStyles), { level: 6 }],
|
||||
} as any);
|
||||
const fhf = importOdt(foreignHf);
|
||||
|
||||
check('foreign hf: header parsed with center align', fhf.header?.content?.[0]?.attrs?.textAlign === 'center', fhf.header);
|
||||
const hPara = fhf.header?.content?.[0];
|
||||
check('foreign hf: page-number field', hPara?.content?.some((n: N) => n.type === 'pageNumber'), hPara);
|
||||
check('foreign hf: page-count field', hPara?.content?.some((n: N) => n.type === 'pageCount'), hPara);
|
||||
check('foreign hf: footer text', fhf.footer?.content?.[0]?.content?.[0]?.text === 'Confidential', fhf.footer);
|
||||
// Body top margin = page margin (1.5) + header height (0.8) + spacing (0.3) = 2.6.
|
||||
check('foreign hf: body top margin reconstructed', Math.abs((fhf.margins?.top ?? 0) - 2.6) < 0.02, fhf.margins);
|
||||
check('foreign hf: body bottom margin reconstructed', Math.abs((fhf.margins?.bottom ?? 0) - 2.4) < 0.02, fhf.margins);
|
||||
check('foreign hf: first-page variant warning',
|
||||
fhf.warnings.some(w => /per-page/i.test(w)), fhf.warnings);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveFilename } from '../../src/lib/export/odt';
|
||||
|
||||
const doc = (...content: any[]) => ({ type: 'doc', content }) as any;
|
||||
const heading = (text: string) => ({ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text }] });
|
||||
const para = (text: string) => ({ type: 'paragraph', content: [{ type: 'text', text }] });
|
||||
|
||||
describe('deriveFilename', () => {
|
||||
it('derives the name from the first non-empty heading', () => {
|
||||
expect(deriveFilename(doc(heading('Invoice Report 2026')))).toBe('Invoice-Report-2026.odt');
|
||||
});
|
||||
|
||||
it('skips leading paragraphs and uses the first heading', () => {
|
||||
expect(deriveFilename(doc(para('intro'), heading('Annual Summary')))).toBe('Annual-Summary.odt');
|
||||
});
|
||||
|
||||
it('strips disallowed characters and collapses whitespace', () => {
|
||||
expect(deriveFilename(doc(heading('Hä?llo / World!')))).toBe('Hllo-World.odt');
|
||||
});
|
||||
|
||||
it('truncates to 50 characters before sanitizing', () => {
|
||||
const long = 'a'.repeat(80);
|
||||
expect(deriveFilename(doc(heading(long)))).toBe(`${'a'.repeat(50)}.odt`);
|
||||
});
|
||||
|
||||
it('falls back to document.odt when there is no heading', () => {
|
||||
expect(deriveFilename(doc(para('just a paragraph')))).toBe('document.odt');
|
||||
});
|
||||
|
||||
it('falls back to document.odt when the heading sanitizes to empty', () => {
|
||||
expect(deriveFilename(doc(heading('???')))).toBe('document.odt');
|
||||
});
|
||||
|
||||
it('falls back to document.odt for an empty document', () => {
|
||||
expect(deriveFilename(doc())).toBe('document.odt');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeColor } from '../../src/lib/export/odt';
|
||||
|
||||
describe('normalizeColor', () => {
|
||||
it('expands 3-digit hex to upper-case #RRGGBB', () => {
|
||||
expect(normalizeColor('#abc')).toBe('#AABBCC');
|
||||
});
|
||||
|
||||
it('drops the alpha nibble of 4-digit hex', () => {
|
||||
expect(normalizeColor('#abcd')).toBe('#AABBCC');
|
||||
});
|
||||
|
||||
it('upper-cases 6-digit hex', () => {
|
||||
expect(normalizeColor('#aabbcc')).toBe('#AABBCC');
|
||||
});
|
||||
|
||||
it('truncates 8-digit hex to its RGB part', () => {
|
||||
expect(normalizeColor('#aabbccdd')).toBe('#AABBCC');
|
||||
});
|
||||
|
||||
it('converts rgb() to hex', () => {
|
||||
expect(normalizeColor('rgb(255, 0, 0)')).toBe('#FF0000');
|
||||
});
|
||||
|
||||
it('converts rgba() to hex, ignoring alpha', () => {
|
||||
expect(normalizeColor('rgba(0, 128, 255, 0.5)')).toBe('#0080FF');
|
||||
});
|
||||
|
||||
it('clamps over-range rgb channels to 255', () => {
|
||||
expect(normalizeColor('rgb(300, 0, 999)')).toBe('#FF00FF');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(normalizeColor(' #ff0000 ')).toBe('#FF0000');
|
||||
});
|
||||
|
||||
it('returns undefined for empty input', () => {
|
||||
expect(normalizeColor(' ')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes named colors through unchanged', () => {
|
||||
expect(normalizeColor('red')).toBe('red');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { orderedTypeDef, orderedTypeFromFormat, DEFAULT_ORDERED_TYPE } from '../../src/lib/editor/orderedListTypes';
|
||||
|
||||
describe('orderedTypeDef', () => {
|
||||
it('returns the matching definition for a known key', () => {
|
||||
const def = orderedTypeDef('lower-roman-paren');
|
||||
expect(def.numFormat).toBe('i');
|
||||
expect(def.numSuffix).toBe(')');
|
||||
});
|
||||
|
||||
it('falls back to the default for null/undefined', () => {
|
||||
expect(orderedTypeDef(null).key).toBe(DEFAULT_ORDERED_TYPE);
|
||||
expect(orderedTypeDef(undefined).key).toBe(DEFAULT_ORDERED_TYPE);
|
||||
});
|
||||
|
||||
it('falls back to the default for an unknown key', () => {
|
||||
expect(orderedTypeDef('bogus').key).toBe(DEFAULT_ORDERED_TYPE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orderedTypeFromFormat (ODF → listStyleType)', () => {
|
||||
it('maps roman + ) to lower-roman-paren', () => {
|
||||
expect(orderedTypeFromFormat('i', ')')).toBe('lower-roman-paren');
|
||||
});
|
||||
|
||||
it('maps decimal + . to decimal', () => {
|
||||
expect(orderedTypeFromFormat('1', '.')).toBe('decimal');
|
||||
});
|
||||
|
||||
it('treats a missing suffix as "."', () => {
|
||||
expect(orderedTypeFromFormat('a', null)).toBe('lower-alpha');
|
||||
});
|
||||
|
||||
it('falls back to decimal for an unknown suffix', () => {
|
||||
expect(orderedTypeFromFormat('1', '1.')).toBe('decimal');
|
||||
});
|
||||
|
||||
it('falls back to decimal for an unknown format', () => {
|
||||
expect(orderedTypeFromFormat('Z', '.')).toBe('decimal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PX_PER_CM, cmToPx, DEFAULT_MARGINS } from '../../src/lib/storage/pageMargins';
|
||||
|
||||
describe('pageMargins units', () => {
|
||||
it('PX_PER_CM is A4 @96dpi (96 / 2.54)', () => {
|
||||
expect(PX_PER_CM).toBeCloseTo(96 / 2.54, 10);
|
||||
});
|
||||
|
||||
it('cmToPx(1) equals PX_PER_CM', () => {
|
||||
expect(cmToPx(1)).toBeCloseTo(PX_PER_CM, 10);
|
||||
});
|
||||
|
||||
it('cmToPx(2.54) ≈ 96px', () => {
|
||||
expect(cmToPx(2.54)).toBeCloseTo(96, 6);
|
||||
});
|
||||
|
||||
it('cmToPx(0) is 0', () => {
|
||||
expect(cmToPx(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('default margins are the documented A4 values', () => {
|
||||
expect(DEFAULT_MARGINS).toEqual({ top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 });
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import pkg from './package.json' with { type: 'json' };
|
||||
@@ -5,4 +6,11 @@ import pkg from './package.json' with { type: 'json' };
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
define: { __APP_VERSION__: JSON.stringify(pkg.version) },
|
||||
// Test-only config; never enters the production bundle (vitest is dev-only).
|
||||
// jsdom supplies a global DOMParser, so the export/import specs need no setup.
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user