diff --git a/CLAUDE.md b/CLAUDE.md index 4b41d8d..40250a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/tests/fuzz-roundtrip.test.ts b/tests/fuzz-roundtrip.test.ts index 9588318..14c20f7 100644 --- a/tests/fuzz-roundtrip.test.ts +++ b/tests/fuzz-roundtrip.test.ts @@ -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); +}); diff --git a/tests/fuzzDoc.ts b/tests/fuzzDoc.ts index bd2f308..16d4905 100644 --- a/tests/fuzzDoc.ts +++ b/tests/fuzzDoc.ts @@ -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 = (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 = (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', '', '"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 }; } diff --git a/tests/fuzzOptions.ts b/tests/fuzzOptions.ts new file mode 100644 index 0000000..55c7f5a --- /dev/null +++ b/tests/fuzzOptions.ts @@ -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 & '] 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 = (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 & ', '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 & ', '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 { + 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 & ']), 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(); + 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 | 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[] = 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; +} diff --git a/tests/kitchenSink.ts b/tests/kitchenSink.ts index db46fa5..b1efbbb 100644 --- a/tests/kitchenSink.ts +++ b/tests/kitchenSink.ts @@ -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: '✓' }, diff --git a/tests/schema-validation.test.ts b/tests/schema-validation.test.ts index 043e141..c8d786d 100644 --- a/tests/schema-validation.test.ts +++ b/tests/schema-validation.test.ts @@ -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 { - const doc = new DOMParser().parseFromString(xml, 'application/xml'); - const root = doc.documentElement; - const out = new Set([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; 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(''); }); }); diff --git a/tests/schemaValidate.ts b/tests/schemaValidate.ts new file mode 100644 index 0000000..f7ee9bc --- /dev/null +++ b/tests/schemaValidate.ts @@ -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 { + const doc = new DOMParser().parseFromString(xml, 'application/xml'); + const root = doc.documentElement; + const out = new Set([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[] { + 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[] { + const failures: string[] = []; + const bySchema = new Map(); + 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; +}