feat: an alphabetical index, marked word by word

References ▸ Index entry marks a place with the term the index should
list — under a key of its own where it belongs elsewhere — and the Index
in the same menu lists each term once, sorted case-insensitively, with
every page it was marked on.

Both formats write a place, not a range, so the entry is an inline atom:
ODF text:alphabetical-index-mark plus text:alphabetical-index, Word XE
plus INDEX. LibreOffice reads our file back unchanged. The bibliography,
the other half of that gap, is still open.
This commit is contained in:
Steffen Becker committed 2026-08-14 08:44:00 +02:00
1 parent c97a38ec77
commit eaaec541af
15 files changed
+351 -24

No files matched your search

@@ -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)}
/>
<RibbonButton
variant="big"
icon="bookmark"
label={t().ribbon.indexEntry}
title={hasSelection ? t().ribbon.indexEntry : t().ribbon.indexEntryNeedsSelection}
disabled={!editor || !!hfActive || !hasSelection}
onclick={markIndexEntry}
/>
</RibbonGroup>
<CaptionDialog bind:open={captionOpen} {editor} />
+4
View File
@@ -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 <text:sequence> and a DOCX SEQ field.
SequenceField,
// A place marked for the alphabetical index. Round-trips to ODF
// <text:alphabetical-index-mark> 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 <text:bookmark-ref> and DOCX REF/PAGEREF fields.
CrossReference,
+1
View File
@@ -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.
+97
View File
@@ -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 <text:alphabetical-index-mark text:string-value="…">,
// 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<ReturnType> {
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<string, { text: string; pages: Set<number> }>();
for (const e of entries) {
const text = e.key1 ? `${e.key1}: ${e.term}` : e.term;
const row = byTerm.get(text) ?? { text, pages: new Set<number>() };
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) }));
}
+32 -6
View File
@@ -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<IndexKind, string> = {
toc: 'Table of Contents',
figures: 'List of Figures',
tables: 'List of Tables',
alphabetical: 'Index',
};
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.',
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) {
+2 -1
View File
@@ -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).
+12
View File
@@ -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
+57 -10
View File
@@ -184,6 +184,8 @@ const FNT = '\uE017';
// Sentinel wrapping a caption's sequence-field index (SEQ{i}SEQ); applySequenceFields
// rewrites it to <text:sequence>. 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 `<text:alphabetical-index-mark text:string-value="${escapeXml(m.term)}"`
+ (m.key1 ? ` text:key1="${escapeXml(m.key1)}"` : '') + '/>';
});
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<IndexKind, { el: string; heading: string; entryStyle: st
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' },
alphabetical: { el: 'alphabetical-index', heading: 'Index_20_Heading', entryStyle: 'Index_20_', docName: 'Alphabetical Index' },
};
function tocXml(toc: TocExport, index: number): string {
@@ -3776,14 +3816,20 @@ function tocXml(toc: TocExport, index: number): string {
`<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"`;
// 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 =
`<text:${spec.el}-source${sourceAttrs}>` +
(title ? `<text:index-title-template text:style-name="${spec.heading}">${escapeXml(title)}</text:index-title-template>` : '') +
@@ -3797,7 +3843,7 @@ function tocXml(toc: TocExport, index: number): string {
`</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>`)
.map(e => `<text:p text:style-name="${tocLevelStyle(toc, e.level)}">${escapeXml(e.text).replace(/\n/g, '<text:line-break/>')}<text:tab/>${e.pages?.join(', ') ?? e.page}</text:p>`)
.join('') +
`</text:index-body>`;
return `<text:${spec.el} text:name="${escapeXml(name)}" text:protected="true">${source}${body}</text:${spec.el}>`;
@@ -3992,7 +4038,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const commentList: CommentExport[] = [];
const seqFields: SequenceExport[] = [];
const revisionList = new Map<string, RevisionExport>();
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);
+4 -1
View File
@@ -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',
+4 -1
View File
@@ -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',
+32 -2
View File
@@ -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.
+16 -1
View File
@@ -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<string, IndexKind | undefined> = {
'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);
+12
View File
@@ -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;