test: the corpus gains a document whose chapters are numbered

15-chapters carries what only a stylesheet holds: numbering on the heading
styles and two heading styles of the document's own. The round-trip legs now
carry that stylesheet too — a style the file names but the built-ins never
heard of can only be written from there — and one absolute assertion says the
chapters read as headings, which a round trip cannot: both its sides go
through the same importer, so a consistent misreading compares equal.

A numbered heading style rides the package's factory slot; a second definition
under the same id leaves two, and LibreOffice reads the first.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:11 +02:00
1 parent e12a564f52
commit 8b006d22a8
4 files changed
+82 -10

No files matched your search

+22 -3
View File
@@ -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.
Binary file not shown.
Binary file not shown.
+60 -7
View File
@@ -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 <text:p> 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.