diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 850fdbb..5735758 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -30,7 +30,7 @@ Five topics are large enough to have their own deep-dive — read the file befor - **`bookmark.ts`** / **`crossReference.ts`** — a named range of text (a mark) and the live references to it (`REF`/`PAGEREF`). `ToolbarExpanded.svelte` owns both dialogs, as it does the link one. - **`searchReplace.ts`** — Find & Replace: decorations highlight all matches, commands navigate/replace. Touches the document only for actual replacements. - **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu. -- **`tableOfContents.ts`** — a generated TOC: block atom listing every heading (levels 1–5). +- **`tableOfContents.ts`** — a generated TOC: block atom listing every heading down to `maxLevel` (ODF `text:outline-level`, Word's `TOC \o` range, default 5 — listing deeper than the file asks inflates the block by pages). `title: ''` is an index that deliberately has none (its heading is a separate paragraph, as Word and LibreOffice write it); only a missing title falls back to "Table of Contents". - **`trailingNode.ts`** — keeps an empty paragraph after a trailing table, text box or columns section (they are isolating, so the caret would otherwise have nowhere to go). - **`headerFooter.ts`** (`hfExtensions`) — the header/footer schema: `Document` constrained to one paragraph, the body's marks, `HardBreak`, `TextAlign`, and the two `pageField.ts` atoms. No image, no date field, no table. - **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`, rendered as ``; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value. diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index 22a42dc..01d0a24 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -11,7 +11,8 @@ import { readVerticalMargins, pageOfElement } from './pageBreaks'; export type TocEntry = { text: string; level: number; page: number }; // The heading above the entries. An imported TOC keeps the one its file used -// ("Inhalt", "Sommaire", …); a freshly inserted one takes this. +// ("Inhalt", "Sommaire", …), or none at all where the file put its heading in a +// separate paragraph (`''`); a freshly inserted one takes this. const TITLE = 'Table of Contents'; // Enough leader dots to cross the widest gap a page can offer; the row clips the rest. const LEADER_DOTS = 200; @@ -35,8 +36,15 @@ export const TableOfContents = Node.create({ return { title: { default: null as string | null, - parseHTML: el => (el as HTMLElement).getAttribute('data-toc-title') || null, - renderHTML: attrs => (attrs.title ? { 'data-toc-title': String(attrs.title) } : {}), + parseHTML: el => (el as HTMLElement).getAttribute('data-toc-title'), + renderHTML: attrs => (attrs.title != null ? { 'data-toc-title': String(attrs.title) } : {}), + }, + // Deepest heading level the index lists (ODF text:outline-level, Word's TOC \o + // range): listing more than the file asks for inflates the block by pages. + maxLevel: { + default: MAX_HEADING_LEVEL, + parseHTML: el => Number((el as HTMLElement).getAttribute('data-toc-levels')) || MAX_HEADING_LEVEL, + renderHTML: attrs => ({ 'data-toc-levels': String(attrs.maxLevel ?? MAX_HEADING_LEVEL) }), }, entries: { default: [] as TocEntry[], @@ -71,7 +79,8 @@ export const TableOfContents = Node.create({ }, }); -const tocTitle = (title: unknown): string => (typeof title === 'string' && title ? title : TITLE); +// `''` is a title the file deliberately doesn't have, so only a missing one defaults. +const tocTitle = (title: unknown): string => (typeof title === 'string' ? title : TITLE); type HeadingRef = { text: string; level: number; pos: number }; @@ -113,13 +122,15 @@ class TocView { }); } - // Every heading (level ≤ MAX_HEADING_LEVEL) with non-empty text, in document order. + // Every heading with non-empty text down to the index's own level, in document order. private headings(): HeadingRef[] { + const max = Math.min(MAX_HEADING_LEVEL, Number(this.node()?.attrs?.maxLevel) || MAX_HEADING_LEVEL); const out: HeadingRef[] = []; this.editor.state.doc.descendants((node, pos) => { if (node.type.name === 'heading') { const text = node.textContent.trim(); - if (text) out.push({ text, level: Math.min(MAX_HEADING_LEVEL, (node.attrs.level as number) ?? 1), pos }); + const level = Math.min(MAX_HEADING_LEVEL, (node.attrs.level as number) ?? 1); + if (text && level <= max) out.push({ text, level, pos }); } }); return out; @@ -149,10 +160,13 @@ class TocView { private paint(entries: TocEntry[], heads: HeadingRef[]): void { this.dom.textContent = ''; - const title = document.createElement('div'); - title.className = 'toc-title'; - title.textContent = tocTitle(this.node()?.attrs?.title); - this.dom.appendChild(title); + const titleText = tocTitle(this.node()?.attrs?.title); + if (titleText) { + const title = document.createElement('div'); + title.className = 'toc-title'; + title.textContent = titleText; + this.dom.appendChild(title); + } if (entries.length === 0) { const empty = document.createElement('div'); diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 4e797c9..8f7f8b3 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -984,12 +984,16 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num textBoxes.push(textBoxDocxDescriptor(node)); out.push(new Paragraph({ children: [new TextRun({ text: `${TBX}${i}${TBX}` })] })); } else if (node.type === 'tableOfContents') { - // A real, recognized TOC field (levels 1–3, hyperlinked); Word/LibreOffice populate - // + link it on field update (features.updateFields does this on open). Title is a - // plain bold paragraph so it isn't itself listed; our importer regenerates the node. - const tocTitle = typeof node.attrs?.title === 'string' && node.attrs.title ? node.attrs.title : 'Table of Contents'; - out.push(new Paragraph({ children: [new TextRun({ text: tocTitle, bold: true, size: 32 })], spacing: { after: cmToTwip(0.3) } })); - out.push(new TableOfContents(tocTitle, { hyperlink: true, headingStyleRange: `1-${MAX_HEADING_LEVEL}` })); + // A real, recognized TOC field (hyperlinked, over the index's own heading levels); + // Word/LibreOffice populate + link it on field update (features.updateFields does + // this on open). Title is a plain bold paragraph so it isn't itself listed, and is + // omitted where the index has none; our importer regenerates the node. + const rawTitle = node.attrs?.title; + const tocTitle = typeof rawTitle === 'string' ? rawTitle : 'Table of Contents'; + const depth = Number(node.attrs?.maxLevel); + const maxLevel = depth >= 1 ? Math.min(MAX_HEADING_LEVEL, depth) : MAX_HEADING_LEVEL; + if (tocTitle) out.push(new Paragraph({ children: [new TextRun({ text: tocTitle, bold: true, size: 32 })], spacing: { after: cmToTwip(0.3) } })); + out.push(new TableOfContents(tocTitle, { hyperlink: true, headingStyleRange: `1-${maxLevel}` })); } } return out; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index d65d7c5..66e2282 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -673,7 +673,7 @@ function replaceColumns(doc: TiptapNode, cols: ColumnsExport[]): TiptapNode { // One generated table of contents, collected by replaceTableOfContents and emitted by // applyToc. Entries are the cached heading→page rows (the node view keeps them current). type TocEntry = { text: string; level: number; page: number }; -type TocExport = { entries: TocEntry[]; title: string | null }; +type TocExport = { entries: TocEntry[]; title: string | null; maxLevel: number }; // Swap each top-level tableOfContents node for a marker paragraph carrying the TOC // sentinel and collect its cached entries. Top-level only (like replacePageBreaks): a @@ -692,7 +692,12 @@ function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode page: Math.max(1, Number(e.page) || 1), })); const rawTitle = child.attrs?.title; - tocs.push({ entries, title: typeof rawTitle === 'string' && rawTitle ? rawTitle : null }); + const depth = Number(child.attrs?.maxLevel); + tocs.push({ + entries, + title: typeof rawTitle === 'string' ? rawTitle : null, + maxLevel: depth >= 1 ? Math.min(MAX_HEADING_LEVEL, depth) : MAX_HEADING_LEVEL, + }); content.push({ type: 'paragraph', content: [{ type: 'text', text: `${TOC_SENT}${tocs.length - 1}${TOC_SENT}` }] }); continue; } @@ -3118,9 +3123,9 @@ function tocXml(toc: TocExport, index: number): string { const title = toc.title ?? 'Table of Contents'; const name = `Table of Contents${index + 1}`; const source = - `` + - `${escapeXml(title)}` + - HEADING_LEVELS + `` + + (title ? `${escapeXml(title)}` : '') + + HEADING_LEVELS.filter(l => l <= toc.maxLevel) .map( l => `` + @@ -3135,9 +3140,11 @@ function tocXml(toc: TocExport, index: number): string { ``; const body = `` + - `` + - `${escapeXml(title)}` + - `` + + (title + ? `` + + `${escapeXml(title)}` + + `` + : '') + toc.entries .map(e => `${escapeXml(e.text)}${e.page}`) .join('') + diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index c2d4410..bbb65b5 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -262,6 +262,17 @@ function documentLanguage(stylesDoc: Document | null, warnings: Set): Do // so field depth is tracked (across one convertBlocks call) to match the TOC's own end. type TocFieldState = { fieldDepth: number; tocDepth: number; instr: string[] }; +// The deepest heading level a TOC field lists: the end of its `\o "1-3"` range (Word's +// own default is 1-9). Anything deeper only inflates the block on screen. +function tocMaxLevel(instr: string): number { + const m = /\\o\s+"?\s*\d+\s*-\s*(\d+)/.exec(instr); + const n = m ? Number(m[1]) : NaN; + return n >= 1 ? Math.min(MAX_HEADING_LEVEL, n) : MAX_HEADING_LEVEL; +} + +const instrTextOf = (el: Element): string => + Array.from(el.getElementsByTagNameNS(W, 'instrText')).map(i => i.textContent ?? '').join(''); + function scanTocField(p: Element, st: TocFieldState): { emit: boolean } { let emit = false; for (const r of Array.from(p.getElementsByTagNameNS(W, 'r'))) { @@ -384,7 +395,11 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef // A paragraph owned by a TOC field is skipped; the field emits one node. const startedInToc = tocState.tocDepth >= 0; const { emit } = scanTocField(el, tocState); - if (emit && kind === 'body') { flush(); out.push({ type: 'tableOfContents', attrs: { entries: [] } }); } + if (emit && kind === 'body') { + flush(); + // The field carries no heading of its own — Word's sits in a separate paragraph. + out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', maxLevel: tocMaxLevel(instrTextOf(el)) } }); + } if (startedInToc || emit) continue; const num = paragraphNum(el, ctx); if (num) { @@ -429,8 +444,12 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef if (sdtIsToc(el)) { // A content-control-wrapped TOC → one node (regenerated live); skip its content. if (kind === 'body') { - const title = tocHeading(fc(el, 'sdtContent')); - out.push({ type: 'tableOfContents', attrs: { entries: [], ...(title ? { title } : {}) } }); + const content = fc(el, 'sdtContent'); + out.push({ type: 'tableOfContents', attrs: { + entries: [], + title: tocHeading(content) ?? '', + maxLevel: tocMaxLevel(content ? instrTextOf(content) : ''), + } }); } } else { const content = fc(el, 'sdtContent'); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 0f1f0dd..c1382f9 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -813,8 +813,13 @@ function convertToc(el: Element): Node { } } // The file's own heading ("Inhalt", "Sommaire", …), so a reopened index keeps its name. - const title = el.getElementsByTagNameNS(NS.text, 'index-title')[0]?.textContent?.trim(); - return { type: 'tableOfContents', attrs: { entries, ...(title ? { title } : {}) } }; + // No means the index really has none — its heading is an ordinary + // paragraph above it, and adding ours would double it. + const title = el.getElementsByTagNameNS(NS.text, 'index-title')[0]?.textContent?.trim() ?? ''; + const source = el.getElementsByTagNameNS(NS.text, 'table-of-content-source')[0]; + const depth = Number(source?.getAttributeNS(NS.text, 'outline-level')); + const maxLevel = depth >= 1 ? Math.min(MAX_HEADING_LEVEL, depth) : MAX_HEADING_LEVEL; + return { type: 'tableOfContents', attrs: { entries, title, maxLevel } }; } // Split a TOC entry paragraph around its last : the text before it is the diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 55f5996..776a72f 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -1396,6 +1396,24 @@ describe('Leg 5: table of contents (text:table-of-content)', () => { check('headings after the TOC survive', blocks.some((n: N) => n.type === 'heading' && n.content?.[0]?.text === 'Introduction')); }); + + // Listing deeper than the index asks for inflates it by whole pages; a title the file + // doesn't have doubles the heading standing above it. + it('round-trips the index depth and a title-less index', async () => { + const shallow: N = { ...tocDoc, content: [ + { type: 'tableOfContents', attrs: { entries: [], title: '', maxLevel: 1 } }, + ...(tocDoc.content ?? []).slice(1), + ] }; + const bytes = await buildOdt(shallow, margins, 'portrait'); + const content = strFromU8(unzipSync(bytes)['content.xml']); + check('source stops at the index depth', content.includes(' n.type === 'tableOfContents'); + check('maxLevel survives', toc?.attrs?.maxLevel === 1, toc?.attrs); + check('title stays empty', toc?.attrs?.title === '', toc?.attrs); + }); }); describe('Leg 10: date/time fields (text:date / text:time)', () => {