test: set up basic tests

This commit is contained in:
Steffen Becker committed 2026-06-24 23:07:18 +02:00
1 parent 453602a440
commit fd2683a01b
9 files changed
+1783 -3

No files matched your search

+978 -1
View File
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -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",
+202
View File
@@ -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);
});
});
+440
View File
@@ -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);
});
});
+37
View File
@@ -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');
});
});
+44
View File
@@ -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');
});
});
+41
View File
@@ -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');
});
});
+24
View File
@@ -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 });
});
});
+8
View File
@@ -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,
},
});