feat: table of content
This commit is contained in:
1 parent
504a202d6b
commit
a5bb2deaa2
12 files changed
+624
-10
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 & 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'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user