From a66ee78ba239e3478b1cacd3dbc01746062a31a7 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 19:25:32 +0200 Subject: [PATCH] test: what a save carries besides the tree, and the kitchen sink read back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corpus legs pass seven of the exporters' nineteen arguments, so the other twelve — note configuration, page decoration, line and page numbering, the header/footer zones, document properties — were never round-tripped from a file. A new unit test sets all of them and reads them back out of both formats. The kitchen sink had only ever been written, never imported; it now makes both round trips and comes back identical. Two fixture repairs it needed: notes in anchor order, and a section break with sections to open. Caught: a document holding no note yet lost its note configuration on .docx. --- src/lib/export/CLAUDE.md | 4 ++ src/lib/export/docx.ts | 5 +- tests/fuzz-roundtrip.test.ts | 14 +--- tests/kitchen-sink-roundtrip.test.ts | 56 ++++++++++++++++ tests/kitchenSink.ts | 13 +++- tests/normalize.ts | 12 ++++ tests/unit/document-settings.test.ts | 96 ++++++++++++++++++++++++++++ 7 files changed, 183 insertions(+), 17 deletions(-) create mode 100644 tests/kitchen-sink-roundtrip.test.ts create mode 100644 tests/unit/document-settings.test.ts diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 07004e7..9b6339d 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -67,6 +67,10 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow text alike; only a side wrap has its side dictated by the wrap itself. - **An index row's tab stop is written even with no page number running to it** — it is where the row's leader is kept, and a stop no tab reaches draws nothing. +- **The note configuration is written whether or not a note exists** (`applyNotePrDocx`, + outside the `docNoteIds.size` gate, as Word keeps its own in settings.xml): a document + numbering its first footnote from 3 must still say so. + - **A note writes the paragraph style it names** (`noteStyleName`, and `usedStyleNames` defines it): the stock `FootnoteText` is 10pt, so a note whose file gives it the body size would shrink. See `docs/architecture/notes.md`. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 672478e..1f90a3b 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -2954,7 +2954,10 @@ export async function buildDocx( const linked = applyOutlineNumberingDocx(applyListStylesDocx(styled, num.styleLinks()), outlineIndex); const packed = applyFormulasDocx(applyTextBoxesDocx(linked, docTextBoxes), docFormulas); const cited = applyBibliographyDocx(applyPlaceholdersDocx(applyRubyDocx(packed, docRubies), docPlaceholders), docSources, docCitationStyle(docJson)); - const withNotes = docNoteIds.size ? applyNoteMarksDocx(applyNotePrDocx(cited, notesSettings)) : cited; + // The note configuration goes out whether or not a note exists yet, as Word keeps its + // own in settings.xml — a document numbering its first footnote from 3 must still say so. + const withNotePr = applyNotePrDocx(cited, notesSettings); + const withNotes = docNoteIds.size ? applyNoteMarksDocx(withNotePr) : withNotePr; const threaded = applyCommentsExtendedDocx(withNotes); const mirrored = margins.mirrored ? applyMirrorMarginsDocx(threaded) : threaded; const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored); diff --git a/tests/fuzz-roundtrip.test.ts b/tests/fuzz-roundtrip.test.ts index 72e16f6..9588318 100644 --- a/tests/fuzz-roundtrip.test.ts +++ b/tests/fuzz-roundtrip.test.ts @@ -5,24 +5,12 @@ 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 } from './normalize'; +import { normalize, firstDiff, stripFontHoist } from './normalize'; import { genDoc, mulberry32 } from './fuzzDoc'; 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 -// A paragraph whose runs share one font legitimately comes back with that font also -// on its attrs (the empty-line-height feature hoists it); ignore it on both sides. -function stripFontHoist(node: any): any { - if (node.content?.length && node.attrs) { - const { fontSize, fontFamily, ...rest } = node.attrs; - node.attrs = Object.keys(rest).length ? rest : undefined; - if (!node.attrs) delete node.attrs; - } - for (const c of node.content ?? []) stripFontHoist(c); - return node; -} - describe('fuzz round-trip: editor → buildOdt → importOdt', () => { it(`${SEEDS} seeded random documents come back identical`, async () => { for (let seed = 1; seed <= SEEDS; seed++) { diff --git a/tests/kitchen-sink-roundtrip.test.ts b/tests/kitchen-sink-roundtrip.test.ts new file mode 100644 index 0000000..4160c30 --- /dev/null +++ b/tests/kitchen-sink-roundtrip.test.ts @@ -0,0 +1,56 @@ +// The one document carrying every feature at once (kitchenSink.ts) had only ever been +// written — schema-checked and rendered, never read back. Here it makes the trip. +import { describe, it, expect } from 'vitest'; +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 { kitchenSinkDoc, kitchenSinkSheet, kitchenSinkOptions } from './kitchenSink'; + +type N = any; + +// A floating frame is placed by its wrap, not by where its anchor sits among the runs, +// so either format may hand it back at the head of the paragraph. +const floats = (c: N) => (c.type === 'image' || c.type === 'textBox') && c.attrs?.wrap && c.attrs.wrap !== 'inline'; +function hoistFloats(node: N): N { + if (node.content?.some(floats)) node.content = [...node.content.filter(floats), ...node.content.filter((c: N) => !floats(c))]; + for (const c of node.content ?? []) hoistFloats(c); + return node; +} + +// ODF leaves a default column width implicit where DOCX always writes w:tcW. +function stripColwidth(node: N): N { + if (node.attrs?.colwidth) { + delete node.attrs.colwidth; + if (!Object.keys(node.attrs).length) delete node.attrs; + } + for (const c of node.content ?? []) stripColwidth(c); + return node; +} + +// ODF has no section that leaves the page: naming a master page is a break (probed), +// so a section start always reads back as one. +function stripSectionBreak(node: N): N { + if (node.attrs?.sectionBreak) delete node.attrs.breakBefore; + for (const c of node.content ?? []) stripSectionBreak(c); + return node; +} + +const clean = (n: N) => stripSectionBreak(hoistFloats(stripColwidth(stripFontHoist(normalize(structuredClone(n)))))); + +const o = kitchenSinkOptions(); +const args = [{ top: 2, bottom: 2, left: 2, right: 2 }, 'portrait', o.hf, o.language, 'A4', + kitchenSinkSheet(), 1.25, 'add', false, o.notesSettings, o.props, false, o.pageNumbering, + o.decor, o.lineNumbering, false, false, true] as const; + +describe.each([['ODT', buildOdt, importOdt], ['DOCX', buildDocx, importDocx]] as const)( + 'the kitchen sink survives a %s round trip', (_name, build, read) => { + it('comes back identical, with no warnings', async () => { + const doc = kitchenSinkDoc() as N; + const res = read(await (build as N)(doc, ...args)) as N; + expect(res.warnings ?? []).toEqual([]); + expect(firstDiff(clean(doc), clean(res.content))).toBeNull(); + }); + }, +); diff --git a/tests/kitchenSink.ts b/tests/kitchenSink.ts index b8b75a4..5d121a8 100644 --- a/tests/kitchenSink.ts +++ b/tests/kitchenSink.ts @@ -191,6 +191,8 @@ export function kitchenSinkDoc(): N { p([t('Und noch ein Absatz im Spaltensatz.')]), ] }, p([t('Nach einem Seitenumbruch.')], { breakBefore: 'page' }), + // ODF has no section that does not also break the page: naming a master page is + // one, so a section start carries both here. p([t('Nach einem Abschnittswechsel.')], { sectionBreak: true }), h(2, 'Unterkapitel'), h(3, 'Tiefer'), @@ -198,10 +200,10 @@ export function kitchenSinkDoc(): N { { type: 'noteSection', content: [ { type: 'note', attrs: { id: 'f1', kind: 'footnote', label: null, text: '1' }, content: [t('Der Fußnotentext.')] }, - { type: 'note', attrs: { id: 'f2', kind: 'footnote', label: '*', text: '*' }, - content: [t('Die Sternnote.')] }, { type: 'note', attrs: { id: 'e1', kind: 'endnote', label: null, text: 'i' }, content: [t('Der Endnotentext.')] }, + { type: 'note', attrs: { id: 'f2', kind: 'footnote', label: '*', text: '*' }, + content: [t('Die Sternnote.')] }, ] }, ] }; } @@ -234,7 +236,12 @@ export function kitchenSinkOptions(): { hf: { header: hfDoc('header'), footer: hfDoc('footer'), headerFirst: null, footerFirst: null, differentFirstPage: true, headerEven: null, footerEven: null, differentOddEven: false, - sections: [], pageCount: 3, headerDistanceCm: 1, footerDistanceCm: 1 }, + // The body's `sectionBreak` opens the second set: without one it switches to nothing. + sections: [ + { header: hfDoc('header'), footer: hfDoc('footer'), differentFirstPage: true }, + { header: hfDoc('header'), footer: null }, + ], + pageCount: 3, headerDistanceCm: 1, footerDistanceCm: 1 }, language: { language: 'de', country: 'DE' }, notesSettings: (() => { const s = structuredClone(DEFAULT_NOTE_SETTINGS); diff --git a/tests/normalize.ts b/tests/normalize.ts index 80df911..df47a4e 100644 --- a/tests/normalize.ts +++ b/tests/normalize.ts @@ -119,3 +119,15 @@ export function firstDiff(a: N, b: N, path = '$'): string | null { } return null; } + +// A paragraph whose runs share one font legitimately comes back with that font also +// on its attrs (the empty-line-height feature hoists it); ignore it on both sides. +export function stripFontHoist(node: any): any { + if (node.content?.length && node.attrs) { + const { fontSize, fontFamily, ...rest } = node.attrs; + node.attrs = Object.keys(rest).length ? rest : undefined; + if (!node.attrs) delete node.attrs; + } + for (const c of node.content ?? []) stripFontHoist(c); + return node; +} diff --git a/tests/unit/document-settings.test.ts b/tests/unit/document-settings.test.ts new file mode 100644 index 0000000..3d443a5 --- /dev/null +++ b/tests/unit/document-settings.test.ts @@ -0,0 +1,96 @@ +// Everything a save carries besides the document tree: the app hands the exporters +// nineteen arguments, and the corpus legs pass seven. This is the other twelve — set to +// non-default values and read back out of both formats. +import { describe, it, expect } from 'vitest'; +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 { builtinStyleSheet } from '../../src/lib/styles/styleSheet'; +import { DEFAULT_NOTE_SETTINGS } from '../../src/lib/storage/noteSettings'; + +type N = any; + +const zone = (t: string) => ({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: t }] }] }); +const doc: N = { type: 'doc', content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'body' }, { type: 'noteRef', attrs: { id: 'n1', kind: 'footnote', text: '1' } }] }, + { type: 'noteSection', content: [{ type: 'note', attrs: { id: 'n1', kind: 'footnote', label: null, text: '1' }, content: [{ type: 'text', text: 'the note' }] }] }, +] }; + +const hf: N = { + header: zone('HEAD'), footer: zone('FOOT'), pageCount: 1, + headerFirst: zone('FIRSTHEAD'), footerFirst: zone('FIRSTFOOT'), differentFirstPage: true, + headerEven: zone('EVENHEAD'), footerEven: zone('EVENFOOT'), differentOddEven: true, + headerDistanceCm: 1.4, footerDistanceCm: 1.6, + sections: [{ + header: zone('HEAD'), footer: zone('FOOT'), differentFirstPage: true, + headerFirst: zone('FIRSTHEAD'), footerFirst: zone('FIRSTFOOT'), differentOddEven: true, + headerEven: zone('EVENHEAD'), footerEven: zone('EVENFOOT'), + }], +}; +const margins = { top: 3, bottom: 3.5, left: 2.5, right: 1.5 }; +const notes = { ...DEFAULT_NOTE_SETTINGS, footnote: { ...DEFAULT_NOTE_SETTINGS.footnote, numFormat: 'a' as const, startAt: 3, restart: 'page' as const } }; +const props = { title: 'T', subject: 'S', author: 'A', keywords: 'K', description: 'D' }; +const decor = { background: '#eeddcc', border: { widthPt: 1.5, color: '#123456', paddingCm: 0.3 }, + watermark: { text: 'DRAFT', font: 'Liberation Sans', color: '#c0c0c0', angle: 30, transparency: 40 } }; +const lineNumbering = { on: true, interval: 3, distanceCm: 0.8, restart: 'page' as const, countEmpty: true }; + +const args = [margins, 'landscape', hf, { language: 'de', country: 'DE' }, 'letter', builtinStyleSheet(), + 1.11, 'max', true, notes, props, true, { format: 'i' as const, start: 7 }, decor, lineNumbering, + true, true, false] as const; + +// A zone's text alone: the upper-case letters of its serialized tree. +const zoneText = (z: N) => (z ? JSON.stringify(z).replace(/[^A-Z]/g, '') : null); + +describe.each([['ODT', buildOdt, importOdt], ['DOCX', buildDocx, importDocx]] as const)( + 'a %s save carries the whole document, not only its tree', (name, build, read) => { + const roundTrip = async () => read(await (build as N)(doc, ...args)) as N; + + it('keeps the page geometry', async () => { + const r = await roundTrip(); + expect(r.margins).toEqual(margins); + expect([r.orientation, r.format, r.rtl]).toEqual(['landscape', 'letter', true]); + }); + + it('keeps every header and footer zone', async () => { + const r = await roundTrip(); + expect([zoneText(r.header), zoneText(r.footer)]).toEqual(['HEAD', 'FOOT']); + expect([zoneText(r.headerFirst), zoneText(r.footerFirst)]).toEqual(['FIRSTHEAD', 'FIRSTFOOT']); + expect([zoneText(r.headerEven), zoneText(r.footerEven)]).toEqual(['EVENHEAD', 'EVENFOOT']); + expect([r.differentFirstPage, r.differentOddEven]).toEqual([true, true]); + expect([r.headerDistanceCm, r.footerDistanceCm]).toEqual([1.4, 1.6]); + }); + + it('keeps the flow settings', async () => { + const r = await roundTrip(); + expect(r.tabIntervalCm).toBe(1.11); + expect([r.spacingModel, r.spacingAtPageStart]).toEqual(['max', false]); + expect([r.hyphenate, r.recordChanges, r.foldMarks]).toEqual([true, true, true]); + expect(r.pageNumbering).toEqual({ format: 'i', start: 7 }); + expect(r.lineNumbering).toEqual(lineNumbering); + }); + + it('keeps the page decoration', async () => { + const r = await roundTrip(); + expect(r.decor.background).toBe('#eeddcc'); + expect(r.decor.watermark).toEqual(decor.watermark); + expect(r.decor.border).toMatchObject({ widthPt: 1.5, color: '#123456' }); + // Word's w:pgBorders/@w:space counts whole points, so the padding lands nearby. + expect(r.decor.border.paddingCm).toBeCloseTo(0.3, 1); + }); + + it('keeps the note configuration and the document properties', async () => { + const r = await roundTrip(); + expect(r.notes.footnote).toMatchObject({ numFormat: 'a', startAt: 3, restart: 'page' }); + expect(r.props).toEqual(props); + }); + + // The configuration is the document's, not the notes' — set before the first note + // exists, it still has to survive the save. + it('keeps the note configuration of a document holding no note', async () => { + const empty: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] }; + const r = read(await (build as N)(empty, ...args)) as N; + expect(r.notes.footnote).toMatchObject({ numFormat: 'a', startAt: 3, restart: 'page' }); + }); + }, +);