fix: an index draws the leader its file declares, or none

A table-of-content entry template that declares its tab stop without a
style:leader-char means the gap is empty — the deputy-standards guidance
has one, and got a row of dots it never asked for. Round-trips.
This commit is contained in:
Steffen Becker committed 2026-08-10 11:47:34 +00:00
1 parent 9b026c849f
commit 1de879b490
3 files changed
+29 -12

No files matched your search

+18 -8
View File
@@ -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<ReturnType> {
@@ -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<HTMLElement>('.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)));
}
}
+3 -2
View File
@@ -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 {
`<text:table-of-content-entry-template text:outline-level="${l}" text:style-name="Contents_20_${l}">` +
`<text:index-entry-link-start/>` +
`<text:index-entry-text/>` +
`<text:index-entry-tab-stop style:type="right" style:leader-char="."/>` +
`<text:index-entry-tab-stop style:type="right"${toc.leader ? ` style:leader-char="${escapeXml(toc.leader)}"` : ''}/>` +
`<text:index-entry-page-number/>` +
`<text:index-entry-link-end/>` +
`</text:table-of-content-entry-template>`,
+8 -2
View File
@@ -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 <text:tab/>: the text before it is the