diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index 879e9eb..d4de404 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -17,7 +17,7 @@ const TITLE = 'Table of Contents'; // Enough leader dots to cross the widest gap a page can offer; fillLeaders cuts each // row's back to what its own gap holds, measuring one dot with this sample. const LEADER_DOTS = 200; -const LEADER_PROBE = '..........'; +const LEADER_PROBE = 10; declare module '@tiptap/core' { interface Commands { @@ -48,6 +48,13 @@ export const TableOfContents = Node.create({ parseHTML: el => Number((el as HTMLElement).getAttribute('data-toc-levels')) || MAX_HEADING_LEVEL, renderHTML: attrs => ({ 'data-toc-levels': String(attrs.maxLevel ?? MAX_HEADING_LEVEL) }), }, + // The character filling the gap to the page number; null = the file's index leaves + // it empty. Round-trips to style:leader-char on the entry template. + leader: { + default: '.', + parseHTML: el => (el as HTMLElement).getAttribute('data-toc-leader') || null, + renderHTML: attrs => (attrs.leader ? { 'data-toc-leader': String(attrs.leader) } : {}), + }, entries: { default: [] as TocEntry[], parseHTML: el => { @@ -212,6 +219,7 @@ class TocView { return; } + const fill = typeof this.node()?.attrs?.leader === 'string' ? String(this.node()!.attrs.leader) : ''; entries.forEach((e, i) => { const row = document.createElement('div'); row.className = `toc-entry toc-level-${e.level}`; @@ -220,9 +228,9 @@ class TocView { text.textContent = e.text; const leader = document.createElement('span'); leader.className = 'toc-leader'; - // Real dots, as a word processor fills the gap: they scale with the font and - // reach the PDF as text. The row clips whatever the gap has no room for. - leader.textContent = '.'.repeat(LEADER_DOTS); + // Real fill characters, as a word processor draws them: they scale with the font + // and reach the PDF as text. The row clips whatever the gap has no room for. + leader.textContent = fill.repeat(fill ? LEADER_DOTS : 0); const page = document.createElement('span'); page.className = 'toc-page'; page.textContent = String(e.page); @@ -245,13 +253,15 @@ class TocView { private fillLeaders(): void { const leaders = Array.from(this.dom.querySelectorAll('.toc-leader')); if (!leaders.length) return; - leaders[0].textContent = LEADER_PROBE; + const fill = leaders[0].textContent?.[0]; + if (!fill) return; + leaders[0].textContent = fill.repeat(LEADER_PROBE); const range = document.createRange(); range.selectNodeContents(leaders[0]); - const dot = range.getBoundingClientRect().width / LEADER_PROBE.length; - if (!(dot > 0)) return; + const one = range.getBoundingClientRect().width / LEADER_PROBE; + if (!(one > 0)) return; for (const el of leaders) { - el.textContent = '.'.repeat(Math.max(0, Math.floor(el.getBoundingClientRect().width / dot))); + el.textContent = fill.repeat(Math.max(0, Math.floor(el.getBoundingClientRect().width / one))); } } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index ba05ffe..7012051 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -679,7 +679,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; maxLevel: number }; +type TocExport = { entries: TocEntry[]; title: string | null; maxLevel: number; leader: 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 @@ -703,6 +703,7 @@ function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode entries, title: typeof rawTitle === 'string' ? rawTitle : null, maxLevel: depth >= 1 ? Math.min(MAX_HEADING_LEVEL, depth) : MAX_HEADING_LEVEL, + leader: normalizeLeader(child.attrs?.leader), }); content.push({ type: 'paragraph', content: [{ type: 'text', text: `${TOC_SENT}${tocs.length - 1}${TOC_SENT}` }] }); continue; @@ -3170,7 +3171,7 @@ function tocXml(toc: TocExport, index: number): string { `` + `` + `` + - `` + + `` + `` + `` + ``, diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index fe0fc5b..022fbbe 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -5,7 +5,7 @@ import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type Styl import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { fitInlineImage } from '../editor/extensions/image'; import { odfChartDataUrl } from './chart'; -import { formatTabStops } from '../editor/extensions/tabStops'; +import { formatTabStops, normalizeLeader } from '../editor/extensions/tabStops'; import type { CapsMode, LineStyle } from '../editor/extensions/textEffects'; import { TABLE_REGIONS, tableLookAttr, type TableLook, type TableRegion } from '../styles/tableStyles'; import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; @@ -919,7 +919,13 @@ function convertToc(el: Element): Node { 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 } }; + // The fill between an entry and its page number. A template that declares the stop but + // no leader-char means the gap really is empty (Word's TOC \p " " does the same); only + // an index with no template at all falls back to dots. + const stop = source?.getElementsByTagNameNS(NS.style, 'index-entry-tab-stop')[0] + ?? source?.getElementsByTagNameNS(NS.text, 'index-entry-tab-stop')[0]; + const leader = source ? normalizeLeader(stop?.getAttributeNS(NS.style, 'leader-char')) : '.'; + return { type: 'tableOfContents', attrs: { entries, title, maxLevel, leader } }; } // Split a TOC entry paragraph around its last : the text before it is the