diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8db0d83..2582f07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -131,6 +131,7 @@
- Recent files in the File menu, as both word processors have: opening or saving a document puts it at the top of the list, and a click reopens the file itself where the browser can hand its handle back (the File System Access API re-asks for permission after a reload). A file that has moved or been declined is reported and dropped from the list rather than left to fail again; a browser without the API keeps the names only
- Pictures are autosaved to IndexedDB, not localStorage: the document JSON keeps a short key per picture, so an image-heavy document no longer runs into the ~5 MB quota that used to stop it saving. Each save sweeps the pictures the document has dropped, a picture used twice is stored once, and where IndexedDB is unavailable everything stays inline as before
- Embedded font loading: fonts embedded in an opened `.odt`/`.docx` (Word `.odttf` de-obfuscated) are registered via the FontFace API so text renders in its real face even when the font isn't installed; persisted per-document in IndexedDB so it survives a reload. Fonts the document only names but neither embeds nor installs are still flagged as substituted
+- Citation styles for the bibliography (References ▸ Citation style): LibreOffice's cite-by-short-name, numbered (`[1]`, counting in citation order), APA, MLA and Chicago. The style sets both what a citation shows in the text and how each row of the list reads, and every citation restyles itself as the document is edited. It round-trips where both word processors keep it: as the ODF entry template LibreOffice regenerates its rows from — so the same rows come back out of LibreOffice — and as Word's `b:Sources StyleName`. The shapes are each style's common form, not a full CSL implementation
- List of figures and list of tables (References ▸ Table of contents ▸ the two caption indexes): the same generated index the table of contents is, over the captions of one category instead of the headings — single-level, with live page numbers and leader dots. Round-trips as ODF `text:illustration-index` / `text:table-index` (found by the counter's name, as LibreOffice finds them) and Word's `TOC \c "Figure"` field
- Page numbering restarts per section (Layout ▸ Page number format ▸ This section): the front matter counts i, ii, iii and the body starts at 1 again. Round-trips as ODF `style:page-number` on the paragraph that switches master page and Word's `w:pgNumType w:start` — a section that restarts is exported as a page-starting break, since both word processors ignore a start on a continuous one
- Line numbering (Layout ▸ Line numbers, LibreOffice's Tools ▸ Line Numbering): numbers in the left margin, continuous or restarting each page, every nth line, with or without empty lines. Measured off the rendered lines, so the preview and the saved file agree; round-trips as ODF `text:linenumbering-configuration` and Word's `w:lnNumType`
@@ -153,7 +154,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14.
**Missing while writing**
- Hyphenation's zone and ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither
-- 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
- A sort dialog's second and third key, and its choice of sort type over the detected one; a formula's own number format (LibreOffice's Number Format dialog per cell — the general format is the only one here), and a formula reaching into another table
- 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
- Linked / chained text frames
diff --git a/src/lib/components/ribbon/tabs/ReferencesTab.svelte b/src/lib/components/ribbon/tabs/ReferencesTab.svelte
index 279cd6e..cab7a35 100644
--- a/src/lib/components/ribbon/tabs/ReferencesTab.svelte
+++ b/src/lib/components/ribbon/tabs/ReferencesTab.svelte
@@ -10,6 +10,7 @@
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint } from '../../../editor/shortcuts';
import { HEADING_LEVELS } from '../../../export/odt';
+ import { CITATION_STYLES, isCitationStyle, type CitationStyle } from '../../../utils/citationStyle';
let { editor, tick, hfActive = null, onNoteOptions }: {
editor: Editor | null;
@@ -49,6 +50,25 @@
if (!editor || !toc) return;
editor.chain().focus().setNodeSelection(toc.pos).updateAttributes('tableOfContents', { maxLevel: level }).run();
}
+
+ // The document's bibliography, whose attrs hold the citation style — both formats keep
+ // it there too, so a document without one cites by key and the button stays disabled.
+ let bibliography = $derived.by<{ pos: number; style: CitationStyle } | null>(() => {
+ if (tick < 0 || !editor) return null;
+ let found: { pos: number; style: CitationStyle } | null = null;
+ editor.state.doc.descendants((node, pos) => {
+ if (found || node.type.name !== 'tableOfContents' || node.attrs.index !== 'bibliography') return;
+ found = { pos, style: isCitationStyle(node.attrs.citationStyle) ? node.attrs.citationStyle : 'key' };
+ });
+ return found;
+ });
+
+ function setCitationStyle(style: CitationStyle) {
+ closeMenu();
+ if (!editor || !bibliography) return;
+ editor.chain().focus().setNodeSelection(bibliography.pos)
+ .updateAttributes('tableOfContents', { citationStyle: style }).run();
+ }
@@ -117,6 +137,27 @@
disabled={!editor || !!hfActive}
onclick={() => (citationOpen = true)}
/>
+
diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md
index d514b00..5141a97 100644
--- a/src/lib/editor/extensions/CLAUDE.md
+++ b/src/lib/editor/extensions/CLAUDE.md
@@ -37,7 +37,7 @@ 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.
-- **`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.
+- **`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. The **citation style** (`utils/citationStyle.ts` — LibreOffice's cite-by-key, numbered, APA, MLA, Chicago) rides the bibliography index's node, which is where both formats keep it: a row is a token list, exactly what an ODF entry template is, so LibreOffice regenerates the same rows (it fills the types the document does not cite with its own default, so the import takes the first template reading as anything but `key`). Word gets the style as `b:Sources StyleName`. Each citation's label is derived, kept true by an `appendTransaction` off the undo stack like the caption numbers. `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.
diff --git a/src/lib/editor/extensions/bibliographyEntry.ts b/src/lib/editor/extensions/bibliographyEntry.ts
index ef86f22..870ff73 100644
--- a/src/lib/editor/extensions/bibliographyEntry.ts
+++ b/src/lib/editor/extensions/bibliographyEntry.ts
@@ -1,5 +1,7 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
+import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
+import { citationLabel, formatBibRow, isCitationStyle, type CitationStyle } from '../../utils/citationStyle';
// 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
@@ -63,6 +65,18 @@ export function isBibType(v: unknown): boolean {
/** What the citation shows where the file names nothing else: LibreOffice's `[key]`. */
export const citationText = (identifier: string): string => `[${identifier}]`;
+// The document's citation style lives on its bibliography index (tableOfContents.ts),
+// which is where both formats keep it too; a document with no bibliography cites by key.
+export function documentCitationStyle(doc: PMNode): CitationStyle {
+ let style: CitationStyle = 'key';
+ doc.descendants((node) => {
+ if (node.type.name !== 'tableOfContents' || node.attrs.index !== 'bibliography') return true;
+ if (isCitationStyle(node.attrs.citationStyle)) style = node.attrs.citationStyle;
+ return false;
+ });
+ return style;
+}
+
declare module '@tiptap/core' {
interface Commands {
bibliographyEntry: {
@@ -117,6 +131,24 @@ export const BibliographyEntry = Node.create({
return ['span', mergeAttributes(HTMLAttributes, { class: 'pm-citation' }), shownText(node.attrs)];
},
+ // The shown text is derived from the document's style and the source's rank, so an
+ // edit that adds or moves a citation restyles them all. Off the undo stack, like the
+ // caption numbers: undoing an edit must not step back through the recount.
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: new PluginKey('bibliographyLabels'),
+ appendTransaction: (transactions, _old, newState) =>
+ transactions.some((t) => t.docChanged) ? relabelTr(newState) : null,
+ }),
+ ];
+ },
+
+ onCreate() {
+ const tr = relabelTr(this.editor.state);
+ if (tr) this.editor.view.dispatch(tr);
+ },
+
addCommands() {
return {
insertBibliographyEntry: (source) => ({ commands }) => {
@@ -134,6 +166,29 @@ export const BibliographyEntry = Node.create({
const shownText = (attrs: Record): string =>
String(attrs.text || citationText(String(attrs.identifier ?? '')));
+// Every citation's label brought up to date, or null when they all already agree.
+function relabelTr(state: EditorState): Transaction | null {
+ const style = documentCitationStyle(state.doc);
+ const entries = bibliographyEntries(state.doc);
+ const rank = sourceRanks(entries);
+ const tr = state.tr;
+ let changed = false;
+ for (const e of entries) {
+ const label = citationLabel(e, style, rank.get(e.identifier) ?? 0);
+ if (label === e.text) continue;
+ tr.setNodeAttribute(e.pos, 'text', label);
+ changed = true;
+ }
+ return changed ? tr.setMeta('addToHistory', false) : null;
+}
+
+/** Each cited source's number: 1 upwards, in the order the document first cites it. */
+export function sourceRanks(entries: BibSource[]): Map {
+ const rank = new Map();
+ for (const e of entries) if (!rank.has(e.identifier)) rank.set(e.identifier, rank.size + 1);
+ return rank;
+}
+
/** Every citation in document order, duplicates included. */
export function bibliographyEntries(doc: PMNode): BibEntry[] {
const out: BibEntry[] = [];
@@ -154,18 +209,18 @@ export function bibliographyEntries(doc: PMNode): BibEntry[] {
* 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 }[] {
+export function bibliographyRows(entries: BibSource[], style: CitationStyle = 'key'): { identifier: string; text: string }[] {
+ const rank = sourceRanks(entries);
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) });
+ out.push({ identifier: e.identifier, text: formatBibRow(e, style, rank.get(e.identifier) ?? 0) });
}
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;
+export function bibRowText(source: BibSource, style: CitationStyle = 'key'): string {
+ return formatBibRow(source, style, 1);
}
diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts
index 941b8dd..1dbf3b0 100644
--- a/src/lib/editor/extensions/tableOfContents.ts
+++ b/src/lib/editor/extensions/tableOfContents.ts
@@ -5,6 +5,7 @@ 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 { isCitationStyle, type CitationStyle } from '../../utils/citationStyle';
import { readVerticalMargins, pageOfElement, topInEditor, FORCE_PAGE_RECALC, type PageGrid } from './pageBreaks';
// A generated index: a block atom listing every source with its live page number — the
@@ -115,6 +116,16 @@ export const TableOfContents = Node.create({
parseHTML: el => indexKindOf((el as HTMLElement).getAttribute('data-toc-index')),
renderHTML: attrs => (attrs.index && attrs.index !== 'toc' ? { 'data-toc-index': String(attrs.index) } : {}),
},
+ // A bibliography's citation style: how its rows read and how a citation names a
+ // source. Both formats keep it with the index, so it rides its node.
+ citationStyle: {
+ default: 'key' as CitationStyle,
+ parseHTML: (el) => {
+ const v = (el as HTMLElement).getAttribute('data-toc-cite');
+ return isCitationStyle(v) ? v : 'key';
+ },
+ renderHTML: attrs => (attrs.citationStyle && attrs.citationStyle !== 'key' ? { 'data-toc-cite': String(attrs.citationStyle) } : {}),
+ },
entries: {
default: [] as TocEntry[],
parseHTML: el => {
@@ -223,7 +234,8 @@ class TocView {
// 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)) {
+ const style = isCitationStyle(this.node()?.attrs?.citationStyle) ? this.node()!.attrs.citationStyle as CitationStyle : 'key';
+ for (const row of bibliographyRows(cited, style)) {
out.push({ text: row.text, level: 1, pos: cited.find(c => c.identifier === row.identifier)!.pos });
}
return out;
diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts
index a7a0749..1e148da 100644
--- a/src/lib/export/docx.ts
+++ b/src/lib/export/docx.ts
@@ -27,6 +27,7 @@ import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSett
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 { DOCX_STYLE_NAME, isCitationStyle, type CitationStyle } from '../utils/citationStyle';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding';
@@ -121,6 +122,17 @@ let docFormulas: FormulaDocx[] = [];
// part and the CITATION fields only name the tag. Module-level like docFormulas.
let docSources: BibSource[] = [];
+// The document's citation style: its bibliography index carries it, as in both formats.
+function docCitationStyle(node: TiptapNode): CitationStyle {
+ if (node.type === 'tableOfContents' && node.attrs?.index === 'bibliography'
+ && isCitationStyle(node.attrs?.citationStyle)) return node.attrs.citationStyle;
+ for (const child of node.content ?? []) {
+ const found = docCitationStyle(child);
+ if (found !== 'key') return found;
+ }
+ return 'key';
+}
+
// 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.
@@ -1041,7 +1053,7 @@ function bibSourceXml(s: BibSource): string {
// 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 {
+function applyBibliographyDocx(bytes: Uint8Array, sources: BibSource[], cite: CitationStyle): Uint8Array {
if (!sources.length) return bytes;
const files = unzipSync(bytes);
const relsPath = 'word/_rels/document.xml.rels';
@@ -1051,7 +1063,7 @@ function applyBibliographyDocx(bytes: Uint8Array, sources: BibSource[]): Uint8Ar
files['customXml/item1.xml'] = strToU8(
``
- + ``
+ + ``
+ sources.map(bibSourceXml).join('') + '');
files['customXml/itemProps1.xml'] = strToU8(
``
@@ -1999,7 +2011,7 @@ export async function buildDocx(
const blob = await Packer.toBlob(doc);
const packed = applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
- const cited = applyBibliographyDocx(packed, docSources);
+ const cited = applyBibliographyDocx(packed, docSources, docCitationStyle(docJson));
const withNotes = docNoteIds.size ? applyNotePrDocx(cited, notesSettings) : cited;
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withNotes) : withNotes;
const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored);
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index 807c2e6..bec98c1 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -31,6 +31,7 @@ import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle,
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 { isCitationStyle, rowTemplate, type CitationStyle } from '../utils/citationStyle';
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';
@@ -1062,7 +1063,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; pages?: number[] };
-type TocExport = { kind: IndexKind; entries: TocEntry[]; title: string | null; maxLevel: number; leader: string | null; tabPosCm: number | null; levelStyles: (string | null)[] | null };
+type TocExport = { kind: IndexKind; entries: TocEntry[]; title: string | null; maxLevel: number; leader: string | null; tabPosCm: number | null; levelStyles: (string | null)[] | null; citationStyle: CitationStyle };
// Swap each top-level tableOfContents node for a marker paragraph carrying the TOC
// sentinel and collect its cached entries. Top-level only (like replacePageBreaks): a
@@ -1092,6 +1093,7 @@ function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode
leader: normalizeLeader(child.attrs?.leader),
tabPosCm: typeof child.attrs?.tabPosCm === 'number' ? child.attrs.tabPosCm : null,
levelStyles: Array.isArray(child.attrs?.levelStyles) ? (child.attrs!.levelStyles as (string | null)[]) : null,
+ citationStyle: isCitationStyle(child.attrs?.citationStyle) ? child.attrs!.citationStyle : 'key',
});
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${TOC_SENT}${tocs.length - 1}${TOC_SENT}` }] });
continue;
@@ -3990,12 +3992,13 @@ const ODF_INDEX: Record ``;
- const body = field('identifier') + ': '
- + [field('author'), field('title'), field('year')].join(', ');
+// rows from it — so it spells out the citation style's own row, token for token (which
+// is what an ODF template is). Only the types actually cited get one; the rest
+// LibreOffice fills in itself.
+function bibEntryTemplates(style: string, types: string[], cite: CitationStyle): string {
+ const body = rowTemplate(cite).map(t => ('field' in t
+ ? ``
+ : `${escapeXml(t.text)}`)).join('');
return (types.length ? types : ['book'])
.map(t => `${body}`)
.join('');
@@ -4017,14 +4020,16 @@ function tocXml(toc: TocExport, index: number, bibTypes: string[]): 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 === 'bibliography'
- ? ''
+ // LibreOffice's own two flags for a numbered bibliography: [1] before each row, and
+ // the rows in the order the document cites them rather than sorted.
+ ? (toc.citationStyle === 'numbered' ? ' text:numbered-entries="true" text:sort-by-position="true"' : '')
: 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 = toc.kind === 'bibliography'
- ? bibEntryTemplates(tocLevelStyle(toc, 1), bibTypes)
+ ? bibEntryTemplates(tocLevelStyle(toc, 1), bibTypes, toc.citationStyle)
: spec.seq
? entry('', tocLevelStyle(toc, 1))
: toc.kind === 'alphabetical'
diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts
index 64c9b76..1360e62 100644
--- a/src/lib/i18n/locales/de.ts
+++ b/src/lib/i18n/locales/de.ts
@@ -571,6 +571,9 @@ const de: Messages = {
below: 'Unter dem Objekt',
},
bibliography: {
+ style: 'Zitierstil',
+ styleNeedsIndex: 'Zuerst ein Literaturverzeichnis einfügen — es trägt den Stil',
+ styles: { key: 'Kurzname [Schlüssel]', numbered: 'Nummeriert [1]', apa: 'APA', mla: 'MLA', chicago: 'Chicago' },
title: 'Literaturangabe einfügen',
identifier: 'Kurzname',
type: 'Typ',
diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts
index 52363a1..8506c82 100644
--- a/src/lib/i18n/locales/en.ts
+++ b/src/lib/i18n/locales/en.ts
@@ -575,6 +575,9 @@ const en = {
below: 'Below the item',
},
bibliography: {
+ style: 'Citation style',
+ styleNeedsIndex: 'Insert a bibliography first — it carries the style',
+ styles: { key: 'Short name [key]', numbered: 'Numbered [1]', apa: 'APA', mla: 'MLA', chicago: 'Chicago' },
title: 'Insert Citation',
identifier: 'Short name',
type: 'Type',
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
index ce24f73..f7686c7 100644
--- a/src/lib/import/docx.ts
+++ b/src/lib/import/docx.ts
@@ -22,6 +22,7 @@ import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
import { clampPageStart, DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
+import { citationStyleFromDocx, type CitationStyle } from '../utils/citationStyle';
import { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, type OdtImportResult } from './odt';
import { chartDataUrl } from './chart';
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
@@ -70,6 +71,8 @@ type Ctx = {
pageRtl: boolean;
// Word's document-wide automatic hyphenation: a paragraph can only turn it off.
hyphenate: boolean;
+ // The citation style the sources part names (b:Sources StyleName).
+ citationStyle: CitationStyle;
// The enclosing table style's w:pPr/w:spacing, applied to its cells' paragraphs.
cellSpacing: ParaSpacing;
// Whether w:tblInd is measured to the cell's text rather than the table's edge.
@@ -186,7 +189,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), hyphenate: docAutoHyphenation(files), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), bibSources: docxSources(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), hyphenate: docAutoHyphenation(files), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), bibSources: docxSources(files), citationStyle: docxCitationStyle(files), notes: [], noteParts: {
footnote: noteParts(files, 'footnotes', 'footnote'),
endnote: noteParts(files, 'endnotes', 'endnote'),
} };
@@ -518,7 +521,8 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
if ((/\bINDEX\b/.test(simple) || /\bBIBLIOGRAPHY\b/.test(simple)) && kind === 'body') {
flush();
const index = /\bBIBLIOGRAPHY\b/.test(simple) ? 'bibliography' : 'alphabetical';
- out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', index } });
+ out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', index,
+ ...(index === 'bibliography' ? { citationStyle: ctx.citationStyle } : {}) } });
continue;
}
const { emit } = scanTocField(el, tocState);
@@ -1441,6 +1445,18 @@ function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[]
}
}
+// The citation style the sources part names, where it names one we know.
+function docxCitationStyle(files: Record): CitationStyle {
+ 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;
+ return citationStyleFromDocx(root.getAttribute('StyleName') ?? '') ?? 'key';
+ }
+ return 'key';
+}
+
// 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 {
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
index 5a9e5eb..bd8ddb4 100644
--- a/src/lib/import/odt.ts
+++ b/src/lib/import/odt.ts
@@ -28,6 +28,7 @@ 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 { citationStyleFromTemplate } from '../utils/citationStyle';
import type { PageDecor } from '../storage/pageDecor';
import type { LineNumbering } from '../storage/lineNumbering';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
@@ -1152,6 +1153,19 @@ function convertToc(el: Element, ctx: Ctx, indexKind: IndexKind): Node {
}
const attrs: Record = { entries, title, maxLevel, leader, tabPosCm, index: indexKind };
if (levelStyles.some(Boolean)) attrs.levelStyles = Array.from(levelStyles, (s) => s ?? null);
+ // A bibliography's citation style is its entry template: the fields it names, in order.
+ // A numbered index says so on the source instead, whatever its template reads.
+ if (indexKind === 'bibliography') {
+ // One template per source type, and LibreOffice fills the types the document does
+ // not cite with its own default — which is the "key" shape. So the document's style
+ // is the first template that reads as anything else.
+ const styles = templates.map((tpl) => citationStyleFromTemplate(
+ Array.from(tpl.getElementsByTagNameNS(NS.text, 'index-entry-bibliography'))
+ .map((f) => f.getAttributeNS(NS.text, 'bibliography-data-field') ?? '')));
+ attrs.citationStyle = source?.getAttributeNS(NS.text, 'numbered-entries') === 'true'
+ ? 'numbered'
+ : styles.find((st) => st && st !== 'key') ?? 'key';
+ }
return { type: 'tableOfContents', attrs };
}
diff --git a/src/lib/utils/citationStyle.ts b/src/lib/utils/citationStyle.ts
new file mode 100644
index 0000000..11c4710
--- /dev/null
+++ b/src/lib/utils/citationStyle.ts
@@ -0,0 +1,116 @@
+// How a bibliography prints its sources and how a citation names one. Both word
+// processors offer a list of styles; these are the four common shapes plus LibreOffice's
+// own default, which cites by the source's short name.
+//
+// A row is a token list — a field, or the literal between two of them — because that is
+// exactly what ODF's entry template is ( next to
+// ), so LibreOffice regenerates the same rows we print.
+
+export type CitationStyle = 'key' | 'numbered' | 'apa' | 'mla' | 'chicago';
+
+export const CITATION_STYLES: CitationStyle[] = ['key', 'numbered', 'apa', 'mla', 'chicago'];
+
+export type BibToken = { field: string } | { text: string };
+
+const F = (field: string): BibToken => ({ field });
+const T = (text: string): BibToken => ({ text });
+
+// Word's own names for the styles it ships, so a .docx says which one it is.
+export const DOCX_STYLE_NAME: Record = {
+ key: 'APA', numbered: 'ISO690Numerical', apa: 'APA', mla: 'MLA', chicago: 'Chicago',
+};
+
+const FROM_DOCX_STYLE: Record = {
+ APA: 'apa', MLA: 'mla', Chicago: 'chicago', ChicagoAuthorDate: 'chicago',
+ ISO690Numerical: 'numbered', IEEE2006: 'numbered',
+};
+
+export function citationStyleFromDocx(name: string): CitationStyle | null {
+ return FROM_DOCX_STYLE[name.replace(/^\\|\.XSL$/gi, '')] ?? null;
+}
+
+export function isCitationStyle(v: unknown): v is CitationStyle {
+ return typeof v === 'string' && (CITATION_STYLES as string[]).includes(v);
+}
+
+/** The row template: what a bibliography entry of this style reads, field by field. */
+export function rowTemplate(style: CitationStyle): BibToken[] {
+ switch (style) {
+ case 'apa':
+ return [F('author'), T(' ('), F('year'), T('). '), F('title'), T('. '), F('publisher'), T('.')];
+ case 'mla':
+ return [F('author'), T('. '), F('title'), T('. '), F('publisher'), T(', '), F('year'), T('.')];
+ case 'chicago':
+ return [F('author'), T('. '), F('title'), T('. '), F('address'), T(': '), F('publisher'), T(', '), F('year'), T('.')];
+ case 'numbered':
+ return [F('author'), T(', '), F('title'), T(', '), F('year')];
+ default:
+ return [F('identifier'), T(': '), F('author'), T(', '), F('title'), T(', '), F('year')];
+ }
+}
+
+type Source = { identifier: string; fields: Record };
+
+const valueOf = (source: Source, field: string): string =>
+ (field === 'identifier' ? source.identifier : source.fields[field] ?? '').trim();
+
+/**
+ * One bibliography row. A field the source leaves empty takes its neighbouring literal
+ * with it, so a missing publisher never leaves a stray ", " behind.
+ */
+export function formatBibRow(source: Source, style: CitationStyle, number = 0): string {
+ let out = '';
+ let pending = '';
+ let skipNext = false;
+ for (const token of rowTemplate(style)) {
+ if ('text' in token) {
+ if (skipNext) skipNext = false;
+ else pending += token.text;
+ continue;
+ }
+ const value = valueOf(source, token.field);
+ if (!value) {
+ // The empty field takes the literal after it, keeping the one before as the
+ // separator — unless that one opens a bracket the closing half just went with.
+ skipNext = !!out;
+ if (!out) pending = '';
+ else if (pending.trimEnd().endsWith('(')) pending = '. ';
+ continue;
+ }
+ out += (out ? pending : '') + value;
+ pending = '';
+ }
+ // A trailing literal is the style's own full stop; it belongs to what was written.
+ if (out && pending.trim() === '.') out += '.';
+ if (!out) out = source.identifier;
+ return style === 'numbered' && number > 0 ? `[${number}] ${out}` : out;
+}
+
+// The surname a citation names: "Knuth, Donald" → Knuth, "Donald Knuth" → Knuth. More
+// than one author is cited by the first plus et al., as all three styles do.
+function surname(author: string): string {
+ const first = author.split(/;| and /i)[0].trim();
+ const name = first.includes(',') ? first.slice(0, first.indexOf(',')) : first.split(/\s+/).pop() ?? '';
+ return /;| and /i.test(author) ? `${name} et al.` : name;
+}
+
+/** What the citation itself shows in the text. */
+export function citationLabel(source: Source, style: CitationStyle, number = 0): string {
+ const author = surname(valueOf(source, 'author'));
+ const year = valueOf(source, 'year');
+ if (style === 'numbered') return `[${number > 0 ? number : 1}]`;
+ if (style === 'key' || !author) return `[${source.identifier}]`;
+ if (style === 'apa') return year ? `(${author}, ${year})` : `(${author})`;
+ if (style === 'chicago') return year ? `(${author} ${year})` : `(${author})`;
+ return `(${author})`;
+}
+
+/** Which style a file's entry template is, matching field order — null if none does. */
+export function citationStyleFromTemplate(fields: string[]): CitationStyle | null {
+ const key = fields.join('|');
+ for (const style of CITATION_STYLES) {
+ const own = rowTemplate(style).filter((t): t is { field: string } => 'field' in t).map((t) => t.field);
+ if (own.join('|') === key) return style;
+ }
+ return null;
+}
diff --git a/tests/unit/citation-style.test.ts b/tests/unit/citation-style.test.ts
new file mode 100644
index 0000000..5b57f23
--- /dev/null
+++ b/tests/unit/citation-style.test.ts
@@ -0,0 +1,82 @@
+// The four common citation shapes, plus LibreOffice's own "cite by short name". A field
+// the source has not got takes its separator with it, so no row ends in a stray comma.
+import { describe, it, expect } from 'vitest';
+import { formatBibRow, citationLabel, citationStyleFromTemplate, rowTemplate } from '../../src/lib/utils/citationStyle';
+
+const book = {
+ identifier: 'knuth74',
+ fields: { author: 'Knuth, Donald E.', title: 'Structured Programming', year: '1974', publisher: 'ACM', address: 'New York' },
+};
+
+describe('citation styles', () => {
+ it('prints a row per style', () => {
+ expect(formatBibRow(book, 'key')).toBe('knuth74: Knuth, Donald E., Structured Programming, 1974');
+ expect(formatBibRow(book, 'apa')).toBe('Knuth, Donald E. (1974). Structured Programming. ACM.');
+ expect(formatBibRow(book, 'mla')).toBe('Knuth, Donald E.. Structured Programming. ACM, 1974.');
+ expect(formatBibRow(book, 'chicago')).toBe('Knuth, Donald E.. Structured Programming. New York: ACM, 1974.');
+ expect(formatBibRow(book, 'numbered', 3)).toBe('[3] Knuth, Donald E., Structured Programming, 1974');
+ });
+
+ it('drops a missing field with its separator', () => {
+ const noAuthor = { identifier: 'X', fields: { title: 'Just a title' } };
+ expect(formatBibRow(noAuthor, 'key')).toBe('X: Just a title');
+ const noYear = { identifier: 'Y', fields: { author: 'Ada', title: 'T', publisher: 'P' } };
+ expect(formatBibRow(noYear, 'apa')).toBe('Ada. T. P.');
+ expect(formatBibRow({ identifier: 'Z', fields: {} }, 'apa')).toBe('Z');
+ });
+
+ it('names the source the way each style cites it', () => {
+ expect(citationLabel(book, 'key')).toBe('[knuth74]');
+ expect(citationLabel(book, 'numbered', 2)).toBe('[2]');
+ expect(citationLabel(book, 'apa')).toBe('(Knuth, 1974)');
+ expect(citationLabel(book, 'mla')).toBe('(Knuth)');
+ expect(citationLabel(book, 'chicago')).toBe('(Knuth 1974)');
+ // Several authors are cited by the first plus et al., as all three styles do.
+ expect(citationLabel({ identifier: 'q', fields: { author: 'Ada Lovelace; Alan Turing', year: '1936' } }, 'apa'))
+ .toBe('(Lovelace et al., 1936)');
+ });
+
+ it('reads a style back off its ODF entry template', () => {
+ for (const style of ['key', 'apa', 'mla', 'chicago'] as const) {
+ const fields = rowTemplate(style).filter((t) => 'field' in t).map((t) => (t as { field: string }).field);
+ expect(citationStyleFromTemplate(fields)).toBe(style);
+ }
+ expect(citationStyleFromTemplate(['title'])).toBe(null);
+ });
+});
+
+// The style rides the bibliography index, which is where both formats keep it: ODF as
+// the entry template it regenerates its rows from, Word as the sources part's StyleName.
+describe('citation style round trip', () => {
+ it('survives ODF and DOCX', async () => {
+ const { buildOdt } = await import('../../src/lib/export/odt');
+ const { buildDocx } = await import('../../src/lib/export/docx');
+ const { importOdt } = await import('../../src/lib/import/odt');
+ const { importDocx } = await import('../../src/lib/import/docx');
+ const { unzipSync, strFromU8 } = await import('fflate');
+
+ const cite = (id: string): any => ({
+ type: 'bibliographyEntry',
+ attrs: { identifier: id, type: 'book', fields: { author: 'Ada Lovelace', title: 'Notes', year: '1843' }, text: '(Lovelace, 1843)' },
+ });
+ const doc: any = {
+ type: 'doc',
+ content: [
+ { type: 'paragraph', content: [{ type: 'text', text: 'As shown ' }, cite('lovelace43')] },
+ { type: 'tableOfContents', attrs: { index: 'bibliography', citationStyle: 'apa', title: 'Bibliography', entries: [] } },
+ ],
+ };
+ const margins = { top: 2, bottom: 2, left: 2, right: 2 };
+
+ const odt = await buildOdt(doc, margins, 'portrait');
+ const xml = strFromU8(unzipSync(odt)['content.xml']);
+ expect(xml).toContain('text:bibliography-data-field="publisher"');
+ const backOdt = importOdt(odt).content as any;
+ expect(backOdt.content.at(-1).attrs.citationStyle).toBe('apa');
+
+ const docx = await buildDocx(doc, margins, 'portrait');
+ expect(strFromU8(unzipSync(docx)['customXml/item1.xml'])).toContain('StyleName="APA"');
+ const backDocx = importDocx(docx).content as any;
+ expect(backDocx.content.at(-1).attrs.citationStyle).toBe('apa');
+ });
+});