fix: an index lists the levels it declares, not all five
The TOC node regenerated over every heading level and minted a title of its own. A file asking for level 1 only (text:outline-level, Word's TOC \o range) got a 43-entry index instead of 12 — a full page where LibreOffice needs a quarter, plus a second "Table of Contents" under the file's own heading. maxLevel and an empty title now round-trip through both formats.
This commit is contained in:
1 parent
2dcf8b8a20
commit
84eb7d6bf3
7 files changed
+97
-30
No files matched your search
@@ -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 `<span data-page-field>`; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value.
|
||||
|
||||
@@ -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');
|
||||
|
||||
+10
-6
@@ -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;
|
||||
|
||||
+15
-8
@@ -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 =
|
||||
`<text:table-of-content-source text:outline-level="${MAX_HEADING_LEVEL}" text:use-index-marks="false" text:use-index-source-styles="false">` +
|
||||
`<text:index-title-template text:style-name="Contents_20_Heading">${escapeXml(title)}</text:index-title-template>` +
|
||||
HEADING_LEVELS
|
||||
`<text:table-of-content-source text:outline-level="${toc.maxLevel}" text:use-index-marks="false" text:use-index-source-styles="false">` +
|
||||
(title ? `<text:index-title-template text:style-name="Contents_20_Heading">${escapeXml(title)}</text:index-title-template>` : '') +
|
||||
HEADING_LEVELS.filter(l => l <= toc.maxLevel)
|
||||
.map(
|
||||
l =>
|
||||
`<text:table-of-content-entry-template text:outline-level="${l}" text:style-name="Contents_20_${l}">` +
|
||||
@@ -3135,9 +3140,11 @@ function tocXml(toc: TocExport, index: number): string {
|
||||
`</text:table-of-content-source>`;
|
||||
const body =
|
||||
`<text:index-body>` +
|
||||
`<text:index-title text:name="${escapeXml(name)}_Head">` +
|
||||
`<text:p text:style-name="Contents_20_Heading">${escapeXml(title)}</text:p>` +
|
||||
`</text:index-title>` +
|
||||
(title
|
||||
? `<text:index-title text:name="${escapeXml(name)}_Head">` +
|
||||
`<text:p text:style-name="Contents_20_Heading">${escapeXml(title)}</text:p>` +
|
||||
`</text:index-title>`
|
||||
: '') +
|
||||
toc.entries
|
||||
.map(e => `<text:p text:style-name="Contents_20_${e.level}">${escapeXml(e.text)}<text:tab/>${e.page}</text:p>`)
|
||||
.join('') +
|
||||
|
||||
+22
-3
@@ -262,6 +262,17 @@ function documentLanguage(stylesDoc: Document | null, warnings: Set<string>): 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');
|
||||
|
||||
@@ -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 <text:index-title> 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 <text:tab/>: the text before it is the
|
||||
|
||||
@@ -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('<text:table-of-content-source text:outline-level="1"'));
|
||||
check('one entry template only', (content.match(/<text:table-of-content-entry-template/g) ?? []).length === 1);
|
||||
check('no index title emitted', !content.includes('<text:index-title'));
|
||||
|
||||
const toc = (importOdt(bytes).content.content ?? []).find((n: N) => 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)', () => {
|
||||
|
||||
Reference in new issue
Block a user