diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b517f5..03a62d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` (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 diff --git a/src/lib/components/ribbon/tabs/ReferencesTab.svelte b/src/lib/components/ribbon/tabs/ReferencesTab.svelte index c44b4b1..1515f48 100644 --- a/src/lib/components/ribbon/tabs/ReferencesTab.svelte +++ b/src/lib/components/ribbon/tabs/ReferencesTab.svelte @@ -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 @@ - editor?.chain().focus().setTableOfContents().run()} - /> +
+ toggleMenu('indexKind')} + /> + {#if isMenuOpen('indexKind')} + + {/if} +
`; `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). diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index d021470..4f1831b 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -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 = { + toc: 'Table of Contents', + figures: 'List of Figures', + tables: 'List of Tables', +}; + +const EMPTY_HINT: Record = { + 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 { 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; } diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 137e5ec..20da7d0 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 1d1da71..67dea9b 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 ( - `` + + `` + `` + `` ); @@ -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 = { + 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 = + ``; + const entry = (levelAttr: string, style: string) => + `` + + `${stop}` + + `` + + ``; + 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 = - `` + - (title ? `${escapeXml(title)}` : '') + - HEADING_LEVELS.filter(l => l <= toc.maxLevel) - .map( - l => - `` + - `` + - `` + - `` + - `` + - `` + - ``, - ) - .join('') + - ``; + `` + + (title ? `${escapeXml(title)}` : '') + + templates + + ``; const body = `` + (title ? `` + - `${escapeXml(title)}` + + `${escapeXml(title)}` + `` : '') + toc.entries .map(e => `${escapeXml(e.text).replace(/\n/g, '')}${e.page}`) .join('') + ``; - return `${source}${body}`; + return `${source}${body}`; } // BMS/BME/XRF sentinels → , 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); diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index f2032f8..68ef052 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 5dc1eb6..a17701b 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 26d3d08..8a42075 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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 { diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 132ed51..18f4873 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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 → 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 = { + '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 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 = { entries, title, maxLevel, leader, tabPosCm }; + const attrs: Record = { entries, title, maxLevel, leader, tabPosCm, index: indexKind }; if (levelStyles.some(Boolean)) attrs.levelStyles = Array.from(levelStyles, (s) => s ?? null); return { type: 'tableOfContents', attrs }; } diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index eeced41..68998e2 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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(' 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('