diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7913990..1795a49 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -73,6 +73,7 @@
- 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
+- Bibliography (References ▸ Citation, then the Bibliography in the index menu): a citation carries its whole source record — short name, type and its fields — so the document needs no database beside it, and the list prints one row per source cited, in document order and without page numbers, as both word processors do. Round-trips as ODF `text:bibliography-mark` + `text:bibliography` (LibreOffice reads every field back and regenerates the list from our entry templates) and as Word's `CITATION` and `BIBLIOGRAPHY` fields over a `b:Sources` custom-XML part — which LibreOffice's own Word export drops, so the record survives our leg and not its
- 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`
@@ -144,7 +145,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`)
-- 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
+- A bibliography's citation style: the list is set the one way ("key: author, title, year"), where both word processors offer APA, MLA, Chicago and the rest, and numbered citations (`[1]`) instead of the source's short name
- 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/BibliographyDialog.svelte b/src/lib/components/BibliographyDialog.svelte
new file mode 100644
index 0000000..a5b4f6d
--- /dev/null
+++ b/src/lib/components/BibliographyDialog.svelte
@@ -0,0 +1,144 @@
+
+
+
+
+
diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts
index 7f84a4f..da959f0 100644
--- a/src/lib/components/ribbon/icons.ts
+++ b/src/lib/components/ribbon/icons.ts
@@ -75,6 +75,8 @@ export const STROKED = {
toc: ['M2 3h6M2 6.5h5M2 10h6M2 13.5h4', 'M11 3h3M10 6.5h4M11 10h3M10 13.5h4'],
tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'],
caption: ['M2 2.5h12v7.5H2z', 'M2 13h7'],
+ // A citation as it is set: a short reference between square brackets.
+ citation: ['M6 3.5H4v9h2', 'M10 3.5h2v9h-2', 'M6.5 8h3'],
watermark: ['M2.5 2h11v12h-11z', 'M4.5 11.5 11.5 4.5'],
trackChanges: ['M2 4h12M2 8h7', 'M9.5 13.5 14 9l-1.6-1.6L7.9 12z', 'M7.9 12 7.4 14.1l2.1-.6'],
close: ['M3.5 3.5 12.5 12.5M12.5 3.5 3.5 12.5'],
diff --git a/src/lib/components/ribbon/tabs/ReferencesTab.svelte b/src/lib/components/ribbon/tabs/ReferencesTab.svelte
index 60e2678..7af2451 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 BibliographyDialog from '../../BibliographyDialog.svelte';
import type { IndexKind } from '../../../editor/extensions/tableOfContents';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import type { HfZone } from '../../../storage/headerFooter';
@@ -29,8 +30,9 @@
return found;
});
const LEVELS = HEADING_LEVELS;
- const INDEX_KINDS: IndexKind[] = ['toc', 'figures', 'tables', 'alphabetical'];
+ const INDEX_KINDS: IndexKind[] = ['toc', 'figures', 'tables', 'alphabetical', 'bibliography'];
let captionOpen = $state(false);
+ let citationOpen = $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
@@ -107,9 +109,18 @@
disabled={!editor || !!hfActive || !hasSelection}
onclick={markIndexEntry}
/>
+ (citationOpen = true)}
+ />
+ and a Word XE field.
IndexEntry,
+ // A citation carrying its whole source record. Round-trips to ODF
+ // and a Word CITATION field over a custom-XML source.
+ BibliographyEntry,
// 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 5d8c896..ea6bbf2 100644
--- a/src/lib/editor/extensions/CLAUDE.md
+++ b/src/lib/editor/extensions/CLAUDE.md
@@ -37,7 +37,8 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`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"`.
+- **`bibliographyEntry.ts`** — one citation, an inline **atom** carrying the whole source record (`identifier`, `type`, and every remaining ODF field in one `fields` record, so a file's fields we have no UI for still round-trip). ODF puts that record on the mark itself, which is what lets the document travel without a database; Word keeps it in a custom-XML part and cites it by tag, so the same attrs feed both. `bibliographyRows` is the list: one row per source, **in document order** — LibreOffice's own default is document position, not alphabetical — reading "key: author, title, year", the shape its entry templates produce. `DOCX_SOURCE_TYPE`/`DOCX_BIB_FIELD` map onto Word's smaller set, and the fields it has no slot for survive only the ODF leg.
+- **`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"`. `bibliography` lists the sources cited (`bibliographyEntry.ts`) and is the one family with **no page number and no leader** — a bibliography row is the source and nothing else, which is how both word processors print one.
- **`trailingNode.ts`** — keeps an empty paragraph after a trailing table, text box or columns section (they are isolating, so the caret would otherwise have nowhere to go).
- **`headerFooter.ts`** (`hfExtensions`) — the header/footer schema: `Document` constrained to one paragraph, the body's marks, `HardBreak`, `TextAlign`, and the `pageField.ts` atoms. No image, no date field, no table.
- **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`/`chapterField`, rendered as ``; `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/bibliographyEntry.ts b/src/lib/editor/extensions/bibliographyEntry.ts
new file mode 100644
index 0000000..ef86f22
--- /dev/null
+++ b/src/lib/editor/extensions/bibliographyEntry.ts
@@ -0,0 +1,171 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+import type { Node as PMNode } from '@tiptap/pm/model';
+
+// One citation: an inline atom carrying the whole source record, which is what ODF
+// writes (every field is an attribute of text:bibliography-mark) and what lets the
+// document travel without a database beside it. Word keeps the record in a custom-XML
+// part instead and cites it by tag; `identifier` is that tag.
+
+export type BibFields = Record;
+export type BibSource = { identifier: string; type: string; fields: BibFields };
+export type BibEntry = BibSource & { pos: number; text: string };
+
+// The ODF text:bibliography-type values — LibreOffice's own list, in its order.
+export const BIB_TYPES = [
+ 'article', 'book', 'booklet', 'conference', 'inbook', 'incollection', 'inproceedings',
+ 'journal', 'manual', 'mastersthesis', 'misc', 'phdthesis', 'proceedings', 'techreport',
+ 'unpublished', 'www', 'email', 'custom1', 'custom2', 'custom3', 'custom4', 'custom5',
+] as const;
+
+// What the type picker offers. LibreOffice's list is the longer one above, and a file
+// naming any of it still imports — this is only what the dialog is worth showing.
+export const BIB_COMMON_TYPES = [
+ 'book', 'article', 'inbook', 'incollection', 'conference',
+ 'techreport', 'phdthesis', 'mastersthesis', 'www', 'misc',
+] as const;
+
+// The fields the dialog offers. A file's other ones (isbn, note, editor, …) ride the
+// `fields` record untouched, so a round trip keeps them.
+export const BIB_FIELDS = ['author', 'title', 'year', 'publisher', 'journal', 'pages', 'url'] as const;
+
+// ODF field name → the child Word writes for it. Word has no slot for the
+// rest, which therefore only survive the ODF leg.
+export const DOCX_BIB_FIELD: Record = {
+ author: 'Author', title: 'Title', year: 'Year', publisher: 'Publisher', address: 'City',
+ journal: 'JournalName', booktitle: 'BookTitle', pages: 'Pages', url: 'URL',
+ edition: 'Edition', volume: 'Volume', number: 'Issue', note: 'Comments',
+};
+
+// ODF bibliography type → Word b:SourceType. Word's set is the smaller one, so several
+// map onto one and the way back takes the commonest of them.
+export const DOCX_SOURCE_TYPE: Record = {
+ book: 'Book', booklet: 'Book', manual: 'Book',
+ article: 'JournalArticle', journal: 'JournalArticle',
+ inbook: 'BookSection', incollection: 'BookSection',
+ conference: 'ConferenceProceedings', inproceedings: 'ConferenceProceedings',
+ proceedings: 'ConferenceProceedings',
+ techreport: 'Report', mastersthesis: 'Report', phdthesis: 'Report',
+ www: 'InternetSite', email: 'ElectronicSource',
+};
+
+const FROM_DOCX_TYPE: Record = {
+ Book: 'book', BookSection: 'inbook', JournalArticle: 'article',
+ ArticleInAPeriodical: 'article', ConferenceProceedings: 'conference', Report: 'techreport',
+ InternetSite: 'www', ElectronicSource: 'email',
+};
+
+export const bibTypeFromDocx = (t: string): string => FROM_DOCX_TYPE[t] ?? 'misc';
+
+export function isBibType(v: unknown): boolean {
+ return typeof v === 'string' && (BIB_TYPES as readonly string[]).includes(v);
+}
+
+/** What the citation shows where the file names nothing else: LibreOffice's `[key]`. */
+export const citationText = (identifier: string): string => `[${identifier}]`;
+
+declare module '@tiptap/core' {
+ interface Commands {
+ bibliographyEntry: {
+ insertBibliographyEntry: (source: BibSource) => ReturnType;
+ };
+ }
+}
+
+export const BibliographyEntry = Node.create({
+ name: 'bibliographyEntry',
+ group: 'inline',
+ inline: true,
+ atom: true,
+
+ addAttributes() {
+ return {
+ identifier: {
+ default: '',
+ parseHTML: el => (el as HTMLElement).getAttribute('data-bib-id') ?? '',
+ renderHTML: attrs => ({ 'data-bib-id': String(attrs.identifier ?? '') }),
+ },
+ type: {
+ default: 'book',
+ parseHTML: el => (el as HTMLElement).getAttribute('data-bib-type') || 'book',
+ renderHTML: attrs => ({ 'data-bib-type': String(attrs.type ?? 'book') }),
+ },
+ // Every remaining ODF field, by its own name. One attr, so a file's fields we have
+ // no UI for still round-trip.
+ fields: {
+ default: {} as BibFields,
+ parseHTML: (el) => {
+ try { return JSON.parse((el as HTMLElement).getAttribute('data-bib-fields') ?? '{}'); }
+ catch { return {}; }
+ },
+ renderHTML: attrs => ({ 'data-bib-fields': JSON.stringify(attrs.fields ?? {}) }),
+ },
+ // What the citation shows. A file's own text wins over `[key]`: LibreOffice writes
+ // the number there when the index numbers its entries.
+ text: {
+ default: '',
+ parseHTML: el => (el as HTMLElement).getAttribute('data-bib-text') ?? '',
+ renderHTML: attrs => (attrs.text ? { 'data-bib-text': String(attrs.text) } : {}),
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'span[data-bib-id]' }];
+ },
+
+ renderHTML({ HTMLAttributes, node }) {
+ return ['span', mergeAttributes(HTMLAttributes, { class: 'pm-citation' }), shownText(node.attrs)];
+ },
+
+ addCommands() {
+ return {
+ insertBibliographyEntry: (source) => ({ commands }) => {
+ const identifier = source.identifier.trim();
+ if (!identifier) return false;
+ return commands.insertContent({
+ type: this.name,
+ attrs: { identifier, type: source.type, fields: source.fields },
+ });
+ },
+ };
+ },
+});
+
+const shownText = (attrs: Record): string =>
+ String(attrs.text || citationText(String(attrs.identifier ?? '')));
+
+/** Every citation in document order, duplicates included. */
+export function bibliographyEntries(doc: PMNode): BibEntry[] {
+ const out: BibEntry[] = [];
+ doc.descendants((node, pos) => {
+ if (node.type.name !== 'bibliographyEntry') return true;
+ const identifier = String(node.attrs.identifier ?? '').trim();
+ const fields = (node.attrs.fields ?? {}) as BibFields;
+ if (identifier) {
+ out.push({ pos, identifier, type: String(node.attrs.type ?? 'book'), fields, text: shownText(node.attrs) });
+ }
+ return false;
+ });
+ return out;
+}
+
+/**
+ * One row per cited source, in document order — LibreOffice's own default, which sorts
+ * a bibliography by document position rather than alphabetically. The row reads
+ * "key: author, title, year", the shape LibreOffice's entry templates produce.
+ */
+export function bibliographyRows(entries: BibSource[]): { identifier: string; text: string }[] {
+ const seen = new Set();
+ const out: { identifier: string; text: string }[] = [];
+ for (const e of entries) {
+ if (seen.has(e.identifier)) continue;
+ seen.add(e.identifier);
+ out.push({ identifier: e.identifier, text: bibRowText(e) });
+ }
+ return out;
+}
+
+export function bibRowText(source: BibSource): string {
+ const rest = ['author', 'title', 'year'].map(f => (source.fields[f] ?? '').trim()).filter(Boolean);
+ return rest.length ? `${source.identifier}: ${rest.join(', ')}` : source.identifier;
+}
diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts
index e093366..fb59121 100644
--- a/src/lib/editor/extensions/tableOfContents.ts
+++ b/src/lib/editor/extensions/tableOfContents.ts
@@ -4,6 +4,7 @@ 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 { bibliographyEntries, bibliographyRows } from './bibliographyEntry';
import { readVerticalMargins, pageOfElement, topInEditor, FORCE_PAGE_RECALC } from './pageBreaks';
// A generated index: a block atom listing every source with its live page number — the
@@ -22,10 +23,12 @@ export type TocEntry = {
// 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' | 'alphabetical';
+export type IndexKind = 'toc' | 'figures' | 'tables' | 'alphabetical' | 'bibliography';
export function indexKindOf(value: unknown): IndexKind {
- return value === 'figures' || value === 'tables' || value === 'alphabetical' ? value : 'toc';
+ return value === 'figures' || value === 'tables' || value === 'alphabetical' || value === 'bibliography'
+ ? value
+ : 'toc';
}
// The heading above the entries. An imported index keeps the one its file used
@@ -36,6 +39,7 @@ export const INDEX_TITLES: Record = {
figures: 'List of Figures',
tables: 'List of Tables',
alphabetical: 'Index',
+ bibliography: 'Bibliography',
};
const EMPTY_HINT: Record = {
@@ -43,6 +47,7 @@ const EMPTY_HINT: Record = {
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.',
+ bibliography: 'No citations 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.
@@ -214,6 +219,15 @@ class TocView {
}
return out;
}
+ if (kind === 'bibliography') {
+ // One row per source cited, at its first citation — no page number, as both word
+ // processors print a bibliography.
+ const cited = bibliographyEntries(this.editor.state.doc);
+ for (const row of bibliographyRows(cited)) {
+ out.push({ text: row.text, level: 1, pos: cited.find(c => c.identifier === row.identifier)!.pos });
+ }
+ return out;
+ }
if (kind !== 'toc') {
const category: SeqCategory = kind === 'tables' ? 'table' : 'figure';
this.editor.state.doc.descendants((node, pos) => {
@@ -322,7 +336,8 @@ class TocView {
return;
}
- const fill = typeof this.node()?.attrs?.leader === 'string' ? String(this.node()!.attrs.leader) : '';
+ const noPage = indexKindOf(this.node()?.attrs?.index) === 'bibliography';
+ const fill = !noPage && typeof this.node()?.attrs?.leader === 'string' ? String(this.node()!.attrs.leader) : '';
const levelStyles = this.node()?.attrs?.levelStyles as (string | null)[] | null | undefined;
entries.forEach((e, i) => {
const row = document.createElement('div');
@@ -335,15 +350,19 @@ class TocView {
if (li) text.appendChild(document.createElement('br'));
text.appendChild(document.createTextNode(part));
});
- const leader = document.createElement('span');
- leader.className = 'toc-leader';
- // Real fill characters, as a word processor draws them: they scale with the font
- // and reach the PDF as text. The row clips whatever the gap has no room for.
- leader.textContent = fill.repeat(fill ? LEADER_DOTS : 0);
- const page = document.createElement('span');
- page.className = 'toc-page';
- page.textContent = e.pages ? e.pages.join(', ') : String(e.page);
- row.append(text, leader, page);
+ if (noPage) {
+ row.append(text);
+ } else {
+ const leader = document.createElement('span');
+ leader.className = 'toc-leader';
+ // Real fill characters, as a word processor draws them: they scale with the font
+ // and reach the PDF as text. The row clips whatever the gap has no room for.
+ leader.textContent = fill.repeat(fill ? LEADER_DOTS : 0);
+ const page = document.createElement('span');
+ page.className = 'toc-page';
+ page.textContent = e.pages ? e.pages.join(', ') : String(e.page);
+ row.append(text, leader, page);
+ }
const pos = heads[i]?.pos;
if (pos != null) {
row.addEventListener('mousedown', ev => {
diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md
index e20b3c4..cb7fc89 100644
--- a/src/lib/export/CLAUDE.md
+++ b/src/lib/export/CLAUDE.md
@@ -31,6 +31,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
- **Bookmarks & cross-references** (`replaceBookmarks`/`applyBookmarks`): the `BMS`/`BME`/`XRF` sentinels are spliced into the **run text** rather than emitted by a run builder, so they ride every odf-kit path (cells, custom paragraphs, lists) with no per-emitter change; one post-pass rewrites them to ``/``/``. DOCX needs no pass at all — the `docx` package has `Bookmark`, `SimpleField` (`REF`/`PAGEREF`) and `InternalHyperlink`. See `docs/architecture/formatting.md`.
- **Captions** (`replaceSequenceFields`/`applySequenceFields`): a `SEQ` sentinel becomes a `` carrying LibreOffice's own `text:formula="ooow:+1"` (what makes its reader recount on load) plus a `` per counter used; DOCX gets a `SimpleField` `SEQ Figure \* ARABIC`. Both keep the editor's resolved rank as the field's cached result.
- **Tracked changes** (`replaceRevisions`/`applyRevisions`): ODF splits the pair. An insertion keeps its text inline and is bracketed by `TCI` sentinels → ``/``; a deletion's text is **cut out of the run** and left as one `TCD` → ``, the text itself going into the `` registry `applyRevisions` builds right after `` (a `` holds the deleted paragraphs, a `` only its ``). The registry's ``/`` need `ensureDcNamespace` — odf-kit declares no `dc:` prefix in content.xml. DOCX needs no string pass: `InsertedTextRun`/`DeletedTextRun` from the `docx` package emit `w:ins`/`w:del` + `w:delText`, with `docRevisionId` numbering them per document as it does comments.
+- **Bibliography** (`replaceBibEntries`/`applyBibEntries`, `applyBibliographyDocx`): a `BIB` sentinel becomes a `` whose attributes are the whole source record and which **wraps** the text the citation shows (unlike an index mark, which is a point). The `` index writes an entry template per type actually cited, spelling out the same "key: author, title, year" the node view prints — LibreOffice regenerates the rows from it, so a template that said anything else would rewrite them. DOCX cites by tag (`CITATION "key"`) and keeps the sources in a **custom-XML part** the `docx` package knows nothing about, so the post-pack pass mints all four pieces itself: `customXml/item1.xml` (`b:Sources`), its `itemProps1.xml`, both relationships and the properties part's content type. (Probed: LibreOffice's own DOCX export writes the `CITATION` field but no sources at all.)
- **`applyListItemStyles`** — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles.
- **`applyListIndents`** — a top-level list's whole-list `indent` (cm) added to its `L#` list-style per-level `fo:margin-left`/`text:list-tab-stop-position` (label-alignment mode ignores the paragraph margin, so the shift must live in the list style). Import reverses it in `convertList` (level-1 margin minus the 1.27cm base).
- **`applyNestedListTypes`** — odf-kit emits nested lists as bare `` sharing the top-level list style, so a nested list of a different kind/format (e.g. ordered inside bullets) gets its own minted 6-level list style.
@@ -56,4 +57,4 @@ U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
`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),
-`IXE` U+E01E (an alphabetical-index entry).
+`IXE` U+E01E (an alphabetical-index entry), `BIB` U+E01F (a citation).
diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts
index f2ce504..0f401d5 100644
--- a/src/lib/export/docx.ts
+++ b/src/lib/export/docx.ts
@@ -26,6 +26,7 @@ import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/he
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 { citationText, DOCX_BIB_FIELD, DOCX_SOURCE_TYPE, type BibSource } from '../editor/extensions/bibliographyEntry';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding';
@@ -116,6 +117,10 @@ const MTH = '';
type FormulaDocx = { latex: string; display: boolean };
let docFormulas: FormulaDocx[] = [];
+// The sources cited, one per tag in document order — Word keeps them in a custom-XML
+// part and the CITATION fields only name the tag. Module-level like docFormulas.
+let docSources: BibSource[] = [];
+
// Word numbers footnotes and endnotes in separate files, so each class counts from 1.
// Filled from the note section before the body is walked, then read by the anchor —
// module-level for the same reason as docFormulas.
@@ -514,6 +519,21 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
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 === 'bibliographyEntry') {
+ // Word's citation: a CITATION field naming the source's tag, its cached result the
+ // text the reader sees. applyBibliographyDocx writes the source itself.
+ const identifier = String(node.attrs?.identifier ?? '').trim();
+ if (identifier) {
+ if (!docSources.some(s => s.identifier === identifier)) {
+ docSources.push({
+ identifier,
+ type: String(node.attrs?.type ?? 'misc'),
+ fields: (node.attrs?.fields ?? {}) as Record,
+ });
+ }
+ const shown = String(node.attrs?.text || citationText(identifier));
+ out.push(new SimpleField(`CITATION "${docxTag(identifier)}"`, shown));
+ }
} else if (node.type === 'formula') {
const latex = typeof node.attrs?.latex === 'string' ? node.attrs.latex : '';
if (latex) {
@@ -952,6 +972,77 @@ function applyTextBoxesDocx(bytes: Uint8Array, boxes: TextBoxDocx[]): Uint8Array
return zipSync(out);
}
+const B_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/bibliography';
+const DS_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/customXml';
+// The part's identity inside the package, not the document's — one fixed value serves.
+const BIB_ITEM_GUID = '{6A3C1F0E-7B12-4B4D-9E42-1B7C0D4A6F31}';
+
+// A source's author list. Word's model is a list of persons, so "Last, First" splits and
+// several are separated by a semicolon; anything else travels whole as a corporate name.
+function bibAuthorXml(value: string): string {
+ const names = value.split(';').map(s => s.trim()).filter(Boolean);
+ const people = names.map((name) => {
+ const at = name.indexOf(',');
+ return at > 0
+ ? `${escapeXml(name.slice(0, at).trim())}`
+ + `${escapeXml(name.slice(at + 1).trim())}`
+ : '';
+ });
+ const inner = people.every(Boolean) && people.length
+ ? `${people.join('')}`
+ : `${escapeXml(value)}`;
+ return `${inner}`;
+}
+
+// The tag as the CITATION instruction and the source agree on it: the instruction is a
+// quoted field argument, so a quote in the short name has nowhere to go.
+const docxTag = (identifier: string): string => identifier.replace(/"/g, '');
+
+function bibSourceXml(s: BibSource): string {
+ const fields = Object.entries(s.fields)
+ .filter(([k, v]) => k !== 'author' && DOCX_BIB_FIELD[k] && String(v ?? '').trim())
+ .map(([k, v]) => `${escapeXml(String(v))}`)
+ .join('');
+ const author = String(s.fields.author ?? '').trim();
+ return `${escapeXml(docxTag(s.identifier))}`
+ + `${DOCX_SOURCE_TYPE[s.type] ?? 'Misc'}`
+ + (author ? bibAuthorXml(author) : '') + fields + '';
+}
+
+// Post-pack pass: the sources the CITATION fields name. Word keeps them in a custom-XML
+// part of their own, which needs its properties part, two relationships and a content
+// type — none of which the package knows about, so all four are minted here.
+function applyBibliographyDocx(bytes: Uint8Array, sources: BibSource[]): Uint8Array {
+ if (!sources.length) return bytes;
+ const files = unzipSync(bytes);
+ const relsPath = 'word/_rels/document.xml.rels';
+ const rels = files[relsPath] ? strFromU8(files[relsPath]) : '';
+ const types = files['[Content_Types].xml'] ? strFromU8(files['[Content_Types].xml']) : '';
+ if (!rels || !types) return bytes;
+
+ files['customXml/item1.xml'] = strToU8(
+ ``
+ + ``
+ + sources.map(bibSourceXml).join('') + '');
+ files['customXml/itemProps1.xml'] = strToU8(
+ ``
+ + ``
+ + ``);
+ files['customXml/_rels/item1.xml.rels'] = strToU8(
+ ``
+ + ``
+ + ``);
+
+ files[relsPath] = strToU8(rels.replace('',
+ ``));
+ files['[Content_Types].xml'] = strToU8(types.replace('',
+ ''));
+
+ const out: Record = {};
+ for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
+ return zipSync(out);
+}
+
// Post-pack pass: swap each sentinel run in word/document.xml for its . A
// display formula additionally wraps its paragraph's content in , which
// is how Word centers a formula on its own line.
@@ -1443,6 +1534,12 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
out.push(new Paragraph({ children: [new SimpleField('INDEX \\h "A" \\c "1" \\e "\t"', '')] }));
continue;
}
+ if (kind === 'bibliography') {
+ // Word's BIBLIOGRAPHY field, filled from the sources in the custom-XML part on a
+ // field update — the same contract the TOC and INDEX fields work under.
+ out.push(new Paragraph({ children: [new SimpleField('BIBLIOGRAPHY', '')] }));
+ 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
@@ -1666,6 +1763,7 @@ export async function buildDocx(
exportSheet = styles;
exportSpacingModel = spacingModel;
docFormulas = [];
+ docSources = [];
const num = new Numbering();
const landscape = orientation === 'landscape';
const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation);
@@ -1813,7 +1911,8 @@ export async function buildDocx(
const blob = await Packer.toBlob(doc);
const packed = applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
- const withNotes = docNoteIds.size ? applyNotePrDocx(packed, notesSettings) : packed;
+ const cited = applyBibliographyDocx(packed, docSources);
+ const withNotes = docNoteIds.size ? applyNotePrDocx(cited, notesSettings) : cited;
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withNotes) : withNotes;
const bidi = rtl ? applyBidiDocx(mirrored) : mirrored;
if (isEmptyPageDecor(decor)) return bidi;
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index 23d9978..11dc0fb 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -29,6 +29,7 @@ import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/e
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 { citationText, isBibType } from '../editor/extensions/bibliographyEntry';
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';
@@ -186,6 +187,8 @@ const FNT = '\uE017';
const SEQ = '\uE01A';
// Sentinel wrapping an index entry's index (IXE{i}IXE); applyIndexEntries resolves it.
const IXE = '\uE01E';
+// Sentinel wrapping a citation's index (BIB{i}BIB); applyBibEntries resolves it.
+const BIB = '\uE01F';
// 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
@@ -736,6 +739,53 @@ function applyIndexEntries(odtBytes: Uint8Array, marks: IndexEntryExport[]): Uin
return rezipOdt(files);
}
+// One citation, collected by replaceBibEntries. ODF keeps the whole source record on
+// the mark, so nothing else has to travel with the document.
+type BibExport = { identifier: string; type: string; fields: Record; text: string };
+
+function replaceBibEntries(node: TiptapNode, marks: BibExport[]): TiptapNode {
+ if (!node.content?.length) return node;
+ const content: TiptapNode[] = [];
+ for (const child of node.content) {
+ if (child.type === 'bibliographyEntry') {
+ const a = child.attrs ?? {};
+ const identifier = String(a.identifier ?? '');
+ marks.push({
+ identifier,
+ type: isBibType(a.type) ? String(a.type) : 'misc',
+ fields: (a.fields ?? {}) as Record,
+ text: String(a.text || citationText(identifier)),
+ });
+ content.push({ type: 'text', text: `${BIB}${marks.length - 1}${BIB}`, marks: child.marks });
+ continue;
+ }
+ content.push(replaceBibEntries(child, marks));
+ }
+ return { ...node, content };
+}
+
+// BIB sentinels → , which unlike an index mark wraps the text
+// the citation shows. Every field is an attribute of its own, LibreOffice's own form.
+function applyBibEntries(odtBytes: Uint8Array, marks: BibExport[]): 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(`${BIB}(\\d+)${BIB}`, 'g'), (_m, idx: string) => {
+ const m = marks[Number(idx)];
+ if (!m) return '';
+ const fields = Object.entries(m.fields)
+ .filter(([, v]) => String(v ?? '').trim())
+ .map(([k, v]) => ` text:${k}="${escapeXml(String(v))}"`)
+ .join('');
+ return `${escapeXml(m.text)}`;
+ });
+ 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' };
@@ -3801,9 +3851,22 @@ const ODF_INDEX: Record ``;
+ const body = field('identifier') + ': '
+ + [field('author'), field('title'), field('year')].join(', ');
+ return (types.length ? types : ['book'])
+ .map(t => `${body}`)
+ .join('');
+}
+
+function tocXml(toc: TocExport, index: number, bibTypes: string[]): string {
const spec = ODF_INDEX[toc.kind];
const title = toc.title ?? INDEX_TITLES[toc.kind];
const name = `${spec.docName}${index + 1}`;
@@ -3818,12 +3881,16 @@ function tocXml(toc: TocExport, index: number): string {
``;
// 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'
+ const sourceAttrs = toc.kind === 'bibliography'
+ ? ''
+ : 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
+ const templates = toc.kind === 'bibliography'
+ ? bibEntryTemplates(tocLevelStyle(toc, 1), bibTypes)
+ : spec.seq
? entry('', tocLevelStyle(toc, 1))
: toc.kind === 'alphabetical'
? entry(' text:outline-level="1"', tocLevelStyle(toc, 1))
@@ -3843,7 +3910,9 @@ function tocXml(toc: TocExport, index: number): string {
``
: '') +
toc.entries
- .map(e => `${escapeXml(e.text).replace(/\n/g, '')}${e.pages?.join(', ') ?? e.page}`)
+ .map(e => `${escapeXml(e.text).replace(/\n/g, '')}`
+ // A bibliography row is the source, nothing else: no tab, no page number.
+ + (toc.kind === 'bibliography' ? '' : `${e.pages?.join(', ') ?? e.page}`) + '')
.join('') +
``;
return `${source}${body}`;
@@ -3960,7 +4029,7 @@ function applyNotes(odtBytes: Uint8Array, notes: NoteExport[]): Uint8Array {
return rezipOdt(files);
}
-function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: number): Uint8Array {
+function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: number, bibTypes: string[]): Uint8Array {
if (!tocs.length) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
@@ -3973,7 +4042,7 @@ function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: numbe
new RegExp(`]*>${TOC_SENT}(\\d+)${TOC_SENT}`, 'g'),
(_m, idx: string) => {
const toc = tocs[Number(idx)];
- return toc ? tocXml(toc, Number(idx)) : '';
+ return toc ? tocXml(toc, Number(idx), bibTypes) : '';
},
);
// Defensive: strip any sentinels not consumed above.
@@ -4039,7 +4108,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const seqFields: SequenceExport[] = [];
const revisionList = new Map();
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 bibMarks: BibExport[] = [];
+ const sentinels = replaceBibEntries(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), bibMarks);
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;
@@ -4239,7 +4309,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 = applyIndexEntries(applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList), indexMarks);
+ const withSequences = applyBibEntries(applyIndexEntries(applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList), indexMarks), bibMarks);
const withFormulas = applyFormulas(withSequences, formulas);
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
const withColumns = applyColumns(withTextBoxes, columns);
@@ -4247,7 +4317,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// After every other content.xml pass: the note's own runs are hoisted into the body,
// so they must be fully resolved before they are cut out and moved into .
const withNotes = applyNotes(withBookmarks, notes);
- const withToc = applyToc(withNotes, tocs, contentWidthCm);
+ const withToc = applyToc(withNotes, tocs, contentWidthCm, [...new Set(bibMarks.map(m => m.type))]);
const withPageBreaks = applyPageBreaks(withToc);
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
const withParaBoxes = applyParagraphBoxes(withEmptyFontSizes);
diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts
index 15ac823..8904b76 100644
--- a/src/lib/i18n/locales/de.ts
+++ b/src/lib/i18n/locales/de.ts
@@ -550,6 +550,21 @@ const de: Messages = {
above: 'Über dem Objekt',
below: 'Unter dem Objekt',
},
+ bibliography: {
+ title: 'Literaturangabe einfügen',
+ identifier: 'Kurzname',
+ type: 'Typ',
+ types: {
+ book: 'Buch', article: 'Zeitschriftenartikel', inbook: 'Buchkapitel',
+ incollection: 'In einem Sammelband', conference: 'Konferenzbeitrag',
+ techreport: 'Bericht', phdthesis: 'Dissertation', mastersthesis: 'Masterarbeit',
+ www: 'Webseite', misc: 'Sonstiges',
+ },
+ fields: {
+ author: 'Autor', title: 'Titel', year: 'Jahr', publisher: 'Verlag',
+ journal: 'Zeitschrift', pages: 'Seiten', url: 'URL',
+ },
+ },
pageDecor: {
title: 'Seitengestaltung',
background: 'Hintergrund',
@@ -712,10 +727,11 @@ const de: Messages = {
tocOptions: 'Ebenen',
tocMaxLevel: 'Tiefste angezeigte Ebene',
insertCaption: 'Beschriftung',
- indexes: { toc: 'Inhaltsverzeichnis', figures: 'Abbildungsverzeichnis', tables: 'Tabellenverzeichnis', alphabetical: 'Stichwortverzeichnis' },
+ indexes: { toc: 'Inhaltsverzeichnis', figures: 'Abbildungsverzeichnis', tables: 'Tabellenverzeichnis', alphabetical: 'Stichwortverzeichnis', bibliography: 'Literaturverzeichnis' },
indexEntry: 'Stichwort',
indexEntryPrompt: 'Stichwort — das Wort, das im Verzeichnis steht:',
indexEntryNeedsSelection: 'Markiere das Wort, das ins Stichwortverzeichnis soll',
+ citation: 'Literaturangabe',
footnote: 'Fußnote',
endnote: 'Endnote',
noteOptions: 'Optionen',
diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts
index e7226a0..940e1c2 100644
--- a/src/lib/i18n/locales/en.ts
+++ b/src/lib/i18n/locales/en.ts
@@ -554,6 +554,21 @@ const en = {
above: 'Above the item',
below: 'Below the item',
},
+ bibliography: {
+ title: 'Insert Citation',
+ identifier: 'Short name',
+ type: 'Type',
+ types: {
+ book: 'Book', article: 'Journal article', inbook: 'Book chapter',
+ incollection: 'In a collection', conference: 'Conference paper',
+ techreport: 'Report', phdthesis: 'Doctoral thesis', mastersthesis: "Master's thesis",
+ www: 'Website', misc: 'Other',
+ },
+ fields: {
+ author: 'Author', title: 'Title', year: 'Year', publisher: 'Publisher',
+ journal: 'Journal', pages: 'Pages', url: 'URL',
+ },
+ },
pageDecor: {
title: 'Page decoration',
background: 'Background',
@@ -714,10 +729,11 @@ const en = {
},
toc: 'Table of contents',
insertCaption: 'Caption',
- indexes: { toc: 'Table of contents', figures: 'List of figures', tables: 'List of tables', alphabetical: 'Alphabetical index' },
+ indexes: { toc: 'Table of contents', figures: 'List of figures', tables: 'List of tables', alphabetical: 'Alphabetical index', bibliography: 'Bibliography' },
indexEntry: 'Index entry',
indexEntryPrompt: 'Index entry — the word the index lists:',
indexEntryNeedsSelection: 'Select the word to file in the index',
+ citation: 'Citation',
tocOptions: 'Levels',
tocMaxLevel: 'Deepest level shown',
footnote: 'Footnote',
diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md
index 6eae338..0c7124a 100644
--- a/src/lib/import/CLAUDE.md
+++ b/src/lib/import/CLAUDE.md
@@ -31,6 +31,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame.
- **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text.
- **A hoisted text box keeps what its anchor paragraph gave it**: an as-char figure frame is centred by the paragraph it sits in, so `wrapAlign` carries that (or the frame's own `style:horizontal-pos`) onto the box. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default.
+- **Bibliography**: a `text:bibliography-mark`'s every `text:` attribute but the identifier and the type is a source field, whatever the file names it, so nothing is lost on a format we have no UI for. Word's `CITATION` names a tag only — the record comes from `customXml/item*.xml` (each is probed for a `b:Sources` root), and a tag no source declares still cites with the field's cached text. `BIBLIOGRAPHY` and `text:bibliography` both become the index node.
- **Tracked changes**: ODF points at a registry, so `odfRevisions(body)` reads `` first and the body walk resolves each `text:change-start`/`-end` and `text:change` against it (the open insertions live in `Ctx` — a range may close in a later paragraph). A `text:change` is a deletion whose text is *only* in the registry: it is emitted there and then, marked. Word needs no registry — `w:ins`/`w:del` wrap their runs, whose text sits in `w:delText` rather than `w:t` for a deletion.
- **Footnotes and endnotes** round-trip: `text:note` (ODF) and `word/footnotes.xml`/`endnotes.xml` (DOCX) become a `noteRef` anchor plus a `note` in the document-final `noteSection`, and the numbering configuration becomes `NoteSettings`. See `docs/architecture/notes.md`.
- **Graceful degradation** (collected as `warnings`, surfaced once): comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`).
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
index 47b514c..5922cb0 100644
--- a/src/lib/import/docx.ts
+++ b/src/lib/import/docx.ts
@@ -1,5 +1,5 @@
import { unzipSync, strFromU8 } from 'fflate';
-import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, toggle as onOff, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
+import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, toggle as onOff, wVal, W, R, WP, A, B, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
import { lengthToPt, WATERMARK_NAME } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
@@ -28,6 +28,7 @@ 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 { bibTypeFromDocx, DOCX_BIB_FIELD, type BibSource } from '../editor/extensions/bibliographyEntry';
import { normalizePageDecor, type PageDecor } from '../storage/pageDecor';
import { DEFAULT_LINE_NUMBERING, normalizeLineNumbering, type LineNumbering } from '../storage/lineNumbering';
import { clampColumnGap } from '../editor/extensions/columns';
@@ -79,6 +80,8 @@ type Ctx = {
openComments: Map>;
// word/comments.xml, by id.
commentDefs: Map>;
+ // The bibliography sources of the custom-XML part, by tag; a CITATION names one.
+ bibSources: Map;
// word/footnotes.xml and endnotes.xml by w:id, and the notes the body referenced, in
// anchor order — the editor keeps them in one section at the document end (notes.ts).
noteParts: Record>;
@@ -130,8 +133,8 @@ function fc(el: Element | null, localName: string): Element | null {
for (const c of Array.from(el.children)) if (c.namespaceURI === W && c.localName === localName) return c;
return null;
}
-function fcAll(el: Element, localName: string): Element[] {
- return Array.from(el.children).filter((c) => c.namespaceURI === W && c.localName === localName);
+function fcAll(el: Element, localName: string, ns: string = W): Element[] {
+ return Array.from(el.children).filter((c) => c.namespaceURI === ns && c.localName === localName);
}
function intAttr(el: Element | null, ns: string, name: string): number | null {
if (!el) return null;
@@ -180,7 +183,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const sectPr = fc(body, 'sectPr');
const contentWidthCm = sectionContentWidthCm(sectPr);
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
- const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, pageRtl: sectPrRtl(sectPr), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), notes: [], noteParts: {
+ const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, pageRtl: sectPrRtl(sectPr), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), bibSources: docxSources(files), notes: [], noteParts: {
footnote: noteParts(files, 'footnotes', 'footnote'),
endnote: noteParts(files, 'endnotes', 'endnote'),
} };
@@ -336,7 +339,9 @@ 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.
+ // INDEX is Word's alphabetical index, BIBLIOGRAPHY its source list; the rest are TOC
+ // fields differing by \\c.
+ if (/\bBIBLIOGRAPHY\b/.test(instr)) return 'bibliography';
if (/\bINDEX\b/.test(instr)) return 'alphabetical';
const m = /\\c\s+"?([^"\\]+)/.exec(instr);
const cat = m ? ODF_SEQ_CATEGORY[m[1].trim()] : undefined;
@@ -494,9 +499,10 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
// 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') {
+ if ((/\bINDEX\b/.test(simple) || /\bBIBLIOGRAPHY\b/.test(simple)) && kind === 'body') {
flush();
- out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', index: 'alphabetical' } });
+ const index = /\bBIBLIOGRAPHY\b/.test(simple) ? 'bibliography' : 'alphabetical';
+ out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', index } });
continue;
}
const { emit } = scanTocField(el, tocState);
@@ -1156,9 +1162,9 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
// A recognized body date/time field: its cached result runs are dropped and a live
// dateTimeField node is emitted in their place when the field ends.
let fieldDateTime: Node | null = null;
- // Same for a REF/PAGEREF field and a caption's SEQ: the cached result runs are
- // collected as the node's display text instead of being pushed.
- let fieldCrossRef: Node | null = null;
+ // Same for a REF/PAGEREF field, a CITATION and a caption's SEQ: the cached result runs
+ // are collected as the node's display text instead of being pushed.
+ let fieldShown: Node | null = null;
let fieldSeq: Node | null = null;
let fieldResultText = '';
@@ -1211,7 +1217,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
const marks = runMarks(r, linkHref);
// Hide a field's cached result: always for a hf field, and for a recognized body
// date/time field (replaced by its live node).
- const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime || !!fieldCrossRef);
+ const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime || !!fieldShown);
// Route a drawing/pict result: an image is inline, a text box a block node riding
// ctx.pendingBlocks. The one-paragraph header/footer zone takes as-char images only —
@@ -1247,29 +1253,29 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
switch (child.localName) {
case 'fldChar': {
const t = child.getAttributeNS(W, 'fldCharType');
- if (t === 'begin') { fieldMode = 'instr'; fieldInstr = ''; fieldDateTime = null; fieldCrossRef = null; fieldSeq = null; fieldResultText = ''; }
+ if (t === 'begin') { fieldMode = 'instr'; fieldInstr = ''; fieldDateTime = null; fieldShown = null; fieldSeq = null; fieldResultText = ''; }
else if (t === 'separate') {
fieldMode = 'result';
if (!hfFields) {
fieldDateTime = dateTimeFieldFromInstr(fieldInstr);
fieldSeq = fieldDateTime ? null : seqFieldFromInstr(fieldInstr);
- fieldCrossRef = fieldDateTime || fieldSeq ? null : crossRefFromInstr(fieldInstr);
+ fieldShown = fieldDateTime || fieldSeq ? null : (crossRefFromInstr(fieldInstr) ?? citationFromInstr(fieldInstr, ctx));
fieldResultText = '';
// Carry the field run's marks so the atom renders in the run's font.
- const field = fieldDateTime ?? fieldSeq ?? fieldCrossRef;
+ const field = fieldDateTime ?? fieldSeq ?? fieldShown;
if (field && marks.length) field.marks = marks;
}
} else if (t === 'end') {
// A reference Word never resolved has no w:separate and so no cached result
// (it shows nothing until the field is updated); the node view fills it in
// when the bookmark is still there.
- if (!hfFields && !fieldDateTime && !fieldCrossRef && !fieldSeq) {
- fieldCrossRef = crossRefFromInstr(fieldInstr);
- if (fieldCrossRef && marks.length) fieldCrossRef.marks = marks;
+ if (!hfFields && !fieldDateTime && !fieldShown && !fieldSeq) {
+ fieldShown = crossRefFromInstr(fieldInstr) ?? citationFromInstr(fieldInstr, ctx);
+ if (fieldShown && marks.length) fieldShown.marks = marks;
}
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 if (fieldShown) out.push({ ...fieldShown, attrs: { ...fieldShown.attrs, text: fieldResultText } });
else {
const mark = !hfFields && indexEntryFromInstr(fieldInstr);
if (mark) out.push(mark);
@@ -1277,7 +1283,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
}
fieldMode = 'none';
fieldDateTime = null;
- fieldCrossRef = null;
+ fieldShown = null;
fieldSeq = null;
}
break;
@@ -1287,7 +1293,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
// revision is only recorded, not applied.
case 'delText':
case 't':
- if ((fieldCrossRef || fieldSeq) && fieldMode === 'result') fieldResultText += child.textContent ?? '';
+ if ((fieldShown || fieldSeq) && fieldMode === 'result') fieldResultText += child.textContent ?? '';
else if (!skipResult()) pushText(child.textContent ?? '', marks);
break;
case 'tab': if (!skipResult()) pushText('\t', marks); break;
@@ -1341,6 +1347,8 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
}
const mark = indexEntryFromInstr(instr);
if (mark) { out.push(mark); break; }
+ const cite = citationFromInstr(instr, ctx);
+ if (cite) { out.push({ ...cite, attrs: { ...cite.attrs, text: el.textContent ?? '' } }); break; }
const seq = seqFieldFromInstr(instr);
if (seq) {
const first = fcAll(el, 'r')[0];
@@ -1413,6 +1421,61 @@ function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[]
}
}
+// Every source Word keeps, by its tag. They live in a custom-XML part of their own —
+// which part is not fixed, so each is probed for the bibliography root.
+function docxSources(files: Record): Map {
+ const out = new Map();
+ for (const path of Object.keys(files)) {
+ if (!/^customXml\/item\d+\.xml$/.test(path)) continue;
+ let root: Element;
+ try { root = parseXml(strFromU8(files[path])).documentElement; } catch { continue; }
+ if (root.localName !== 'Sources') continue;
+ for (const src of Array.from(root.getElementsByTagNameNS(B, 'Source'))) {
+ const identifier = fcAll(src, 'Tag', B)[0]?.textContent?.trim() ?? '';
+ if (!identifier || out.has(identifier)) continue;
+ const fields: Record = {};
+ for (const [odf, word] of Object.entries(DOCX_BIB_FIELD)) {
+ const v = fcAll(src, word, B)[0]?.textContent?.trim();
+ if (v) fields[odf] = v;
+ }
+ const author = bibAuthorFrom(src);
+ if (author) fields.author = author;
+ const type = fcAll(src, 'SourceType', B)[0]?.textContent?.trim() ?? '';
+ out.set(identifier, { identifier, type: bibTypeFromDocx(type), fields });
+ }
+ }
+ return out;
+}
+
+// Word's author list back to one string: persons as "Last, First", several separated by
+// a semicolon, a corporate name verbatim.
+function bibAuthorFrom(src: Element): string {
+ const holder = fcAll(src, 'Author', B)[0];
+ const inner = holder ? fcAll(holder, 'Author', B)[0] : null;
+ if (!inner) return '';
+ const corporate = fcAll(inner, 'Corporate', B)[0]?.textContent?.trim();
+ if (corporate) return corporate;
+ return Array.from(inner.getElementsByTagNameNS(B, 'Person'))
+ .map((p) => [fcAll(p, 'Last', B)[0]?.textContent?.trim(), fcAll(p, 'First', B)[0]?.textContent?.trim()]
+ .filter(Boolean).join(', '))
+ .filter(Boolean)
+ .join('; ');
+}
+
+// A CITATION field instruction → a citation, its record taken from the sources part. A
+// tag no source declares still cites: the field's cached text is what the reader saw.
+function citationFromInstr(instr: string, ctx: Ctx): Node | null {
+ const m = /^\s*CITATION\s+("[^"]+"|\S+)/i.exec(instr);
+ if (!m) return null;
+ const identifier = m[1].replace(/^"|"$/g, '').trim();
+ if (!identifier) return null;
+ const source = ctx.bibSources.get(identifier);
+ return {
+ type: 'bibliographyEntry',
+ attrs: { identifier, type: source?.type ?? 'misc', fields: source?.fields ?? {}, text: '' },
+ };
+}
+
// 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 {
diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts
index e0e68a4..b287738 100644
--- a/src/lib/import/docxStyles.ts
+++ b/src/lib/import/docxStyles.ts
@@ -16,6 +16,8 @@ export const PKG_REL = 'http://schemas.openxmlformats.org/package/2006/relations
export const WPS = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape';
export const MC = 'http://schemas.openxmlformats.org/markup-compatibility/2006';
export const VML = 'urn:schemas-microsoft-com:vml';
+// The bibliography sources Word keeps in a custom-XML part.
+export const B = 'http://schemas.openxmlformats.org/officeDocument/2006/bibliography';
// A run's resolved character properties; only set keys are present so layers merge cleanly.
export type RunProps = {
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
index 3d38cdd..a2685df 100644
--- a/src/lib/import/odt.ts
+++ b/src/lib/import/odt.ts
@@ -27,6 +27,7 @@ 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 { isBibType } from '../editor/extensions/bibliographyEntry';
import type { PageDecor } from '../storage/pageDecor';
import type { LineNumbering } from '../storage/lineNumbering';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
@@ -1042,9 +1043,26 @@ 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',
+ 'alphabetical-index': 'alphabetical', 'bibliography': 'bibliography',
};
+// A → a citation. Its non-empty text: attributes other than the
+// identifier and the type are the source's fields, whatever the file happens to name.
+function bibEntryFromMark(el: Element): Node | null {
+ const identifier = (el.getAttributeNS(NS.text, 'identifier') ?? '').trim();
+ if (!identifier) return null;
+ const fields: Record = {};
+ for (const a of Array.from(el.attributes)) {
+ if (a.namespaceURI !== NS.text || a.localName === 'identifier' || a.localName === 'bibliography-type') continue;
+ if (a.value.trim()) fields[a.localName] = a.value;
+ }
+ const type = el.getAttributeNS(NS.text, 'bibliography-type') ?? '';
+ return {
+ type: 'bibliographyEntry',
+ attrs: { identifier, type: isBibType(type) ? type : 'misc', fields, text: el.textContent ?? '' },
+ };
+}
+
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 }[] = [];
@@ -1909,6 +1927,12 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
continue;
}
if (!hfFields && e.localName === 'alphabetical-index-mark-end') continue;
+ // A citation. Every field is an attribute of the mark, so the source record
+ // travels with it; the element's text is what the citation showed.
+ if (!hfFields && e.localName === 'bibliography-mark') {
+ const entry = bibEntryFromMark(e);
+ if (entry) { out.push(entry); 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 e148d43..7a6edf2 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -1035,6 +1035,12 @@
background: var(--color-field-shade, rgba(0, 0, 0, 0.18));
}
+/* A citation is a field: it shows its text on the field shade both word processors use. */
+.tiptap .pm-citation {
+ background: var(--color-field-shade, rgba(0, 0, 0, 0.08));
+ border-radius: 2px;
+}
+
.tiptap .pm-insertion {
color: #b5179e;
text-decoration: underline;
diff --git a/tests/lo-roundtrip.test.ts b/tests/lo-roundtrip.test.ts
index 7885d9d..6e62e68 100644
--- a/tests/lo-roundtrip.test.ts
+++ b/tests/lo-roundtrip.test.ts
@@ -362,4 +362,50 @@ describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () =
check('LO notes: the note text comes back', JSON.stringify(section).includes('The footnote, with'), JSON.stringify(section)?.slice(0, 200));
check('LO notes: bold inside the note survives', JSON.stringify(section).includes('"bold"'), JSON.stringify(section)?.slice(0, 300));
});
+
+ it('survives a `soffice` re-save of a bibliography', { timeout: 180000 }, async () => {
+ const cite = (identifier: string, type: string, fields: Record): N =>
+ ({ type: 'bibliographyEntry', attrs: { identifier, type, fields, text: '' } });
+ const KAF = { author: 'Kafka, Franz', title: 'Der Process', year: '1925', publisher: 'Kurt Wolff' };
+ const MEY = { author: 'Meyer, Anna', title: 'Zur Sache', year: '1999', journal: 'Zeitschrift' };
+ const doc: N = { type: 'doc', content: [
+ P(null, T('Ein Satz mit Beleg '), cite('KAF01', 'book', KAF), T('.')),
+ P(null, T('Und noch einer '), cite('MEY99', 'article', MEY), T(', wieder '), cite('KAF01', 'book', KAF), T('.')),
+ { type: 'tableOfContents', attrs: { index: 'bibliography', title: 'Literaturverzeichnis', entries: [
+ { text: 'KAF01: Kafka, Franz, Der Process, 1925', level: 1, page: 1 },
+ { text: 'MEY99: Meyer, Anna, Zur Sache, 1999', level: 1, page: 1 },
+ ] } },
+ ] };
+ mkdirSync('/tmp/lo-rt', { recursive: true });
+ writeFileSync('/tmp/lo-rt/bib.odt', await buildOdt(doc, margins, 'portrait'));
+ execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/bibout /tmp/lo-rt/bib.odt', { stdio: 'pipe', timeout: 120000 });
+ const resaved = new Uint8Array(readFileSync('/tmp/lo-rt/bibout/bib.odt'));
+ const xml = strFromU8(unzipSync(resaved)['content.xml']);
+
+ check('LO bibliography: all three marks survive', (xml.match(/]*>/g));
+ check('LO bibliography: a mark keeps its whole record',
+ xml.includes('text:author="Kafka, Franz"') && xml.includes('text:publisher="Kurt Wolff"') && xml.includes('text:year="1925"'),
+ xml.match(/]*>/g)?.[0]);
+ check('LO bibliography: the index stays one', xml.includes('-][^>]*>/g));
+
+ const res = importOdt(resaved);
+ const cites: N[] = [];
+ let index: N = null;
+ (function walk(n: N) {
+ if (n.type === 'bibliographyEntry') cites.push(n);
+ if (n.type === 'tableOfContents') index = n;
+ for (const c of n.content ?? []) walk(c);
+ })(res.content);
+ check('LO bibliography: every citation comes back',
+ cites.map((c) => c.attrs.identifier).join(',') === 'KAF01,MEY99,KAF01', cites.map((c) => c.attrs));
+ check('LO bibliography: the record comes back with it',
+ cites[0]?.attrs?.fields?.author === 'Kafka, Franz' && cites[0]?.attrs?.fields?.year === '1925', cites[0]?.attrs?.fields);
+ check('LO bibliography: the type comes back', cites[1]?.attrs?.type === 'article', cites[1]?.attrs?.type);
+ check('LO bibliography: the index comes back as one', index?.attrs?.index === 'bibliography', index?.attrs?.index);
+ check('LO bibliography: its rows come back',
+ (index?.attrs?.entries ?? []).map((e: N) => e.text).join(' | ')
+ === 'KAF01: Kafka, Franz, Der Process, 1925 | MEY99: Meyer, Anna, Zur Sache, 1999',
+ index?.attrs?.entries);
+ });
});
diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts
index d4a6e0b..8dac3c8 100644
--- a/tests/roundtrip.test.ts
+++ b/tests/roundtrip.test.ts
@@ -2280,3 +2280,83 @@ describe('Leg 25: alphabetical index', () => {
check('index round-trips as alphabetical', indexKind(back) === 'alphabetical', indexKind(back));
});
});
+
+describe('Leg 26: bibliography', () => {
+ const cite = (identifier: string, type: string, fields: Record): N =>
+ ({ type: 'bibliographyEntry', attrs: { identifier, type, fields, text: '' } });
+ const KAF = { author: 'Kafka, Franz', title: 'Der Process', year: '1925', publisher: 'Kurt Wolff' };
+ const MEY = { author: 'Meyer, Anna', title: 'Zur Sache', year: '1999', journal: 'Zeitschrift' };
+ const doc: N = { type: 'doc', content: [
+ P(null, T('Ein Satz mit Beleg '), cite('KAF01', 'book', KAF), T('.')),
+ P(null, T('Und noch einer '), cite('MEY99', 'article', MEY), T(', wieder '), cite('KAF01', 'book', KAF), T('.')),
+ { type: 'tableOfContents', attrs: {
+ index: 'bibliography', title: 'Literaturverzeichnis',
+ entries: [
+ { text: 'KAF01: Kafka, Franz, Der Process, 1925', level: 1, page: 1 },
+ { text: 'MEY99: Meyer, Anna, Zur Sache, 1999', level: 1, page: 1 },
+ ],
+ } },
+ ] };
+ const cites = (back: N) => {
+ const out: string[] = [];
+ const walk = (n: N): void => {
+ if (n?.type === 'bibliographyEntry') out.push(`${n.attrs.identifier}/${n.attrs.type}/${n.attrs.fields?.author ?? ''}`);
+ (n?.content ?? []).forEach(walk);
+ };
+ walk(back);
+ return out.join(' ');
+ };
+ const indexKind = (back: N) => (back.content.content ?? [])
+ .find((n: N) => n.type === 'tableOfContents')?.attrs?.index;
+ const WANT = 'KAF01/book/Kafka, Franz MEY99/article/Meyer, Anna KAF01/book/Kafka, Franz';
+
+ it('ODT: bibliography marks plus a text:bibliography, and back', async () => {
+ const bytes = await buildOdt(doc, margins, 'portrait');
+ const xml = strFromU8(unzipSync(bytes)['content.xml']);
+ check('mark carries its whole record',
+ xml.includes(']*>/g));
+ check('the mark wraps the text the citation shows', xml.includes('>[KAF01]'),
+ xml.match(//g));
+ check('index element emitted', xml.includes('-][^>]*>/g));
+ check('a template per type cited',
+ xml.includes('text:bibliography-type="book" text:style-name="Bibliography_20_1"')
+ && xml.includes('text:bibliography-type="article" text:style-name="Bibliography_20_1"'),
+ xml.match(/]*>/g));
+ check('a row has no tab and no page number',
+ xml.includes('>KAF01: Kafka, Franz, Der Process, 1925'),
+ 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('citations round-trip', cites(back.content as N) === WANT, cites(back.content as N));
+ check('index round-trips as a bibliography', indexKind(back) === 'bibliography', indexKind(back));
+ });
+
+ it('DOCX: CITATION fields over a custom-XML source, and back', async () => {
+ const bytes = await buildDocx(doc, margins, 'portrait');
+ const files = unzipSync(bytes);
+ const xml = strFromU8(files['word/document.xml']);
+ check('CITATION emitted', xml.includes('w:instr="CITATION "KAF01""'), xml.match(/w:instr="[^"]*"/g));
+ check('BIBLIOGRAPHY field emitted', xml.includes('w:instr="BIBLIOGRAPHY"'), xml.match(/w:instr="[^"]*"/g));
+
+ const item = files['customXml/item1.xml'] ? strFromU8(files['customXml/item1.xml']) : '';
+ check('one source per tag, not per citation', (item.match(//g) ?? []).length === 2,
+ item.match(/[^<]*<\/b:Tag>/g));
+ check('the author splits into Word’s name list',
+ item.includes('KafkaFranz'), item.slice(0, 600));
+ check('the type maps onto Word’s', item.includes('Book')
+ && item.includes('JournalArticle'), item.match(/[^<]* k.startsWith('customXml')));
+ check('the document relates to it',
+ strFromU8(files['word/_rels/document.xml.rels']).includes('../customXml/item1.xml'));
+ check('its properties part has a content type',
+ strFromU8(files['[Content_Types].xml']).includes('/customXml/itemProps1.xml'));
+
+ const back = importDocx(bytes);
+ check('citations round-trip', cites(back.content as N) === WANT, cites(back.content as N));
+ check('index round-trips as a bibliography', indexKind(back) === 'bibliography', indexKind(back));
+ });
+});
diff --git a/tests/unit/bibliography.test.ts b/tests/unit/bibliography.test.ts
new file mode 100644
index 0000000..058e708
--- /dev/null
+++ b/tests/unit/bibliography.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect } from 'vitest';
+import { getSchema } from '@tiptap/core';
+import { Node as PMNode } from '@tiptap/pm/model';
+import { extensions } from '../../src/lib/editor/extensions';
+import {
+ bibliographyEntries, bibliographyRows, bibRowText, bibTypeFromDocx, citationText,
+} from '../../src/lib/editor/extensions/bibliographyEntry';
+
+const schema = getSchema(extensions);
+
+const cite = (identifier: string, type: string, fields: Record) =>
+ ({ type: 'bibliographyEntry', attrs: { identifier, type, fields, text: '' } });
+const KAF = { author: 'Kafka, Franz', title: 'Der Process', year: '1925' };
+const MEY = { author: 'Meyer, Anna', title: 'Zur Sache', year: '1999' };
+
+const doc = PMNode.fromJSON(schema, { type: 'doc', content: [
+ { type: 'paragraph', content: [{ type: 'text', text: 'a ' }, cite('KAF01', 'book', KAF)] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'b ' }, cite('MEY99', 'article', MEY), cite('KAF01', 'book', KAF)] },
+] });
+
+describe('bibliography', () => {
+ it('collects every citation in document order', () => {
+ expect(bibliographyEntries(doc).map(e => e.identifier)).toEqual(['KAF01', 'MEY99', 'KAF01']);
+ });
+
+ it('shows [key] where the file names no text of its own', () => {
+ expect(bibliographyEntries(doc)[0].text).toBe(citationText('KAF01'));
+ });
+
+ it('lists one row per source, in document order', () => {
+ expect(bibliographyRows(bibliographyEntries(doc))).toEqual([
+ { identifier: 'KAF01', text: 'KAF01: Kafka, Franz, Der Process, 1925' },
+ { identifier: 'MEY99', text: 'MEY99: Meyer, Anna, Zur Sache, 1999' },
+ ]);
+ });
+
+ it('leaves out a field the source has not got', () => {
+ expect(bibRowText({ identifier: 'X', type: 'misc', fields: { title: 'Nur ein Titel' } }))
+ .toBe('X: Nur ein Titel');
+ expect(bibRowText({ identifier: 'X', type: 'misc', fields: {} })).toBe('X');
+ });
+
+ it('maps Word source types back, unknown ones to misc', () => {
+ expect(bibTypeFromDocx('Book')).toBe('book');
+ expect(bibTypeFromDocx('JournalArticle')).toBe('article');
+ expect(bibTypeFromDocx('Patent')).toBe('misc');
+ });
+});