diff --git a/CHANGELOG.md b/CHANGELOG.md index ca3febb..7913990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ - Date and time fields: picker with 7 date and 4 time formats (live samples) and an "update automatically" toggle — fixed fields keep the inserted moment, auto fields refresh on open. Round-trips to ODF `text:date`/`text:time` (minted `number:date/time-style`) and DOCX `DATE`/`TIME` fields; the field carries the surrounding font - Page numbering options (Layout ▸ Page numbers): the five formats both word processors offer (1 / i / I / a / A) and a start value. Round-trips to ODF `style:num-format` on the page layout plus `style:page-number` on the first paragraph — where LibreOffice keeps the start, ODF having no document-level one — and to Word's `w:pgNumType` - Table of contents: generated from headings (H1–H5) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field +- Alphabetical index (References ▸ Index entry, then the Index in the same menu): mark a word — under a key of its own where the index should file it elsewhere — and the index lists every term once, sorted case-insensitively, with all the pages it was marked on. Round-trips as ODF `text:alphabetical-index-mark` + `text:alphabetical-index` (LibreOffice reads both back unchanged) and as Word's `XE` and `INDEX` fields - Comments: annotate a selection (Review tab or the context menu) and work through them in the reviewing pane — edit, resolve, remove, click one to jump to the text it marks. Round-trips to ODF `office:annotation`/`office:annotation-end` (with LibreOffice's `loext:resolved`) and to Word's `w:commentRangeStart`/`-End` + `word/comments.xml`; a LibreOffice point comment, which has no range, attaches to the run before it - Track changes (Review ▸ Record changes): while recording, typed text is marked as an insertion and deleted text stays in the document struck through instead of going away, each carrying its author and date; consecutive typing is one change, and accept / reject work on the change at the cursor or on all of them. Round-trips to ODF's `text:tracked-changes` registry — `text:change-start`/`-end` around an insertion, `text:change` where a deletion was, its text kept in the registry — and to Word's `w:ins`/`w:del` + `w:delText` - Footnotes and endnotes (Ctrl+Alt+F / Ctrl+Alt+D): a footnote is drawn at the foot of the page its anchor sits on, with the separator line above it and the body text moved up to make room; endnotes are collected on their own page at the document end. Notes renumber themselves as anchors move, and deleting an anchor deletes its note. An options dialog covers numbering format, start value, restart, position, prefix/suffix, the two styles and the separator's length, thickness, spacing, alignment and colour. Round-trips to ODF `text:note` + `text:notes-configuration` and to Word's `word/footnotes.xml`/`endnotes.xml` + `w:footnotePr` @@ -143,7 +144,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. - 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 - 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 +- Bibliography: neither the entry (ODF `text:bibliography-mark`, Word's `CITATION` field over a source in its own custom-XML part) nor the generated list. The alphabetical index above is the same machinery, so this is a matter of the entry's fields and a source list, not of new plumbing - Section-level page **size and orientation**: only the margins are per section. A file whose sections disagree on the page size keeps the last one's - Table extras: sort, sum/formula in a cell, number recognition - A list level's own hanging indent: the marker sits at the 0.635 cm both exports write (`LIST_HANGING_CM`), so a wider *left*-set marker overflows where Word moves the text to the next list tab (a **right**-set one — `w:lvlJc`, which is what the built-in Roman numberings use — grows into the margin and is fine). Reading the value back naively also moves the markers of our own ODT exports — LibreOffice draws its own flat hanging at exactly the value odf-kit writes for level 2; see `tests/render-parity/README.md` before building on it diff --git a/src/lib/components/ribbon/tabs/ReferencesTab.svelte b/src/lib/components/ribbon/tabs/ReferencesTab.svelte index 1515f48..60e2678 100644 --- a/src/lib/components/ribbon/tabs/ReferencesTab.svelte +++ b/src/lib/components/ribbon/tabs/ReferencesTab.svelte @@ -29,8 +29,18 @@ return found; }); const LEVELS = HEADING_LEVELS; - const INDEX_KINDS: IndexKind[] = ['toc', 'figures', 'tables']; + const INDEX_KINDS: IndexKind[] = ['toc', 'figures', 'tables', 'alphabetical']; let captionOpen = $state(false); + let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty); + + // The selected text is the term unless the reader gives another — the same prompt + // both word processors open, which fills itself from the selection. + function markIndexEntry() { + if (!editor) return; + const { from, to } = editor.state.selection; + const term = prompt(t().ribbon.indexEntryPrompt, editor.state.doc.textBetween(from, to, ' ')); + if (term?.trim()) editor.chain().focus().setTextSelection(to).insertIndexEntry(term).run(); + } function setMaxLevel(level: number) { closeMenu(); @@ -89,6 +99,14 @@ disabled={!editor || !!hfActive} onclick={() => (captionOpen = true)} /> + diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index b75c5f5..d5d068b 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -52,6 +52,7 @@ import { TrailingNode } from './extensions/trailingNode'; import { Image } from './extensions/image'; import { DateTimeField } from './extensions/dateTimeField'; import { SequenceField } from './extensions/caption'; +import { IndexEntry } from './extensions/indexEntry'; import { CrossReference } from './extensions/crossReference'; import { Formula } from './extensions/formula'; import { TextBox } from './extensions/textBox'; @@ -111,6 +112,9 @@ export const extensions = [ // A caption's running number, one counter per category in document order. Round-trips // to an ODF and a DOCX SEQ field. SequenceField, + // A place marked for the alphabetical index. Round-trips to ODF + // and a Word XE field. + IndexEntry, // Inline reference to a bookmark, showing its text or its page; kept live by its // node view. Round-trips to ODF and DOCX REF/PAGEREF fields. CrossReference, diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index c3547d1..5d8c896 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -36,6 +36,7 @@ Five topics are large enough to have their own deep-dive — read the file befor - **`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. - **`outline.ts`** — the document's chapters for `NavigatorPane.svelte`: every **top-level** heading owning the blocks up to the next heading of its level or above (one inside a cell or a text box is not a chapter — moving it would move it out of its container). The two operations are pure functions the commands only apply: `chapterMove` returns the range to lift and where to put it back — the insert position for a move down is already corrected for the delete — and `chapterLevels` the headings a promote/demote rewrites, null when any of the subtree would leave 1…`maxLevel`. A move swaps with the adjacent chapter at the **same** level and stops at a heading above it, so a subheading never reparents itself; the level shift rewrites `styleName` too, or the paragraph would keep the old heading's look. +- **`indexEntry.ts`** — one place marked for the alphabetical index: an inline **atom**, not a mark, because that is what both formats write (a point `text:alphabetical-index-mark`, a Word `XE` field). It carries the term, so the marked text and the word the index lists can differ, plus `key1` for the heading it files under. `indexRows` merges the marks into the index's rows — one per term, its pages de-duplicated and sorted, the terms sorted case-insensitively by locale. - **`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. diff --git a/src/lib/editor/extensions/indexEntry.ts b/src/lib/editor/extensions/indexEntry.ts new file mode 100644 index 0000000..3d6ed58 --- /dev/null +++ b/src/lib/editor/extensions/indexEntry.ts @@ -0,0 +1,97 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; + +// One entry of the alphabetical index: an inline atom marking the place a term is +// discussed. It carries the term itself, so the marked text and the word the index +// lists can differ — which is the point of marking rather than searching. +// +// LibreOffice writes a point , +// Word an XE field. Both are places, not ranges, so this is an atom and not a mark. + +export type IndexEntry = { pos: number; term: string; key1: string }; + +declare module '@tiptap/core' { + interface Commands { + indexEntry: { + /** Mark the cursor's position as an entry for `term` (under `key1` if given). */ + insertIndexEntry: (term: string, key1?: string) => ReturnType; + }; + } +} + +export const IndexEntry = Node.create({ + name: 'indexEntry', + group: 'inline', + inline: true, + atom: true, + selectable: false, + + addAttributes() { + return { + term: { + default: '', + parseHTML: el => (el as HTMLElement).getAttribute('data-index-term') ?? '', + renderHTML: attrs => ({ 'data-index-term': String(attrs.term ?? '') }), + }, + // The heading this entry files under, where it differs from the term itself + // (LibreOffice's "1st key", Word's `XE "key:term"`). + key1: { + default: '', + parseHTML: el => (el as HTMLElement).getAttribute('data-index-key') ?? '', + renderHTML: attrs => (attrs.key1 ? { 'data-index-key': String(attrs.key1) } : {}), + }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-index-term]' }]; + }, + + renderHTML({ HTMLAttributes, node }) { + // Title, not text: the marker is a field, and its term belongs in the tooltip. + return ['span', mergeAttributes(HTMLAttributes, { + class: 'pm-index-entry', + title: String(node.attrs.term ?? ''), + })]; + }, + + addCommands() { + return { + insertIndexEntry: (term, key1 = '') => ({ commands }) => { + const text = term.trim(); + if (!text) return false; + return commands.insertContent({ type: this.name, attrs: { term: text, key1: key1.trim() } }); + }, + }; + }, +}); + +/** Every index entry in document order. */ +export function indexEntries(doc: PMNode): IndexEntry[] { + const out: IndexEntry[] = []; + doc.descendants((node, pos) => { + if (node.type.name !== 'indexEntry') return true; + const term = String(node.attrs.term ?? '').trim(); + if (term) out.push({ pos, term, key1: String(node.attrs.key1 ?? '').trim() }); + return false; + }); + return out; +} + +/** + * The index rows: one per term, its pages merged and de-duplicated, sorted the way + * both word processors sort an alphabetical index — case-insensitively, by locale. + * A term filed under a key sorts and prints under that key. + */ +export function indexRows(entries: { term: string; key1: string; page: number }[]): { text: string; pages: number[] }[] { + const byTerm = new Map }>(); + for (const e of entries) { + const text = e.key1 ? `${e.key1}: ${e.term}` : e.term; + const row = byTerm.get(text) ?? { text, pages: new Set() }; + row.pages.add(e.page); + byTerm.set(text, row); + } + return [...byTerm.values()] + .sort((a, b) => a.text.localeCompare(b.text, undefined, { sensitivity: 'base' })) + .map((r) => ({ text: r.text, pages: [...r.pages].sort((a, b) => a - b) })); +} diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index 4f1831b..e093366 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -3,6 +3,7 @@ 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 { indexEntries, indexRows } from './indexEntry'; import { readVerticalMargins, pageOfElement, topInEditor, FORCE_PAGE_RECALC } from './pageBreaks'; // A generated index: a block atom listing every source with its live page number — the @@ -10,15 +11,21 @@ import { readVerticalMargins, pageOfElement, topInEditor, FORCE_PAGE_RECALC } fr // 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 }; +export type TocEntry = { + text: string; + level: number; + page: number; + /** Alphabetical index only: every page the term appears on, `page` being the first. */ + pages?: number[]; +}; // 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 type IndexKind = 'toc' | 'figures' | 'tables' | 'alphabetical'; export function indexKindOf(value: unknown): IndexKind { - return value === 'figures' || value === 'tables' ? value : 'toc'; + return value === 'figures' || value === 'tables' || value === 'alphabetical' ? value : 'toc'; } // The heading above the entries. An imported index keeps the one its file used @@ -28,12 +35,14 @@ export const INDEX_TITLES: Record = { toc: 'Table of Contents', figures: 'List of Figures', tables: 'List of Tables', + alphabetical: 'Index', }; 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.', + alphabetical: 'No index entries yet — mark a word 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. @@ -198,6 +207,13 @@ class TocView { private sources(): HeadingRef[] { const kind = indexKindOf(this.node()?.attrs?.index); const out: HeadingRef[] = []; + if (kind === 'alphabetical') { + // One source per mark; render() merges them into a row per term. + for (const e of indexEntries(this.editor.state.doc)) { + out.push({ text: e.key1 ? `${e.key1}: ${e.term}` : e.term, level: 1, pos: e.pos }); + } + return out; + } if (kind !== 'toc') { const category: SeqCategory = kind === 'tables' ? 'table' : 'figure'; this.editor.state.doc.descendants((node, pos) => { @@ -235,9 +251,19 @@ class TocView { private render(): void { if (this.editor.isDestroyed || !this.dom.isConnected) return; - 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) })); + let heads = this.sources(); + let entries: TocEntry[]; + if (indexKindOf(this.node()?.attrs?.index) === 'alphabetical') { + // A term marked five times is one row with five page numbers, and the row jumps + // to the first of them. + const marks = heads.map(h => ({ ...h, page: this.pageOf(h.pos, cycle) })); + const rows = indexRows(marks.map(m => ({ term: m.text, key1: '', page: m.page }))); + entries = rows.map(r => ({ text: r.text, level: 1, page: r.pages[0], pages: r.pages })); + heads = rows.map(r => marks.find(m => m.text === r.text)!); + } else { + entries = heads.map(h => ({ text: h.text, level: h.level, page: this.pageOf(h.pos, cycle) })); + } const key = JSON.stringify(entries); if (key !== this.lastKey) { this.lastKey = key; @@ -316,7 +342,7 @@ class TocView { leader.textContent = fill.repeat(fill ? LEADER_DOTS : 0); const page = document.createElement('span'); page.className = 'toc-page'; - page.textContent = String(e.page); + page.textContent = e.pages ? e.pages.join(', ') : String(e.page); row.append(text, leader, page); const pos = heads[i]?.pos; if (pos != null) { diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 3c7bad8..e20b3c4 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -55,4 +55,5 @@ U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles), `CHP` U+E016 (the chapter field), `FNT` U+E017 (footnotes/endnotes), `CMS`/`CME` U+E018/U+E019 (a comment's range), `SEQ` U+E01A (a caption's number), `TCI` U+E01B (a tracked insertion's range), `TCD` U+E01C (where a tracked deletion was), -`TXBX_NUM` U+E01D (DOCX only: a numbering id a box's list has yet to be given). +`TXBX_NUM` U+E01D (DOCX only: a numbering id a box's list has yet to be given), +`IXE` U+E01E (an alphabetical-index entry). diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 83a541e..f2ce504 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -508,6 +508,12 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline out.push(dateTimeRun(node)); } else if (node.type === 'sequenceField') { out.push(sequenceField(node)); + } else if (node.type === 'indexEntry') { + // Word's index entry: a hidden XE field, its term in the instruction. A key files + // the term under it, "key:term", exactly as LibreOffice's text:key1 does. + const term = String(node.attrs?.term ?? '').trim(); + const key1 = String(node.attrs?.key1 ?? '').trim(); + if (term) out.push(new SimpleField(`XE "${(key1 ? `${key1}:${term}` : term).replace(/"/g, "'")}"`, '')); } else if (node.type === 'formula') { const latex = typeof node.attrs?.latex === 'string' ? node.attrs.latex : ''; if (latex) { @@ -1431,6 +1437,12 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num 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) } })); + if (kind === 'alphabetical') { + // Word's INDEX field, which it fills from the XE entries on a field update — + // the same contract the TOC field above works under. + out.push(new Paragraph({ children: [new SimpleField('INDEX \\h "A" \\c "1" \\e "\t"', '')] })); + continue; + } 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 diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 05d8499..23d9978 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -184,6 +184,8 @@ const FNT = '\uE017'; // Sentinel wrapping a caption's sequence-field index (SEQ{i}SEQ); applySequenceFields // rewrites it to . U+E01A. const SEQ = '\uE01A'; +// Sentinel wrapping an index entry's index (IXE{i}IXE); applyIndexEntries resolves it. +const IXE = '\uE01E'; // Sentinels for recorded revisions (trackChanges.ts): TCI{i}TCI brackets an insertion's // runs, TCD{i}TCD stands where a deletion's text was cut out. applyRevisions rewrites @@ -699,6 +701,41 @@ function applySequenceFields(odtBytes: Uint8Array, fields: SequenceExport[]): Ui return rezipOdt(files); } +// One alphabetical-index entry, collected by replaceIndexEntries. +type IndexEntryExport = { term: string; key1: string }; + +function replaceIndexEntries(node: TiptapNode, marks: IndexEntryExport[]): TiptapNode { + if (!node.content?.length) return node; + const content: TiptapNode[] = []; + for (const child of node.content) { + if (child.type === 'indexEntry') { + const a = child.attrs ?? {}; + marks.push({ term: String(a.term ?? ''), key1: String(a.key1 ?? '') }); + content.push({ type: 'text', text: `${IXE}${marks.length - 1}${IXE}`, marks: child.marks }); + continue; + } + content.push(replaceIndexEntries(child, marks)); + } + return { ...node, content }; +} + +// Resolve IXE sentinels into the point mark LibreOffice writes for an index entry. +function applyIndexEntries(odtBytes: Uint8Array, marks: IndexEntryExport[]): Uint8Array { + if (!marks.length) return odtBytes; + const files = unzipSync(odtBytes); + const contentBytes = files['content.xml']; + if (!contentBytes) return odtBytes; + let content = strFromU8(contentBytes); + content = content.replace(new RegExp(`${IXE}(\\d+)${IXE}`, 'g'), (_m, idx: string) => { + const m = marks[Number(idx)]; + if (!m) return ''; + return `'; + }); + files['content.xml'] = strToU8(content); + return rezipOdt(files); +} + // One cross-reference, collected by replaceCrossRefs and emitted by applyBookmarks. type CrossRefExport = { name: string; format: 'text' | 'page' }; @@ -964,7 +1001,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 TocEntry = { text: string; level: number; page: number; pages?: number[] }; 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 @@ -982,6 +1019,8 @@ function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode text: String(e.text), level: Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(e.level) || 1)), page: Math.max(1, Number(e.page) || 1), + // An alphabetical row lists every page its term appears on. + ...(Array.isArray(e.pages) && e.pages.length ? { pages: e.pages.map((n: unknown) => Math.max(1, Number(n) || 1)) } : {}), })); const rawTitle = child.attrs?.title; const depth = Number(child.attrs?.maxLevel); @@ -3761,6 +3800,7 @@ const ODF_INDEX: Record${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"`; + // An alphabetical index is fed by its marks, is single-level, and merges the pages of + // a term the reader marked more than once — LibreOffice's text:combine-entries. + const sourceAttrs = toc.kind === 'alphabetical' + ? ' text:combine-entries="true" text:ignore-case="true"' + : 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(''); + : toc.kind === 'alphabetical' + ? entry(' text:outline-level="1"', 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)}` : '') + @@ -3797,7 +3843,7 @@ function tocXml(toc: TocExport, index: number): string { `` : '') + toc.entries - .map(e => `${escapeXml(e.text).replace(/\n/g, '')}${e.page}`) + .map(e => `${escapeXml(e.text).replace(/\n/g, '')}${e.pages?.join(', ') ?? e.page}`) .join('') + ``; return `${source}${body}`; @@ -3992,7 +4038,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const commentList: CommentExport[] = []; const seqFields: SequenceExport[] = []; const revisionList = new Map(); - const sentinels = replaceRevisions(replaceSequenceFields(replaceComments(replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs), commentList), seqFields), revisionList); + const indexMarks: IndexEntryExport[] = []; + const sentinels = replaceIndexEntries(replaceRevisions(replaceSequenceFields(replaceComments(replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs), commentList), seqFields), revisionList), indexMarks); const raw = markTextEffects(bakeListCharStyles(sentinels, styles)); let headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null; let footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null; @@ -4192,7 +4239,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const withBreaks = applyInlineSentinels(applyTabLeaders(cleaned)); const withImages = applyImages(withBreaks, images); const withDateFields = applyDateTimeFields(withImages, dateFields, language ?? null); - const withSequences = applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList); + const withSequences = applyIndexEntries(applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList), indexMarks); const withFormulas = applyFormulas(withSequences, formulas); const withTextBoxes = applyTextBoxes(withFormulas, textBoxes); const withColumns = applyColumns(withTextBoxes, columns); diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index e435ced..15ac823 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -712,7 +712,10 @@ const de: Messages = { tocOptions: 'Ebenen', tocMaxLevel: 'Tiefste angezeigte Ebene', insertCaption: 'Beschriftung', - indexes: { toc: 'Inhaltsverzeichnis', figures: 'Abbildungsverzeichnis', tables: 'Tabellenverzeichnis' }, + indexes: { toc: 'Inhaltsverzeichnis', figures: 'Abbildungsverzeichnis', tables: 'Tabellenverzeichnis', alphabetical: 'Stichwortverzeichnis' }, + indexEntry: 'Stichwort', + indexEntryPrompt: 'Stichwort — das Wort, das im Verzeichnis steht:', + indexEntryNeedsSelection: 'Markiere das Wort, das ins Stichwortverzeichnis soll', footnote: 'Fußnote', endnote: 'Endnote', noteOptions: 'Optionen', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index c0447a4..e7226a0 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -714,7 +714,10 @@ const en = { }, toc: 'Table of contents', insertCaption: 'Caption', - indexes: { toc: 'Table of contents', figures: 'List of figures', tables: 'List of tables' }, + indexes: { toc: 'Table of contents', figures: 'List of figures', tables: 'List of tables', alphabetical: 'Alphabetical index' }, + indexEntry: 'Index entry', + indexEntryPrompt: 'Index entry — the word the index lists:', + indexEntryNeedsSelection: 'Select the word to file in the index', tocOptions: 'Levels', tocMaxLevel: 'Deepest level shown', footnote: 'Footnote', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 9c2f393..47b514c 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -336,6 +336,8 @@ function tocMaxLevel(instr: string): number { // 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 { + // INDEX is Word's alphabetical index; the rest are TOC fields differing by \\c. + if (/\bINDEX\b/.test(instr)) return 'alphabetical'; const m = /\\c\s+"?([^"\\]+)/.exec(instr); const cat = m ? ODF_SEQ_CATEGORY[m[1].trim()] : undefined; return cat === 'table' ? 'tables' : cat === 'figure' ? 'figures' : 'toc'; @@ -359,7 +361,7 @@ function scanTocField(p: Element, st: TocFieldState): { emit: boolean } { } } else if (c.localName === 'instrText' && st.fieldDepth > 0) { st.instr[st.fieldDepth] += c.textContent ?? ''; - if (st.tocDepth < 0 && /\bTOC\b/.test(st.instr[st.fieldDepth])) { + if (st.tocDepth < 0 && /\b(TOC|INDEX)\b/.test(st.instr[st.fieldDepth])) { st.tocDepth = st.fieldDepth; emit = true; } @@ -488,6 +490,15 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef if (el.localName === 'p') { // A paragraph owned by a TOC field is skipped; the field emits one node. const startedInToc = tocState.tocDepth >= 0; + // A simple INDEX field is a whole index in one element — the form our own export + // writes, since the package has no INDEX class to spread over runs. + const simple = Array.from(el.getElementsByTagNameNS(W, 'fldSimple')) + .map((f) => f.getAttributeNS(W, 'instr') ?? '').join(' '); + if (/\bINDEX\b/.test(simple) && kind === 'body') { + flush(); + out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', index: 'alphabetical' } }); + continue; + } const { emit } = scanTocField(el, tocState); if (emit && kind === 'body') { flush(); @@ -1259,7 +1270,11 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD if (fieldDateTime) out.push(fieldDateTime); else if (fieldSeq) out.push({ ...fieldSeq, attrs: { ...fieldSeq.attrs, number: seqNumberOf(fieldResultText) } }); else if (fieldCrossRef) out.push({ ...fieldCrossRef, attrs: { ...fieldCrossRef.attrs, text: fieldResultText } }); - else emitField(out, fieldInstr, hfFields, marks); + else { + const mark = !hfFields && indexEntryFromInstr(fieldInstr); + if (mark) out.push(mark); + else emitField(out, fieldInstr, hfFields, marks); + } fieldMode = 'none'; fieldDateTime = null; fieldCrossRef = null; @@ -1324,6 +1339,8 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD out.push({ ...xref, attrs: { ...xref.attrs, text: el.textContent ?? '' } }); break; } + const mark = indexEntryFromInstr(instr); + if (mark) { out.push(mark); break; } const seq = seqFieldFromInstr(instr); if (seq) { const first = fcAll(el, 'r')[0]; @@ -1396,6 +1413,19 @@ function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[] } } +// An XE field instruction → an index entry. Word files the term under a key with +// "key:term", exactly as LibreOffice's text:key1 does. +function indexEntryFromInstr(instr: string): Node | null { + const m = /^\s*XE\s+"((?:[^"\\]|\\.)*)"/i.exec(instr); + if (!m) return null; + const raw = m[1].replace(/\\(.)/g, '$1').trim(); + if (!raw) return null; + const at = raw.indexOf(':'); + const key1 = at > 0 ? raw.slice(0, at).trim() : ''; + const term = at > 0 ? raw.slice(at + 1).trim() : raw; + return term ? { type: 'indexEntry', attrs: { term, key1 } } : null; +} + // A REF/PAGEREF field instruction → a cross-reference node; its cached result text is // filled in by the caller when the field closes. Every other field (SEQ, CITATION, …) // returns null and keeps showing the text the producer cached. diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 3bce8a8..3d38cdd 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -1042,6 +1042,7 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef // numbers live after mount, so parse fidelity isn't critical. const ODF_INDEX_KIND: Record = { 'table-of-content': 'toc', 'illustration-index': 'figures', 'table-index': 'tables', + 'alphabetical-index': 'alphabetical', }; function convertToc(el: Element, ctx: Ctx, indexKind: IndexKind): Node { @@ -1054,7 +1055,10 @@ function convertToc(el: Element, ctx: Ctx, indexKind: IndexKind): Node { const m = /Contents_20_(\d+)/.exec(style); const level = m ? Math.min(MAX_HEADING_LEVEL, Math.max(1, parseInt(m[1], 10))) : 1; const { text, page } = tocEntryTextAndPage(p); - if (text) entries.push({ text, level, page: Math.max(1, parseInt(page, 10) || 1) }); + // An alphabetical row's number cell is a list ("3, 7, 12"); the node view rebuilds + // it from the marks, so the cache only has to survive until then. + const pages = page.split(/[,;]/).map((n) => Math.max(1, parseInt(n, 10) || 1)).filter(Boolean); + if (text) entries.push({ text, level, page: pages[0] ?? 1, ...(pages.length > 1 ? { pages } : {}) }); } } // The file's own heading ("Inhalt", "Sommaire", …), so a reopened index keeps its name. @@ -1894,6 +1898,17 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl continue; } } + // A place marked for the alphabetical index. LibreOffice writes a point + // mark; a range one (-mark-start/-end) is kept at its start, the editor's + // entry being a place too. + if (!hfFields && (e.localName === 'alphabetical-index-mark' || e.localName === 'alphabetical-index-mark-start')) { + const term = e.getAttributeNS(NS.text, 'string-value') ?? ''; + if (term.trim()) { + out.push({ type: 'indexEntry', attrs: { term: term.trim(), key1: (e.getAttributeNS(NS.text, 'key1') ?? '').trim() } }); + } + continue; + } + if (!hfFields && e.localName === 'alphabetical-index-mark-end') continue; // Other text fields (title, …) store their evaluated value as element // text — keep what the source document showed. if (e.textContent) pushText(e.textContent, props, linkHref); diff --git a/src/styles/editor.css b/src/styles/editor.css index 8a0139b..a9567b3 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -1023,6 +1023,18 @@ /* Recorded revisions (trackChanges.ts). Both word processors underline an insertion and strike a deletion, in the reviewer's colour; one colour here, since the editor has a single author. */ +/* An index entry is a place, not text: a thin grey bar the way both word processors + shade a field, so it can be seen and deleted without taking room in the line. */ +.tiptap .pm-index-entry { + display: inline-block; + width: 3px; + height: 0.9em; + margin: 0 1px; + vertical-align: -0.05em; + border-radius: 1px; + background: var(--color-field-shade, rgba(0, 0, 0, 0.18)); +} + .tiptap .pm-insertion { color: #b5179e; text-decoration: underline; diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 8362d11..d4a6e0b 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -2223,3 +2223,60 @@ describe('Leg 24: preset shapes beyond rect / round-rect / ellipse', () => { check('round-trips', kinds(importDocx(bytes)) === 'triangle,star5,rightArrow', kinds(importDocx(bytes))); }); }); + +describe('Leg 25: alphabetical index', () => { + const mark = (term: string, key1 = ''): N => ({ type: 'indexEntry', attrs: { term, key1 } }); + const doc: N = { type: 'doc', content: [ + P(null, T('Der '), mark('Kaffee'), T('Kaffee kommt aus Äthiopien.')), + P(null, T('Auch '), mark('Bohne', 'Kaffee'), T('die Bohne.')), + P(null, T('Und wieder '), mark('Kaffee'), T('Kaffee.')), + { type: 'tableOfContents', attrs: { + index: 'alphabetical', title: 'Index', leader: '.', + entries: [ + { text: 'Kaffee', level: 1, page: 1, pages: [1, 2] }, + { text: 'Kaffee: Bohne', level: 1, page: 1 }, + ], + } }, + ] }; + const terms = (back: N) => { + const out: string[] = []; + const walk = (n: N): void => { + if (n?.type === 'indexEntry') out.push(`${n.attrs.key1 ? `${n.attrs.key1}:` : ''}${n.attrs.term}`); + (n?.content ?? []).forEach(walk); + }; + walk(back.content); + return out.join(' '); + }; + const indexKind = (back: N) => (back.content.content ?? []) + .find((n: N) => n.type === 'tableOfContents')?.attrs?.index; + + it('ODT: point marks plus a text:alphabetical-index, and back', async () => { + const bytes = await buildOdt(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['content.xml']); + check('mark emitted', xml.includes(''), + xml.match(/]*>/g)); + check('a key files the term under it', xml.includes('text:string-value="Bohne" text:key1="Kaffee"'), + xml.match(/]*>/g)); + check('index element emitted', xml.includes(''), + xml.match(/]*>/g)); + check('source merges repeated entries', xml.includes('text:combine-entries="true"')); + check('the row keeps every page it was marked on', xml.includes('1, 2'), + xml.match(/[\s\S]*?<\/text:index-body>/)?.[0]); + const back = importOdt(bytes); + check('no warnings on own export', back.warnings.length === 0, back.warnings); + check('marks round-trip', terms(back as N) === 'Kaffee Kaffee:Bohne Kaffee', terms(back as N)); + check('index round-trips as alphabetical', indexKind(back) === 'alphabetical', indexKind(back)); + }); + + it('DOCX: XE fields plus an INDEX field, and back', async () => { + const bytes = await buildDocx(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['word/document.xml']); + check('XE emitted', xml.includes('XE "Kaffee"'), xml.match(/w:instr="[^"]*XE[^"]*"/g)); + check('a key rides the term', xml.includes('XE "Kaffee:Bohne"'), xml.match(/w:instr="[^"]*XE[^"]*"/g)); + check('INDEX field emitted', /w:instr="INDEX[^"]*"/.test(xml), xml.match(/w:instr="[^"]*"/g)); + + const back = importDocx(bytes); + check('marks round-trip', terms(back as N) === 'Kaffee Kaffee:Bohne Kaffee', terms(back as N)); + check('index round-trips as alphabetical', indexKind(back) === 'alphabetical', indexKind(back)); + }); +});