feat: a list of figures and a list of tables

The same generated index the table of contents is, over the captions of one
category instead of the headings — an `index` attr picks the family, so the
node view, the page numbers and the leader dots are shared. Round-trips as
ODF text:illustration-index / text:table-index, found by the counter's name
the way LibreOffice finds them, and Word's TOC \c "Figure" field.
This commit is contained in:
Steffen Becker committed 2026-08-14 06:29:43 +02:00
1 parent 53aacd9f77
commit f455a7c82f
11 files changed
+243 -76

No files matched your search

+1 -1
View File
@@ -115,6 +115,7 @@
- ODT import / open existing file: parses content.xml / styles.xml directly, adopts the file's margins and orientation, and reports graceful-degradation warnings
- Opens `.ott` templates (OpenDocument Text Template): read like an `.odt`, but as a new untitled document — the first Save writes a fresh `.odt` and never overwrites the template (Word/LibreOffice behavior)
- Embedded font loading: fonts embedded in an opened `.odt`/`.docx` (Word `.odttf` de-obfuscated) are registered via the FontFace API so text renders in its real face even when the font isn't installed; persisted per-document in IndexedDB so it survives a reload. Fonts the document only names but neither embeds nor installs are still flagged as substituted
- List of figures and list of tables (References ▸ Table of contents ▸ the two caption indexes): the same generated index the table of contents is, over the captions of one category instead of the headings — single-level, with live page numbers and leader dots. Round-trips as ODF `text:illustration-index` / `text:table-index` (found by the counter's name, as LibreOffice finds them) and Word's `TOC \c "Figure"` field
- Captions (References ▸ Caption, LibreOffice's Insert ▸ Caption): a caption paragraph in the `Caption` style with a running number that counts itself — one counter per category (figure / table), in document order, renumbering as the document is edited. Round-trips as an ODF `<text:sequence>` (with LibreOffice's own `ooow:Illustration+1` formula) and a Word `SEQ Figure \* ARABIC` field
- Word (.docx) export and import — round-trips the editor's formatting (text, fonts, lists, tables, images, headers/footers, page geometry) and opens real Word documents
- Document properties (title, subject, author, keywords, comments) in the File menu — LibreOffice's File ▸ Properties / Word's File ▸ Info. Round-trips through ODF `meta.xml` (one `meta:keyword` per keyword) and DOCX `docProps/core.xml`
@@ -133,7 +134,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
**Missing while writing**
- Hyphenation beyond the document switch: a per-paragraph "don't hyphenate", and the zone / ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither
- The lists built from captions (list of figures / tables). Captions themselves are implemented (see above); the TOC machinery exists, the other index families do not
- Restarting the page numbering **per section** (only the document's own format and start value exist)
- Heading levels 7–10 (Word and LibreOffice go that far; HTML stops at `h6`)
- Alphabetical index and bibliography
@@ -3,6 +3,7 @@
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import CaptionDialog from '../../CaptionDialog.svelte';
import type { IndexKind } from '../../../editor/extensions/tableOfContents';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import type { HfZone } from '../../../storage/headerFooter';
import { t } from '../../../i18n/i18n.svelte';
@@ -16,17 +17,19 @@
onNoteOptions?: () => void;
} = $props();
// The levels button drives a table of contents only — a caption index has one level.
// The document holds at most one, so the first hit is it.
let toc = $derived.by<{ pos: number; maxLevel: number } | null>(() => {
if (tick < 0 || !editor) return null;
let found: { pos: number; maxLevel: number } | null = null;
editor.state.doc.descendants((node, pos) => {
if (found || node.type.name !== 'tableOfContents') return;
if (found || node.type.name !== 'tableOfContents' || node.attrs.index !== 'toc') return;
found = { pos, maxLevel: (node.attrs.maxLevel ?? 5) as number };
});
return found;
});
const LEVELS = HEADING_LEVELS;
const INDEX_KINDS: IndexKind[] = ['toc', 'figures', 'tables'];
let captionOpen = $state(false);
function setMaxLevel(level: number) {
@@ -37,14 +40,27 @@
</script>
<RibbonGroup label={t().ribbon.groups.toc}>
<RibbonButton
variant="big"
icon="toc"
label={t().ribbon.toc}
title={hfActive ? t().toolbarExpanded.tocNotInHf : t().toolbarExpanded.insertToc}
disabled={!editor || !!hfActive}
onclick={() => editor?.chain().focus().setTableOfContents().run()}
/>
<div class="rb-menu-wrap" use:clickOutside={'indexKind'}>
<RibbonButton
variant="big"
icon="toc"
label={t().ribbon.toc}
title={hfActive ? t().toolbarExpanded.tocNotInHf : t().toolbarExpanded.insertToc}
disabled={!editor || !!hfActive}
caret
active={isMenuOpen('indexKind')}
onclick={() => toggleMenu('indexKind')}
/>
{#if isMenuOpen('indexKind')}
<div class="ribbon-menu" use:anchored role="menu">
{#each INDEX_KINDS as k}
<button onclick={() => { closeMenu(); editor?.chain().focus().setTableOfContents(k).run(); }}>
{t().ribbon.indexes[k]}
</button>
{/each}
</div>
{/if}
</div>
<div class="rb-menu-wrap" use:clickOutside={'tocLevels'}>
<RibbonButton
variant="big"
+1 -1
View File
@@ -34,7 +34,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. `useRegex` takes the term as a pattern instead of escaping it (a whole-word search then wraps it in a non-capturing group first, so the `\b` guards bound the alternation); each match keeps its captures so `expandGroups` can fill `$1`…`$9`/`$&` in the replacement.
- **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu.
- **`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". `levelStyles` is the named paragraph style each level's entries use (ODF's per-level entry template, which is what LibreOffice regenerates from — the cached body paragraphs point at automatic styles derived from it): the rows carry it as `data-style`, so the document stylesheet gives them the file's own indent, space-before and font, and `editor.css`'s 0.5cm-per-level fallback applies only to an index that names none.
- **`tableOfContents.ts`** — a generated index. The `index` attr picks the family: `toc` lists 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". `levelStyles` is the named paragraph style each level's entries use (ODF's per-level entry template, which is what LibreOffice regenerates from — the cached body paragraphs point at automatic styles derived from it): the rows carry it as `data-style`, so the document stylesheet gives them the file's own indent, space-before and font, and `editor.css`'s 0.5cm-per-level fallback applies only to an index that names none. `figures`/`tables` instead list every caption paragraph counting that category (`caption.ts`), single-level as LibreOffice's are — ODF `text:illustration-index`/`text:table-index` (found by `text:caption-sequence-name`, not by outline level), Word the same TOC field with `\c "Figure"`.
- **`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 `pageField.ts` atoms. No image, no date field, no table.
- **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`/`chapterField`, rendered as `<span data-page-field>`; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value. `chapterField` is the running head (ODF `text:chapter`, Word `STYLEREF`): its `level` is the outline level to show, `text` the file's cached name, and the page's own chapter is the last heading at or above that level to have started (`Editor.svelte` collects the heading→page map on each pagination pass, only when a zone shows one).
+76 -21
View File
@@ -2,18 +2,39 @@ import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { MAX_HEADING_LEVEL } from '../../export/odt';
import { seqCategoryOf, sequenceFieldText, type SeqCategory } from './caption';
import { readVerticalMargins, pageOfElement, topInEditor, FORCE_PAGE_RECALC } from './pageBreaks';
// A generated table of contents: a block atom listing every heading (levels 1–5) 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.
// A generated index: a block atom listing every source with its live page number — the
// headings for a table of contents, the captions of one category for a list of figures
// or tables. The node view regenerates entries from the sources + pagination and caches
// them in `entries` (persisted like a Word/LO field); round-trips to ODF/DOCX.
export type TocEntry = { text: string; level: number; page: number };
// The heading above the entries. An imported TOC keeps the one its file used
// Which sources the index collects. ODF has an element per family
// (text:table-of-content / -illustration-index / -table-index), Word the TOC field's
// \c switch.
export type IndexKind = 'toc' | 'figures' | 'tables';
export function indexKindOf(value: unknown): IndexKind {
return value === 'figures' || value === 'tables' ? value : 'toc';
}
// The heading above the entries. An imported index keeps the one its file used
// ("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';
export const INDEX_TITLES: Record<IndexKind, string> = {
toc: 'Table of Contents',
figures: 'List of Figures',
tables: 'List of Tables',
};
const EMPTY_HINT: Record<IndexKind, string> = {
toc: 'No headings yet — add an H1/H2/H3 to build the contents.',
figures: 'No figure captions yet — insert one from the References tab.',
tables: 'No table captions yet — insert one from the References tab.',
};
// 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;
@@ -22,7 +43,7 @@ const LEADER_PROBE = 10;
declare module '@tiptap/core' {
interface Commands<ReturnType> {
tableOfContents: {
setTableOfContents: () => ReturnType;
setTableOfContents: (index?: IndexKind) => ReturnType;
};
}
}
@@ -73,6 +94,13 @@ export const TableOfContents = Node.create({
},
renderHTML: attrs => (attrs.levelStyles ? { 'data-toc-styles': JSON.stringify(attrs.levelStyles) } : {}),
},
// Which family of index this is. Absent = a table of contents, so a stored
// document from before the other two reads as one.
index: {
default: 'toc' as IndexKind,
parseHTML: el => indexKindOf((el as HTMLElement).getAttribute('data-toc-index')),
renderHTML: attrs => (attrs.index && attrs.index !== 'toc' ? { 'data-toc-index': String(attrs.index) } : {}),
},
entries: {
default: [] as TocEntry[],
parseHTML: el => {
@@ -89,15 +117,15 @@ export const TableOfContents = Node.create({
},
renderHTML({ HTMLAttributes, node }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-toc': 'true' }), tocTitle(node.attrs.title)];
return ['div', mergeAttributes(HTMLAttributes, { 'data-toc': 'true' }), tocTitle(node.attrs.title, node.attrs.index)];
},
addCommands() {
return {
setTableOfContents:
() =>
(index = 'toc') =>
({ commands }) =>
commands.insertContent({ type: this.name }),
commands.insertContent({ type: this.name, attrs: { index } }),
};
},
@@ -107,10 +135,24 @@ export const TableOfContents = Node.create({
});
// `''` 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);
const tocTitle = (title: unknown, index: unknown): string =>
typeof title === 'string' ? title : INDEX_TITLES[indexKindOf(index)];
type HeadingRef = { text: string; level: number; pos: number };
// A block's text with its atoms spelled out: a hard break is a line of the entry (a
// book's "Chapter 1" / title pair is two lines in LibreOffice's own index) and a
// sequence field is its number, which is most of what a caption entry says.
function entryText(node: PMNode): string {
let raw = '';
node.forEach((child) => {
raw += child.type.name === 'hardBreak' ? '\n'
: child.type.name === 'sequenceField' ? sequenceFieldText(child)
: child.textContent;
});
return raw.trim();
}
// 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.
@@ -150,17 +192,30 @@ class TocView {
});
}
// 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);
// What the index lists, in document order: the headings down to its own level, or —
// for a list of figures/tables — every caption paragraph counting that category. A
// caption index has one level, as LibreOffice's does.
private sources(): HeadingRef[] {
const kind = indexKindOf(this.node()?.attrs?.index);
const out: HeadingRef[] = [];
if (kind !== 'toc') {
const category: SeqCategory = kind === 'tables' ? 'table' : 'figure';
this.editor.state.doc.descendants((node, pos) => {
if (!node.isTextblock) return true;
let has = false;
node.forEach((child) => {
if (child.type.name === 'sequenceField' && seqCategoryOf(child.attrs.category as string) === category) has = true;
});
const text = has ? entryText(node) : '';
if (text) out.push({ text, level: 1, pos });
return false;
});
return out;
}
const max = Math.min(MAX_HEADING_LEVEL, Number(this.node()?.attrs?.maxLevel) || MAX_HEADING_LEVEL);
this.editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
// A hard break inside the heading is a line of the entry too — a book's
// "Chapter 1" / title pair is two lines in LibreOffice's own index.
let raw = '';
node.forEach((child) => { raw += child.type.name === 'hardBreak' ? '\n' : child.textContent; });
const text = raw.trim();
const text = entryText(node);
const level = Math.min(MAX_HEADING_LEVEL, (node.attrs.level as number) ?? 1);
if (text && level <= max) out.push({ text, level, pos });
}
@@ -180,7 +235,7 @@ class TocView {
private render(): void {
if (this.editor.isDestroyed || !this.dom.isConnected) return;
const heads = this.headings();
const heads = this.sources();
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);
@@ -225,7 +280,7 @@ class TocView {
private paint(entries: TocEntry[], heads: HeadingRef[]): void {
this.dom.textContent = '';
const titleText = tocTitle(this.node()?.attrs?.title);
const titleText = tocTitle(this.node()?.attrs?.title, this.node()?.attrs?.index);
if (titleText) {
const title = document.createElement('div');
title.className = 'toc-title';
@@ -236,7 +291,7 @@ class TocView {
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.';
empty.textContent = EMPTY_HINT[indexKindOf(this.node()?.attrs?.index)];
this.dom.appendChild(empty);
return;
}
+12 -5
View File
@@ -24,6 +24,7 @@ import type { SpacingModel } from '../storage/spacingModel';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
import { DOCX_SEQ_NAME, seqCategoryOf } from '../editor/extensions/caption';
import { indexKindOf, INDEX_TITLES } from '../editor/extensions/tableOfContents';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding';
@@ -1171,15 +1172,21 @@ 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 TOC field over the index's own heading levels, populated and linked on
// field update (features.updateFields). The title is a plain bold paragraph so it
// isn't itself listed, and is omitted where the index has none.
// A real TOC field, populated and linked on field update (features.updateFields).
// A list of figures/tables is the same field over a caption label (\c) instead of
// the heading levels. The title is a plain bold paragraph so it isn't itself
// listed, and is omitted where the index has none.
const kind = indexKindOf(node.attrs?.index);
const rawTitle = node.attrs?.title;
const tocTitle = typeof rawTitle === 'string' ? rawTitle : 'Table of Contents';
const tocTitle = typeof rawTitle === 'string' ? rawTitle : INDEX_TITLES[kind];
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}` }));
out.push(new TableOfContents(tocTitle, kind === 'toc'
? { hyperlink: true, headingStyleRange: `1-${maxLevel}` }
// `\c`, not `\a`: Word's own Insert Table of Figures keeps the label and number
// in the entry, which is what the editor's cached entries already read.
: { hyperlink: true, captionLabelIncludingNumbers: DOCX_SEQ_NAME[kind === 'tables' ? 'table' : 'figure'] }));
}
}
return out;
+51 -29
View File
@@ -25,6 +25,7 @@ import { normalizeLeader, parseTabStops } from '../editor/extensions/tabStops';
import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker';
import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, formatOrdinal, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { ODF_SEQ_NAME, seqCategoryOf, type SeqCategory } from '../editor/extensions/caption';
import { indexKindOf, INDEX_TITLES, type IndexKind } from '../editor/extensions/tableOfContents';
import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes';
import { findFormat, renderFormat, odfNumberStyle, toDateValue, toTimeValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime';
import { parseLatex } from '../math/latex';
@@ -869,7 +870,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; leader: string | null; tabPosCm: number | null; levelStyles: (string | null)[] | null };
type TocExport = { kind: IndexKind; entries: TocEntry[]; title: string | null; maxLevel: number; leader: string | null; tabPosCm: number | null; levelStyles: (string | null)[] | 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
@@ -890,6 +891,7 @@ function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode
const rawTitle = child.attrs?.title;
const depth = Number(child.attrs?.maxLevel);
tocs.push({
kind: indexKindOf(child.attrs?.index),
entries,
title: typeof rawTitle === 'string' ? rawTitle : null,
maxLevel: depth >= 1 ? Math.min(MAX_HEADING_LEVEL, depth) : MAX_HEADING_LEVEL,
@@ -3542,10 +3544,10 @@ function contentsEntryStyle(name: string, level: number, tabPosCm: number): stri
);
}
// The TOC title style (bold, 16pt) — mirrors the .toc-title on screen.
function contentsHeadingStyle(): string {
// The index title style (bold, 16pt) — mirrors the .toc-title on screen.
function contentsHeadingStyle(name: string): string {
return (
`<style:style style:name="Contents_20_Heading" style:family="paragraph" style:parent-style-name="Standard">` +
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="Standard">` +
`<style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0.3cm"/>` +
`<style:text-properties fo:font-size="16pt" fo:font-weight="bold"/></style:style>`
);
@@ -3555,42 +3557,55 @@ function contentsHeadingStyle(): string {
// automatic Contents_20_N this export mints.
function tocLevelStyle(toc: TocExport, level: number): string {
const own = toc.levelStyles?.[level - 1];
return own ? odfStyleName(own) : `Contents_20_${level}`;
return own ? odfStyleName(own) : `${ODF_INDEX[toc.kind].entryStyle}${level}`;
}
// ODF has one element family per index. A caption index is single-level and finds its
// entries by the counter's name, so its template carries no text:outline-level.
const ODF_INDEX: Record<IndexKind, { el: string; heading: string; entryStyle: string; docName: string; seq?: string }> = {
toc: { el: 'table-of-content', heading: 'Contents_20_Heading', entryStyle: 'Contents_20_', docName: 'Table of Contents' },
figures: { el: 'illustration-index', heading: 'Illustration_20_Index_20_Heading', entryStyle: 'Illustration_20_Index_20_', docName: 'Illustration Index', seq: 'Illustration' },
tables: { el: 'table-index', heading: 'Table_20_Index_20_Heading', entryStyle: 'Table_20_Index_20_', docName: 'Table Index', seq: 'Table' },
};
function tocXml(toc: TocExport, index: number): string {
const title = toc.title ?? 'Table of Contents';
const name = `Table of Contents${index + 1}`;
const spec = ODF_INDEX[toc.kind];
const title = toc.title ?? INDEX_TITLES[toc.kind];
const name = `${spec.docName}${index + 1}`;
const stop =
`<text:index-entry-tab-stop style:type="right"` +
`${toc.tabPosCm ? ` style:position="${toc.tabPosCm}cm"` : ''}` +
`${toc.leader ? ` style:leader-char="${escapeXml(toc.leader)}"` : ''}/>`;
const entry = (levelAttr: string, style: string) =>
`<text:${spec.el}-entry-template${levelAttr} text:style-name="${style}">` +
`<text:index-entry-link-start/><text:index-entry-text/>${stop}` +
`<text:index-entry-page-number/><text:index-entry-link-end/>` +
`</text:${spec.el}-entry-template>`;
const sourceAttrs = spec.seq
? ` text:use-caption="true" text:caption-sequence-name="${spec.seq}" text:caption-sequence-format="category-and-value"`
: ` text:outline-level="${toc.maxLevel}" text:use-index-marks="false" text:use-index-source-styles="false"`;
const templates = spec.seq
? entry('', tocLevelStyle(toc, 1))
: HEADING_LEVELS.filter(l => l <= toc.maxLevel)
.map(l => entry(` text:outline-level="${l}"`, tocLevelStyle(toc, l)))
.join('');
const source =
`<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="${tocLevelStyle(toc, l)}">` +
`<text:index-entry-link-start/>` +
`<text:index-entry-text/>` +
`<text:index-entry-tab-stop style:type="right"` +
`${toc.tabPosCm ? ` style:position="${toc.tabPosCm}cm"` : ''}` +
`${toc.leader ? ` style:leader-char="${escapeXml(toc.leader)}"` : ''}/>` +
`<text:index-entry-page-number/>` +
`<text:index-entry-link-end/>` +
`</text:table-of-content-entry-template>`,
)
.join('') +
`</text:table-of-content-source>`;
`<text:${spec.el}-source${sourceAttrs}>` +
(title ? `<text:index-title-template text:style-name="${spec.heading}">${escapeXml(title)}</text:index-title-template>` : '') +
templates +
`</text:${spec.el}-source>`;
const body =
`<text:index-body>` +
(title
? `<text:index-title text:name="${escapeXml(name)}_Head">` +
`<text:p text:style-name="Contents_20_Heading">${escapeXml(title)}</text:p>` +
`<text:p text:style-name="${spec.heading}">${escapeXml(title)}</text:p>` +
`</text:index-title>`
: '') +
toc.entries
.map(e => `<text:p text:style-name="${tocLevelStyle(toc, e.level)}">${escapeXml(e.text).replace(/\n/g, '<text:line-break/>')}<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>`;
return `<text:${spec.el} text:name="${escapeXml(name)}" text:protected="true">${source}${body}</text:${spec.el}>`;
}
// BMS/BME/XRF sentinels → <text:bookmark-start/>, <text:bookmark-end/> and
@@ -3724,9 +3739,16 @@ function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: numbe
content = content.split(TOC_SENT).join('');
const tabPosCm = Math.max(1, Math.round(contentWidthCm * 1000) / 1000);
const styles =
contentsHeadingStyle() +
HEADING_LEVELS.map(l => contentsEntryStyle(`Contents_20_${l}`, l, tabPosCm)).join('');
// One set of entry styles per index family actually present; a caption index has the
// single level its ODF template declares.
const styles = [...new Set(tocs.map(t => t.kind))]
.map((kind) => {
const spec = ODF_INDEX[kind];
const levels = kind === 'toc' ? HEADING_LEVELS : [1];
return contentsHeadingStyle(spec.heading)
+ levels.map(l => contentsEntryStyle(`${spec.entryStyle}${l}`, l, tabPosCm)).join('');
})
.join('');
content = injectAutomaticStyles(content, styles);
files['content.xml'] = strToU8(content);
+1
View File
@@ -647,6 +647,7 @@ const de: Messages = {
tocOptions: 'Ebenen',
tocMaxLevel: 'Tiefste angezeigte Ebene',
insertCaption: 'Beschriftung',
indexes: { toc: 'Inhaltsverzeichnis', figures: 'Abbildungsverzeichnis', tables: 'Tabellenverzeichnis' },
footnote: 'Fußnote',
endnote: 'Endnote',
noteOptions: 'Optionen',
+1
View File
@@ -649,6 +649,7 @@ const en = {
},
toc: 'Table of contents',
insertCaption: 'Caption',
indexes: { toc: 'Table of contents', figures: 'List of figures', tables: 'List of tables' },
tocOptions: 'Levels',
tocMaxLevel: 'Deepest level shown',
footnote: 'Footnote',
+14 -2
View File
@@ -26,6 +26,7 @@ import { chartDataUrl } from './chart';
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
import { ODF_SEQ_CATEGORY } from '../editor/extensions/caption';
import type { IndexKind } from '../editor/extensions/tableOfContents';
import { clampColumnGap } from '../editor/extensions/columns';
import { astToLatex } from '../math/latex';
import { parseOmml, OMML_NS } from '../math/omml';
@@ -327,6 +328,14 @@ function tocMaxLevel(instr: string): number {
return n >= 1 ? Math.min(MAX_HEADING_LEVEL, n) : MAX_HEADING_LEVEL;
}
// The TOC field's \c switch names a caption label, which makes it a list of figures or
// tables rather than a contents. Word's own labels and LibreOffice's travel alike.
function tocIndexKind(instr: string): IndexKind {
const m = /\\c\s+"?([^"\\]+)/.exec(instr);
const cat = m ? ODF_SEQ_CATEGORY[m[1].trim()] : undefined;
return cat === 'table' ? 'tables' : cat === 'figure' ? 'figures' : 'toc';
}
const instrTextOf = (el: Element): string =>
Array.from(el.getElementsByTagNameNS(W, 'instrText')).map(i => i.textContent ?? '').join('');
@@ -478,7 +487,8 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
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)) } });
const instr = instrTextOf(el);
out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', maxLevel: tocMaxLevel(instr), index: tocIndexKind(instr) } });
}
if (startedInToc || emit) continue;
const num = paragraphNum(el, ctx);
@@ -525,10 +535,12 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
// A content-control-wrapped TOC → one node (regenerated live); skip its content.
if (kind === 'body') {
const content = fc(el, 'sdtContent');
const instr = content ? instrTextOf(content) : '';
out.push({ type: 'tableOfContents', attrs: {
entries: [],
title: tocHeading(content) ?? '',
maxLevel: tocMaxLevel(content ? instrTextOf(content) : ''),
maxLevel: tocMaxLevel(instr),
index: tocIndexKind(instr),
} });
}
} else {
+16 -8
View File
@@ -25,6 +25,7 @@ import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperti
import { clampPageStart, type PageNumbering } from '../storage/pageNumbering';
import { newCommentId } from '../editor/extensions/comment';
import { ODF_SEQ_CATEGORY } from '../editor/extensions/caption';
import type { IndexKind } from '../editor/extensions/tableOfContents';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
@@ -980,9 +981,10 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef
const cols = ctx.resolver.sectionColumns(el.getAttributeNS(NS.text, 'style-name'));
if (kind === 'body' && cols) pushColumnRuns(inner, cols, out, ctx);
else out.push(...inner);
} else if (el.localName === 'table-of-content' && kind === 'body') {
// Generated table of contents → a tableOfContents node (regenerated live).
out.push(convertToc(el, ctx));
} else if (ODF_INDEX_KIND[el.localName] && kind === 'body') {
// A generated contents / list of figures / list of tables → a tableOfContents
// node of that family (regenerated live).
out.push(convertToc(el, ctx, ODF_INDEX_KIND[el.localName]!));
} else if (/-index$|^table-of-content$|^bibliography$/.test(el.localName)) {
// Other generated indexes (bibliography, …) or a TOC nested in a cell: keep the
// rendered text from index-body.
@@ -1026,7 +1028,11 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef
// 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, ctx: Ctx): Node {
const ODF_INDEX_KIND: Record<string, IndexKind | undefined> = {
'table-of-content': 'toc', 'illustration-index': 'figures', 'table-index': 'tables',
};
function convertToc(el: Element, ctx: Ctx, indexKind: IndexKind): Node {
const indexBody = el.getElementsByTagNameNS(NS.text, 'index-body')[0];
const entries: { text: string; level: number; page: number }[] = [];
if (indexBody) {
@@ -1043,7 +1049,8 @@ function convertToc(el: Element, ctx: Ctx): Node {
// 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 family = el.localName;
const source = el.getElementsByTagNameNS(NS.text, `${family}-source`)[0];
const depth = Number(source?.getAttributeNS(NS.text, 'outline-level'));
const maxLevel = depth >= 1 ? Math.min(MAX_HEADING_LEVEL, depth) : MAX_HEADING_LEVEL;
// The fill between an entry and its page number. A template that declares the stop but
@@ -1055,7 +1062,7 @@ function convertToc(el: Element, ctx: Ctx): Node {
// Where the page number ends: the entry template's own stop, else the right stop of
// the paragraph style its entries use. Word and LibreOffice both put it short of the
// text width in some templates, and the number then hangs 45mm out of place.
const templates = Array.from(source?.getElementsByTagNameNS(NS.text, 'table-of-content-entry-template') ?? []);
const templates = Array.from(source?.getElementsByTagNameNS(NS.text, `${family}-entry-template`) ?? []);
const template = templates[0];
const styled = ctx.resolver.tabStops(template?.getAttributeNS(NS.text, 'style-name') ?? null);
const tabPosCm = lengthToCm(stop?.getAttributeNS(NS.style, 'position'))
@@ -1064,13 +1071,14 @@ function convertToc(el: Element, ctx: Ctx): Node {
// stylesheet gives them the file's indent, spacing and font instead of our defaults.
const levelStyles: (string | null)[] = [];
for (const tpl of templates) {
const level = Number(tpl.getAttributeNS(NS.text, 'outline-level'));
// A caption index has one level and declares no text:outline-level.
const level = Number(tpl.getAttributeNS(NS.text, 'outline-level')) || 1;
const named = ctx.resolver.namedAncestor(tpl.getAttributeNS(NS.text, 'style-name'));
if (!(level >= 1) || !named) continue;
ctx.usedStyles.add(named);
levelStyles[level - 1] = ctx.styleNames.get(named) ?? named;
}
const attrs: Record<string, unknown> = { entries, title, maxLevel, leader, tabPosCm };
const attrs: Record<string, unknown> = { entries, title, maxLevel, leader, tabPosCm, index: indexKind };
if (levelStyles.some(Boolean)) attrs.levelStyles = Array.from(levelStyles, (s) => s ?? null);
return { type: 'tableOfContents', attrs };
}
+45
View File
@@ -1997,3 +1997,48 @@ describe('Leg 19: captions (a sequence field per category)', () => {
check('no SEQ field', !strFromU8(unzipSync(await buildDocx(plain, margins, 'portrait'))['word/document.xml']).includes('SEQ '));
});
});
describe('Leg 20: list of figures / list of tables', () => {
const idx = (index: string, ...entries: { text: string; level: number; page: number }[]): N =>
({ type: 'tableOfContents', attrs: { index, title: null, maxLevel: 5, leader: '.', tabPosCm: null, entries } });
const doc: N = { type: 'doc', content: [
idx('figures', { text: 'Figure 1: a picture', level: 1, page: 1 }),
idx('tables', { text: 'Table 1: a table', level: 1, page: 2 }),
P(null, T('body')),
] };
it('ODT: the two caption index families, and back', async () => {
const bytes = await buildOdt(doc, margins, 'portrait');
const content = strFromU8(unzipSync(bytes)['content.xml']);
check('illustration index emitted', content.includes('<text:illustration-index '), content.slice(0, 200));
check('table index emitted', content.includes('<text:table-index '), content.slice(0, 200));
check('found by the counter name', content.includes('text:caption-sequence-name="Illustration"')
&& content.includes('text:caption-sequence-name="Table"'),
content.match(/text:caption-sequence-name="[^"]*"/g));
const back = importOdt(bytes).content as N;
check('kinds round-trip', back.content.slice(0, 2).map((n: N) => n.attrs?.index).join() === 'figures,tables',
back.content.map((n: N) => [n.type, n.attrs?.index]));
check('entries survive', back.content[0].attrs?.entries?.[0]?.text === 'Figure 1: a picture',
back.content[0].attrs?.entries);
});
it('DOCX: a TOC field over a caption label, and back', async () => {
const bytes = await buildDocx(doc, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
// The instruction is an XML attribute, so its quotes arrive escaped.
check('\\c switches emitted', /TOC[^<]*\\c\s+&quot;Figure&quot;/.test(xml) && /TOC[^<]*\\c\s+&quot;Table&quot;/.test(xml),
xml.match(/TOC[^<]*/g));
const back = importDocx(bytes).content as N;
const kinds = back.content.filter((n: N) => n.type === 'tableOfContents').map((n: N) => n.attrs?.index);
check('kinds round-trip', kinds.join() === 'figures,tables', kinds);
});
it('a plain table of contents is unchanged by the new families', async () => {
const plain: N = { type: 'doc', content: [idx('toc', { text: 'Heading', level: 1, page: 1 })] };
const content = strFromU8(unzipSync(await buildOdt(plain, margins, 'portrait'))['content.xml']);
check('still a table-of-content', content.includes('<text:table-of-content ')
&& !content.includes('caption-sequence-name'), content.slice(0, 200));
check('reads back as toc', (importOdt(await buildOdt(plain, margins, 'portrait')).content as N)
.content[0].attrs?.index === 'toc');
});
});