test: what a save carries besides the tree, and the kitchen sink read back
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.
This commit is contained in:
1 parent
759759a997
commit
a66ee78ba2
7 files changed
+183
-17
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
+10
-3
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user