feat: table of content

This commit is contained in:
Steffen Becker committed 2026-07-01 21:17:44 +02:00
1 parent 504a202d6b
commit a5bb2deaa2
12 files changed
+624 -10

No files matched your search

+1 -1
View File
@@ -36,6 +36,7 @@
- Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style), cell background shading, header-row toggle (bold + shading); a table splits cleanly across page boundaries
- Images: inline or floating with text wrap (left / right / top-bottom), resize handles, rotation, live size badge; insert via toolbar, drag-and-drop, or paste
- Special characters picker
- Table of contents: generated from headings (H1–H3) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
- Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip
- Manual page break (Ctrl+Enter); round-trips to ODF `fo:break-before`
@@ -70,7 +71,6 @@ Planned word-processor features (to match Word/LibreOffice):
- Footnotes / endnotes (currently dropped on import)
- Clear formatting (remove all marks)
- Multi-column (newspaper) layout
- Table of contents (generated from headings)
- Comments / annotations (currently dropped on import)
- Horizontal rule
- Page numbering options (start value / format) + different first-page header
+17
View File
@@ -663,6 +663,12 @@
editor.chain().focus().setTextSelection(range).insertTable({ rows, cols, withHeaderRow: false }).run();
}
// --- Table of contents insertion (tableOfContents.ts) ---
function insertToc() {
if (!editor || hfActive) return; // body-only; the HF schema has no TOC node
editor.chain().focus().setTableOfContents().run();
}
// --- Special character insertion (SpecialCharPicker.svelte) ---
let specialCharOpen = $state(false);
@@ -1225,6 +1231,17 @@
style="display:none"
onchange={onImageFile}
/>
<button
onclick={insertToc}
disabled={!!hfActive}
title={hfActive ? 'A table of contents is not available in headers/footers' : 'Insert table of contents'}
aria-label="Insert table of contents"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3.5h6M2 6.5h8M2 9.5h5M2 12.5h7" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path d="M12.5 3.5h1.5M13.5 6.5H14M12 9.5h2M12.5 12.5H14" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
<div class="link-wrap" use:linkClickOutside>
<button
class:active={isLink}
+4
View File
@@ -38,6 +38,7 @@ import { TableCellBackground } from './extensions/tableCellBackground';
import { TableHeaderRow } from './extensions/tableHeaderRow';
import { TrailingNode } from './extensions/trailingNode';
import { Image } from './extensions/image';
import { TableOfContents } from './extensions/tableOfContents';
export const extensions = [
Document,
@@ -65,6 +66,9 @@ export const extensions = [
// Inline, as-character image; round-trips to ODF <draw:frame>/<draw:image>
// (export/odt.ts replaceImages/applyImages, import/odt.ts).
Image,
// Generated table of contents from headings; round-trips to ODF
// <text:table-of-content> (export/odt.ts, import/odt.ts) and a DOCX TOC field.
TableOfContents,
LineHeight,
ParagraphSpacing,
PageBreak,
+13
View File
@@ -630,6 +630,19 @@ export const PageBreaks = Extension.create({
walkTableRows(child, inTableCell);
continue;
}
// The generated table of contents (tableOfContents.ts) is a block atom with
// no inner doc positions, so it can't be line-split — measure it atomically
// (pushed whole if it overflows; a TOC taller than a page can't be pushed).
if (child.dataset?.toc) {
leaves.push({
el: child,
kind: 'atomic',
naturalTop: topWithin(child) - cumulativeSpacerHeight,
naturalHeight: child.offsetHeight,
inTableCell,
});
continue;
}
// A paragraph holding a floated/block image paginates atomically (pushed
// whole, spacer placed before it): a line-split spacer inside it would sit
// next to the float, where ProseMirror drops the widget.
@@ -0,0 +1,228 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
// A generated table of contents: a block atom listing every heading (levels 1–3) with its
// live page number. The node view regenerates entries from the headings + pagination and
// caches them in `entries` (persisted like a Word/LO TOC field); round-trips to ODF/DOCX.
export type TocEntry = { text: string; level: number; page: number };
// Mirrors pageBreaks.ts PAGE_GAP + Editor.svelte getCycle(): the rendered page of a
// heading is its offsetTop within .tiptap (spacers included) divided by the cycle.
const PAGE_GAP = 20;
const FALLBACK_PAGE_HEIGHT = 1123;
const TITLE = 'Table of Contents';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
tableOfContents: {
setTableOfContents: () => ReturnType;
};
}
}
export const TableOfContents = Node.create({
name: 'tableOfContents',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
entries: {
default: [] as TocEntry[],
parseHTML: el => {
try { return JSON.parse((el as HTMLElement).getAttribute('data-entries') ?? '[]'); }
catch { return []; }
},
renderHTML: attrs => ({ 'data-entries': JSON.stringify(attrs.entries ?? []) }),
},
};
},
parseHTML() {
return [{ tag: 'div[data-toc]' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-toc': 'true' }), TITLE];
},
addCommands() {
return {
setTableOfContents:
() =>
({ commands }) =>
commands.insertContent({ type: this.name }),
};
},
addNodeView() {
return ({ editor, getPos }) => new TocView(editor, getPos as () => number);
},
});
type HeadingRef = { text: string; level: number; pos: number };
// Node view: renders the title + one clickable row per heading. Recomputes on each
// pagination settle (pm-pagecount, caught on the .paper ancestor) and on doc change,
// writing entries back to the node attr — guarded by a serialized key against a loop.
class TocView {
dom: HTMLElement;
private editor: Editor;
private getPos: () => number;
private scheduled = false;
private lastKey = '';
private paper: HTMLElement | null = null;
private onPageCount = () => this.schedule();
constructor(editor: Editor, getPos: () => number) {
this.editor = editor;
this.getPos = getPos;
this.dom = document.createElement('div');
this.dom.className = 'toc';
this.dom.dataset.toc = 'true';
this.dom.setAttribute('contenteditable', 'false');
// Mount deferred so .paper exists and the first pagination pass has run.
requestAnimationFrame(() => {
this.paper = this.dom.closest('.paper') as HTMLElement | null;
this.paper?.addEventListener('pm-pagecount', this.onPageCount);
this.render();
});
}
private schedule(): void {
if (this.scheduled) return;
this.scheduled = true;
requestAnimationFrame(() => {
this.scheduled = false;
this.render();
});
}
// Every heading (level ≤ 3) with non-empty text, in document order.
private headings(): HeadingRef[] {
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(3, (node.attrs.level as number) ?? 1), pos });
}
});
return out;
}
private cycle(): number {
const tiptap = this.editor.view.dom as HTMLElement;
const ph = parseFloat(getComputedStyle(tiptap).getPropertyValue('--user-page-height'));
return (Number.isFinite(ph) ? ph : FALLBACK_PAGE_HEIGHT) + PAGE_GAP;
}
// Rendered page of a heading: sum offsetTop up the offsetParent chain to .tiptap (the
// live DOM already includes pagination spacers, so this is the on-page position),
// divided by the page cycle. Mirrors topWithin/getPageForY in pageBreaks.ts.
private pageOf(pos: number, cycle: number): number {
const tiptap = this.editor.view.dom as HTMLElement;
const el = this.editor.view.nodeDOM(pos) as HTMLElement | null;
if (!el || el.nodeType !== 1) return 1;
let top = 0;
let n: HTMLElement | null = el;
while (n && n !== tiptap) {
top += n.offsetTop;
n = n.offsetParent as HTMLElement | null;
}
return Math.max(1, Math.floor(top / cycle) + 1);
}
private render(): void {
if (this.editor.isDestroyed || !this.dom.isConnected) return;
const heads = this.headings();
const cycle = this.cycle();
const entries: TocEntry[] = heads.map(h => ({ text: h.text, level: h.level, page: this.pageOf(h.pos, cycle) }));
const key = JSON.stringify(entries);
if (key === this.lastKey) return;
this.lastKey = key;
this.paint(entries, heads);
this.syncAttr(entries);
}
private paint(entries: TocEntry[], heads: HeadingRef[]): void {
this.dom.textContent = '';
const title = document.createElement('div');
title.className = 'toc-title';
title.textContent = TITLE;
this.dom.appendChild(title);
if (entries.length === 0) {
const empty = document.createElement('div');
empty.className = 'toc-empty';
empty.textContent = 'No headings yet — add an H1/H2/H3 to build the contents.';
this.dom.appendChild(empty);
return;
}
entries.forEach((e, i) => {
const row = document.createElement('div');
row.className = `toc-entry toc-level-${e.level}`;
const text = document.createElement('span');
text.className = 'toc-text';
text.textContent = e.text;
const leader = document.createElement('span');
leader.className = 'toc-leader';
const page = document.createElement('span');
page.className = 'toc-page';
page.textContent = String(e.page);
row.append(text, leader, page);
const pos = heads[i]?.pos;
if (pos != null) {
row.addEventListener('mousedown', ev => {
ev.preventDefault();
ev.stopPropagation();
this.goTo(pos);
});
}
this.dom.appendChild(row);
});
}
// Scroll the heading into view and drop the cursor into it.
private goTo(pos: number): void {
const dom = this.editor.view.nodeDOM(pos) as HTMLElement | null;
dom?.scrollIntoView({ behavior: 'smooth', block: 'start' });
this.editor.chain().focus().setTextSelection(pos + 1).run();
}
private syncAttr(entries: TocEntry[]): void {
const pos = this.getPos();
if (typeof pos !== 'number') return;
const node = this.editor.state.doc.nodeAt(pos);
if (!node || node.type.name !== 'tableOfContents') return;
if (JSON.stringify(node.attrs.entries ?? []) === JSON.stringify(entries)) return;
const tr = this.editor.state.tr.setNodeAttribute(pos, 'entries', entries).setMeta('addToHistory', false);
this.editor.view.dispatch(tr);
}
update(node: PMNode): boolean {
if (node.type.name !== 'tableOfContents') return false;
this.schedule();
return true;
}
// Own the mouse on entry rows (navigation) but let clicks on the TOC background reach
// ProseMirror so the block can be selected + deleted.
stopEvent(event: Event): boolean {
return event.type.startsWith('mouse') && !!(event.target as HTMLElement)?.closest?.('.toc-entry');
}
ignoreMutation(): boolean {
return true;
}
destroy(): void {
this.paper?.removeEventListener('pm-pagecount', this.onPageCount);
}
}
+15 -3
View File
@@ -1,5 +1,6 @@
import {
Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, Tab,
TableOfContents,
Table, TableRow, TableCell, Header, Footer, PageNumber,
AlignmentType, HeadingLevel, LevelFormat, UnderlineType, BorderStyle, ShadingType,
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType,
@@ -329,7 +330,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
// ---- lists -----------------------------------------------------------------
function listToParagraphs(
node: TiptapNode, depth: number, reference: string, extraIndentCm: number,
num: Numbering, out: (Paragraph | Table)[],
num: Numbering, out: (Paragraph | Table | TableOfContents)[],
): void {
const indentCm = extraIndentCm + (typeof node.attrs?.indent === 'number' ? node.attrs.indent : 0);
num.ensureLevel(reference, depth, node, indentCm);
@@ -441,8 +442,8 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
}
// ---- top-level walk --------------------------------------------------------
function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: number): (Paragraph | Table)[] {
const out: (Paragraph | Table)[] = [];
function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: number): (Paragraph | Table | TableOfContents)[] {
const out: (Paragraph | Table | TableOfContents)[] = [];
for (const node of content) {
if (node.type === 'paragraph' || node.type === 'heading') {
out.push(paragraphToDocx(node));
@@ -452,6 +453,12 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
out.push(tableToDocx(node, contentWidthCm, num));
} else if (node.type === 'image') {
out.push(new Paragraph({ children: inlineToRuns([node]) }));
} 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.
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-3' }));
}
}
return out;
@@ -495,6 +502,10 @@ export async function buildDocx(
const body = blocksToDocx(docJson.content ?? [], num, contentWidthCm);
if (body.length === 0) body.push(new Paragraph({}));
// A TOC field is empty until its field is calculated; ask the reader to update fields
// on open so Word/LibreOffice populate + hyperlink it (standard for TOC fields).
const hasToc = (docJson.content ?? []).some(n => n.type === 'tableOfContents');
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
// Word's model: header/footer distance is from the page edge; the body still starts
@@ -505,6 +516,7 @@ export async function buildDocx(
const doc = new Document({
creator: 'Web ODF Editor',
defaultTabStop: cmToTwip(1.25),
...(hasToc ? { features: { updateFields: true } } : {}),
styles: buildStyles(language),
numbering: { config: num.config },
sections: [{
+129 -2
View File
@@ -64,6 +64,11 @@ const IMG = '';
// fo:break-before="page" paragraph style for it, and strips the sentinel. U+E005.
const PGB = '';
// Sentinel wrapping a table-of-contents index (TOC{i}TOC), emitted as a marker
// paragraph's run text by replaceTableOfContents so it rides odf-kit's paragraph path;
// applyToc rewrites the whole marker <text:p> to a <text:table-of-content>. U+E006.
const TOC_SENT = '';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -241,6 +246,36 @@ function replaceImages(node: TiptapNode, images: ImageExport[]): TiptapNode {
return { ...node, content };
}
// 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[] };
// 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
// sentinel in a cell/list paragraph would corrupt the SEG-based cell rebuild.
function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode {
if (!doc.content?.length) return doc;
const content: TiptapNode[] = [];
for (const child of doc.content) {
if (child.type === 'tableOfContents') {
const raw = Array.isArray(child.attrs?.entries) ? (child.attrs!.entries as TocEntry[]) : [];
const entries = raw
.filter(e => e && typeof e.text === 'string')
.map(e => ({
text: String(e.text),
level: Math.min(3, Math.max(1, Number(e.level) || 1)),
page: Math.max(1, Number(e.page) || 1),
}));
tocs.push({ entries });
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${TOC_SENT}${tocs.length - 1}${TOC_SENT}` }] });
continue;
}
content.push(child);
}
return { ...doc, content };
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
@@ -1259,6 +1294,96 @@ function applyImages(odtBytes: Uint8Array, images: ImageExport[]): Uint8Array {
return rezipOdt(files);
}
// Minted automatic paragraph style for a TOC entry level: per-level left indent and a
// right tab stop at the text width with a dotted leader (so the page number right-aligns
// with dots, matching the on-screen TOC and Word/LibreOffice).
function contentsEntryStyle(name: string, level: number, tabPosCm: number): string {
const indentCm = level === 2 ? 0.6 : level === 3 ? 1.2 : 0;
const margin = indentCm > 0 ? ` fo:margin-left="${indentCm}cm"` : '';
return (
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="Standard">` +
`<style:paragraph-properties${margin}>` +
`<style:tab-stops><style:tab-stop style:position="${tabPosCm}cm" style:type="right" style:leader-style="dotted" style:leader-text="."/></style:tab-stops>` +
`</style:paragraph-properties></style:style>`
);
}
// The TOC title style (bold, 16pt) — mirrors the .toc-title on screen.
function contentsHeadingStyle(): string {
return (
`<style:style style:name="Contents_20_Heading" style:family="paragraph" style:parent-style-name="Standard">` +
`<style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0.212cm"/>` +
`<style:text-properties fo:font-size="16pt" fo:font-weight="bold"/></style:style>`
);
}
// The full <text:table-of-content>: a source (title + per-level entry templates that
// 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 source =
`<text:table-of-content-source text:outline-level="3" 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>` +
[1, 2, 3]
.map(
l =>
`<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-page-number/>` +
`<text:index-entry-link-end/>` +
`</text:table-of-content-entry-template>`,
)
.join('') +
`</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>` +
toc.entries
.map(e => `<text:p text:style-name="Contents_20_${e.level}">${escapeXml(e.text)}<text:tab/>${e.page}</text:p>`)
.join('') +
`</text:index-body>`;
return `<text:table-of-content text:name="${escapeXml(name)}" text:protected="true">${source}${body}</text:table-of-content>`;
}
// Resolve TOC sentinels: rewrite each marker <text:p>TOC{i}TOC</text:p> to its
// <text:table-of-content> and mint the Contents_20_* paragraph styles it references.
function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: number): Uint8Array {
if (!tocs.length) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
if (!content.includes(TOC_SENT)) return odtBytes;
content = content.replace(
new RegExp(`<text:p\\b[^>]*>${TOC_SENT}(\\d+)${TOC_SENT}</text:p>`, 'g'),
(_m, idx: string) => {
const toc = tocs[Number(idx)];
return toc ? tocXml(toc, Number(idx)) : '';
},
);
// Defensive: strip any sentinels not consumed above.
content = content.split(TOC_SENT).join('');
const tabPosCm = Math.max(1, Math.round(contentWidthCm * 1000) / 1000);
const styles =
contentsHeadingStyle() +
contentsEntryStyle('Contents_20_1', 1, tabPosCm) +
contentsEntryStyle('Contents_20_2', 2, tabPosCm) +
contentsEntryStyle('Contents_20_3', 3, tabPosCm);
content = injectAutomaticStyles(content, styles);
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// Header/footer input for the export: one single-paragraph doc per zone (or null),
// the page count snapshot for the stored <text:page-count> value, and the page-edge
// distances (cm; default HF_DISTANCE_CM) for header (from top) / footer (from bottom).
@@ -1275,7 +1400,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// Collect embedded images and swap them for IMG sentinels before serialization;
// applyImages resolves the sentinels and writes the Pictures/ + manifest entries.
const images: ImageExport[] = [];
const raw = replaceImages(replaceTabs(replaceHardBreaks(replacePageBreaks(docJson))), images);
const tocs: TocExport[] = [];
const raw = replaceImages(replaceTabs(replaceHardBreaks(replacePageBreaks(replaceTableOfContents(docJson, tocs)))), images);
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
// Distance from the page edge to the header (top) / footer (bottom). Becomes the
@@ -1387,7 +1513,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const cleaned = collapseRunWhitespace(styledRows);
const withBreaks = applyInlineSentinels(cleaned);
const withImages = applyImages(withBreaks, images);
const withPageBreaks = applyPageBreaks(withImages);
const withToc = applyToc(withImages, tocs, contentWidthCm);
const withPageBreaks = applyPageBreaks(withToc);
const withStyles = rewriteStylesXml(withPageBreaks, language ?? null);
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist);
}
+56 -2
View File
@@ -146,9 +146,52 @@ function documentLanguage(stylesDoc: Document | null, warnings: Set<string>): Do
}
// ---- block conversion (paragraphs, lists, tables) --------------------------
// A Word/LibreOffice TOC is a `TOC` field spanning several paragraphs (begin+instrText
// first, cached entries between, end last), each entry itself a nested PAGEREF field — so
// field depth is tracked (across one convertBlocks call) to match the TOC's own end.
type TocFieldState = { fieldDepth: number; tocDepth: number; instr: string[] };
function scanTocField(p: Element, st: TocFieldState): { emit: boolean } {
let emit = false;
for (const r of Array.from(p.getElementsByTagNameNS(W, 'r'))) {
for (const c of Array.from(r.children)) {
if (c.namespaceURI !== W) continue;
if (c.localName === 'fldChar') {
const t = c.getAttributeNS(W, 'fldCharType');
if (t === 'begin') { st.fieldDepth++; st.instr[st.fieldDepth] = ''; }
else if (t === 'end') {
if (st.tocDepth === st.fieldDepth) st.tocDepth = -1;
st.instr[st.fieldDepth] = '';
st.fieldDepth = Math.max(0, st.fieldDepth - 1);
}
} else if (c.localName === 'instrText' && st.fieldDepth > 0) {
st.instr[st.fieldDepth] += c.textContent ?? '';
if (st.tocDepth < 0 && /\bTOC\b/.test(st.instr[st.fieldDepth])) {
st.tocDepth = st.fieldDepth;
emit = true;
}
}
}
}
return { emit };
}
// docx-lib (and Word) wrap a TOC in a content control; detect it by its gallery type or
// a TOC field instruction inside its content.
function sdtIsToc(sdt: Element): boolean {
const gallery = fc(sdt, 'sdtPr')?.getElementsByTagNameNS(W, 'docPartGallery')[0];
if (gallery && /table of contents/i.test(gallery.getAttributeNS(W, 'val') ?? '')) return true;
const content = fc(sdt, 'sdtContent');
if (!content) return false;
return Array.from(content.getElementsByTagNameNS(W, 'instrText')).some(i => /\bTOC\b/.test(i.textContent ?? ''));
}
function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
const out: Node[] = [];
const stack: { ilvl: number; numId: number; list: Node }[] = [];
// Table-of-contents field tracking (see scanTocField). A TOC node is emitted for the
// body only; its cached paragraphs are skipped. The node view regenerates entries live.
const tocState: TocFieldState = { fieldDepth: 0, tocDepth: -1, instr: [] };
const closeTop = () => {
const top = stack.pop()!;
@@ -166,6 +209,11 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
for (const el of children) {
if (el.namespaceURI !== W) continue;
if (el.localName === 'p') {
// 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 (startedInToc || emit) continue;
const num = paragraphNum(el, ctx);
if (num) {
const para = convertParagraph(el, ctx, 'list', boldByDefault);
@@ -192,8 +240,14 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
out.push(...flattenTable(el, ctx));
}
} else if (el.localName === 'sdt') {
const content = fc(el, 'sdtContent');
if (content) { flush(); out.push(...convertBlocks(Array.from(content.children), ctx, kind, boldByDefault)); }
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: [] } });
} else {
const content = fc(el, 'sdtContent');
if (content) out.push(...convertBlocks(Array.from(content.children), ctx, kind, boldByDefault));
}
}
}
flush();
+43 -1
View File
@@ -269,8 +269,12 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef
if (list) out.push(list);
} else if (el.localName === 'section') {
out.push(...convertBlocks(Array.from(el.children), ctx, kind, boldByDefault));
} else if (el.localName === 'table-of-content' && kind === 'body') {
// Generated table of contents → a tableOfContents node (regenerated live).
out.push(convertToc(el));
} else if (/-index$|^table-of-content$|^bibliography$/.test(el.localName)) {
// Generated indexes (ToC, …): keep the rendered text from index-body.
// Other generated indexes (bibliography, …) or a TOC nested in a cell: keep the
// rendered text from index-body.
const indexBody = el.getElementsByTagNameNS(NS.text, 'index-body')[0];
if (indexBody) out.push(...convertBlocks(Array.from(indexBody.children), ctx, kind, boldByDefault));
}
@@ -295,6 +299,44 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef
return out;
}
// A <text:table-of-content> → a tableOfContents node. Entries (text + level + page) are
// parsed from the cached index-body as a starting cache; the node view recomputes page
// numbers live after mount, so parse fidelity isn't critical.
function convertToc(el: Element): Node {
const indexBody = el.getElementsByTagNameNS(NS.text, 'index-body')[0];
const entries: { text: string; level: number; page: number }[] = [];
if (indexBody) {
for (const p of Array.from(indexBody.children)) {
if (p.namespaceURI !== NS.text || p.localName !== 'p') continue; // skip index-title
const style = p.getAttributeNS(NS.text, 'style-name') ?? '';
const m = /Contents_20_(\d+)/.exec(style);
const level = m ? Math.min(3, Math.max(1, parseInt(m[1], 10))) : 1;
const { text, page } = tocEntryTextAndPage(p);
if (text) entries.push({ text, level, page: Math.max(1, parseInt(page, 10) || 1) });
}
}
return { type: 'tableOfContents', attrs: { entries } };
}
// Split a TOC entry paragraph around its last <text:tab/>: the text before it is the
// entry text, the run after it is the page number. Tabs contribute no textContent, so
// partition the text nodes by their document position relative to the tab element.
function tocEntryTextAndPage(p: Element): { text: string; page: string } {
const FOLLOWING = 0x04; // Node.DOCUMENT_POSITION_FOLLOWING (the DOM Node is shadowed here)
const tabs = p.getElementsByTagNameNS(NS.text, 'tab');
const lastTab = tabs.length ? tabs[tabs.length - 1] : null;
let before = '';
let after = '';
const walker = p.ownerDocument.createTreeWalker(p, NodeFilter.SHOW_TEXT);
let n: ChildNode | null;
while ((n = walker.nextNode() as ChildNode | null)) {
const txt = n.nodeValue ?? '';
if (lastTab && lastTab.compareDocumentPosition(n) & FOLLOWING) after += txt;
else before += txt;
}
return { text: before.trim(), page: after.trim() };
}
function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = false): Node {
const { resolver } = ctx;
const styleName = el.getAttributeNS(NS.text, 'style-name');
+46
View File
@@ -163,6 +163,52 @@
background: rgba(255, 145, 0, 0.8);
}
/* Generated table of contents (tableOfContents.ts). A non-editable block listing
headings with dot leaders to their live page number; entries are clickable. */
.paper .tiptap .toc {
margin: 0 0 0.5cm;
user-select: none;
cursor: default;
}
.paper .tiptap .toc-title {
font-size: 16pt;
font-weight: bold;
margin-bottom: 0.3cm;
}
.paper .tiptap .toc-empty {
font-style: italic;
opacity: 0.6;
}
.paper .tiptap .toc-entry {
display: flex;
align-items: baseline;
cursor: pointer;
line-height: 1.5;
}
.paper .tiptap .toc-entry:hover .toc-text {
text-decoration: underline;
}
.paper .tiptap .toc-level-2 { padding-left: 0.6cm; }
.paper .tiptap .toc-level-3 { padding-left: 1.2cm; }
.paper .tiptap .toc-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 1 auto;
}
/* Dotted leader filling the gap; nudged up to sit on the text baseline. */
.paper .tiptap .toc-leader {
flex: 1 1 auto;
min-width: 1em;
margin: 0 4px;
border-bottom: 1px dotted currentColor;
transform: translateY(-0.25em);
}
.paper .tiptap .toc-page {
flex: 0 0 auto;
white-space: nowrap;
}
/* Inline, as-character images (image.ts). The wrapper reserves the rotated bounding
box; the rotor holds the <img>, resize handles and rotate grip, rotating together. */
.paper .tiptap .image-node {
+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { zipSync, strToU8 } from 'fflate';
import { zipSync, strToU8, unzipSync, strFromU8 } from 'fflate';
import { buildDocx } from '../src/lib/export/docx';
import { importDocx } from '../src/lib/import/docx';
import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow';
@@ -235,3 +235,34 @@ describe('DOCX import detects headings by outline level (non-"HeadingN" style id
expect(walk(h, 'text')[0].text).toBe('Localised heading');
});
});
describe('DOCX table of contents (TOC field) round trip', () => {
const tocDoc = {
type: 'doc',
content: [
{ type: 'tableOfContents', attrs: { entries: [
{ text: 'Alpha', level: 1, page: 1 },
{ text: 'Beta', level: 2, page: 2 },
] } },
heading(1, 'Alpha'),
para('body of alpha'),
heading(2, 'Beta'),
],
};
it('exports a TOC field (updateFields) and re-imports it as a tableOfContents node', async () => {
const bytes = await buildDocx(tocDoc as any);
const files = unzipSync(bytes);
const docXml = strFromU8(files['word/document.xml']);
expect(/\bTOC\b/.test(docXml)).toBe(true);
const settings = files['word/settings.xml'];
expect(settings ? strFromU8(settings).includes('updateFields') : false).toBe(true);
const res = importDocx(bytes).content as N;
const tocs = walk(res, 'tableOfContents');
expect(tocs.length).toBe(1);
// Headings survive alongside the TOC.
const headings = walk(res, 'heading');
expect(headings.map(h => walk(h, 'text')[0]?.text)).toEqual(['Alpha', 'Beta']);
});
});
+40
View File
@@ -542,3 +542,43 @@ describe('Leg 4: foreign header/footer → importOdt', () => {
fhf.warnings.some(w => /per-page/i.test(w)), fhf.warnings);
});
});
describe('Leg 5: table of contents (text:table-of-content)', () => {
const tocDoc: N = {
type: 'doc',
content: [
{ type: 'tableOfContents', attrs: { entries: [
{ text: 'Introduction', level: 1, page: 1 },
{ text: 'Background & Aims', level: 2, page: 2 },
{ text: 'Deep Dive', level: 3, page: 3 },
] } },
H({ level: 1 }, T('Introduction')),
P(null, T('intro text')),
H({ level: 2 }, T('Background & Aims')),
H({ level: 3 }, T('Deep Dive')),
],
};
it('exports a real <text:table-of-content> and re-imports it as a tableOfContents node', async () => {
const bytes = await buildOdt(tocDoc, margins, 'portrait');
const content = strFromU8(unzipSync(bytes)['content.xml']);
check('emits <text:table-of-content>', content.includes('<text:table-of-content '), content.slice(0, 200));
check('source spans outline-level 3', content.includes('text:outline-level="3"'));
check('mints Contents_20_1 style', content.includes('style:name="Contents_20_1"'));
check('cached entry carries a tab + page', /Contents_20_1">Introduction<text:tab\/>1<\/text:p>/.test(content));
check('ampersand in entry text is escaped', content.includes('Background &amp; Aims'));
check('no leftover sentinel', !content.includes(''));
const res = importOdt(bytes);
const blocks = res.content.content ?? [];
const toc = blocks.find((n: N) => n.type === 'tableOfContents');
check('imports a tableOfContents node', !!toc, blocks.map((n: N) => n.type));
const entries = toc?.attrs?.entries ?? [];
check('3 entries parsed', entries.length === 3, entries);
check('entry 1 text/level', entries[0]?.text === 'Introduction' && entries[0]?.level === 1, entries[0]);
check('entry 2 text/level (ampersand)', entries[1]?.text === 'Background & Aims' && entries[1]?.level === 2, entries[1]);
check('entry 3 level 3', entries[2]?.level === 3, entries[2]);
check('headings after the TOC survive',
blocks.some((n: N) => n.type === 'heading' && n.content?.[0]?.text === 'Introduction'));
});
});