From b217de06c13be5b986baa0d69ea1e03f161a10b9 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 8 Aug 2026 00:13:59 +0000 Subject: [PATCH] feat: a table of contents keeps the heading its file gave it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A German document's "Inhalt" came back as "Table of Contents" — the node carried no title of its own, so the generated one always won. Now the imported heading rides on the node and goes back out with it. --- src/lib/editor/extensions/tableOfContents.ts | 20 ++++++++++++--- src/lib/export/docx.ts | 5 ++-- src/lib/export/odt.ts | 9 ++++--- src/lib/import/docx.ts | 14 ++++++++++- src/lib/import/odt.ts | 26 ++++++++++++-------- 5 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index b1d9ca1..db0616d 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -13,6 +13,8 @@ export type TocEntry = { text: string; level: number; page: number }; // heading is its offsetTop within .tiptap (spacers included) divided by the cycle. const PAGE_GAP = 20; const FALLBACK_PAGE_HEIGHT = 1123; +// The heading above the entries. An imported TOC keeps the one its file used +// ("Inhalt", "Sommaire", …); a freshly inserted one takes this. const TITLE = 'Table of Contents'; declare module '@tiptap/core' { @@ -32,6 +34,11 @@ export const TableOfContents = Node.create({ addAttributes() { 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) } : {}), + }, entries: { default: [] as TocEntry[], parseHTML: el => { @@ -47,8 +54,8 @@ export const TableOfContents = Node.create({ return [{ tag: 'div[data-toc]' }]; }, - renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes(HTMLAttributes, { 'data-toc': 'true' }), TITLE]; + renderHTML({ HTMLAttributes, node }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-toc': 'true' }), tocTitle(node.attrs.title)]; }, addCommands() { @@ -65,6 +72,8 @@ export const TableOfContents = Node.create({ }, }); +const tocTitle = (title: unknown): string => (typeof title === 'string' && title ? title : TITLE); + type HeadingRef = { text: string; level: number; pos: number }; // Node view: renders the title + one clickable row per heading. Recomputes on each @@ -155,7 +164,7 @@ class TocView { this.dom.textContent = ''; const title = document.createElement('div'); title.className = 'toc-title'; - title.textContent = TITLE; + title.textContent = tocTitle(this.node()?.attrs?.title); this.dom.appendChild(title); if (entries.length === 0) { @@ -197,6 +206,11 @@ class TocView { this.editor.chain().focus().setTextSelection(pos + 1).run(); } + private node(): PMNode | null { + const pos = this.getPos(); + return typeof pos === 'number' ? this.editor.state.doc.nodeAt(pos) : null; + } + private syncAttr(entries: TocEntry[]): void { const pos = this.getPos(); if (typeof pos !== 'number') return; diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index c6e492f..bce758c 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -843,8 +843,9 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num // 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. - out.push(new Paragraph({ children: [new TextRun({ text: 'Table of Contents', bold: true, size: 32 })], spacing: { after: cmToTwip(0.3) } })); - out.push(new TableOfContents('Table of Contents', { hyperlink: true, headingStyleRange: `1-${MAX_HEADING_LEVEL}` })); + 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}` })); } } return out; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 7174d0e..652dbca 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -547,7 +547,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[] }; +type TocExport = { entries: TocEntry[]; title: string | null }; // 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 @@ -565,7 +565,8 @@ function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode level: Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(e.level) || 1)), page: Math.max(1, Number(e.page) || 1), })); - tocs.push({ entries }); + const rawTitle = child.attrs?.title; + tocs.push({ entries, title: typeof rawTitle === 'string' && rawTitle ? rawTitle : null }); content.push({ type: 'paragraph', content: [{ type: 'text', text: `${TOC_SENT}${tocs.length - 1}${TOC_SENT}` }] }); continue; } @@ -2758,8 +2759,8 @@ function contentsHeadingStyle(): string { // carry a right tab stop, page number, and link markers so LibreOffice rebuilds it as a // linking TOC) plus a cached index-body (title + one entry paragraph per heading). function tocXml(toc: TocExport, index: number): string { - const title = 'Table of Contents'; - const name = `${title}${index + 1}`; + const title = toc.title ?? 'Table of Contents'; + const name = `Table of Contents${index + 1}`; const source = `` + `${escapeXml(title)}` + diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 9dda301..13b55a4 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -293,6 +293,15 @@ function sdtIsToc(sdt: Element): boolean { return Array.from(content.getElementsByTagNameNS(W, 'instrText')).some(i => /\bTOC\b/.test(i.textContent ?? '')); } +// The heading Word caches above a TOC's entries ("Inhalt", "Sommaire", …): the control's +// first paragraph, when it is plain text rather than part of the field itself. +function tocHeading(content: Element | null): string | null { + const first = content ? fcAll(content, 'p')[0] : null; + if (!first || first.getElementsByTagNameNS(W, 'fldChar').length || first.getElementsByTagNameNS(W, 'instrText').length) return null; + const text = (first.textContent ?? '').trim(); + return text && text.length <= 60 ? text : null; +} + function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] { const out: Node[] = []; const stack: { ilvl: number; numId: number; list: Node }[] = []; @@ -387,7 +396,10 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef flush(); if (sdtIsToc(el)) { // A content-control-wrapped TOC → one node (regenerated live); skip its content. - if (kind === 'body') out.push({ type: 'tableOfContents', attrs: { entries: [] } }); + if (kind === 'body') { + const title = tocHeading(fc(el, 'sdtContent')); + out.push({ type: 'tableOfContents', attrs: { entries: [], ...(title ? { title } : {}) } }); + } } else { const content = fc(el, 'sdtContent'); if (content) out.push(...convertBlocks(Array.from(content.children), ctx, kind, boldByDefault)); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index a1bbd45..3b86c89 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -9,7 +9,7 @@ import { TABLE_REGIONS, tableLookAttr, type TableLook, type TableRegion } from ' import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes'; import { matchFormat, toDateValue, type Token } from '../utils/dateTime'; -import { imageDataUrl, type ConvertedImages } from './imageFormats'; +import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats'; import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; @@ -170,16 +170,20 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null { .filter(Boolean); if (!hrefs.length) return null; const href = hrefs.find(h => loadImageDataUrl(h, ctx)) ?? hrefs[0]; - const src = loadImageDataUrl(href, ctx); - if (!src) { - ctx.warnings.add(ctx.files[href] - ? 'Images in a format the browser can’t display (e.g. WMF, EMF, SVM, TIFF) were removed' - : 'Some images could not be read and were skipped'); - return null; - } - const attrs: Record = { src }; + let src = loadImageDataUrl(href, ctx); const wCm = lengthToCm(frame.getAttributeNS(NS.svg, 'width')); const hCm = lengthToCm(frame.getAttributeNS(NS.svg, 'height')); + if (!src) { + // The frame still occupies its box, so an undrawable picture comes in as a + // placeholder of that size rather than collapsing the layout around it. + if (wCm == null || hCm == null) { + ctx.warnings.add('Some images could not be read and were skipped'); + return null; + } + ctx.warnings.add('Images in a format the browser can’t display (e.g. WMF, EMF, SVM) were replaced by a placeholder'); + src = placeholderImage('Image', cmToPx(wCm), cmToPx(hCm)); + } + const attrs: Record = { src }; if (wCm != null) attrs.width = Math.round(cmToPx(wCm)); if (hCm != null) attrs.height = Math.round(cmToPx(hCm)); const title = frame.getElementsByTagNameNS(NS.svg, 'title')[0]?.textContent; @@ -641,7 +645,9 @@ function convertToc(el: Element): Node { if (text) entries.push({ text, level, page: Math.max(1, parseInt(page, 10) || 1) }); } } - return { type: 'tableOfContents', attrs: { entries } }; + // 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 } : {}) } }; } // Split a TOC entry paragraph around its last : the text before it is the