feat: table of content
This commit is contained in:
1 parent
504a202d6b
commit
a5bb2deaa2
12 files changed
+624
-10
No files matched your search
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 & 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'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user