test: semantic package lint, LO style resolution, cross-format rendering
Three legs against the closed-loop blind spot the duplicate-style bug exposed: package-lint.test.ts checks invariants the schemas cannot express (unique style ids/names, dangling style/num/rel references, balanced ranges, manifest completeness); lo-roundtrip gains a soffice→fodt leg asserting the styles LibreOffice resolves (Heading chain Arial/bold/sizes, Word defaults); lo-render-consistency.test.ts renders the kitchen sink from both formats through LibreOffice and compares pagination and pixels (ratchet thresholds). CI's LO job gains poppler-utils; test:lo runs both LO files.
This commit is contained in:
1 parent
4295baa284
commit
1e88427154
7 files changed
+358
-6
No files matched your search
@@ -39,7 +39,8 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libreoffice-writer fonts-liberation
|
||||
# poppler-utils rasterizes the PDFs the render-consistency leg compares.
|
||||
- run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libreoffice-writer fonts-liberation poppler-utils
|
||||
- run: npm ci
|
||||
- run: npm run test:lo
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ npm run preview # serve the dist/ build locally
|
||||
npm run check # svelte-check type-check (svelte + ts)
|
||||
npm test # Vitest suite once (tests/**/*.test.ts)
|
||||
npm run test:watch # Vitest in watch mode
|
||||
npm run test:lo # LibreOffice round-trip leg only (needs `soffice` on PATH)
|
||||
npm run test:lo # LibreOffice legs: round trip + ODT/DOCX render consistency (needs `soffice`)
|
||||
npm run test:smoke # boots the dist/ build in headless Chromium (tests/smoke/run.mjs)
|
||||
npm run test:coverage # vitest + v8 coverage over src/ → coverage/index.html
|
||||
npm run test:parity # render parity vs LibreOffice (tests/render-parity/README.md)
|
||||
@@ -30,7 +30,7 @@ Tests live in `tests/` (outside `src/`, so `svelte-check` ignores them), jsdom v
|
||||
documents (authored by `render-parity/make-fixtures.mjs`, never by our own exporter);
|
||||
`fuzz-roundtrip.test.ts` round-trips seeded random documents (`fuzzDoc.ts` generator);
|
||||
`schema-validation.test.ts` validates the `kitchenSink.ts` exports against the vendored
|
||||
schemas in `tests/schemas/` (self-skips without `xmllint`); `tests/unit/` holds fast helper tests. All test tooling stays a
|
||||
schemas in `tests/schemas/` (self-skips without `xmllint`); `package-lint.test.ts` checks the semantic invariants schemas can't express (unique ids, dangling references); `tests/unit/` holds fast helper tests. All test tooling stays a
|
||||
`devDependency`. No linter/formatter. CI (`.github/workflows/ci.yml`) runs `check` + `test`.
|
||||
|
||||
## Rules
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:lo": "vitest run tests/lo-roundtrip.test.ts",
|
||||
"test:lo": "vitest run tests/lo-roundtrip.test.ts tests/lo-render-consistency.test.ts",
|
||||
"test:smoke": "node tests/smoke/run.mjs",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:parity": "node tests/render-parity/run.mjs",
|
||||
|
||||
@@ -52,9 +52,9 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
|
||||
- **`collapseRunWhitespace`** — strips the bare `\n` separators odf-kit inserts between runs inside a paragraph (they'd otherwise collapse to spurious spaces mid-word).
|
||||
- **`rewriteStylesXml`** — `style:page-usage="mirrored"` on the page layout when the margins mirror (the left/right pair is already the inner/outer one), default font Liberation Serif → Times New Roman, heading sizes/margins → the editor's values, Standard's `fo:margin-bottom` → 0 (odf-kit emits 0.212cm; every paragraph and list item inherits it, and the editor has no paragraph spacing — see below).
|
||||
- **`normalizeColor`** — coerces colors to `#RRGGBB` (ODF requirement; rejects/normalizes `rgb()` and short hex).
|
||||
- **Schema conformance** (guarded by `tests/schema-validation.test.ts`; LibreOffice forgives all of this, Word's strict reader does not): `applyOdfVersion` stamps every ODF part **1.3** — the version LibreOffice writes, and the first with `style:header-first` — over odf-kit's 1.2. `draw:image` carries the `xlink:type/show/actuate` trio (`xlink:type` is mandatory beside `xlink:href`); `text:time-value` is an xsd dateTime, never a `PT…S` duration (the ODT importer still reads the legacy duration); `index-entry-link-start/-end` only in TOC entry templates; `text:dont-balance-text-columns` on `style:section-properties`. DOCX: `orderDocxSettings` (last pass) re-sorts `w:settings` children into the fixed CT_Settings sequence the prepend-passes scramble; `w14:paraId` in comments.xml requires `mc:Ignorable="w14"` on the root; `styleXmlComponent` unwraps `ImportedXmlComponent.fromXmlString`'s nameless wrapper, which would serialize as a literal `<undefined>` element.
|
||||
- **Schema conformance** (guarded by `tests/schema-validation.test.ts`; LibreOffice forgives all of this, Word's strict reader does not): `applyOdfVersion` stamps every ODF part **1.3** — the version LibreOffice writes, and the first with `style:header-first` — over odf-kit's 1.2. `draw:image` carries the `xlink:type/show/actuate` trio (`xlink:type` is mandatory beside `xlink:href`); `text:time-value` is an xsd dateTime, never a `PT…S` duration (the ODT importer still reads the legacy duration); `index-entry-link-start/-end` only in TOC entry templates; `text:dont-balance-text-columns` on `style:section-properties`. DOCX: `orderDocxSettings` (last pass) re-sorts `w:settings` children into the fixed CT_Settings sequence the prepend-passes scramble; `w14:paraId` in comments.xml requires `mc:Ignorable="w14"` on the root. `tests/package-lint.test.ts` guards the invariants the schemas cannot express (unique style ids, no dangling style/num/rel references, balanced ranges, manifest completeness).
|
||||
|
||||
- **Factory style slots** (`FACTORY_SLOTS`, `buildStyles`): the `docx` package always writes its own Title/Heading1–6 into styles.xml, so the registry's versions ride `styles.default.title/headingN` instead of `paragraphStyles` — a second definition under the same `w:styleId` makes Word **and** LibreOffice drop the `basedOn` chain (headings lose their sans/bold).
|
||||
- **Factory style slots** (`FACTORY_SLOTS`, `buildStyles`): the `docx` package always writes its own Title/Heading1–6 into styles.xml, so the registry's versions ride `styles.default.title/headingN` instead of `paragraphStyles` — a second definition under the same `w:styleId` makes Word **and** LibreOffice drop the `basedOn` chain (headings lose their sans/bold). Table and numbering styles are **spliced post-pack** (`applyRawStylesDocx`): passing them as `importedStyles` makes the package's Styles merge replace its whole factory set, `w:docDefaults` (default font/size/language) included.
|
||||
|
||||
The filename is derived from the first non-empty heading (max 50 chars, sanitized), falling back to `document.odt`.
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Cross-format render consistency: the same document exported as .odt and .docx,
|
||||
// both rendered by LibreOffice, must paginate alike and stay visually close — one
|
||||
// document, two formats, one renderer, so every diff is an export bug in one of them.
|
||||
// Requires soffice + pdftoppm (poppler-utils); self-skips without either.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { writeFileSync, readFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { buildDocx } from '../src/lib/export/docx';
|
||||
import { buildOdt } from '../src/lib/export/odt';
|
||||
import { kitchenSinkDoc, kitchenSinkSheet, kitchenSinkOptions } from './kitchenSink';
|
||||
|
||||
function has(cmd: string): boolean {
|
||||
try { execSync(`command -v ${cmd}`, { stdio: 'ignore' }); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
const DIR = '/tmp/lo-render-consistency';
|
||||
|
||||
function pgm(path: string): { w: number; h: number; pixels: Uint8Array } {
|
||||
const bytes = readFileSync(path);
|
||||
const m = /^P5\s+(\d+)\s+(\d+)\s+(\d+)\s/.exec(bytes.subarray(0, 64).toString('latin1'));
|
||||
if (!m) throw new Error(`not a P5 PGM: ${path}`);
|
||||
return { w: Number(m[1]), h: Number(m[2]), pixels: new Uint8Array(bytes.subarray(m[0].length)) };
|
||||
}
|
||||
|
||||
function renderPages(file: string, prefix: string): string[] {
|
||||
execSync(`soffice --headless --convert-to pdf --outdir ${DIR}/${prefix} ${DIR}/${file}`, { stdio: 'pipe', timeout: 120000 });
|
||||
const pdf = `${DIR}/${prefix}/${file.replace(/\.\w+$/, '.pdf')}`;
|
||||
execSync(`pdftoppm -gray -r 60 ${pdf} ${DIR}/${prefix}/page`, { stdio: 'pipe', timeout: 120000 });
|
||||
return readdirSync(`${DIR}/${prefix}`).filter((f) => f.endsWith('.pgm')).sort()
|
||||
.map((f) => `${DIR}/${prefix}/${f}`);
|
||||
}
|
||||
|
||||
describe.skipIf(!has('soffice') || !has('pdftoppm'))('ODT and DOCX render alike in LibreOffice', () => {
|
||||
it('paginates identically and stays within the diff budget', { timeout: 300000 }, async () => {
|
||||
const doc = kitchenSinkDoc();
|
||||
const sheet = kitchenSinkSheet();
|
||||
const opts = kitchenSinkOptions();
|
||||
// Neutral page options: decor and line numbering ride LibreOffice's DOCX import
|
||||
// differently and would drown the content comparison.
|
||||
const args = [doc, undefined, 'portrait', undefined, opts.language, 'A4',
|
||||
sheet, undefined, 'add', false, opts.notesSettings, opts.props, true,
|
||||
opts.pageNumbering] as const;
|
||||
rmSync(DIR, { recursive: true, force: true });
|
||||
mkdirSync(DIR, { recursive: true });
|
||||
writeFileSync(`${DIR}/doc.odt`, await buildOdt(...(args as any)));
|
||||
writeFileSync(`${DIR}/doc.docx`, await buildDocx(...(args as any)));
|
||||
|
||||
const odtPages = renderPages('doc.odt', 'odt');
|
||||
const docxPages = renderPages('doc.docx', 'docx');
|
||||
expect(docxPages.length, 'both formats paginate to the same page count').toBe(odtPages.length);
|
||||
|
||||
// Ratchet thresholds over the measured status quo (max 7.6%, mean 3.6% on
|
||||
// 2026-08-28) — tighten them as the cross-format deviations get fixed.
|
||||
const perPage: number[] = [];
|
||||
for (let i = 0; i < odtPages.length; i++) {
|
||||
const a = pgm(odtPages[i]);
|
||||
const b = pgm(docxPages[i]);
|
||||
expect([b.w, b.h], `page ${i + 1} geometry`).toEqual([a.w, a.h]);
|
||||
let differing = 0;
|
||||
for (let p = 0; p < a.pixels.length; p++) if (Math.abs(a.pixels[p] - b.pixels[p]) > 48) differing++;
|
||||
perPage.push((100 * differing) / a.pixels.length);
|
||||
}
|
||||
console.log(' [cross-format diff %]', perPage.map((d) => d.toFixed(2)).join(' '));
|
||||
for (let i = 0; i < perPage.length; i++) expect(perPage[i], `page ${i + 1} diff %`).toBeLessThan(10);
|
||||
const mean = perPage.reduce((s, d) => s + d, 0) / perPage.length;
|
||||
expect(mean, 'mean diff %').toBeLessThan(5);
|
||||
});
|
||||
});
|
||||
@@ -464,6 +464,49 @@ describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () =
|
||||
lists[2]?.type === 'orderedList' && lists[2]?.content?.[0]?.content?.[1]?.type === 'bulletList', lists[2]);
|
||||
});
|
||||
|
||||
// The one leg where a foreign consumer resolves our styles instead of our own
|
||||
// importer: duplicate or dangling definitions surface here, not in the round trips.
|
||||
it('resolves the DOCX style chain as the editor does (soffice → fodt)', { timeout: 180000 }, async () => {
|
||||
const { buildDocx } = await import('../src/lib/export/docx');
|
||||
const doc: N = { type: 'doc', content: [
|
||||
P({ styleName: 'Title' }, T('Der Titel')),
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [T('Kapitel')] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [T('Abschnitt')] },
|
||||
P(null, T('Fließtext.')),
|
||||
// The table style used to make the library drop its factory set, w:docDefaults
|
||||
// (default font, size, language) included — keep one in this fixture forever.
|
||||
{ type: 'table', attrs: { tableStyle: 'Box List Blue' }, content: [
|
||||
{ type: 'tableRow', content: [
|
||||
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: null }, content: [P(null, T('Zelle'))] },
|
||||
] },
|
||||
] },
|
||||
] };
|
||||
mkdirSync('/tmp/lo-rt', { recursive: true });
|
||||
writeFileSync('/tmp/lo-rt/res.docx', await buildDocx(doc));
|
||||
execSync('soffice --headless --convert-to fodt --outdir /tmp/lo-rt/resout /tmp/lo-rt/res.docx', { stdio: 'pipe', timeout: 120000 });
|
||||
const fodt = readFileSync('/tmp/lo-rt/resout/res.fodt', 'utf8');
|
||||
const style = (name: string) =>
|
||||
new RegExp(`<style:style style:name="${name}"[^>]*>[\\s\\S]*?</style:style>`).exec(fodt)?.[0] ?? '';
|
||||
|
||||
// The chain: Heading carries the sans font and bold, the levels size it.
|
||||
const heading = style('Heading');
|
||||
check('LO resolve: Heading is Arial', /style:font-name="Arial"|fo:font-family="[^"]*Arial/.test(heading), heading);
|
||||
check('LO resolve: Heading is bold', /fo:font-weight="bold"/.test(heading), heading);
|
||||
const h1 = style('Heading_20_1');
|
||||
check('LO resolve: Heading 1 inherits from Heading', /style:parent-style-name="Heading"/.test(h1), h1);
|
||||
check('LO resolve: Heading 1 is 18pt', /fo:font-size="18pt"/.test(h1), h1);
|
||||
const h2 = style('Heading_20_2');
|
||||
check('LO resolve: Heading 2 inherits from Heading', /style:parent-style-name="Heading"/.test(h2), h2);
|
||||
check('LO resolve: Heading 2 is 16pt', /fo:font-size="16pt"/.test(h2), h2);
|
||||
const title = style('Title');
|
||||
check('LO resolve: Title inherits from Heading', /style:parent-style-name="Heading"/.test(title), title);
|
||||
check('LO resolve: Title is 28pt', /fo:font-size="28pt"/.test(title), title);
|
||||
// w:docDefaults arrives as the style LibreOffice mints for Word's defaults.
|
||||
const defaults = style('Standard_20__28_WW_29_');
|
||||
check('LO resolve: the default font arrives', /Times New Roman/.test(defaults), defaults);
|
||||
check('LO resolve: the default size arrives', /fo:font-size="12pt"/.test(defaults), defaults);
|
||||
});
|
||||
|
||||
it('survives a `soffice` re-save of the record-changes flag', { timeout: 180000 }, async () => {
|
||||
const doc: N = { type: 'doc', content: [P(null, T('Ein Satz.'))] };
|
||||
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', undefined,
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// Semantic lint over both exported packages: invariants the schemas cannot express
|
||||
// (unique ids, no dangling references, balanced ranges, complete manifests). This is
|
||||
// the layer that catches what a consumer resolves differently than we meant.
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { buildDocx } from '../src/lib/export/docx';
|
||||
import { buildOdt } from '../src/lib/export/odt';
|
||||
import { kitchenSinkDoc, kitchenSinkSheet, kitchenSinkOptions } from './kitchenSink';
|
||||
|
||||
const R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
||||
|
||||
function parse(xml: string): Document {
|
||||
const doc = new DOMParser().parseFromString(xml, 'text/xml');
|
||||
expect(doc.querySelector('parsererror')).toBeNull();
|
||||
return doc;
|
||||
}
|
||||
|
||||
function walk(el: Element, fn: (el: Element) => void) {
|
||||
fn(el);
|
||||
for (const child of Array.from(el.children)) walk(child, fn);
|
||||
}
|
||||
|
||||
describe('exported packages hold their semantic invariants', () => {
|
||||
const opts = kitchenSinkOptions();
|
||||
let docx: Record<string, Uint8Array>;
|
||||
let odt: Record<string, Uint8Array>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const doc = kitchenSinkDoc();
|
||||
const sheet = kitchenSinkSheet();
|
||||
const args = [doc, undefined, 'portrait', opts.hf, opts.language, 'A4',
|
||||
sheet, undefined, 'add', false, opts.notesSettings, opts.props, true,
|
||||
opts.pageNumbering, opts.decor, opts.lineNumbering, true, true] as const;
|
||||
docx = unzipSync(await buildDocx(...(args as any)));
|
||||
odt = unzipSync(await buildOdt(...(args as any)));
|
||||
});
|
||||
|
||||
const partXml = (files: Record<string, Uint8Array>, name: string) => strFromU8(files[name]);
|
||||
const contentParts = () => Object.keys(docx).filter((n) =>
|
||||
/^word\/(document|header\d+|footer\d+|footnotes|endnotes|comments)\.xml$/.test(n));
|
||||
|
||||
it('DOCX: styles.xml defines every id and name exactly once', () => {
|
||||
const styles = parse(partXml(docx, 'word/styles.xml'));
|
||||
const ids: string[] = [];
|
||||
const names: string[] = [];
|
||||
walk(styles.documentElement, (el) => {
|
||||
if (el.tagName !== 'w:style') return;
|
||||
ids.push(`${el.getAttribute('w:type')}/${el.getAttribute('w:styleId')}`);
|
||||
const name = el.getElementsByTagName('w:name')[0]?.getAttribute('w:val');
|
||||
if (name) names.push(name);
|
||||
});
|
||||
// Word resolves duplicates by silently dropping pieces of one definition —
|
||||
// this is how exported headings once lost their sans font and bold.
|
||||
expect(ids).toEqual([...new Set(ids)]);
|
||||
expect(names).toEqual([...new Set(names)]);
|
||||
// The ids must also be unique across types: consumers key styles by id alone.
|
||||
const bare = ids.map((i) => i.split('/')[1]);
|
||||
expect(bare).toEqual([...new Set(bare)]);
|
||||
expect(styles.getElementsByTagName('w:docDefaults').length).toBe(1);
|
||||
});
|
||||
|
||||
it('DOCX: every style reference names a defined style', () => {
|
||||
const styles = parse(partXml(docx, 'word/styles.xml'));
|
||||
const defined = new Set<string>();
|
||||
walk(styles.documentElement, (el) => {
|
||||
if (el.tagName === 'w:style') defined.add(el.getAttribute('w:styleId')!);
|
||||
});
|
||||
// Word's default table and character styles exist implicitly in every document.
|
||||
defined.add('TableNormal');
|
||||
defined.add('DefaultParagraphFont');
|
||||
const missing: string[] = [];
|
||||
const refEls = ['w:pStyle', 'w:rStyle', 'w:tblStyle', 'w:basedOn', 'w:next', 'w:link'];
|
||||
for (const part of [...contentParts(), 'word/styles.xml']) {
|
||||
walk(parse(partXml(docx, part)).documentElement, (el) => {
|
||||
if (!refEls.includes(el.tagName)) return;
|
||||
const val = el.getAttribute('w:val');
|
||||
if (val && !defined.has(val)) missing.push(`${part}: ${el.tagName}=${val}`);
|
||||
});
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('DOCX: every numbering reference resolves', () => {
|
||||
const numbering = parse(partXml(docx, 'word/numbering.xml'));
|
||||
const abstracts = new Set<string>();
|
||||
const nums = new Set<string>();
|
||||
const usedAbstracts = new Set<string>();
|
||||
walk(numbering.documentElement, (el) => {
|
||||
if (el.tagName === 'w:abstractNum') abstracts.add(el.getAttribute('w:abstractNumId')!);
|
||||
if (el.tagName === 'w:num') {
|
||||
nums.add(el.getAttribute('w:numId')!);
|
||||
const abs = el.getElementsByTagName('w:abstractNumId')[0]?.getAttribute('w:val');
|
||||
expect(abs && abstracts.has(abs), `w:num ${el.getAttribute('w:numId')} → abstract ${abs}`).toBe(true);
|
||||
usedAbstracts.add(abs!);
|
||||
}
|
||||
});
|
||||
expect([...abstracts].filter((a) => !usedAbstracts.has(a))).toEqual([]);
|
||||
const missing: string[] = [];
|
||||
for (const part of [...contentParts(), 'word/styles.xml']) {
|
||||
walk(parse(partXml(docx, part)).documentElement, (el) => {
|
||||
if (el.tagName !== 'w:numId') return;
|
||||
const val = el.getAttribute('w:val');
|
||||
// val 0 means "no numbering" (it unlinks an inherited reference).
|
||||
if (val && val !== '0' && !nums.has(val)) missing.push(`${part}: numId=${val}`);
|
||||
});
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('DOCX: every relationship id and target resolves, every part is typed', () => {
|
||||
const problems: string[] = [];
|
||||
for (const part of Object.keys(docx)) {
|
||||
if (!part.endsWith('.xml') || part.includes('_rels')) continue;
|
||||
const dir = part.includes('/') ? part.slice(0, part.lastIndexOf('/')) : '';
|
||||
const relsName = `${dir ? dir + '/' : ''}_rels/${part.split('/').pop()}.rels`;
|
||||
const rels = docx[relsName] ? parse(strFromU8(docx[relsName])) : null;
|
||||
const relIds = new Set<string>();
|
||||
if (rels) walk(rels.documentElement, (el) => {
|
||||
if (el.tagName !== 'Relationship') return;
|
||||
relIds.add(el.getAttribute('Id')!);
|
||||
if (el.getAttribute('TargetMode') === 'External') return;
|
||||
const target = el.getAttribute('Target')!.replace(/^\//, '');
|
||||
const resolved = target.startsWith('..')
|
||||
? target.replace(/^\.\.\//, '') : `${dir ? dir + '/' : ''}${target}`;
|
||||
if (!docx[resolved]) problems.push(`${relsName}: target ${target} missing`);
|
||||
});
|
||||
walk(parse(partXml(docx, part)).documentElement, (el) => {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (attr.namespaceURI !== R_NS) continue;
|
||||
if (!relIds.has(attr.value)) problems.push(`${part}: ${attr.name}=${attr.value} has no relationship`);
|
||||
}
|
||||
});
|
||||
}
|
||||
const types = parse(partXml(docx, '[Content_Types].xml'));
|
||||
const defaults = new Set<string>();
|
||||
const overrides = new Set<string>();
|
||||
walk(types.documentElement, (el) => {
|
||||
if (el.tagName === 'Default') defaults.add(el.getAttribute('Extension')!.toLowerCase());
|
||||
if (el.tagName === 'Override') overrides.add(el.getAttribute('PartName')!);
|
||||
});
|
||||
for (const part of Object.keys(docx)) {
|
||||
if (part === '[Content_Types].xml' || part.endsWith('/')) continue;
|
||||
const ext = part.split('.').pop()!.toLowerCase();
|
||||
if (!defaults.has(ext) && !overrides.has(`/${part}`)) problems.push(`untyped part ${part}`);
|
||||
}
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it('DOCX: bookmark ranges balance in every part', () => {
|
||||
for (const part of contentParts()) {
|
||||
const starts = new Set<string>();
|
||||
const ends = new Set<string>();
|
||||
walk(parse(partXml(docx, part)).documentElement, (el) => {
|
||||
if (el.tagName === 'w:bookmarkStart') starts.add(el.getAttribute('w:id')!);
|
||||
if (el.tagName === 'w:bookmarkEnd') ends.add(el.getAttribute('w:id')!);
|
||||
});
|
||||
expect([...starts].sort(), part).toEqual([...ends].sort());
|
||||
}
|
||||
});
|
||||
|
||||
it('ODT: every style reference names a defined style', () => {
|
||||
const content = parse(partXml(odt, 'content.xml'));
|
||||
const styles = parse(partXml(odt, 'styles.xml'));
|
||||
const defined = new Set<string>();
|
||||
for (const root of [content.documentElement, styles.documentElement]) {
|
||||
walk(root, (el) => {
|
||||
const name = el.getAttribute('style:name');
|
||||
if (name) defined.add(name);
|
||||
});
|
||||
}
|
||||
const refAttrs = new Set([
|
||||
'text:style-name', 'table:style-name', 'draw:style-name', 'draw:text-style-name',
|
||||
'style:parent-style-name', 'style:next-style-name', 'style:list-style-name',
|
||||
'style:master-page-name', 'style:page-layout-name', 'style:data-style-name',
|
||||
'text:visited-style-name', 'text:citation-style-name', 'text:citation-body-style-name',
|
||||
'text:default-style-name',
|
||||
]);
|
||||
const missing: string[] = [];
|
||||
for (const [part, root] of [['content.xml', content], ['styles.xml', styles]] as const) {
|
||||
walk(root.documentElement, (el) => {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (!refAttrs.has(attr.name) || !attr.value) continue;
|
||||
if (!defined.has(attr.value)) missing.push(`${part}: <${el.tagName} ${attr.name}="${attr.value}">`);
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('ODT: the manifest and the package agree', () => {
|
||||
const manifest = parse(partXml(odt, 'META-INF/manifest.xml'));
|
||||
const listed = new Set<string>();
|
||||
walk(manifest.documentElement, (el) => {
|
||||
if (el.tagName === 'manifest:file-entry') listed.add(el.getAttribute('manifest:full-path')!);
|
||||
});
|
||||
const problems: string[] = [];
|
||||
for (const name of Object.keys(odt)) {
|
||||
if (name === 'mimetype' || name.startsWith('META-INF/')) continue;
|
||||
// A sub-document (Formula1/) is listed as its directory entry.
|
||||
if (!listed.has(name) && !listed.has(name.split('/')[0] + '/')) problems.push(`unlisted ${name}`);
|
||||
}
|
||||
for (const entry of listed) {
|
||||
if (entry === '/' || entry.endsWith('/')) continue;
|
||||
if (!odt[entry]) problems.push(`listed but missing ${entry}`);
|
||||
}
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it('ODT: internal targets, ranges and change ids resolve', () => {
|
||||
const content = parse(partXml(odt, 'content.xml'));
|
||||
const problems: string[] = [];
|
||||
const bkStarts = new Set<string>();
|
||||
const bkEnds = new Set<string>();
|
||||
const annStarts = new Set<string>();
|
||||
const annEnds = new Set<string>();
|
||||
const changeRegions = new Set<string>();
|
||||
const changeRefs = new Set<string>();
|
||||
walk(content.documentElement, (el) => {
|
||||
const href = el.getAttribute('xlink:href');
|
||||
if (href && !/^[a-z]+:|^#/.test(href)) {
|
||||
// A formula object's href names its sub-document directory.
|
||||
const path = href.replace(/^\.\//, '');
|
||||
if (!odt[path] && !odt[`${path}/content.xml`]) problems.push(`dangling xlink:href ${href}`);
|
||||
}
|
||||
if (el.tagName === 'text:bookmark-start') bkStarts.add(el.getAttribute('text:name')!);
|
||||
if (el.tagName === 'text:bookmark-end') bkEnds.add(el.getAttribute('text:name')!);
|
||||
if (el.tagName === 'office:annotation') annStarts.add(el.getAttribute('office:name') ?? '');
|
||||
if (el.tagName === 'office:annotation-end') annEnds.add(el.getAttribute('office:name')!);
|
||||
if (el.tagName === 'text:changed-region') changeRegions.add(el.getAttribute('xml:id') ?? el.getAttribute('text:id')!);
|
||||
const chId = el.getAttribute('text:change-id');
|
||||
if (chId) changeRefs.add(chId);
|
||||
});
|
||||
expect([...bkStarts].sort()).toEqual([...bkEnds].sort());
|
||||
// A point annotation has no end; every end must name a start.
|
||||
expect([...annEnds].filter((n) => !annStarts.has(n))).toEqual([]);
|
||||
expect([...changeRefs].filter((id) => !changeRegions.has(id))).toEqual([]);
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user