test: the fuzz leg draws every export option and validates each export against the schemas

tests/fuzzOptions.ts generates styles, outline numbering, sections with
zones, margins, paper and page numbering, decor, line numbering, notes and
metadata; the round trip compares per-section effective values, and what a
format has no place for is listed as lossy. The xmllint part of
schema-validation.test.ts moves to tests/schemaValidate.ts (one call per
schema) and a third leg validates every seed as ODT and DOCX. FUZZ_SEEDS
and SCHEMA_SEEDS widen the sweep.
This commit is contained in:
Steffen Becker committed 2026-09-06 19:07:14 +02:00
1 parent 7b43ee71ad
commit 0d5c17a704
7 files changed
+569 -166

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ Tests live in `tests/` (outside `src/`, so `svelte-check` ignores them), jsdom v
`lo-roundtrip.test.ts` re-saves through LibreOffice and **self-skips** without `soffice`, so
`npm test`/CI stay green; `corpus.test.ts` round-trips the committed `tests/corpus/`
documents (by `make-fixtures.mjs` + Word re-saves in `corpus/word/`, never our exporter);
`fuzz-roundtrip.test.ts` round-trips seeded random documents (`fuzzDoc.ts` generator);
`fuzz-roundtrip.test.ts` round-trips seeded random documents under random export options (`fuzzDoc.ts`, `fuzzOptions.ts`) through both formats and validates every export against the schemas (`schemaValidate.ts`; `FUZZ_SEEDS=500` widens it);
`schema-validation.test.ts` validates the `kitchenSink.ts` exports against the vendored
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; the two browser runs (`tests/smoke/`, `tests/dom/`) share `tests/browser.mjs`. All test tooling stays a
`devDependency`. No linter/formatter. CI (`.github/workflows/ci.yml`) runs `check` + `test`.
+56 -15
View File
@@ -1,24 +1,38 @@
// Property leg: seeded random documents (fuzzDoc.ts) -> buildOdt -> importOdt must
// come back unchanged (normalized). A failure names the seed, so it reproduces.
// Property leg: seeded random documents under random export options (fuzzDoc.ts,
// fuzzOptions.ts) -> buildOdt/buildDocx -> import come back unchanged (normalized), and every
// export validates against the schemas. A failure names the seed: genCase(mulberry32(seed)).
import { describe, it, expect } from 'vitest';
import { unzipSync } from 'fflate';
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';
import { normalize, firstDiff, stripFontHoist } from './normalize';
import { genDoc, mulberry32 } from './fuzzDoc';
import { mulberry32 } from './fuzzDoc';
import { genCase, exportArgs, expectedOptions, importedOptions, diffLoose, omit } from './fuzzOptions';
import { hasXmllint, validateOdt, validateDocx } from './schemaValidate';
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
const SEEDS = Number(process.env.FUZZ_SEEDS ?? 50); // FUZZ_SEEDS=500 for a wide sweep
// The schema leg pays ~0.7s of xmllint per seed, so it takes fewer by default.
const SCHEMA_SEEDS = Number(process.env.SCHEMA_SEEDS ?? Math.min(SEEDS, 20));
// What each format has no place for (see the importers' CLAUDE.md); dropped from both
// sides of its comparison. Word spaces paragraphs by the larger of the two values and
// draws its note separator its own way; it has no note prefix/suffix and counts every line.
const ODT_LOSSY: string[] = [];
const DOCX_LOSSY: string[] = ['spacingModel', 'notes.separator', 'notes.footnote.prefix', 'notes.footnote.suffix',
'notes.endnote.prefix', 'notes.endnote.suffix', 'lineNumbering.countEmpty'];
describe('fuzz round-trip: editor → buildOdt → importOdt', () => {
it(`${SEEDS} seeded random documents come back identical`, async () => {
for (let seed = 1; seed <= SEEDS; seed++) {
const doc = genDoc(mulberry32(seed));
const res = importOdt(await buildOdt(doc, margins, 'portrait'));
const { doc, opts } = genCase(mulberry32(seed));
const res = importOdt(await buildOdt(doc, ...exportArgs(opts)));
expect.soft(res.warnings, `seed ${seed}: warnings`).toEqual([]);
const diff = firstDiff(stripFontHoist(normalize(doc)), stripFontHoist(normalize(res.content)));
expect.soft(diff, `seed ${seed} (re-run genDoc(mulberry32(${seed})) to reproduce)`).toBeNull();
expect.soft(diff, `seed ${seed}: content`).toBeNull();
const optDiff = diffLoose(omit(expectedOptions(opts), ODT_LOSSY), omit(importedOptions(res, opts), ODT_LOSSY));
expect.soft(optDiff, `seed ${seed}: options`).toBeNull();
}
}, 600_000);
@@ -33,16 +47,43 @@ describe('fuzz round-trip: editor → buildOdt → importOdt', () => {
return node;
}
// Cross-format: the same document through both pipelines must import identically —
// catches one exporter silently losing what the other keeps.
it(`${SEEDS} seeded documents agree between the ODT and DOCX round-trips`, async () => {
// Odd/even pages are a document setting in Word (w:evenAndOddHeaders): once any
// section asks for it, every section has it, repeating its running zones.
function docWideOddEven(canon: any): any {
if (!canon.sections.some((s: any) => s.differentOddEven)) return canon;
for (const s of canon.sections) {
if (s.differentOddEven) continue;
Object.assign(s, { differentOddEven: true, headerEven: s.header, footerEven: s.footer });
}
return canon;
}
// The DOCX leg: its options against what went out, and its content against the ODT
// reading — catches one exporter silently losing what the other keeps.
it(`${SEEDS} seeded documents survive DOCX and agree with the ODT round-trip`, async () => {
for (let seed = 1; seed <= SEEDS; seed++) {
const doc = genDoc(mulberry32(seed));
const viaOdt = importOdt(await buildOdt(doc, margins, 'portrait')).content;
const viaDocx = importDocx(await buildDocx(doc, margins, 'portrait')).content;
const { doc, opts } = genCase(mulberry32(seed));
const viaOdt = importOdt(await buildOdt(doc, ...exportArgs(opts))).content;
const res = importDocx(await buildDocx(doc, ...exportArgs(opts)));
const diff = firstDiff(stripColwidth(stripFontHoist(normalize(viaOdt))),
stripColwidth(stripFontHoist(normalize(viaDocx))));
expect.soft(diff, `seed ${seed}: ODT vs DOCX import (genDoc(mulberry32(${seed})))`).toBeNull();
stripColwidth(stripFontHoist(normalize(res.content))));
expect.soft(diff, `seed ${seed}: ODT vs DOCX import`).toBeNull();
const optDiff = diffLoose(docWideOddEven(omit(expectedOptions(opts), DOCX_LOSSY)),
omit(importedOptions(res, opts), DOCX_LOSSY));
expect.soft(optDiff, `seed ${seed}: DOCX options`).toBeNull();
}
}, 600_000);
});
describe.skipIf(!hasXmllint)('fuzz: every export validates against the format schemas', () => {
it(`${SCHEMA_SEEDS} seeded documents are schema-valid as ODT and as DOCX`, async () => {
for (let seed = 1; seed <= SCHEMA_SEEDS; seed++) {
const { doc, opts } = genCase(mulberry32(seed));
const failures = [
...validateOdt(unzipSync(await buildOdt(doc, ...exportArgs(opts)))),
...validateDocx(unzipSync(await buildDocx(doc, ...exportArgs(opts)))),
];
expect.soft(failures.join('\n\n'), `seed ${seed}`).toBe('');
}
}, 1_800_000);
});
+13 -7
View File
@@ -4,7 +4,7 @@ import {
} from '../src/lib/styles/tableStyles';
type N = any;
type Rng = () => number;
export type Rng = () => number;
export function mulberry32(seed: number): Rng {
let a = seed >>> 0;
@@ -16,9 +16,9 @@ export function mulberry32(seed: number): Rng {
};
}
const pick = <T,>(r: Rng, arr: readonly T[]): T => arr[Math.floor(r() * arr.length)];
const int = (r: Rng, min: number, max: number) => min + Math.floor(r() * (max - min + 1));
const maybe = (r: Rng, p: number) => r() < p;
export const pick = <T,>(r: Rng, arr: readonly T[]): T => arr[Math.floor(r() * arr.length)];
export const int = (r: Rng, min: number, max: number) => min + Math.floor(r() * (max - min + 1));
export const maybe = (r: Rng, p: number) => r() < p;
// XML-hostile characters, umlauts, an astral-plane glyph and a tab on purpose.
const WORDS = ['Lorem', 'ipsum', 'Größe', 'fünf', 'a&b', '<tag>', '"quote"', "it's",
@@ -99,6 +99,9 @@ function paraAttrs(r: Rng, indents: boolean, top: boolean): N | null {
if (indents && maybe(r, 0.1)) attrs.indentFirst = pick(r, [0.75, -0.75]);
if (indents && maybe(r, 0.1)) attrs.indentRight = 1.5;
if (top && maybe(r, 0.05)) attrs.breakBefore = 'page';
// A section break is a page break as well: naming a master page breaks the page in
// ODF, and the DOCX sectPr is a nextPage one.
if (top && maybe(r, 0.06)) { attrs.sectionBreak = true; attrs.breakBefore = 'page'; }
// Paragraph box: shading and/or rule lines (ParaStyle carries them into cells/lists).
if (maybe(r, 0.08)) {
if (maybe(r, 0.6)) attrs.backgroundColor = pick(r, ['#CCFFFF', '#FFE0E0']);
@@ -418,7 +421,8 @@ function table(r: Rng): N {
// Roman labels for endnotes match the importer's default numbering.
const ROMAN = ['i', 'ii', 'iii', 'iv', 'v'] as const;
export function genDoc(r: Rng): N {
// `styleNames` are the sheet's user styles; a styled block may name one of them.
export function genDoc(r: Rng, styleNames: string[] = []): N {
bmNames = [];
commentSeq = 0;
revSeq = 0;
@@ -443,8 +447,8 @@ export function genDoc(r: Rng): N {
// Title/Subtitle centre + size and Quotations' indent live in the style, and a
// direct value equal to the style's own is suppressed on import — a styled
// block carries heading-safe runs and no paragraph attrs of its own.
block = maybe(r, 0.07)
? { type: 'paragraph', attrs: { styleName: pick(r, ['Title', 'Subtitle', 'Quotations', 'Caption']) },
block = maybe(r, styleNames.length ? 0.15 : 0.07)
? { type: 'paragraph', attrs: { styleName: pick(r, ['Title', 'Subtitle', 'Quotations', 'Caption', ...styleNames]) },
content: runs(r, true) }
: paragraph(r, true, true);
// not beside a lone display formula: company would cost it its own line (= display)
@@ -473,6 +477,8 @@ export function genDoc(r: Rng): N {
blocks.push(block);
prev = block.type;
}
// The first block opens section 1 as it is; only a later one can start another.
if (blocks[0]?.attrs?.sectionBreak) delete blocks[0].attrs.sectionBreak;
if (notes.length) blocks.push({ type: 'noteSection', content: notes });
return { type: 'doc', content: blocks };
}
+328
View File
@@ -0,0 +1,328 @@
// The export arguments beside the document — page setup, zones and sections, styles,
// notes, decor — drawn per seed, and both importers' readings of them reduced to one
// shape (`expectedOptions`/`importedOptions`) so a round trip compares field by field.
import { builtinStyleSheet, type StyleSheet, type Style, type ParaProps, type TextProps } from '../src/lib/styles/styleSheet';
import { DEFAULT_OUTLINE_LEVEL, MAX_OUTLINE_LEVELS, type OutlineNumbering } from '../src/lib/styles/outlineNumbering';
import { EMPTY_HF_SET, HF_DISTANCE_CM, type HfSet, type HfDoc } from '../src/lib/storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS, type NoteSettings } from '../src/lib/storage/noteSettings';
import { EMPTY_PAGE_DECOR, type PageDecor } from '../src/lib/storage/pageDecor';
import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../src/lib/storage/lineNumbering';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../src/lib/storage/pageNumbering';
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../src/lib/storage/docProperties';
import { LANGUAGES } from '../src/lib/storage/documentLanguage';
import type { PageMargins } from '../src/lib/storage/pageMargins';
import type { PageFormat } from '../src/lib/storage/pageFormat';
import type { Orientation } from '../src/lib/storage/pageOrientation';
import type { SpacingModel } from '../src/lib/storage/spacingModel';
import type { HfExport } from '../src/lib/export/odt';
import type { OdtImportResult } from '../src/lib/import/odt';
import { genDoc, pick, int, maybe, type Rng } from './fuzzDoc';
import { normalize } from './normalize';
type N = any;
export type FuzzOptions = {
margins: PageMargins; orientation: Orientation; hf: HfExport | undefined;
language: { language: string; country: string } | null; pageFormat: PageFormat;
styles: StyleSheet; tabIntervalCm: number; spacingModel: SpacingModel;
notes: NoteSettings; props: DocProperties; hyphenate: boolean; pageNumbering: PageNumbering;
decor: PageDecor; lineNumbering: LineNumbering; recordChanges: boolean; foldMarks: boolean;
spacingAtPageStart: boolean;
// The user styles the document references (plus their parents): the only ones the
// export writes, so the only ones that can come back.
userStyles: string[];
};
// The positional arguments after the document, for buildOdt and buildDocx alike.
export function exportArgs(o: FuzzOptions) {
return [o.margins, o.orientation, o.hf, o.language, o.pageFormat, o.styles, o.tabIntervalCm,
o.spacingModel, false, o.notes, o.props, o.hyphenate, o.pageNumbering, o.decor,
o.lineNumbering, o.recordChanges, o.foldMarks, o.spacingAtPageStart] as const;
}
const STYLE_NAMES = ['Merksatz', 'Fuzz Body', 'Größe & <Stil>'] as const;
// User styles chain parent → child; each style draws its values from its own pool, so
// none equals what its chain resolves to (an equal value reads as inherited and is dropped).
function genStyles(r: Rng): { sheet: StyleSheet; names: string[] } {
const sheet = builtinStyleSheet();
const names = STYLE_NAMES.slice(0, int(r, 0, 3));
names.forEach((name, i) => {
const own = <T,>(pool: readonly [T, T, T]): T => pool[i];
const para: ParaProps = {};
const text: TextProps = {};
if (maybe(r, 0.4)) para.textAlign = own(['justify', 'right', 'center']);
if (maybe(r, 0.4)) para.lineHeight = own(['1.15', '1.5', '2']);
if (maybe(r, 0.4)) para.spaceBefore = own([6, 12, 3]);
if (maybe(r, 0.4)) para.spaceAfter = own([8, 4, 2]);
if (maybe(r, 0.4)) para.indent = own([1.5, 0.75, 2.25]);
if (maybe(r, 0.2)) para.backgroundColor = own(['#EEF3F8', '#FFF2CC', '#E2F0D9']);
if (maybe(r, 0.2)) {
para.borderBottom = own(['1pt solid #FF0000', '0.5pt solid #00B050', '2pt solid #0070C0']);
if (maybe(r, 0.5)) para.borderPadding = own([2, 4, 6]);
}
if (maybe(r, 0.4)) text.fontFamily = own(['Liberation Sans', 'Courier New', 'Liberation Mono']);
if (maybe(r, 0.4)) text.fontSizePt = own([10, 14, 11]);
if (maybe(r, 0.4)) text[own(['bold', 'italic', 'underline'] as const)] = true;
if (maybe(r, 0.4)) text.color = own(['#004080', '#7030A0', '#385723']);
if (maybe(r, 0.2)) text.letterSpacingPt = own([0.5, 1.2, 2]);
const style: Style = { name, parent: i > 0 && maybe(r, 0.4) ? names[i - 1] : 'Standard',
next: maybe(r, 0.3) ? name : 'Standard', para, text };
sheet.paragraph[name] = style;
});
if (maybe(r, 0.25)) {
const depth = int(r, 1, 3);
sheet.outline = Array.from({ length: MAX_OUTLINE_LEVELS }, (_, i) => i < depth
? { format: pick(r, ['1', 'a', 'I'] as const), prefix: maybe(r, 0.3) ? '§' : '',
suffix: pick(r, ['.', ')', '']), displayLevels: int(r, 1, i + 1), start: pick(r, [1, 1, 3]) }
: { ...DEFAULT_OUTLINE_LEVEL });
}
return { sheet, names };
}
const MARGIN_SETS: PageMargins[] = [
{ top: 2, bottom: 2, left: 2, right: 2 },
{ top: 2.5, bottom: 2, left: 3, right: 1.5 },
{ top: 1.5, bottom: 1.5, left: 1.9, right: 1.9 },
];
function zone(r: Rng): HfDoc {
const attrs: N = {};
if (maybe(r, 0.4)) attrs.textAlign = pick(r, ['center', 'right']);
const marks = maybe(r, 0.3) ? [{ type: 'bold' }] : undefined;
const content: N[] = [{ type: 'text', text: pick(r, ['Bericht', 'a & <b>', 'Größe']), ...(marks ? { marks } : {}) }];
if (maybe(r, 0.3)) content.unshift({ type: 'chapterField', attrs: { level: 1, text: 'Kapitel' } }, { type: 'text', text: ' ' });
if (maybe(r, 0.5)) content.push({ type: 'text', text: '\t' }, { type: 'pageNumber' });
if (maybe(r, 0.3)) content.push({ type: 'text', text: ' of ' }, { type: 'pageCount' });
if (maybe(r, 0.2)) content.push({ type: 'hardBreak' }, { type: 'text', text: 'zweite Zeile' });
return { type: 'doc', content: [{ type: 'paragraph', ...(Object.keys(attrs).length ? { attrs } : {}), content }] };
}
// The zones of one set. The first-page and even-page flags need a running zone to
// ride on: the file carries them as elements beside it, nowhere else.
function zones(r: Rng): HfSet {
const s: HfSet = { ...EMPTY_HF_SET };
if (maybe(r, 0.6)) s.header = zone(r);
if (maybe(r, 0.6)) s.footer = zone(r);
const running = !!(s.header || s.footer);
if (running && maybe(r, 0.3)) {
s.differentFirstPage = true;
if (maybe(r, 0.6)) s.headerFirst = zone(r);
if (maybe(r, 0.5)) s.footerFirst = zone(r);
}
if (running && maybe(r, 0.25)) {
s.differentOddEven = true;
if (maybe(r, 0.6)) s.headerEven = zone(r);
if (maybe(r, 0.5)) s.footerEven = zone(r);
}
return s;
}
// A section past the first: its zones plus whatever page setup of its own it takes.
function section(r: Rng): HfSet {
const s = zones(r);
if (maybe(r, 0.2)) s.margins = pick(r, MARGIN_SETS);
if (maybe(r, 0.15)) { s.format = pick(r, ['A5', 'letter']); s.orientation = pick(r, ['portrait', 'landscape']); }
if (maybe(r, 0.2)) s.pageNumberStart = int(r, 1, 9);
if (maybe(r, 0.15)) s.pageNumberFormat = pick(r, ['i', 'I', 'a', 'A']);
if (maybe(r, 0.15)) s.distances = { header: pick(r, [0.6, 1]), footer: pick(r, [0.6, 1]) };
if (maybe(r, 0.15)) s.startsOn = pick(r, ['odd', 'even']);
return s;
}
function genHf(r: Rng, nSections: number): HfExport | undefined {
const top = zones(r);
if (!top.header && !top.footer && nSections === 1) return undefined;
const hf: HfExport = {
header: top.header, footer: top.footer,
headerFirst: top.headerFirst, footerFirst: top.footerFirst, differentFirstPage: top.differentFirstPage,
headerEven: top.headerEven, footerEven: top.footerEven, differentOddEven: top.differentOddEven,
pageCount: nSections + 2,
};
// Distances stay under the smallest margin (the export clamps them there).
if (top.header && maybe(r, 0.4)) hf.headerDistanceCm = pick(r, [0.8, 1]);
if (top.footer && maybe(r, 0.4)) hf.footerDistanceCm = pick(r, [0.8, 1]);
if (nSections > 1) hf.sections = [top, ...Array.from({ length: nSections - 1 }, () => section(r))];
return hf;
}
const HOSTILE = ['Jahresbericht & <Q1>', 'Größe "quoted"', "it's"] as const;
// `carrier`: whether the document holds a paragraph or heading — ODF keeps the first
// page number on the first paragraph, so a document of one empty index has no place for it.
function genOptions(r: Rng, sheet: StyleSheet, nSections: number, carrier: boolean): Omit<FuzzOptions, 'userStyles'> {
const margins = { ...pick(r, MARGIN_SETS), ...(maybe(r, 0.2) ? { mirrored: true } : {}) };
const notes = structuredClone(DEFAULT_NOTE_SETTINGS);
if (maybe(r, 0.3)) {
notes.footnote.numFormat = pick(r, ['1', 'i', 'I', 'a', 'A']);
notes.footnote.startAt = int(r, 1, 5);
notes.footnote.restart = pick(r, ['document', 'page']);
}
if (maybe(r, 0.2)) { notes.endnote.numFormat = pick(r, ['1', 'I', 'a', 'A']); notes.endnote.startAt = int(r, 1, 5); }
if (maybe(r, 0.2)) { notes.footnote.prefix = '('; notes.footnote.suffix = ')'; }
if (maybe(r, 0.2)) notes.separator = { ...notes.separator, relWidthPercent: 60, weightPt: 1.5, align: 'center' };
const decor: PageDecor = { ...EMPTY_PAGE_DECOR };
if (maybe(r, 0.15)) decor.background = pick(r, ['#fffdf5', '#eef3f8']);
if (maybe(r, 0.15)) decor.border = { widthPt: pick(r, [0.5, 1, 2.25]), color: '#0046a0', paddingCm: pick(r, [0.05, 0.2, 0.5]) };
if (maybe(r, 0.15)) {
decor.watermark = { text: pick(r, ['ENTWURF', 'DRAFT & <x>']), font: 'Liberation Sans',
color: '#c0c0c0', angle: pick(r, [0, 45, -30, 135]), transparency: pick(r, [0, 50, 80]) };
}
const lineNumbering: LineNumbering = maybe(r, 0.15)
? { on: true, interval: pick(r, [1, 5, 10]), distanceCm: pick(r, [0.5, 1]),
restart: pick(r, ['continuous', 'page']), countEmpty: maybe(r, 0.5) }
: { ...DEFAULT_LINE_NUMBERING };
return {
margins,
orientation: maybe(r, 0.15) ? 'landscape' : 'portrait',
hf: genHf(r, nSections),
language: pick(r, [LANGUAGES[0].odf, LANGUAGES[1].odf, null]),
pageFormat: maybe(r, 0.3) ? pick(r, ['A5', 'letter', 'legal', 'A3', 'executive', 'isoB5']) : 'A4',
styles: sheet,
tabIntervalCm: pick(r, [1.25, 1.25, 2, 0.5]),
spacingModel: maybe(r, 0.15) ? 'max' : 'add',
notes,
props: maybe(r, 0.3)
? { title: pick(r, HOSTILE), subject: 'Finanzen', author: pick(r, HOSTILE), keywords: 'Bilanz, Prüfung', description: pick(r, HOSTILE) }
: { ...EMPTY_DOC_PROPERTIES },
hyphenate: maybe(r, 0.2),
pageNumbering: maybe(r, 0.2) ? { format: pick(r, ['i', 'I', 'a', 'A']), start: carrier ? int(r, 1, 7) : 1 } : { ...DEFAULT_PAGE_NUMBERING },
decor,
lineNumbering,
recordChanges: maybe(r, 0.1),
foldMarks: maybe(r, 0.1),
spacingAtPageStart: !maybe(r, 0.2),
};
}
function usedStyles(doc: N, sheet: StyleSheet, names: string[]): string[] {
const used = new Set<string>();
for (const block of doc.content ?? []) {
let name: string | undefined = block.attrs?.styleName;
while (name && names.includes(name) && !used.has(name)) {
used.add(name);
name = sheet.paragraph[name]?.parent ?? undefined;
}
}
return names.filter((n) => used.has(n));
}
export function genCase(r: Rng): { doc: N; opts: FuzzOptions } {
const { sheet, names } = genStyles(r);
const doc = genDoc(r, names);
const nSections = 1 + doc.content.filter((b: N) => b.attrs?.sectionBreak).length;
const carrier = doc.content.some((b: N) => b.type === 'paragraph' || b.type === 'heading');
const opts = genOptions(r, sheet, nSections, carrier);
return { doc, opts: { ...opts, userStyles: usedStyles(doc, sheet, names) } };
}
// --- the imported reading and the authored options, in one shape ---
const compact = (o: N): N => Object.fromEntries(Object.entries(o ?? {}).filter(([, v]) => v != null));
const zoneDoc = (d: HfDoc | undefined): N => (d ? normalize(structuredClone(d)) : null);
const marginsOf = (m: PageMargins | null | undefined): N =>
m ? { top: m.top, bottom: m.bottom, left: m.left, right: m.right, ...(m.mirrored ? { mirrored: true } : {}) } : null;
// The document's page setup, which fills whatever a section has none of its own.
type DocSetup = {
margins: PageMargins | null; format: PageFormat | null; orientation: Orientation | null;
numFormat: string; headerDist: number | null; footerDist: number | null;
};
// One section's effective page setup: its zones, and the document's values wherever it
// has none of its own. An importer may elect a later section's master page as the
// document's, so only what a section ends up with is comparable, not where it is kept.
function hfSet(s: Partial<HfSet> | undefined, index: number, doc: DocSetup): N {
const z: HfSet = { ...EMPTY_HF_SET, ...(s ?? {}) };
const out: N = { header: zoneDoc(z.header), footer: zoneDoc(z.footer),
differentFirstPage: !!z.differentFirstPage, differentOddEven: !!z.differentOddEven };
if (out.differentFirstPage) { out.headerFirst = zoneDoc(z.headerFirst); out.footerFirst = zoneDoc(z.footerFirst); }
if (out.differentOddEven) { out.headerEven = zoneDoc(z.headerEven); out.footerEven = zoneDoc(z.footerEven); }
const has = { header: !!(z.header || z.headerFirst || z.headerEven), footer: !!(z.footer || z.footerFirst || z.footerEven) };
// Mirroring is the document's, whichever margins a section has of its own.
const margins = z.margins ?? doc.margins;
Object.assign(out, {
margins: margins && marginsOf({ ...margins, mirrored: !!doc.margins?.mirrored }),
format: z.format ?? doc.format, orientation: z.orientation ?? doc.orientation,
pageNumberFormat: z.pageNumberFormat ?? doc.numFormat,
headerDist: has.header ? z.distances?.header ?? doc.headerDist : null,
footerDist: has.footer ? z.distances?.footer ?? doc.footerDist : null,
pageNumberStart: index > 0 ? z.pageNumberStart ?? null : null,
startsOn: index > 0 ? z.startsOn ?? null : null,
});
return out;
}
const styleOf = (s: Style | undefined): N => (s ? { parent: s.parent, next: s.next, para: compact(s.para), text: compact(s.text) } : null);
// null where nothing is numbered; the resolved label look is import-only.
function outlineOf(o: OutlineNumbering | null | undefined): N {
if (!o || o.every((l) => l.format === 'none')) return null;
return Array.from({ length: MAX_OUTLINE_LEVELS }, (_, i) => {
const { labelText, ...rest } = o[i] ?? DEFAULT_OUTLINE_LEVEL;
return compact(rest);
});
}
export function expectedOptions(o: FuzzOptions): N {
const hf = o.hf;
const doc: DocSetup = { margins: o.margins, format: o.pageFormat, orientation: o.orientation,
numFormat: o.pageNumbering.format,
headerDist: hf?.headerDistanceCm ?? HF_DISTANCE_CM, footerDist: hf?.footerDistanceCm ?? HF_DISTANCE_CM };
const sets: Partial<HfSet>[] = hf?.sections ?? [hf ?? {}];
return {
language: o.language ? LANGUAGES.find((l) => l.odf.language === o.language!.language)!.code : null,
tabIntervalCm: o.tabIntervalCm, spacingModel: o.spacingModel, hyphenate: o.hyphenate,
recordChanges: o.recordChanges, foldMarks: o.foldMarks, spacingAtPageStart: o.spacingAtPageStart,
pageNumberStart: o.pageNumbering.start, decor: o.decor, lineNumbering: o.lineNumbering, props: o.props,
notes: o.notes,
sections: sets.map((s, i) => hfSet(s, i, doc)),
styles: Object.fromEntries(o.userStyles.map((n) => [n, styleOf(o.styles.paragraph[n])])),
outline: outlineOf(o.styles.outline),
};
}
export function importedOptions(res: OdtImportResult, o: FuzzOptions): N {
const doc: DocSetup = { margins: res.margins, format: res.format, orientation: res.orientation,
numFormat: res.pageNumbering.format, headerDist: res.headerDistanceCm, footerDist: res.footerDistanceCm };
return {
language: res.language,
tabIntervalCm: res.tabIntervalCm, spacingModel: res.spacingModel, hyphenate: res.hyphenate,
recordChanges: res.recordChanges, foldMarks: res.foldMarks, spacingAtPageStart: res.spacingAtPageStart,
pageNumberStart: res.pageNumbering.start, decor: res.decor, lineNumbering: res.lineNumbering, props: res.props,
notes: res.notes,
sections: (res.hfSections ?? []).map((s, i) => hfSet(s, i, doc)),
styles: Object.fromEntries(o.userStyles.map((n) => [n, styleOf(res.styles.paragraph[n])])),
outline: outlineOf(res.styles.outline),
};
}
// firstDiff with the slack a unit conversion needs: numbers within 0.02 agree, and a
// missing value equals a null one.
export function diffLoose(a: N, b: N, path = '$'): string | null {
if (a == null && b == null) return null;
if (typeof a === 'number' && typeof b === 'number') return Math.abs(a - b) <= 0.02 ? null : `${path}: ${a} vs ${b}`;
if (typeof a !== typeof b || a == null || b == null) return `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`;
if (typeof a !== 'object') 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 = diffLoose(a[k], b[k], `${path}.${k}`);
if (d) return d;
}
return null;
}
// Drops the paths a format cannot carry (`a.b`, `sections.*.c`) from a canonical reading.
export function omit(canon: N, paths: readonly string[]): N {
const out = structuredClone(canon);
const drop = (node: N, parts: string[]): void => {
if (!node || typeof node !== 'object') return;
const [head, ...rest] = parts;
const keys = head === '*' ? Object.keys(node) : [head];
for (const k of keys) {
if (rest.length) drop(node[k], rest);
else delete node[k];
}
};
for (const p of paths) drop(out, p.split('.'));
return out;
}
+2 -2
View File
@@ -214,8 +214,8 @@ export function kitchenSinkDoc(): N {
export function kitchenSinkSheet(): StyleSheet {
const sheet = builtinStyleSheet();
sheet.paragraph['Merksatz'] = { name: 'Merksatz', parent: 'Standard',
para: { spaceBeforePt: 6, spaceAfterPt: 6, indentCm: 1 },
text: { bold: true, color: '#004080' } } as N;
para: { spaceBefore: 6, spaceAfter: 6, indent: 1 },
text: { bold: true, color: '#004080' } };
sheet.list['Prüfliste'] = { name: 'Prüfliste', levels: [
{ kind: 'number', numType: 'upper-roman-paren', markerAlign: 'right', indentCm: 0.5 },
{ kind: 'bullet', bulletChar: '✓' },
+6 -141
View File
@@ -1,107 +1,11 @@
// Validates the kitchen-sink exports against the vendored OOXML XSDs and the ODF 1.2
// RelaxNG (tests/schemas/) — the structural half of what Word's strict reader enforces
// and LibreOffice silently forgives. Self-skips without xmllint on PATH.
// The kitchen-sink exports (every feature at once) against the format schemas —
// tests/schemaValidate.ts does the work; the fuzz leg runs the same check over seeds.
import { describe, it, expect, beforeAll } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { unzipSync, strFromU8 } from 'fflate';
import { unzipSync } from 'fflate';
import { buildOdt } from '../src/lib/export/odt';
import { buildDocx } from '../src/lib/export/docx';
import { kitchenSinkDoc, kitchenSinkSheet, kitchenSinkOptions } from './kitchenSink';
const hasXmllint = spawnSync('xmllint', ['--version']).status === 0;
const SCHEMAS = resolve(__dirname, 'schemas');
const MC_NS = 'http://schemas.openxmlformats.org/markup-compatibility/2006';
// A DrawingML text box (wps:wsp inside a:graphicData) is post-ECMA-376 Microsoft
// markup Word accepts natively; no ECMA schema covers it, so it is stripped like an
// ignorable — its inner w:p content is exercised by the body paragraphs instead.
const WPS_NS = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape';
// The namespaces the ODF 1.2 schema defines. Anything else (loext, …) is a foreign
// element/attribute an "extended conforming" consumer removes before validating.
const ODF_NS = new Set([
'urn:oasis:names:tc:opendocument:xmlns:office:1.0',
'urn:oasis:names:tc:opendocument:xmlns:style:1.0',
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'urn:oasis:names:tc:opendocument:xmlns:table:1.0',
'urn:oasis:names:tc:opendocument:xmlns:drawing:1.0',
'urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0',
'urn:oasis:names:tc:opendocument:xmlns:meta:1.0',
'urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0',
'urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0',
'urn:oasis:names:tc:opendocument:xmlns:chart:1.0',
'urn:oasis:names:tc:opendocument:xmlns:dr3d:1.0',
'urn:oasis:names:tc:opendocument:xmlns:form:1.0',
'urn:oasis:names:tc:opendocument:xmlns:script:1.0',
'urn:oasis:names:tc:opendocument:xmlns:config:1.0',
'urn:oasis:names:tc:opendocument:xmlns:database:1.0',
'urn:oasis:names:tc:opendocument:xmlns:presentation:1.0',
'urn:oasis:names:tc:opendocument:xmlns:animation:1.0',
'urn:oasis:names:tc:opendocument:xmlns:smil-compatible:1.0',
'urn:oasis:names:tc:opendocument:xmlns:manifest:1.0',
'http://purl.org/dc/elements/1.1/',
'http://www.w3.org/1998/Math/MathML',
'http://www.w3.org/1999/xlink',
'http://www.w3.org/1999/xhtml',
'http://www.w3.org/2002/xforms',
'http://www.w3.org/2003/g/data-view#',
'http://www.w3.org/XML/1998/namespace',
'http://www.w3.org/2000/xmlns/',
]);
// Removes foreign elements and attributes: keep what `keep(ns)` allows, drop the rest.
// For OOXML `keep` is "not declared mc:Ignorable"; for ODF it is the ODF_NS whitelist.
function stripForeign(xml: string, keep: (ns: string | null) => boolean): string {
const doc = new DOMParser().parseFromString(xml, 'application/xml');
const prune = (el: Element): void => {
for (const child of [...el.children]) {
if (child.namespaceURI === MC_NS && child.localName === 'AlternateContent') {
// An ECMA-376-only consumer takes the Fallback branch.
const fb = [...child.children].find(
(c) => c.namespaceURI === MC_NS && c.localName === 'Fallback');
for (const inner of fb ? [...fb.children] : []) el.insertBefore(inner, child);
el.removeChild(child);
continue;
}
if (!keep(child.namespaceURI)) { el.removeChild(child); continue; }
prune(child);
}
for (const attr of [...el.attributes]) {
if (attr.namespaceURI === MC_NS
|| (attr.namespaceURI && !keep(attr.namespaceURI))) el.removeAttributeNode(attr);
}
};
prune(doc.documentElement);
return new XMLSerializer().serializeToString(doc);
}
// The namespace URIs a part's root declares mc:Ignorable — an MCE consumer removes
// their elements and attributes before schema validation, exactly as Word reads them.
function mceIgnorable(xml: string): Set<string> {
const doc = new DOMParser().parseFromString(xml, 'application/xml');
const root = doc.documentElement;
const out = new Set<string>([MC_NS]);
const ig = root.getAttributeNS(MC_NS, 'Ignorable') ?? '';
for (const prefix of ig.trim().split(/\s+/).filter(Boolean)) {
const uri = root.getAttribute(`xmlns:${prefix}`);
if (uri) out.add(uri);
}
out.add(WPS_NS);
return out;
}
let tmp: string;
function validate(schema: string, kind: 'xsd' | 'rng', xml: string, label: string): string | null {
const file = join(tmp, `${label.replace(/[^\w.-]+/g, '_')}.xml`);
writeFileSync(file, xml);
const flag = kind === 'xsd' ? '--schema' : '--relaxng';
const r = spawnSync('xmllint', ['--noout', '--nonet', flag, join(SCHEMAS, schema), file],
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
if (r.status === 0) return null;
return `${label} (${schema}):\n${(r.stderr || r.stdout || `exit ${r.status}`).slice(0, 4000)}`;
}
import { hasXmllint, validateOdt, validateDocx } from './schemaValidate';
describe.skipIf(!hasXmllint)('exported XML validates against the format schemas', () => {
const opts = kitchenSinkOptions();
@@ -109,7 +13,6 @@ describe.skipIf(!hasXmllint)('exported XML validates against the format schemas'
let docx: Record<string, Uint8Array>;
beforeAll(async () => {
tmp = mkdtempSync(join(tmpdir(), 'edentext-schema-'));
const doc = kitchenSinkDoc();
const sheet = kitchenSinkSheet();
odt = unzipSync(await buildOdt(doc, undefined, 'portrait', opts.hf, opts.language, 'A4',
@@ -121,50 +24,12 @@ describe.skipIf(!hasXmllint)('exported XML validates against the format schemas'
});
it('every ODF part is schema-valid (foreign namespaces stripped, as extended conformance allows)', () => {
const failures: string[] = [];
const keepOdf = (ns: string | null) => ns == null || ODF_NS.has(ns);
for (const [name, bytes] of Object.entries(odt)) {
const isDoc = /^(content|styles|meta|settings)\.xml$/.test(name);
const isManifest = name === 'META-INF/manifest.xml';
// Formula sub-documents root math:math, which the schema's start pattern
// excludes; mimetype and Pictures/ are not XML.
if (!isDoc && !isManifest) continue;
const schema = isManifest
? 'odf/OpenDocument-v1.3-manifest-schema.rng' : 'odf/OpenDocument-v1.3-schema.rng';
const fail = validate(schema, 'rng', stripForeign(strFromU8(bytes), keepOdf), `odt ${name}`);
if (fail) failures.push(fail);
}
expect(Object.keys(odt)).toContain('content.xml');
expect(failures.join('\n\n')).toBe('');
expect(validateOdt(odt).join('\n\n')).toBe('');
});
it('every DOCX part is schema-valid after MCE resolution, as Word reads it', () => {
const partSchema = (name: string): string | null => {
if (/^word\/(document|styles|numbering|settings|fontTable|footnotes|endnotes|comments|header\d+|footer\d+)\.xml$/.test(name)) return 'ooxml/wml.xsd';
if (name === 'docProps/core.xml') return 'opc/opc-coreProperties.xsd';
if (name === 'docProps/app.xml') return 'ooxml/shared-documentPropertiesExtended.xsd';
if (name === 'docProps/custom.xml') return 'ooxml/shared-documentPropertiesCustom.xsd';
// commentsExtended is a Microsoft w15 part with no ECMA-376 schema.
if (name === 'word/commentsExtended.xml') return null;
if (name === '[Content_Types].xml') return 'opc/opc-contentTypes.xsd';
if (/(^|\/)_rels\/[^/]+$/.test(name)) return 'opc/opc-relationships.xsd';
if (/^customXml\/itemProps\d+\.xml$/.test(name)) return 'ooxml/shared-customXmlDataProperties.xsd';
if (/^customXml\/item\d+\.xml$/.test(name)) return 'ooxml/shared-bibliography.xsd';
if (/^word\/(media|fonts)\//.test(name)) return null; // binary
return `UNMAPPED:${name}`; // a new part must be mapped or listed here
};
const failures: string[] = [];
for (const [name, bytes] of Object.entries(docx)) {
if (name.endsWith('/')) continue; // zip directory entry
const schema = partSchema(name);
if (schema == null) continue;
if (schema.startsWith('UNMAPPED:')) { failures.push(schema); continue; }
const xml = strFromU8(bytes);
const ignorable = mceIgnorable(xml);
const fail = validate(schema, 'xsd', stripForeign(xml, (ns) => !ns || !ignorable.has(ns)), `docx ${name}`);
if (fail) failures.push(fail);
}
expect(Object.keys(docx)).toContain('word/document.xml');
expect(failures.join('\n\n')).toBe('');
expect(validateDocx(docx).join('\n\n')).toBe('');
});
});
+163
View File
@@ -0,0 +1,163 @@
// Validates an exported package against the vendored schemas (tests/schemas/) through
// xmllint — the ODF 1.3 RelaxNG and the ECMA-376 XSDs, the structural half of what Word's
// strict reader enforces and LibreOffice forgives. Callers self-skip without `hasXmllint`.
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { strFromU8 } from 'fflate';
export const hasXmllint = spawnSync('xmllint', ['--version']).status === 0;
const SCHEMAS = resolve(__dirname, 'schemas');
const MC_NS = 'http://schemas.openxmlformats.org/markup-compatibility/2006';
// A DrawingML text box (wps:wsp inside a:graphicData) is post-ECMA-376 Microsoft
// markup Word accepts natively; no ECMA schema covers it, so it is stripped like an
// ignorable — its inner w:p content is exercised by the body paragraphs instead.
const WPS_NS = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape';
// The namespaces the ODF 1.2 schema defines. Anything else (loext, …) is a foreign
// element/attribute an "extended conforming" consumer removes before validating.
const ODF_NS = new Set([
'urn:oasis:names:tc:opendocument:xmlns:office:1.0',
'urn:oasis:names:tc:opendocument:xmlns:style:1.0',
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'urn:oasis:names:tc:opendocument:xmlns:table:1.0',
'urn:oasis:names:tc:opendocument:xmlns:drawing:1.0',
'urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0',
'urn:oasis:names:tc:opendocument:xmlns:meta:1.0',
'urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0',
'urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0',
'urn:oasis:names:tc:opendocument:xmlns:chart:1.0',
'urn:oasis:names:tc:opendocument:xmlns:dr3d:1.0',
'urn:oasis:names:tc:opendocument:xmlns:form:1.0',
'urn:oasis:names:tc:opendocument:xmlns:script:1.0',
'urn:oasis:names:tc:opendocument:xmlns:config:1.0',
'urn:oasis:names:tc:opendocument:xmlns:database:1.0',
'urn:oasis:names:tc:opendocument:xmlns:presentation:1.0',
'urn:oasis:names:tc:opendocument:xmlns:animation:1.0',
'urn:oasis:names:tc:opendocument:xmlns:smil-compatible:1.0',
'urn:oasis:names:tc:opendocument:xmlns:manifest:1.0',
'http://purl.org/dc/elements/1.1/',
'http://www.w3.org/1998/Math/MathML',
'http://www.w3.org/1999/xlink',
'http://www.w3.org/1999/xhtml',
'http://www.w3.org/2002/xforms',
'http://www.w3.org/2003/g/data-view#',
'http://www.w3.org/XML/1998/namespace',
'http://www.w3.org/2000/xmlns/',
]);
// Removes foreign elements and attributes: keep what `keep(ns)` allows, drop the rest.
// For OOXML `keep` is "not declared mc:Ignorable"; for ODF it is the ODF_NS whitelist.
function stripForeign(xml: string, keep: (ns: string | null) => boolean): string {
const doc = new DOMParser().parseFromString(xml, 'application/xml');
const prune = (el: Element): void => {
for (const child of [...el.children]) {
if (child.namespaceURI === MC_NS && child.localName === 'AlternateContent') {
// An ECMA-376-only consumer takes the Fallback branch.
const fb = [...child.children].find(
(c) => c.namespaceURI === MC_NS && c.localName === 'Fallback');
for (const inner of fb ? [...fb.children] : []) el.insertBefore(inner, child);
el.removeChild(child);
continue;
}
if (!keep(child.namespaceURI)) { el.removeChild(child); continue; }
prune(child);
}
for (const attr of [...el.attributes]) {
if (attr.namespaceURI === MC_NS
|| (attr.namespaceURI && !keep(attr.namespaceURI))) el.removeAttributeNode(attr);
}
};
prune(doc.documentElement);
return new XMLSerializer().serializeToString(doc);
}
// The namespace URIs a part's root declares mc:Ignorable — an MCE consumer removes
// their elements and attributes before schema validation, exactly as Word reads them.
function mceIgnorable(xml: string): Set<string> {
const doc = new DOMParser().parseFromString(xml, 'application/xml');
const root = doc.documentElement;
const out = new Set<string>([MC_NS]);
const ig = root.getAttributeNS(MC_NS, 'Ignorable') ?? '';
for (const prefix of ig.trim().split(/\s+/).filter(Boolean)) {
const uri = root.getAttribute(`xmlns:${prefix}`);
if (uri) out.add(uri);
}
out.add(WPS_NS);
return out;
}
type Part = { label: string; xml: string };
let tmp: string | null = null;
// One xmllint run per schema: parsing the schema is what costs, so every part it
// covers rides the same call. Returns the failures, one entry per schema.
function validate(schema: string, kind: 'xsd' | 'rng', parts: Part[]): string[] {
if (!parts.length) return [];
tmp ??= mkdtempSync(join(tmpdir(), 'edentext-schema-'));
const files = parts.map((p) => {
const file = join(tmp!, `${p.label.replace(/[^\w.-]+/g, '_')}.xml`);
writeFileSync(file, p.xml);
return file;
});
const flag = kind === 'xsd' ? '--schema' : '--relaxng';
const r = spawnSync('xmllint', ['--noout', '--nonet', flag, join(SCHEMAS, schema), ...files],
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
if (r.status === 0) return [];
const out = (r.stderr || r.stdout || `exit ${r.status}`).split('\n')
.filter((line) => !/ validates$/.test(line)).join('\n');
return [`${schema}:\n${out.slice(0, 4000)}`];
}
// Every XML part of an unzipped .odt against the ODF schema (foreign namespaces
// stripped, as extended conformance allows). Formula sub-documents root math:math,
// which the schema's start pattern excludes; mimetype and Pictures/ are not XML.
export function validateOdt(files: Record<string, Uint8Array>): string[] {
const keepOdf = (ns: string | null) => ns == null || ODF_NS.has(ns);
const docs: Part[] = [];
const manifests: Part[] = [];
for (const [name, bytes] of Object.entries(files)) {
const part = { label: `odt ${name}`, xml: stripForeign(strFromU8(bytes), keepOdf) };
if (/^(content|styles|meta|settings)\.xml$/.test(name)) docs.push(part);
else if (name === 'META-INF/manifest.xml') manifests.push(part);
}
return [
...validate('odf/OpenDocument-v1.3-schema.rng', 'rng', docs),
...validate('odf/OpenDocument-v1.3-manifest-schema.rng', 'rng', manifests),
];
}
function docxPartSchema(name: string): string | null {
if (/^word\/(document|styles|numbering|settings|fontTable|footnotes|endnotes|comments|header\d+|footer\d+)\.xml$/.test(name)) return 'ooxml/wml.xsd';
if (name === 'docProps/core.xml') return 'opc/opc-coreProperties.xsd';
if (name === 'docProps/app.xml') return 'ooxml/shared-documentPropertiesExtended.xsd';
if (name === 'docProps/custom.xml') return 'ooxml/shared-documentPropertiesCustom.xsd';
// commentsExtended is a Microsoft w15 part with no ECMA-376 schema.
if (name === 'word/commentsExtended.xml') return null;
if (name === '[Content_Types].xml') return 'opc/opc-contentTypes.xsd';
if (/(^|\/)_rels\/[^/]+$/.test(name)) return 'opc/opc-relationships.xsd';
if (/^customXml\/itemProps\d+\.xml$/.test(name)) return 'ooxml/shared-customXmlDataProperties.xsd';
if (/^customXml\/item\d+\.xml$/.test(name)) return 'ooxml/shared-bibliography.xsd';
if (/^word\/(media|fonts)\//.test(name)) return null; // binary
return `UNMAPPED:${name}`; // a new part must be mapped or listed here
}
// Every part of an unzipped .docx against its schema after MCE resolution, as Word
// reads it.
export function validateDocx(files: Record<string, Uint8Array>): string[] {
const failures: string[] = [];
const bySchema = new Map<string, Part[]>();
for (const [name, bytes] of Object.entries(files)) {
if (name.endsWith('/')) continue; // zip directory entry
const schema = docxPartSchema(name);
if (schema == null) continue;
if (schema.startsWith('UNMAPPED:')) { failures.push(schema); continue; }
const xml = strFromU8(bytes);
const ignorable = mceIgnorable(xml);
const part = { label: `docx ${name}`, xml: stripForeign(xml, (ns) => !ns || !ignorable.has(ns)) };
bySchema.set(schema, [...(bySchema.get(schema) ?? []), part]);
}
for (const [schema, parts] of bySchema) failures.push(...validate(schema, 'xsd', parts));
return failures;
}