test: a committed corpus CI can read

Ten documents the docx lib authors directly, plus the ODT twin
LibreOffice converts each into, now live in tests/corpus/ instead of the
gitignored fixtures dir. corpus.test.ts asserts both formats of a
document read the same and that each survives our export and import
either way. The heading styles gained the outline level Word writes —
without it LibreOffice converts a heading to a paragraph that merely
carries the style, and the two formats disagreed.
This commit is contained in:
Steffen Becker committed 2026-08-15 11:54:36 +02:00
1 parent 9be1578b97
commit 889f11cffc
26 files changed
+107 -21

No files matched your search

+1 -2
View File
@@ -19,6 +19,7 @@
- Fixed A4 paper canvas (794 × 1123 px @ 96 dpi) with CSS-simulated pagination and visual page breaks
- LocalStorage auto-save (debounced 1 s); restores document, theme, zoom, margins, orientation, etc. on reload
- Fully client-side / serverless; hand-coded SVG favicon
- A committed corpus of ten documents (`tests/corpus/`) CI runs against: each is authored with the `docx` lib directly — never with our own exporter, which would test it against itself — and converted to its ODT twin by LibreOffice. `corpus.test.ts` asserts that both formats of a document read the same, and that each survives our export and import in either format
- Placeholder text on an empty document
**Text formatting**
@@ -169,8 +170,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14.
- A vertical writing mode for the **page** (ODF `tb-rl` on the page layout): a text box can run its text top-to-bottom, the body cannot — pagination fills a page downwards. A ruby annotation's own alignment and position are not offered either; both products' defaults are what we write
- Password-protected ODT/DOCX; digital signatures
- Split view: two views of one document, scrolled independently
- A committed corpus of documents we author ourselves, so CI can test against real files
(`.gitignore` excludes all of `tests/render-parity/fixtures/` today)
**Out of scope for now**
- Mail merge, data sources, form fields
+3 -1
View File
@@ -22,7 +22,9 @@ npm run test:parity # render parity vs LibreOffice (tests/render-parity/README.
Tests live in `tests/` (outside `src/`, so `svelte-check` ignores them), jsdom via Vitest.
`roundtrip.test.ts` covers the ODF export↔import round trip + a foreign-doc/style-resolver leg;
`lo-roundtrip.test.ts` re-saves through LibreOffice and **self-skips** without `soffice`, so
`npm test`/CI stay green; `tests/unit/` holds fast helper tests. All test tooling stays a
`npm test`/CI stay green; `corpus.test.ts` round-trips the committed `tests/corpus/`
documents (authored by `render-parity/make-fixtures.mjs`, never by our own exporter);
`tests/unit/` holds fast helper tests. All test tooling stays a
`devDependency`. No linter/formatter. CI (`.github/workflows/ci.yml`) runs `check` + `test`.
## Rules
+75
View File
@@ -0,0 +1,75 @@
// The committed corpus (`tests/corpus/`: documents the `docx` lib writes, plus the ODT
// twins LibreOffice converts them into) read as documents rather than as renderer
// fixtures — a file this editor did not write has to import, survive our own export,
// and read back the same.
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildOdt } from '../src/lib/export/odt';
import { importOdt } from '../src/lib/import/odt';
import { buildDocx } from '../src/lib/export/docx';
import { importDocx } from '../src/lib/import/docx';
type N = any;
const FIX = join(dirname(fileURLToPath(import.meta.url)), 'corpus');
// `make-fixtures.mjs` writes them; a real-world document stays out of the repo, in
// render-parity/fixtures/, and out of this run.
const files = existsSync(FIX)
? readdirSync(FIX).filter((f) => /^\d\d-.+\.(docx|odt)$/.test(f)).sort()
: [];
const load = (f: string) => new Uint8Array(readFileSync(join(FIX, f)));
const importAny = (f: string, bytes: Uint8Array): N =>
(f.endsWith('.odt') ? importOdt(bytes) : importDocx(bytes)).content;
// A block's type and its text, flattened depth-first: the shape a round trip must keep.
function outline(node: N, out: string[] = []): string[] {
for (const child of node.content ?? []) {
if (child.type === 'text') continue;
out.push(`${child.type}:${textOf(child).replace(/\s+/g, ' ').trim()}`);
outline(child, out);
}
return out;
}
function textOf(node: N): string {
if (node.type === 'text') return node.text ?? '';
return (node.content ?? []).map(textOf).join('');
}
describe.skipIf(!files.length)('the authored corpus', () => {
it('has both formats of every document', () => {
const docx = files.filter((f) => f.endsWith('.docx')).map((f) => f.slice(0, -5));
const odt = files.filter((f) => f.endsWith('.odt')).map((f) => f.slice(0, -4));
expect(odt).toEqual(docx);
});
for (const f of files) {
it(`${f} imports and survives its own round trip`, async () => {
const doc = importAny(f, load(f));
expect(outline(doc).length).toBeGreaterThan(0);
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
const again = f.endsWith('.odt')
? importOdt(await buildOdt(doc, margins, 'portrait'))
: importDocx(await buildDocx(doc, margins, 'portrait'));
expect(outline(again.content)).toEqual(outline(doc));
});
}
// The same document in both formats, and each one exported as the other: the four
// legs a document takes through this editor have to agree on what it says.
for (const name of files.filter((f) => f.endsWith('.docx')).map((f) => f.slice(0, -5))) {
it(`${name} reads the same out of either format`, async () => {
const fromDocx = importAny(`${name}.docx`, load(`${name}.docx`));
const fromOdt = importAny(`${name}.odt`, load(`${name}.odt`));
expect(outline(fromOdt)).toEqual(outline(fromDocx));
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
expect(outline(importOdt(await buildOdt(fromDocx, margins, 'portrait')).content))
.toEqual(outline(fromDocx));
expect(outline(importDocx(await buildDocx(fromOdt, margins, 'portrait')).content))
.toEqual(outline(fromOdt));
});
}
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8 -5
View File
@@ -61,9 +61,12 @@ The runner starts `npm run dev` on port 5199 itself if nothing answers there.
`make-fixtures.mjs` authors the baseline corpus with the `docx` lib **directly**,
not through `src/lib/export/docx.ts` — otherwise the corpus would test our exporter
against itself. Real-world `.docx`/`.odt` files dropped into `fixtures/` are picked
up too. The directory is gitignored: run the generator once, and keep whatever
real-world files you test against local.
against itself — and converts each document to its ODT twin with LibreOffice. It
writes them to **`tests/corpus/`, which is committed**: `tests/corpus.test.ts` reads
them at the model level (import → our own export → import) on every `npm test`, so
CI runs against documents this editor did not write. A run with no argument compares
that corpus *and* `fixtures/`, which stays gitignored — keep whatever real-world
files you test against there and local.
## Names stay local
@@ -73,8 +76,8 @@ that reason.
Only this harness reads `fixtures/`. The directory is gitignored, so a `tests/unit/` test
reading it fails with `ENOENT` on a fresh clone; it zips its own document instead
(`docx-onoff.test.ts` — both importers take a `Uint8Array`, not a path). CI greps for the
read.
(`docx-onoff.test.ts` — both importers take a `Uint8Array`, not a path), or reads
`tests/corpus/`, which is committed. CI greps for the read.
## Prerequisites, part two: Calibri Light
+19 -12
View File
@@ -1,7 +1,9 @@
// Authors the baseline .docx corpus with the `docx` lib directly (not via
// src/lib/export/docx.ts, so the fixtures don't test our exporter against itself).
// Drop real-world files into fixtures/ alongside them.
import { writeFileSync, mkdirSync, readdirSync } from 'node:fs';
// src/lib/export/docx.ts, so the corpus doesn't test our exporter against itself).
// It is committed (tests/corpus/) and CI reads it; real-world files stay local,
// in render-parity/fixtures/.
import { writeFileSync, mkdirSync, readdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
@@ -11,7 +13,7 @@ import {
Header, Footer, PageNumber, TabStopType,
} from 'docx';
const OUT = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus');
mkdirSync(OUT, { recursive: true });
const LOREM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
@@ -19,13 +21,13 @@ const LOREM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do e
const page = { margin: { top: 1134, bottom: 1134, left: 1134, right: 1134 } }; // 2cm
const para = (text, o = {}) => new Paragraph({ children: [new TextRun({ text, ...o.run })], ...o.p });
// Heading styles declare spacing and font explicitly: LibreOffice fills the gaps of
// a built-in style it knows by name from its own defaults, where Word uses only what
// the file declares — an omitted property makes the reference disagree with Word.
const heading = (id, name, halfPt, before, after) => ({
// Spacing, font and outline level are declared: LibreOffice fills a built-in style's
// gaps from its own defaults where Word uses only what the file declares, and without
// the level it converts a heading to a <text:p> merely carrying the heading's style.
const heading = (id, name, halfPt, before, after, level) => ({
id, name, basedOn: 'Normal', next: 'Normal', quickFormat: true,
run: { font: 'Arial', size: halfPt, bold: true },
paragraph: { spacing: { before, after } },
paragraph: { spacing: { before, after }, outlineLevel: level },
});
async function write(name, sections, defaultRun = { font: 'Times New Roman', size: 24 }, extra = {}) {
@@ -35,8 +37,8 @@ async function write(name, sections, defaultRun = { font: 'Times New Roman', siz
paragraphStyles: [
// Word always writes an explicit Normal; the docx lib would emit docDefaults only.
{ id: 'Normal', name: 'Normal', run: defaultRun, paragraph: { spacing: { after: 0 } } },
heading('Heading1', 'Heading 1', 36, 240, 120),
heading('Heading2', 'Heading 2', 32, 200, 100),
heading('Heading1', 'Heading 1', 36, 240, 120, 0),
heading('Heading2', 'Heading 2', 32, 200, 100, 1),
],
},
...extra,
@@ -199,12 +201,17 @@ await write('04-table.docx', [{
// its own conventions (percentage font sizes, Text Body, list styles) and exercise the
// foreign-document path our own exporter never produces.
const docxFiles = readdirSync(OUT).filter((f) => f.endsWith('.docx'));
// Its own profile, in a temp dir: LibreOffice writes a whole user installation into
// it, and this one's parent is committed.
const profile = mkdtempSync(join(tmpdir(), 'corpus-lo-'));
try {
execFileSync('soffice', [
'--headless', '--norestore', `-env:UserInstallation=file://${OUT}/.loprofile`,
'--headless', '--norestore', `-env:UserInstallation=file://${profile}`,
'--convert-to', 'odt', '--outdir', OUT, ...docxFiles.map((f) => join(OUT, f)),
], { stdio: 'pipe', timeout: 300_000 });
console.log(`converted ${docxFiles.length} ODT twins`);
} catch {
console.log('soffice missing — skipped the ODT twins');
} finally {
rmSync(profile, { recursive: true, force: true });
}
+1 -1
View File
@@ -268,7 +268,7 @@ async function devServer() {
}
function corpus(args) {
const paths = args.length ? args : [join(HERE, 'fixtures')];
const paths = args.length ? args : [join(HERE, '..', 'corpus'), join(HERE, 'fixtures')];
const out = [];
for (const p of paths) {
const abs = resolve(p);