Files
edentext/tests/lo-roundtrip.test.ts
T
Steffen Becker 78e35cef2c feat: a reference finds the text it points at, and follows it
Bookmarks are a mark, not a point: both formats store a range, and only a
range lets a REF recompute its text. A cross-reference caches its display
like a Word field and resolves live on each pagination settle — the
bookmark's text, or the page it renders on — keeping the cached value when
the target is gone. Ctrl+click jumps to it, internal #name links too.

ODF splices its sentinels into the run text so cells and custom paragraphs
need no separate emitter; DOCX needs no post-pass at all.
2026-08-09 13:56:22 +00:00

330 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// LibreOffice round-trip leg: editor JSON -> buildOdt -> `soffice --convert-to odt`
// re-save -> importOdt -> compare. Requires soffice on PATH; the whole suite self-skips
// when it is absent (so plain `npm test` / CI stay green).
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 TBX = (attrs: N, ...content: N[]): N => ({ type: 'textBox', attrs, content });
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' } }),
),
// Character effects: LibreOffice must read back what we write for each of them.
P(null,
T('caps ', { type: 'textStyle', attrs: { caps: 'uppercase' } }),
T('petite ', { type: 'textStyle', attrs: { caps: 'smallCaps' } }),
T('dotted ', { type: 'underline', attrs: { lineStyle: 'dotted', lineColor: '#FF0000' } }),
T('twice ', { type: 'underline', attrs: { lineStyle: 'double' } }),
T('crossed ', { type: 'strike', attrs: { lineStyle: 'double' } }),
// 4pt at 16pt is a whole 25%, the unit ODF stores it in — LibreOffice rounds
// the percentage when it re-saves, so a fractional one comes back a notch off.
T('raised', { type: 'textStyle', attrs: { fontSize: '16pt', textPosition: 4 } }),
),
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], borderTop: 'none', borderRight: '2.25pt solid #FF0000' }, content: [P(null, T('B2'))] },
] },
] },
TBX({ width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 },
P(null, T('box text')), P(null, T('second para'))),
TBX({ width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 },
P(null, T('in ellipse'))),
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,
shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1,
};
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
if (k === 'strokeWidthPt') { attrs.strokeWidthPt = Math.round((v as number) * 4) / 4; continue; } // pt↔in noise
// Cell borders: LO re-saves widths with pt↔cm noise; quantize to 0.25pt steps.
if (k.startsWith('border') && typeof v === 'string' && v !== 'none') {
const bm = /^([\d.]+)pt solid (#[0-9A-F]{6})$/i.exec(v);
if (bm) { attrs[k] = `${Math.round(parseFloat(bm[1]) * 4) / 4}pt solid ${bm[2].toUpperCase()}`; continue; }
}
if (k === 'fillColor' || k === 'strokeColor') { attrs[k] = typeof v === 'string' ? v.toUpperCase() : v; continue; }
// LO re-parents text-box paragraphs onto its Frame-contents style (margin 0), so
// an explicit spaceAfter 0 comes back where Standard's default was suppressed.
if (k === 'spaceAfter' && v === 0) continue;
attrs[k] = v;
}
if (Object.keys(attrs).length) out.attrs = attrs;
if (node.content?.length) {
const mapped = node.content.map(normalize);
// LibreOffice re-anchors paragraph-anchored (floating) frames to the paragraph
// start; a float's inline position is visually meaningless, so canonicalize it.
const isFloat = (c: N) => c.type === 'image' && c.attrs?.wrap;
const ordered = [...mapped.filter(isFloat), ...mapped.filter((c: N) => !isFloat(c))];
const kids: N[] = [];
for (const c of ordered) {
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;
}
// Generous per-test timeouts: a cold soffice start under a parallel `npm test`
// run easily exceeds vitest's 5s default.
describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () => {
it('survives a `soffice --convert-to odt` re-save of the body document', { timeout: 180000 }, 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));
// Text boxes: geometry, colors and shape kind must survive the LO re-save.
const boxes = (res.content.content ?? []).filter((n: N) => n.type === 'textBox');
check('LO: both text boxes survive', boxes.length === 2, (res.content.content ?? []).map((n: N) => n.type));
const [plain, ellipse] = boxes;
check('LO: box geometry survives (288×96)', Math.abs((plain?.attrs?.width ?? 0) - 288) <= 3 && Math.abs((plain?.attrs?.height ?? 0) - 96) <= 3, plain?.attrs);
check('LO: box paragraphs survive', plain?.content?.length === 2, plain?.content);
check('LO: ellipse kind + fill survive', ellipse?.attrs?.shapeKind === 'ellipse' && String(ellipse?.attrs?.fillColor).toUpperCase() === '#FFEE00', ellipse?.attrs);
const brot = ellipse?.attrs?.rotation ?? 0;
check('LO: ellipse rotation survives (~30°)', Math.abs(brot - 30) <= 2 || Math.abs(brot - 330) <= 2, brot);
check('LO: ellipse wrap survives', !!ellipse?.attrs?.wrap && ellipse.attrs.wrap !== 'inline', ellipse?.attrs);
});
it('survives a `soffice` re-save of the header/footer geometry', { timeout: 180000 }, 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);
});
it('survives a `soffice` re-save of the tab interval and a stop leader', { timeout: 180000 }, async () => {
const tabDoc: N = { type: 'doc', content: [
{ type: 'paragraph', attrs: { tabStops: '6l.;12r_' }, content: [T('Kapitel\t1\tS. 3')] },
] };
const bytes = await buildOdt(tabDoc, margins, 'portrait', undefined, undefined, 'A4', undefined, 1.27);
mkdirSync('/tmp/lo-rt', { recursive: true });
writeFileSync('/tmp/lo-rt/tab.odt', bytes);
execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/tabout /tmp/lo-rt/tab.odt', { stdio: 'pipe', timeout: 120000 });
const res = importOdt(new Uint8Array(readFileSync('/tmp/lo-rt/tabout/tab.odt')));
check('LO tab: no warnings', res.warnings.length === 0, res.warnings);
check('LO tab: interval ≈1.27cm', Math.abs((res.tabIntervalCm ?? 0) - 1.27) < 0.01, res.tabIntervalCm);
const stops = (res.content.content ?? [])[0]?.attrs?.tabStops;
check('LO tab: stops + leaders survive', stops === '6l.;12r_', stops);
});
// Needs the libreoffice-math package: without it LibreOffice silently drops every
// formula object on load, so this leg reports zero formulas instead of failing loudly.
it('survives a `soffice` re-save of embedded formula objects', { timeout: 180000 }, async () => {
const F = (latex: string, display: boolean): N => ({ type: 'formula', attrs: { latex, display } });
const inline = '\\phi _{ref}=\\frac{a+1}{2\\pi }';
const block = '\\sum_{i=1}^{n} \\sqrt{x_{i}^{2}+1}=\\left(\\frac{\\alpha }{\\beta }\\right)';
const doc: N = { type: 'doc', content: [
P(null, T('Inline: '), F(inline, false), T(' im Text.')),
P(null, F(block, true)),
] };
mkdirSync('/tmp/lo-rt', { recursive: true });
writeFileSync('/tmp/lo-rt/math.odt', await buildOdt(doc, margins, 'portrait'));
execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/mathout /tmp/lo-rt/math.odt', { stdio: 'pipe', timeout: 120000 });
const resaved = new Uint8Array(readFileSync('/tmp/lo-rt/mathout/math.odt'));
// LibreOffice keeps them as real formula sub-documents (it adds its own settings.xml).
const names = Object.keys(unzipSync(resaved)).filter((p) => /content\.xml$/.test(p) && p !== 'content.xml');
check('LO math: both objects survive as sub-documents', names.length === 2, names);
const res = importOdt(resaved);
check('LO math: no warnings', res.warnings.length === 0, res.warnings);
const found: N[] = [];
(function walk(n: N) { if (n.type === 'formula') found.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
check('LO math: both formulas come back', found.length === 2, found.length);
check('LO math: inline source survives', found[0]?.attrs?.latex === inline, found[0]?.attrs?.latex);
check('LO math: display source survives', found[1]?.attrs?.latex === block, found[1]?.attrs?.latex);
// LibreOffice writes display="block" on every formula it re-saves, so the flag is
// read off the paragraph instead — an inline formula must not come back displayed.
check('LO math: inline stays inline, display stays display',
found[0]?.attrs?.display === false && found[1]?.attrs?.display === true,
found.map((f) => f.attrs?.display));
});
it('survives a `soffice` re-save of bookmarks and cross-references', { timeout: 180000 }, async () => {
const doc: N = { type: 'doc', content: [
P(null, T('Figure '), T('Figure 1', { type: 'bookmark', attrs: { name: 'Fig1' } }), T(' – a caption')),
P(null,
T('See '), { type: 'crossRef', attrs: { name: 'Fig1', format: 'text', text: 'Figure 1' } },
T(' on page '), { type: 'crossRef', attrs: { name: 'Fig1', format: 'page', text: '1' } },
T(' or '), T('jump', { type: 'link', attrs: { href: '#Fig1' } }), T('.'),
),
] };
mkdirSync('/tmp/lo-rt', { recursive: true });
writeFileSync('/tmp/lo-rt/bm.odt', await buildOdt(doc, margins, 'portrait'));
execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/bmout /tmp/lo-rt/bm.odt', { stdio: 'pipe', timeout: 120000 });
const resaved = new Uint8Array(readFileSync('/tmp/lo-rt/bmout/bm.odt'));
const xml = strFromU8(unzipSync(resaved)['content.xml']);
check('LO bookmarks: the range survives', xml.includes('text:bookmark-start text:name="Fig1"') && xml.includes('text:bookmark-end text:name="Fig1"'));
check('LO bookmarks: both references stay fields', (xml.match(/<text:bookmark-ref/g) ?? []).length === 2, xml.match(/<text:bookmark-ref[^>]*>/g));
const res = importOdt(resaved);
const marked: N[] = [];
const refs: N[] = [];
(function walk(n: N) {
if (n.type === 'crossRef') refs.push(n);
if ((n.marks ?? []).some((m: N) => m.type === 'bookmark')) marked.push(n);
for (const c of n.content ?? []) walk(c);
})(res.content);
check('LO bookmarks: the mark comes back on its text', marked[0]?.text === 'Figure 1', marked.map((m) => m.text));
check('LO bookmarks: both references come back',
refs.map((r) => `${r.attrs.name}/${r.attrs.format}`).join(',') === 'Fig1/text,Fig1/page',
refs.map((r) => r.attrs));
// LibreOffice re-evaluates the fields on load, so the shown values are its own.
check('LO bookmarks: the text reference resolves to the caption', refs[0]?.attrs?.text === 'Figure 1', refs[0]?.attrs?.text);
});
});