Files
edentext/tests/unit/outline-numbering.test.ts
Steffen Becker 7b43ee71ad fix: sections, zones, styles and page numbering survive the round trip in both formats
Found with the showcase documents and by fuzzing the export options through
the round trip and the schemas. ODT: a style's line spacing as a percentage,
section masters laid out from their own zones, variants in schema order, the
watermark on section pages, NCName-encoded style names. DOCX: a section begins
a page, odd/even and w:fmt document-wide, styles with line spacing, shading and
borders, w:numPr and w:pBdr in order. Both importers: the next style, page
setup and text width per section. Editor: chapter counters via counter-set,
a heading in columns stays with its text.
2026-09-06 19:07:14 +02:00

141 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { zipSync, strToU8, unzipSync, strFromU8 } from 'fflate';
import { importOdt } from '../../src/lib/import/odt';
import { importDocx } from '../../src/lib/import/docx';
import { buildOdt } from '../../src/lib/export/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { builtinStyleSheet, styleCss } from '../../src/lib/styles/styleSheet';
import { outlineCss, outlineLabel, type OutlineNumbering } from '../../src/lib/styles/outlineNumbering';
import { formatOrdinal } from '../../src/lib/utils/orderedListTypes';
const NS =
'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" ' +
'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" ' +
'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" ' +
'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"';
// Level 1 "1. ", level 2 "1.1 " — LibreOffice's own Tools ▸ Heading Numbering shape.
const outline: OutlineNumbering = [
{ format: '1', prefix: '', suffix: '. ', displayLevels: 1, start: 1 },
{ format: '1', prefix: '', suffix: ' ', displayLevels: 2, start: 1 },
{ format: 'A', prefix: '', suffix: ') ', displayLevels: 1, start: 2 },
];
function odtWithOutline(): Uint8Array {
const styles = `<?xml version="1.0"?><office:document-styles ${NS}><office:styles>
<style:style style:name="Standard" style:family="paragraph"/>
<text:outline-style style:name="Outline">
<text:outline-level-style text:level="1" style:num-suffix=". " style:num-format="1"/>
<text:outline-level-style text:level="2" style:num-suffix=" " style:num-format="1" text:display-levels="2"/>
<text:outline-level-style text:level="3" style:num-suffix=") " style:num-format="A" text:start-value="2"/>
</text:outline-style>
</office:styles></office:document-styles>`;
const content = `<?xml version="1.0"?><office:document-content ${NS}><office:body><office:text>
<text:h text:outline-level="1" text:style-name="Standard">One</text:h>
</office:text></office:body></office:document-content>`;
return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) });
}
// The shape LibreOffice writes for a chapter number of its own size: a character style
// on the level, and the label hung out of the paragraph's indent.
function odtWithGeometry(): Uint8Array {
const styles = `<?xml version="1.0"?><office:document-styles ${NS}><office:styles>
<style:style style:name="Standard" style:family="paragraph"/>
<style:style style:name="Chapter_20_Number" style:display-name="Chapter Number" style:family="text">
<style:text-properties fo:color="#B2B2B2" fo:font-size="96pt"/></style:style>
<text:outline-style style:name="Outline">
<text:outline-level-style text:level="1" text:style-name="Chapter_20_Number" style:num-suffix=". " style:num-format="1">
<style:list-level-properties text:list-level-position-and-space-mode="label-alignment">
<style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="7.62mm"
fo:text-indent="-7.62mm" fo:margin-left="7.62mm"/>
</style:list-level-properties></text:outline-level-style>
</text:outline-style>
</office:styles></office:document-styles>`;
const content = `<?xml version="1.0"?><office:document-content ${NS}><office:body><office:text>
<text:h text:outline-level="1" text:style-name="Standard">One</text:h>
</office:text></office:body></office:document-content>`;
return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) });
}
const headingDoc = {
type: 'doc',
content: [1, 2, 3].map((level) => ({
type: 'heading', attrs: { level }, content: [{ type: 'text', text: `H${level}` }],
})),
};
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
describe('chapter numbering', () => {
it('reads text:outline-style, level 1 first', () => {
expect(importOdt(odtWithOutline()).styles?.outline).toEqual(outline);
});
it('round-trips through ODT', async () => {
const sheet = { ...builtinStyleSheet(), outline };
const bytes = await buildOdt(headingDoc as never, margins, 'portrait', undefined, null, 'A4', sheet);
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
expect(styles).toContain('<text:outline-style');
expect(importOdt(bytes).styles?.outline).toEqual(outline);
});
it('round-trips through DOCX as a numbering the heading styles carry', async () => {
const sheet = { ...builtinStyleSheet(), outline };
const bytes = await buildDocx(headingDoc as never, margins, 'portrait', undefined, null, 'A4', sheet);
const files = unzipSync(bytes);
expect(strFromU8(files['word/numbering.xml'])).toContain('<w:pStyle w:val="Heading1"/>');
expect(strFromU8(files['word/styles.xml'])).toMatch(/w:styleId="Heading2"[\s\S]*?<w:numPr><w:ilvl w:val="1"\/>/);
// Word carries no prefix/suffix of its own — they live in the level text, so what
// comes back is the same label, split the same way.
expect(importDocx(bytes).styles?.outline).toEqual(outline);
});
it('labels a level from the counts in force', () => {
expect(outlineLabel(outline, 1, [3], formatOrdinal)).toBe('3. ');
expect(outlineLabel(outline, 2, [3, 4], formatOrdinal)).toBe('3.4 ');
expect(outlineLabel(outline, 3, [3, 4, 2], formatOrdinal)).toBe('B) ');
});
it('draws the label with counters that reset down the levels', () => {
const css = outlineCss(outline);
expect(css).toContain('counter-increment: edt-outline-1');
// Set, not reset: a reset on a later chapter would open a counter its sections skip.
expect(css).toContain('counter-set: edt-outline-2 0 edt-outline-3 1');
expect(css).toMatch(/h2[^{\n]*::before \{\n {2}content: counter\(edt-outline-1, decimal\) "\." counter\(edt-outline-2, decimal\) " ";/);
// A heading in a cell or a list item is not part of the chapter count.
expect(css).toContain(':not(:is(td, th, li, .frame-node) *)');
});
it('reads the label position and the style it is set in', () => {
const level = importOdt(odtWithGeometry()).styles?.outline?.[0];
expect(level?.indentCm).toBeCloseTo(0.762, 3);
expect(level?.firstIndentCm).toBeCloseTo(-0.762, 3);
expect(level?.tabCm).toBeCloseTo(0.762, 3);
expect(level?.charStyle).toBe('Chapter Number');
expect(level?.labelText).toMatchObject({ fontSizePt: 96, color: '#B2B2B2' });
});
it('hangs the label out of the indent, in the size the file gives it', () => {
const sheet = importOdt(odtWithGeometry()).styles!;
const css = styleCss(sheet);
expect(css).toMatch(/h1[^{]*\{\n[^}]*margin-left: calc\(var\(--sec-inset-left, 0px\) \+ 0\.762cm\)/);
expect(css).toMatch(/h1[^{]*\{\n[^}]*text-indent: -0\.762cm/);
// The stop the label's tab runs to is its minimum width; a wider label overruns it.
expect(css).toMatch(/h1[^{]*::before \{\n[^}]*min-width: 0\.762cm/);
expect(css).toMatch(/h1[^{]*::before \{\n[^}]*font-size: 96pt/);
});
it('carries the label position through both formats', async () => {
const sheet = { ...builtinStyleSheet(), outline: importOdt(odtWithGeometry()).styles!.outline };
const odt = await buildOdt(headingDoc as never, margins, 'portrait', undefined, null, 'A4', sheet);
expect(strFromU8(unzipSync(odt)['styles.xml'])).toContain('text:list-tab-stop-position="0.762cm"');
expect(importOdt(odt).styles?.outline?.[0]).toMatchObject({ indentCm: 0.762, firstIndentCm: -0.762, tabCm: 0.762 });
const docx = await buildDocx(headingDoc as never, margins, 'portrait', undefined, null, 'A4', sheet);
const back = importDocx(docx).styles?.outline?.[0];
// Word's twips round the same tenth of a millimetre both ways.
expect(back?.indentCm).toBeCloseTo(0.762, 2);
expect(back?.firstIndentCm).toBeCloseTo(-0.762, 2);
expect(back?.labelText).toMatchObject({ fontSizePt: 96, color: '#B2B2B2' });
});
});