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:
1 parent
9b026c849f
commit
1de879b490
3 files changed
+29
-12
No files matched your search
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user