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:
1 parent
53aacd9f77
commit
f455a7c82f
11 files changed
+243
-76
No files matched your search
+1
-1
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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+"Figure"/.test(xml) && /TOC[^<]*\\c\s+"Table"/.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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user