feat: table of content

This commit is contained in:
Steffen Becker committed 2026-07-01 21:17:44 +02:00
1 parent 504a202d6b
commit a5bb2deaa2
12 files changed
+624 -10

No files matched your search

+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { zipSync, strToU8 } from 'fflate';
import { zipSync, strToU8, unzipSync, strFromU8 } from 'fflate';
import { buildDocx } from '../src/lib/export/docx';
import { importDocx } from '../src/lib/import/docx';
import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow';
@@ -235,3 +235,34 @@ describe('DOCX import detects headings by outline level (non-"HeadingN" style id
expect(walk(h, 'text')[0].text).toBe('Localised heading');
});
});
describe('DOCX table of contents (TOC field) round trip', () => {
const tocDoc = {
type: 'doc',
content: [
{ type: 'tableOfContents', attrs: { entries: [
{ text: 'Alpha', level: 1, page: 1 },
{ text: 'Beta', level: 2, page: 2 },
] } },
heading(1, 'Alpha'),
para('body of alpha'),
heading(2, 'Beta'),
],
};
it('exports a TOC field (updateFields) and re-imports it as a tableOfContents node', async () => {
const bytes = await buildDocx(tocDoc as any);
const files = unzipSync(bytes);
const docXml = strFromU8(files['word/document.xml']);
expect(/\bTOC\b/.test(docXml)).toBe(true);
const settings = files['word/settings.xml'];
expect(settings ? strFromU8(settings).includes('updateFields') : false).toBe(true);
const res = importDocx(bytes).content as N;
const tocs = walk(res, 'tableOfContents');
expect(tocs.length).toBe(1);
// Headings survive alongside the TOC.
const headings = walk(res, 'heading');
expect(headings.map(h => walk(h, 'text')[0]?.text)).toEqual(['Alpha', 'Beta']);
});
});
+40
View File
@@ -542,3 +542,43 @@ describe('Leg 4: foreign header/footer → importOdt', () => {
fhf.warnings.some(w => /per-page/i.test(w)), fhf.warnings);
});
});
describe('Leg 5: table of contents (text:table-of-content)', () => {
const tocDoc: N = {
type: 'doc',
content: [
{ type: 'tableOfContents', attrs: { entries: [
{ text: 'Introduction', level: 1, page: 1 },
{ text: 'Background & Aims', level: 2, page: 2 },
{ text: 'Deep Dive', level: 3, page: 3 },
] } },
H({ level: 1 }, T('Introduction')),
P(null, T('intro text')),
H({ level: 2 }, T('Background & Aims')),
H({ level: 3 }, T('Deep Dive')),
],
};
it('exports a real <text:table-of-content> and re-imports it as a tableOfContents node', async () => {
const bytes = await buildOdt(tocDoc, margins, 'portrait');
const content = strFromU8(unzipSync(bytes)['content.xml']);
check('emits <text:table-of-content>', content.includes('<text:table-of-content '), content.slice(0, 200));
check('source spans outline-level 3', content.includes('text:outline-level="3"'));
check('mints Contents_20_1 style', content.includes('style:name="Contents_20_1"'));
check('cached entry carries a tab + page', /Contents_20_1">Introduction<text:tab\/>1<\/text:p>/.test(content));
check('ampersand in entry text is escaped', content.includes('Background &amp; Aims'));
check('no leftover sentinel', !content.includes(''));
const res = importOdt(bytes);
const blocks = res.content.content ?? [];
const toc = blocks.find((n: N) => n.type === 'tableOfContents');
check('imports a tableOfContents node', !!toc, blocks.map((n: N) => n.type));
const entries = toc?.attrs?.entries ?? [];
check('3 entries parsed', entries.length === 3, entries);
check('entry 1 text/level', entries[0]?.text === 'Introduction' && entries[0]?.level === 1, entries[0]);
check('entry 2 text/level (ampersand)', entries[1]?.text === 'Background & Aims' && entries[1]?.level === 2, entries[1]);
check('entry 3 level 3', entries[2]?.level === 3, entries[2]);
check('headings after the TOC survive',
blocks.some((n: N) => n.type === 'heading' && n.content?.[0]?.text === 'Introduction'));
});
});