diff --git a/tests/corpus.test.ts b/tests/corpus.test.ts index b7eb2e5..729a929 100644 --- a/tests/corpus.test.ts +++ b/tests/corpus.test.ts @@ -53,16 +53,35 @@ describe.skipIf(!files.length)('the authored corpus', () => { for (const f of files) { it(`${f} imports and survives its own round trip`, async () => { - const doc = importAny(f, load(f)); + const read = readAny(f, load(f)); + const doc = read.content; expect(outline(doc).length).toBeGreaterThan(0); const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + // The file's own stylesheet goes back out with it, as it does when the app saves: + // a style the document names but builtinStyleSheet() has never heard of can only + // be written from there. const again = f.endsWith('.odt') - ? importOdt(await buildOdt(doc, margins, 'portrait')) - : importDocx(await buildDocx(doc, margins, 'portrait')); + ? importOdt(await buildOdt(doc, margins, 'portrait', undefined, undefined, undefined, read.styles)) + : importDocx(await buildDocx(doc, margins, 'portrait', undefined, undefined, undefined, read.styles)); expect(outline(again.content)).toEqual(outline(doc)); }); } + // What the legs above cannot see: both sides of a round trip go through the same + // importer, so a file read wrongly but consistently still compares equal. 15-chapters + // carries chapter numbering on its heading styles and two heading styles of its own — + // read as a list, or as body text, either would round-trip happily. + const CHAPTERS = ['heading1+brk:Introduction', 'heading2:Motivation', + 'heading1+brk:Results', 'heading2:Method', + 'heading1[Appendix 1]+brk:Appendix', 'heading2[Appendix 2]:List of Tables']; + for (const f of files.filter((n) => n.startsWith('15-chapters'))) { + it(`${f} reads its numbered chapters as headings`, () => { + const doc = importAny(f, load(f)); + expect(outline(doc).filter((l) => l.startsWith('heading'))).toEqual(CHAPTERS); + expect(outline(doc).filter((l) => /^(ordered|bullet)List|^listItem/.test(l))).toEqual([]); + }); + } + // Word re-saves of the same documents (tests/corpus/word/): Word rewrites the file // in its own dialect — rsids, proofErr, separator notes, theme docDefaults — which // neither the docx lib nor LibreOffice produce, so this is the third author here. diff --git a/tests/corpus/15-chapters.docx b/tests/corpus/15-chapters.docx new file mode 100644 index 0000000..89441ef Binary files /dev/null and b/tests/corpus/15-chapters.docx differ diff --git a/tests/corpus/15-chapters.odt b/tests/corpus/15-chapters.odt new file mode 100644 index 0000000..ac5c59b Binary files /dev/null and b/tests/corpus/15-chapters.odt differ diff --git a/tests/render-parity/make-fixtures.mjs b/tests/render-parity/make-fixtures.mjs index c903645..6af9ef1 100644 --- a/tests/render-parity/make-fixtures.mjs +++ b/tests/render-parity/make-fixtures.mjs @@ -31,25 +31,35 @@ const para = (text, o = {}) => new Paragraph({ children: [new TextRun({ text, .. // Spacing, font and outline level are declared: LibreOffice fills a built-in style's // gaps from its own defaults where Word uses only what the file declares, and without // the level it converts a heading to a merely carrying the heading's style. -const heading = (id, name, halfPt, before, after, level) => ({ +const heading = (id, name, halfPt, before, after, level, numbering) => ({ id, name, basedOn: 'Normal', next: 'Normal', quickFormat: true, run: { font: 'Arial', size: halfPt, bold: true }, - paragraph: { spacing: { before, after }, outlineLevel: level }, + paragraph: { spacing: { before, after }, outlineLevel: level, ...(numbering ? { numbering } : {}) }, }); -async function write(name, sections, defaultRun = { font: 'Times New Roman', size: 24 }, extra = {}) { +// `chapters` names a numbering the heading styles carry: chapter numbering lives on the +// style in both products, never on the paragraph. `extra.styles` adds the document's own +// styles beside the built-ins rather than replacing the whole block. +async function write(name, sections, defaultRun = { font: 'Times New Roman', size: 24 }, extra = {}, chapters = null) { if (only && !only.test(name)) return; + const { styles: ownStyles, ...rest } = extra; + const h1 = heading('Heading1', 'Heading 1', 36, 240, 120, 0, chapters && { reference: chapters, level: 0 }); + const h2 = heading('Heading2', 'Heading 2', 32, 200, 100, 1, chapters && { reference: chapters, level: 1 }); + // The package writes its own Heading1-6 regardless, so a second definition under the + // same id leaves two — and LibreOffice reads the first, dropping everything declared + // here. A numbered heading therefore rides the factory slot instead (see FACTORY_SLOTS + // in src/lib/export/CLAUDE.md); the plain fixtures keep the shape they were built with. const doc = new Document({ styles: { - default: { document: { run: defaultRun } }, + default: { document: { run: defaultRun }, ...(chapters ? { heading1: h1, heading2: h2 } : {}) }, paragraphStyles: [ // Word always writes an explicit Normal; the docx lib would emit docDefaults only. { id: 'Normal', name: 'Normal', run: defaultRun, paragraph: { spacing: { after: 0 } } }, - heading('Heading1', 'Heading 1', 36, 240, 120, 0), - heading('Heading2', 'Heading 2', 32, 200, 100, 1), + ...(chapters ? [] : [h1, h2]), + ...(ownStyles?.paragraphStyles ?? []), ], }, - ...extra, + ...rest, sections, }); writeFileSync(join(OUT, name), await Packer.toBuffer(doc)); @@ -286,6 +296,49 @@ await write('14-formulas.docx', [{ ], }]); +// 15. Chapter numbering on the heading styles, plus heading styles of the document's +// own ("Appendix 1"/"2", named nothing like "Heading n"). Both live in the stylesheet +// and nowhere in the document tree, so only a leg carrying it exercises them. +await write('15-chapters.docx', [{ + properties: { page }, + children: [ + para('Introduction', { p: { style: 'Heading1', pageBreakBefore: true } }), + para(LOREM), + para('Motivation', { p: { style: 'Heading2' } }), + para(LOREM), + para('Results', { p: { style: 'Heading1', pageBreakBefore: true } }), + para(LOREM), + para('Method', { p: { style: 'Heading2' } }), + para(LOREM), + para('Appendix', { p: { style: 'Appendix1', pageBreakBefore: true } }), + para(LOREM), + para('List of Tables', { p: { style: 'Appendix2' } }), + para(LOREM), + ], +}], undefined, { + styles: { + paragraphStyles: [ + { id: 'Appendix1', name: 'Appendix 1', basedOn: 'Heading1', next: 'Normal', quickFormat: true, + paragraph: { outlineLevel: 0 } }, + { id: 'Appendix2', name: 'Appendix 2', basedOn: 'Heading2', next: 'Normal', quickFormat: true, + paragraph: { outlineLevel: 1 } }, + ], + }, + numbering: { + config: [{ + reference: 'chapter-numbering', + levels: [ + // The level names the style it numbers and the style points back at the level: + // both halves, or LibreOffice reads the pair as an ordinary list. + { level: 0, format: LevelFormat.DECIMAL, text: '%1.', alignment: AlignmentType.START, + style: { paragraph: { indent: { left: 0, hanging: 360 } }, style: 'Heading1' } }, + { level: 1, format: LevelFormat.DECIMAL, text: '%1.%2', alignment: AlignmentType.START, + style: { paragraph: { indent: { left: 0, hanging: 432 } }, style: 'Heading2' } }, + ], + }], + }, +}, 'chapter-numbering'); + // ODT twins, written by LibreOffice itself — the dominant ODT producer, so they carry // its own conventions (percentage font sizes, Text Body, list styles) and exercise the // foreign-document path our own exporter never produces.