feat: captions that number themselves

A caption is a paragraph in the Caption style holding a sequence field —
one counter per category, in document order, renumbered as the document is
edited and on load (a file's cached numbers are unreadable wherever the
format is not arabic). Round-trips as an ODF text:sequence with
LibreOffice's own ooow:Illustration+1 formula and a Word SEQ field.
Caption style values probed off LibreOffice: 10pt italic, 0.212cm around.
This commit is contained in:
Steffen Becker committed 2026-08-14 06:21:30 +02:00
1 parent 55f27da855
commit 53aacd9f77
17 files changed
+644 -18

No files matched your search

+2 -1
View File
@@ -115,6 +115,7 @@
- ODT import / open existing file: parses content.xml / styles.xml directly, adopts the file's margins and orientation, and reports graceful-degradation warnings
- Opens `.ott` templates (OpenDocument Text Template): read like an `.odt`, but as a new untitled document — the first Save writes a fresh `.odt` and never overwrites the template (Word/LibreOffice behavior)
- Embedded font loading: fonts embedded in an opened `.odt`/`.docx` (Word `.odttf` de-obfuscated) are registered via the FontFace API so text renders in its real face even when the font isn't installed; persisted per-document in IndexedDB so it survives a reload. Fonts the document only names but neither embeds nor installs are still flagged as substituted
- Captions (References ▸ Caption, LibreOffice's Insert ▸ Caption): a caption paragraph in the `Caption` style with a running number that counts itself — one counter per category (figure / table), in document order, renumbering as the document is edited. Round-trips as an ODF `<text:sequence>` (with LibreOffice's own `ooow:Illustration+1` formula) and a Word `SEQ Figure \* ARABIC` field
- Word (.docx) export and import — round-trips the editor's formatting (text, fonts, lists, tables, images, headers/footers, page geometry) and opens real Word documents
- Document properties (title, subject, author, keywords, comments) in the File menu — LibreOffice's File ▸ Properties / Word's File ▸ Info. Round-trips through ODF `meta.xml` (one `meta:keyword` per keyword) and DOCX `docProps/core.xml`
- PDF export — Raster (pixel-exact copy of the editor with a selectable text layer) and Vector (crisp, fonts embedded, via the browser print dialog)
@@ -132,7 +133,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
**Missing while writing**
- Hyphenation beyond the document switch: a per-paragraph "don't hyphenate", and the zone / ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither
- Captions with numbered figures/tables ("Abbildung 1: …") and the lists built from them (list of figures / tables). The TOC machinery exists, the other index families do not
- The lists built from captions (list of figures / tables). Captions themselves are implemented (see above); the TOC machinery exists, the other index families do not
- Restarting the page numbering **per section** (only the document's own format and start value exist)
- Heading levels 7–10 (Word and LibreOffice go that far; HTML stops at `h6`)
- Alphabetical index and bibliography
+161
View File
@@ -0,0 +1,161 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { SEQ_CATEGORIES, type SeqCategory } from '../editor/extensions/caption';
import { t } from '../i18n/i18n.svelte';
// LibreOffice's Insert ▸ Caption: a category label, the running number, a separator
// and the text. Position follows its defaults — a table is captioned above, a
// picture below — and stays the user's choice.
let { open = $bindable(false), editor }: { open?: boolean; editor: Editor | null } = $props();
let dialogEl = $state<HTMLDialogElement | null>(null);
let category = $state<SeqCategory>('figure');
let text = $state('');
let separator = $state(': ');
let above = $state(false);
// The label is the category's name in the UI language until the user overrides it —
// LibreOffice's own categories are localized and travel as plain caption text.
let label = $state<string | null>(null);
const shownLabel = $derived(label ?? t().caption.categories[category]);
$effect(() => {
const el = dialogEl;
if (!el) return;
if (open && !el.open) el.showModal();
else if (!open && el.open) el.close();
});
// Opening on a table proposes the table category, captioned above, as both word
// processors do.
$effect(() => {
if (!open || !editor) return;
const inTable = editor.isActive('table');
category = inTable ? 'table' : 'figure';
above = inTable;
label = null;
text = '';
});
function insert() {
editor?.chain().focus().insertCaption({ category, label: shownLabel, separator, text, above }).run();
open = false;
}
</script>
<dialog
bind:this={dialogEl}
onclose={() => (open = false)}
onclick={(e) => e.target === dialogEl && (open = false)}
aria-label={t().caption.title}
>
<div class="body">
<h2>{t().caption.title}</h2>
<label class="row">
<span>{t().caption.category}</span>
<select value={category} onchange={(e) => { category = e.currentTarget.value as SeqCategory; label = null; }}>
{#each SEQ_CATEGORIES as c}<option value={c}>{t().caption.categories[c]}</option>{/each}
</select>
</label>
<label class="row">
<span>{t().caption.label}</span>
<input value={shownLabel} oninput={(e) => (label = e.currentTarget.value)} />
</label>
<label class="row">
<span>{t().caption.separator}</span>
<input class="short" value={separator} oninput={(e) => (separator = e.currentTarget.value)} />
</label>
<label class="row">
<span>{t().caption.text}</span>
<input
bind:value={text}
onkeydown={(e) => { if (e.key === 'Enter') insert(); }}
/>
</label>
<label class="row">
<span>{t().caption.position}</span>
<select value={above ? 'above' : 'below'} onchange={(e) => (above = e.currentTarget.value === 'above')}>
<option value="below">{t().caption.below}</option>
<option value="above">{t().caption.above}</option>
</select>
</label>
<p class="preview">{shownLabel} 1{separator}{text}</p>
<div class="actions">
<span class="spacer"></span>
<button onclick={() => (open = false)}>{t().common.cancel}</button>
<button class="primary" onclick={insert}>{t().common.ok}</button>
</div>
</div>
</dialog>
<style>
dialog {
/* The global reset zeroes every margin, which also takes the auto centring a
modal <dialog> gets by default. */
margin: auto;
border: none;
border-radius: 8px;
padding: 0;
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
.body {
display: flex;
flex-direction: column;
gap: 8px;
width: 400px;
padding: 18px 20px 16px;
font-family: var(--font-sans);
font-size: 0.85rem;
}
h2 { font-size: 1rem; margin-bottom: 4px; }
.row { display: flex; align-items: center; gap: 8px; }
.row > span { flex: 1; color: var(--color-text-muted); }
input, select {
height: 26px;
width: 200px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 0 6px;
font: inherit;
}
input.short { width: 70px; }
.preview {
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 6px 8px;
color: var(--color-text-muted);
font-style: italic;
}
.actions { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.spacer { flex: 1; }
.actions button {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 5px 14px;
font: inherit;
cursor: pointer;
}
.actions button:hover { background: var(--color-btn-hover); }
.actions .primary { border-color: var(--color-primary); background: var(--color-primary); color: #fff; }
</style>
+1
View File
@@ -77,6 +77,7 @@ export const STROKED = {
shapeEllipse: ['M8 12.5c3.6 0 6.5-2 6.5-4.5S11.6 3.5 8 3.5 1.5 5.5 1.5 8s2.9 4.5 6.5 4.5z'],
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'],
wordCount: ['M2.5 2.5h11v11h-11z', 'M5 6h6M5 9h4'],
// A speech bubble with its tail, the shape both word processors use for a comment.
comment: ['M2 3.25h12v7.5H7.5L4.5 13.5v-2.75H2z', 'M4.75 6h6.5M4.75 8.25h4'],
@@ -2,6 +2,7 @@
import type { Editor } from '@tiptap/core';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import CaptionDialog from '../../CaptionDialog.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import type { HfZone } from '../../../storage/headerFooter';
import { t } from '../../../i18n/i18n.svelte';
@@ -26,6 +27,7 @@
return found;
});
const LEVELS = HEADING_LEVELS;
let captionOpen = $state(false);
function setMaxLevel(level: number) {
closeMenu();
@@ -63,8 +65,18 @@
</div>
{/if}
</div>
<RibbonButton
variant="big"
icon="caption"
label={t().ribbon.insertCaption}
title={t().caption.title}
disabled={!editor || !!hfActive}
onclick={() => (captionOpen = true)}
/>
</RibbonGroup>
<CaptionDialog bind:open={captionOpen} {editor} />
<RibbonGroup label={t().ribbon.groups.notes}>
<RibbonButton
variant="big"
+4
View File
@@ -51,6 +51,7 @@ import { TableStyle } from './extensions/tableStyle';
import { TrailingNode } from './extensions/trailingNode';
import { Image } from './extensions/image';
import { DateTimeField } from './extensions/dateTimeField';
import { SequenceField } from './extensions/caption';
import { CrossReference } from './extensions/crossReference';
import { Formula } from './extensions/formula';
import { TextBox } from './extensions/textBox';
@@ -103,6 +104,9 @@ export const extensions = [
// Inline date/time field (fixed or auto-updating); round-trips to ODF
// <text:date>/<text:time> and DOCX DATE/TIME fields.
DateTimeField,
// A caption's running number, one counter per category in document order. Round-trips
// to an ODF <text:sequence> and a DOCX SEQ field.
SequenceField,
// Inline reference to a bookmark, showing its text or its page; kept live by its
// node view. Round-trips to ODF <text:bookmark-ref> and DOCX REF/PAGEREF fields.
CrossReference,
+1
View File
@@ -30,6 +30,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`bulletList.ts`** / **`orderedList.ts`** — the TipTap lists plus a marker-type attr: `setBulletChar` (innermost list, `null` = default cycle) and the ordered numbering cycle (`utils/orderedListTypes.ts`, multilevel targets the outermost list).
- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's.
- **`comment.ts`** — an annotation on a range of text, a mark like `bookmark`: the whole comment (author, date, body, resolved) rides its attrs, so two runs of one comment merge back together and no second store is needed. `commentRanges(doc)` walks every range (an id split by a paragraph boundary yields several, which is why the commands walk all of them), `comments(doc)` keeps one per id for `CommentsPane.svelte`. A point comment — LibreOffice's, made with nothing selected, written with no `office:name` and no end element — attaches on import to the run before it, else the one after: the editor's comment is a mark and needs a range.
- **`caption.ts`** — a caption is an ordinary paragraph in the `Caption` style (LibreOffice's, probed: 10pt italic, 0.212cm above and below) holding one `sequenceField` atom, its running number. `sequenceFields(doc)` counts one counter per category in document order; the cached `number` attr is what an export writes, and an `appendTransaction` (off the undo stack) plus an `onCreate` pass keep it true — a file's own cached numbers are unreadable wherever the format is not arabic. The counter's name differs per format: ODF `Illustration`/`Table`, Word `Figure`/`Table`, and `ODF_SEQ_CATEGORY` reads both back. `CaptionDialog.svelte` (References tab) owns the insert.
- **`bookmark.ts`** / **`crossReference.ts`** — a named range of text (a mark) and the live references to it (`REF`/`PAGEREF`). `ToolbarExpanded.svelte` owns both dialogs, as it does the link one.
- **`searchReplace.ts`** — Find & Replace: decorations highlight all matches, commands navigate/replace. Touches the document only for actual replacements. `useRegex` takes the term as a pattern instead of escaping it (a whole-word search then wraps it in a non-capturing group first, so the `\b` guards bound the alternation); each match keeps its captures so `expandGroups` can fill `$1`…`$9`/`$&` in the replacement.
- **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu.
+160
View File
@@ -0,0 +1,160 @@
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 { formatOrdinal } from '../../utils/orderedListTypes';
import type { NoteNumFormat } from '../../storage/noteSettings';
// A caption is an ordinary paragraph in the `Caption` style carrying one inline atom:
// its running number (LibreOffice's sequence variable, Word's SEQ field). One counter
// per category, in document order — the same rule both word processors apply, and the
// only thing an index needs to find its entries.
// LibreOffice's own paragraph style for a caption, and the parent of its per-category
// ones (Figure/Table/Illustration/Drawing) — 10pt italic, 0.212cm above and below.
export const CAPTION_STYLE = 'Caption';
export type SeqCategory = 'figure' | 'table';
export const SEQ_CATEGORIES: SeqCategory[] = ['figure', 'table'];
// The counter's name in each format. ODF keeps LibreOffice's own "Illustration"; Word
// names it after the label, and an index that asks for another name finds nothing.
export const ODF_SEQ_NAME: Record<SeqCategory, string> = { figure: 'Illustration', table: 'Table' };
export const DOCX_SEQ_NAME: Record<SeqCategory, string> = { figure: 'Figure', table: 'Table' };
// A file's counter name → the category the editor counts. Both spellings of the picture
// counter turn up: LibreOffice writes "Illustration", Word "Figure", and each keeps the
// other's name when it converts. A counter under any other name is not a caption.
export const ODF_SEQ_CATEGORY: Record<string, SeqCategory | undefined> = {
Illustration: 'figure', Figure: 'figure', Table: 'table',
};
export function seqCategoryOf(value: string | null | undefined): SeqCategory {
return value === 'table' ? 'table' : 'figure';
}
// Every sequence field in document order, per category — what the numbering and the
// indexes both read. The number is the field's 1-based rank within its own category.
export function sequenceFields(doc: PMNode): { pos: number; node: PMNode; number: number }[] {
const out: { pos: number; node: PMNode; number: number }[] = [];
const counts = new Map<string, number>();
doc.descendants((node, pos) => {
if (node.type.name !== 'sequenceField') return true;
const cat = seqCategoryOf(node.attrs.category as string);
const number = (counts.get(cat) ?? 0) + 1;
counts.set(cat, number);
out.push({ pos, node, number });
return false;
});
return out;
}
const key = new PluginKey('sequenceFieldNumbers');
declare module '@tiptap/core' {
interface Commands<ReturnType> {
caption: {
/** A caption paragraph above or below the block the caret is in. */
insertCaption: (opts: {
category: SeqCategory; label: string; separator: string; text: string; above: boolean;
}) => ReturnType;
};
}
}
export const SequenceField = Node.create({
name: 'sequenceField',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
category: {
default: 'figure' as SeqCategory,
parseHTML: (el: HTMLElement) => seqCategoryOf(el.getAttribute('data-seq')),
renderHTML: (attrs) => ({ 'data-seq': String(attrs.category ?? 'figure') }),
},
format: {
default: '1' as NoteNumFormat,
parseHTML: (el: HTMLElement) => (el.getAttribute('data-seq-format') || '1') as NoteNumFormat,
renderHTML: (attrs) => ({ 'data-seq-format': String(attrs.format ?? '1') }),
},
// The rank the field last resolved to. Both formats cache their field's result the
// same way, so it is what an export writes and what a reader sees before the
// numbering plugin has run.
number: {
default: 1,
parseHTML: (el: HTMLElement) => Number(el.getAttribute('data-seq-number')) || 1,
renderHTML: (attrs) => ({ 'data-seq-number': String(attrs.number ?? 1) }),
},
};
},
parseHTML() {
return [{ tag: 'span[data-seq]' }];
},
renderHTML({ node, HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes), sequenceFieldText(node)];
},
renderText({ node }) {
return sequenceFieldText(node);
},
addCommands() {
return {
insertCaption:
({ category, label, separator, text, above }) =>
({ state, chain }) => {
const { $from } = state.selection;
if ($from.depth < 1) return false;
// The caption goes beside the whole top-level block — the picture's paragraph
// or the table — never inside it.
const at = above ? $from.before(1) : $from.after(1);
const content: Record<string, unknown>[] = [];
if (label) content.push({ type: 'text', text: `${label} ` });
content.push({ type: this.name, attrs: { category, format: '1', number: 1 } });
const tail = `${separator}${text}`;
if (tail) content.push({ type: 'text', text: tail });
return chain()
.insertContentAt(at, { type: 'paragraph', attrs: { styleName: CAPTION_STYLE }, content })
.focus()
.run();
},
};
},
// Renumber whenever an edit moved the fields around. Off the undo stack: the numbers
// are derived, so undoing an edit must not step back through their recount.
addProseMirrorPlugins() {
return [
new Plugin({
key,
appendTransaction: (transactions, _oldState, newState) =>
transactions.some((t) => t.docChanged) ? renumberTr(newState) : null,
}),
];
},
// An opened file carries whatever numbers its producer cached — wrong wherever the
// format is not arabic, since neither field reads its own letters back.
onCreate() {
const tr = renumberTr(this.editor.state);
if (tr) this.editor.view.dispatch(tr);
},
});
// The transaction that brings every field's cached rank up to date, or null when they
// all already agree.
function renumberTr(state: EditorState): Transaction | null {
const stale = sequenceFields(state.doc).filter((f) => f.node.attrs.number !== f.number);
if (!stale.length) return null;
const tr = state.tr;
for (const f of stale) tr.setNodeAttribute(f.pos, 'number', f.number);
return tr.setMeta('addToHistory', false);
}
export function sequenceFieldText(node: PMNode): string {
return formatOrdinal(Number(node.attrs.number) || 1, (node.attrs.format as NoteNumFormat) ?? '1');
}
+3 -1
View File
@@ -27,6 +27,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
- **Right-to-left pages**: ODF gets `style:writing-mode="rl-tb"` on the page layout (`rewriteStylesXml`), DOCX a `<w:bidi/>` in every `w:sectPr` from a post-pack pass (`applyBidiDocx`) — the package exposes only the paragraph-level flag. A **paragraph's** own direction (`textDirection.ts`) rides that same flag in DOCX and the `ParaStyle`/PBX-spec pipe in ODF, so it reaches cells and list items as well as top-level blocks.
- **Mirror margins** (`applyMirrorMarginsDocx`, `export/docx.ts`): the `docx` package writes only the per-section `w:pgMar`, so Word's document-level `w:mirrorMargins` goes into `word/settings.xml` in the same post-pack pass; ODF's flag rides `rewriteStylesXml`. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel.
- **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 `<text:bookmark-start/>`/`<text:bookmark-end/>`/`<text:bookmark-ref>`. 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 `<text:sequence>` carrying LibreOffice's own `text:formula="ooow:<name>+1"` (what makes its reader recount on load) plus a `<text:sequence-decl>` per counter used; DOCX gets a `SimpleField` `SEQ Figure \* ARABIC`. Both keep the editor's resolved rank as the field's cached result.
- **`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 `<text:list>` 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.
@@ -48,4 +49,5 @@ U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
`CST` U+E00E (character styles), `TEF` U+E00F (text effects), `PBX` (paragraph boxes), `SEC` U+E010
(section breaks), `LEAD` U+E011 (a tab stop's leader, inside `style:type`), `MTH` U+E012 (formulas),
`BMS`/`BME` U+E013/U+E014 (a bookmark's range), `XRF` U+E015 (cross-references),
`CHP` U+E016 (the chapter field), `FNT` U+E017 (footnotes/endnotes).
`CHP` U+E016 (the chapter field), `FNT` U+E017 (footnotes/endnotes),
`CMS`/`CME` U+E018/U+E019 (a comment's range), `SEQ` U+E01A (a caption's number).
+19 -1
View File
@@ -22,7 +22,8 @@ import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
import type { SpacingModel } from '../storage/spacingModel';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteSettings } from '../storage/noteSettings';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
import { DOCX_SEQ_NAME, seqCategoryOf } from '../editor/extensions/caption';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding';
@@ -347,6 +348,21 @@ function dateTimeRun(node: TiptapNode): Inline {
return new SimpleField(`${kind === 'time' ? 'TIME' : 'DATE'} \\@ "${docxPicture(fmt)}"`, text);
}
// Word's numeric-picture switch per ODF num-format — the SEQ field's own formatting.
const DOCX_SEQ_SWITCH: Record<NoteNumFormat, string> = {
'1': 'ARABIC', a: 'alphabetic', A: 'ALPHABETIC', i: 'roman', I: 'ROMAN',
};
// A caption's running number: a SEQ field whose cached result is the rank the editor
// resolved, so Word and LibreOffice show it before anyone updates fields.
function sequenceField(node: TiptapNode): SimpleField {
const a = node.attrs ?? {};
const format = (typeof a.format === 'string' && a.format ? a.format : '1') as NoteNumFormat;
const number = typeof a.number === 'number' && a.number > 0 ? a.number : 1;
const name = DOCX_SEQ_NAME[seqCategoryOf(a.category as string)];
return new SimpleField(`SEQ ${name} \\* ${DOCX_SEQ_SWITCH[format] ?? 'ARABIC'}`, formatOrdinal(number, format));
}
// A cross-reference: a REF/PAGEREF field with the resolved text as its cached result,
// so Word and LibreOffice show it before anyone updates fields.
function crossRefField(node: TiptapNode): SimpleField {
@@ -437,6 +453,8 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
out.push(new SimpleField(`STYLEREF "Heading ${Number(node.attrs?.level) || 1}" \\* MERGEFORMAT`, String(node.attrs?.text ?? '')));
} else if (node.type === 'dateTimeField') {
out.push(dateTimeRun(node));
} else if (node.type === 'sequenceField') {
out.push(sequenceField(node));
} else if (node.type === 'formula') {
const latex = typeof node.attrs?.latex === 'string' ? node.attrs.latex : '';
if (latex) {
+66 -4
View File
@@ -6,7 +6,7 @@ import type { SpacingModel } from '../storage/spacingModel';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS, NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteSettings } from '../storage/noteSettings';
import { DEFAULT_NOTE_SETTINGS, NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
import { EMPTY_DOC_PROPERTIES, keywordList, type DocProperties } from '../storage/docProperties';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet';
@@ -23,7 +23,8 @@ import { parseCellPadding, DEFAULT_CELL_PADDING, type CellPadding } from '../edi
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { normalizeLeader, parseTabStops } from '../editor/extensions/tabStops';
import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker';
import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
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 { 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';
@@ -176,6 +177,10 @@ const CME = '\uE019';
// the body out and splices it into a <text:note> at the anchor. U+E017.
const FNT = '\uE017';
// Sentinel wrapping a caption's sequence-field index (SEQ{i}SEQ); applySequenceFields
// rewrites it to <text:sequence>. U+E01A.
const SEQ = '\uE01A';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -541,6 +546,61 @@ function replaceDateTimeFields(node: TiptapNode, fields: DateTimeFieldExport[]):
return { ...node, content };
}
// One caption sequence field, collected by replaceSequenceFields and emitted by
// applySequenceFields. `number` is the rank the editor resolved; both formats cache it.
type SequenceExport = { category: SeqCategory; format: string; number: number };
// Replace every inline `sequenceField` node with a SEQ-sentinel text run, as
// replaceDateTimeFields does, so it rides every odf-kit path.
function replaceSequenceFields(node: TiptapNode, fields: SequenceExport[]): TiptapNode {
if (!node.content?.length) return node;
const content: TiptapNode[] = [];
for (const child of node.content) {
if (child.type === 'sequenceField') {
const a = child.attrs ?? {};
fields.push({
category: seqCategoryOf(a.category as string),
format: typeof a.format === 'string' && a.format ? a.format : '1',
number: typeof a.number === 'number' && a.number > 0 ? a.number : 1,
});
content.push({ type: 'text', text: `${SEQ}${fields.length - 1}${SEQ}`, marks: child.marks });
continue;
}
content.push(replaceSequenceFields(child, fields));
}
return { ...node, content };
}
// Resolve SEQ sentinels into <text:sequence>. The formula is LibreOffice's own
// "ooow:<name>+1", which is what makes its reader recount the captions on load; the
// element text is the cached number, and text:ref-name is the anchor an index links to.
function applySequenceFields(odtBytes: Uint8Array, fields: SequenceExport[]): Uint8Array {
if (!fields.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(`${SEQ}(\\d+)${SEQ}`, 'g'), (_m, idx: string) => {
const f = fields[Number(idx)];
if (!f) return '';
const name = ODF_SEQ_NAME[f.category];
const display = escapeXml(formatOrdinal(f.number, f.format as NoteNumFormat));
return `<text:sequence text:ref-name="ref${name}${idx}" text:name="${name}"`
+ ` text:formula="ooow:${name}+1" style:num-format="${f.format}">${display}</text:sequence>`;
});
// Every sequence needs its variable declared, as LibreOffice writes them.
const used = [...new Set(fields.map((f) => ODF_SEQ_NAME[f.category]))];
const decls = used.map((n) => `<text:sequence-decl text:display-outline-level="0" text:name="${n}"/>`).join('');
content = /<text:sequence-decls>/.test(content)
? content.replace('<text:sequence-decls>', `<text:sequence-decls>${decls}`)
: content.replace('<office:text>', `<office:text><text:sequence-decls>${decls}</text:sequence-decls>`);
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' };
@@ -3713,7 +3773,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const crossRefs: CrossRefExport[] = [];
const notes: NoteExport[] = [];
const commentList: CommentExport[] = [];
const sentinels = replaceComments(replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs), commentList);
const seqFields: SequenceExport[] = [];
const sentinels = 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);
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;
@@ -3913,7 +3974,8 @@ 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 withFormulas = applyFormulas(withDateFields, formulas);
const withSequences = applySequenceFields(withDateFields, seqFields);
const withFormulas = applyFormulas(withSequences, formulas);
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
const withColumns = applyColumns(withTextBoxes, columns);
const withBookmarks = applyComments(applyBookmarks(withColumns, crossRefs), commentList);
+12
View File
@@ -509,6 +509,17 @@ const de: Messages = {
noLeader: 'Kein Füllzeichen',
defaultInterval: 'Standardtabstopps',
},
caption: {
title: 'Beschriftung einfügen',
category: 'Kategorie',
categories: { figure: 'Abbildung', table: 'Tabelle' },
label: 'Bezeichnung',
separator: 'Trennzeichen',
text: 'Beschriftung',
position: 'Position',
above: 'Über dem Objekt',
below: 'Unter dem Objekt',
},
notesDialog: {
title: 'Fuß- und Endnoten',
tabFootnotes: 'Fußnoten',
@@ -635,6 +646,7 @@ const de: Messages = {
toc: 'Inhaltsverzeichnis',
tocOptions: 'Ebenen',
tocMaxLevel: 'Tiefste angezeigte Ebene',
insertCaption: 'Beschriftung',
footnote: 'Fußnote',
endnote: 'Endnote',
noteOptions: 'Optionen',
+12
View File
@@ -513,6 +513,17 @@ const en = {
noLeader: 'No leader',
defaultInterval: 'Default tab stops',
},
caption: {
title: 'Insert Caption',
category: 'Category',
categories: { figure: 'Figure', table: 'Table' },
label: 'Label',
separator: 'Separator',
text: 'Caption',
position: 'Position',
above: 'Above the item',
below: 'Below the item',
},
notesDialog: {
title: 'Footnotes and Endnotes',
tabFootnotes: 'Footnotes',
@@ -637,6 +648,7 @@ const en = {
wide: 'Wide',
},
toc: 'Table of contents',
insertCaption: 'Caption',
tocOptions: 'Levels',
tocMaxLevel: 'Deepest level shown',
footnote: 'Footnote',
+47 -10
View File
@@ -25,6 +25,7 @@ import { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, type OdtImpor
import { chartDataUrl } from './chart';
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
import { ODF_SEQ_CATEGORY } from '../editor/extensions/caption';
import { clampColumnGap } from '../editor/extensions/columns';
import { astToLatex } from '../math/latex';
import { parseOmml, OMML_NS } from '../math/omml';
@@ -1127,10 +1128,11 @@ 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: the cached result runs are collected as the node's
// display text instead of being pushed.
// 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;
let fieldCrossRefText = '';
let fieldSeq: Node | null = null;
let fieldResultText = '';
const pushText = (text: string, marks: Mark[]) => {
if (!text) return;
@@ -1217,37 +1219,40 @@ 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; fieldCrossRefText = ''; }
if (t === 'begin') { fieldMode = 'instr'; fieldInstr = ''; fieldDateTime = null; fieldCrossRef = null; fieldSeq = null; fieldResultText = ''; }
else if (t === 'separate') {
fieldMode = 'result';
if (!hfFields) {
fieldDateTime = dateTimeFieldFromInstr(fieldInstr);
fieldCrossRef = fieldDateTime ? null : crossRefFromInstr(fieldInstr);
fieldCrossRefText = '';
fieldSeq = fieldDateTime ? null : seqFieldFromInstr(fieldInstr);
fieldCrossRef = fieldDateTime || fieldSeq ? null : crossRefFromInstr(fieldInstr);
fieldResultText = '';
// Carry the field run's marks so the atom renders in the run's font.
const field = fieldDateTime ?? fieldCrossRef;
const field = fieldDateTime ?? fieldSeq ?? fieldCrossRef;
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) {
if (!hfFields && !fieldDateTime && !fieldCrossRef && !fieldSeq) {
fieldCrossRef = crossRefFromInstr(fieldInstr);
if (fieldCrossRef && marks.length) fieldCrossRef.marks = marks;
}
if (fieldDateTime) out.push(fieldDateTime);
else if (fieldCrossRef) out.push({ ...fieldCrossRef, attrs: { ...fieldCrossRef.attrs, text: fieldCrossRefText } });
else if (fieldSeq) out.push({ ...fieldSeq, attrs: { ...fieldSeq.attrs, number: seqNumberOf(fieldResultText) } });
else if (fieldCrossRef) out.push({ ...fieldCrossRef, attrs: { ...fieldCrossRef.attrs, text: fieldResultText } });
else emitField(out, fieldInstr, hfFields, marks);
fieldMode = 'none';
fieldDateTime = null;
fieldCrossRef = null;
fieldSeq = null;
}
break;
}
case 'instrText': if (fieldMode === 'instr') fieldInstr += child.textContent ?? ''; break;
case 't':
if (fieldCrossRef && fieldMode === 'result') fieldCrossRefText += child.textContent ?? '';
if ((fieldCrossRef || fieldSeq) && fieldMode === 'result') fieldResultText += child.textContent ?? '';
else if (!skipResult()) pushText(child.textContent ?? '', marks);
break;
case 'tab': if (!skipResult()) pushText('\t', marks); break;
@@ -1299,6 +1304,14 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
out.push({ ...xref, attrs: { ...xref.attrs, text: el.textContent ?? '' } });
break;
}
const seq = seqFieldFromInstr(instr);
if (seq) {
const first = fcAll(el, 'r')[0];
const m = first ? runMarks(first) : [];
if (m.length) seq.marks = m;
out.push({ ...seq, attrs: { ...seq.attrs, number: seqNumberOf(el.textContent ?? '') } });
break;
}
const field = dateTimeFieldFromInstr(instr);
if (field) {
const first = fcAll(el, 'r')[0];
@@ -1355,6 +1368,30 @@ function crossRefFromInstr(instr: string): Node | null {
return { type: 'crossRef', attrs: { name: m[2], format: /^PAGEREF$/i.test(m[1]) ? 'page' : 'text', text: '' } };
}
// Word's numeric-picture switch → the ODF num-format the editor stores. Anything else
// counts in arabic, which is what Word itself falls back to.
const DOCX_SEQ_FORMAT: Record<string, NoteNumFormat> = {
alphabetic: 'a', ALPHABETIC: 'A', roman: 'i', ROMAN: 'I',
};
// `SEQ Figure \* ARABIC` → a caption's running number. A counter under a name the
// editor doesn't count (a footnote sequence, a user's own) stays the field's text.
function seqFieldFromInstr(instr: string): Node | null {
const m = /^\s*SEQ\s+("[^"]*"|\S+)/i.exec(instr);
if (!m) return null;
const category = ODF_SEQ_CATEGORY[m[1].replace(/"/g, '')];
if (!category) return null;
const sw = /\\\*\s*(\w+)/.exec(instr)?.[1] ?? '';
return { type: 'sequenceField', attrs: { category, format: DOCX_SEQ_FORMAT[sw] ?? '1', number: 1 } };
}
// The rank a field cached, from its result text. Only an arabic one reads back; the
// editor's own numbering pass fixes the rest on load.
function seqNumberOf(text: string): number {
const n = parseInt(text.replace(/\D/g, ''), 10);
return Number.isFinite(n) && n > 0 ? n : 1;
}
// A DATE/TIME field instruction with a picture switch we recognize → a live
// dateTimeField node (always auto: Word writes a fixed date as plain text). Returns
// null for an unknown picture so the caller keeps the field's shown text.
+33
View File
@@ -24,6 +24,7 @@ import type { NoteKind, NoteSettings } from '../storage/noteSettings';
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
import { clampPageStart, type PageNumbering } from '../storage/pageNumbering';
import { newCommentId } from '../editor/extensions/comment';
import { ODF_SEQ_CATEGORY } from '../editor/extensions/caption';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
@@ -1592,6 +1593,25 @@ function convertDateTimeField(e: Element, ctx: Ctx): Node | null {
return { type: 'dateTimeField', attrs: { kind, format, fixed, value: fieldValue(kind, raw) } };
}
// <text:sequence> → a caption's running number. The counter's name is the category
// (LibreOffice's "Illustration"/"Table"); the element text is the file's cached value,
// which the editor's own numbering overwrites on the first edit.
function convertSequenceField(e: Element): Node | null {
const name = e.getAttributeNS(NS.text, 'name') ?? '';
const category = ODF_SEQ_CATEGORY[name];
if (!category) return null;
const format = e.getAttributeNS(NS.style, 'num-format');
const number = parseInt((e.textContent ?? '').replace(/\D/g, ''), 10);
return {
type: 'sequenceField',
attrs: {
category,
format: format && '1aAiI'.includes(format) ? format : '1',
number: Number.isFinite(number) && number > 0 ? number : 1,
},
};
}
// The note's paragraph style: the one its own <text:p> names, else the class's
// configured default. Registered as used, or collectStyleSheet drops it.
function noteBodyStyle(e: Element, ctx: Ctx, kind: NoteKind): string | null {
@@ -1796,6 +1816,19 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
continue;
}
}
// A caption's running number (caption.ts). Only the two categories the
// editor counts survive as a live field; any other sequence (a user
// variable, a chapter counter) keeps its evaluated text.
if (!hfFields && e.localName === 'sequence') {
const field = convertSequenceField(e);
if (field) {
const marks = marksFor(props, ctx.resolver, defaults);
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
if (marks.length) field.marks = marks;
out.push(field);
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);
+5 -1
View File
@@ -82,6 +82,10 @@ const BUILTINS: Style[] = [
para: { textAlign: 'center' }, text: { fontSizePt: 18 } },
{ name: 'Quotations', parent: DEFAULT_STYLE, next: DEFAULT_STYLE, builtin: true,
para: { indent: 1, spaceAfter: 14 }, text: {} },
// LibreOffice's caption style (probed): 10pt italic, 0.212cm above and below, and it
// follows itself. Its per-category children (Figure, Table, …) only rename it.
{ name: 'Caption', parent: DEFAULT_STYLE, next: 'Caption', builtin: true,
para: { spaceBefore: 6, spaceAfter: 6 }, text: { fontSizePt: 10, italic: true } },
];
// LibreOffice's character styles (its "Emphasis"/"Strong Emphasis"/"Source Text").
@@ -93,7 +97,7 @@ const CHAR_BUILTINS: Style[] = [
// Bumped whenever the built-in definitions change: a stored sheet from an older version
// keeps its user styles but takes the new factory built-ins (see mergeStoredSheet).
export const STYLE_SHEET_VERSION = 8;
export const STYLE_SHEET_VERSION = 9;
// A persisted sheet merged onto the current built-ins. Same version: stored entries win
// (a document's own styles, and edits to built-ins). Older: only user styles survive.
+48
View File
@@ -1949,3 +1949,51 @@ describe('Leg 18: per-paragraph text direction', () => {
check('no w:bidi', !strFromU8(unzipSync(await buildDocx(plain, margins, 'portrait'))['word/document.xml']).includes('<w:bidi'));
});
});
describe('Leg 19: captions (a sequence field per category)', () => {
const SEQ = (category: string, number: number): N =>
({ type: 'sequenceField', attrs: { category, format: '1', number } });
const CAP = (category: string, label: string, n: number, text: string): N =>
P({ styleName: 'Caption' }, T(`${label} `), SEQ(category, n), T(`: ${text}`));
const doc: N = { type: 'doc', content: [
CAP('figure', 'Figure', 1, 'first picture'),
CAP('table', 'Table', 1, 'first table'),
CAP('figure', 'Figure', 2, 'second picture'),
] };
it('ODT: <text:sequence> per category, declared and counting', async () => {
const bytes = await buildOdt(doc, margins, 'portrait');
const content = strFromU8(unzipSync(bytes)['content.xml']);
check('both counters declared', content.includes('text:name="Illustration"/>')
&& content.includes('text:name="Table"/>'), content.match(/<text:sequence-decl[^>]*>/g));
check('LibreOffice’s own formula', (content.match(/text:formula="ooow:Illustration\+1"/g) ?? []).length === 2,
content.match(/text:formula="[^"]*"/g));
const back = importOdt(bytes).content as N;
const fields = back.content.map((p: N) => p.content?.find((c: N) => c.type === 'sequenceField'));
check('categories round-trip', fields.map((f: N) => f?.attrs?.category).join() === 'figure,table,figure',
fields.map((f: N) => f?.attrs));
check('numbers round-trip', fields.map((f: N) => f?.attrs?.number).join() === '1,1,2',
fields.map((f: N) => f?.attrs?.number));
check('caption style kept', back.content[0].attrs?.styleName === 'Caption', back.content[0].attrs);
});
it('DOCX: a SEQ field per category, and back', async () => {
const bytes = await buildDocx(doc, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
check('three SEQ fields', (xml.match(/SEQ (Figure|Table) \\\* ARABIC/g) ?? []).length === 3,
xml.match(/SEQ [^"]*/g));
const back = importDocx(bytes).content as N;
const fields = back.content.map((p: N) => p.content?.find((c: N) => c.type === 'sequenceField'));
check('categories round-trip', fields.map((f: N) => f?.attrs?.category).join() === 'figure,table,figure',
fields.map((f: N) => f?.attrs));
check('numbers round-trip', fields.map((f: N) => f?.attrs?.number).join() === '1,1,2',
fields.map((f: N) => f?.attrs?.number));
});
it('a document without captions declares no counter', async () => {
const plain: N = { type: 'doc', content: [P(null, T('plain'))] };
const content = strFromU8(unzipSync(await buildOdt(plain, margins, 'portrait'))['content.xml']);
check('no sequence declared', !content.includes('text:sequence-decl'), content.slice(0, 200));
check('no SEQ field', !strFromU8(unzipSync(await buildDocx(plain, margins, 'portrait'))['word/document.xml']).includes('SEQ '));
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { Schema } from '@tiptap/pm/model';
import { sequenceFields, ODF_SEQ_CATEGORY, seqCategoryOf } from '../../src/lib/editor/extensions/caption';
// The numbering rule both word processors apply: one counter per category, in document
// order. A schema of its own keeps the check off the editor's full extension list.
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
text: { group: 'inline' },
sequenceField: {
group: 'inline', inline: true, atom: true,
attrs: { category: { default: 'figure' }, number: { default: 1 } },
toDOM: () => ['span'],
},
},
});
const seq = (category: string, number = 1) => schema.nodes.sequenceField.create({ category, number });
const para = (...content: ReturnType<typeof seq>[]) => schema.nodes.paragraph.create(null, content);
describe('sequenceFields', () => {
it('counts each category on its own, in document order', () => {
const doc = schema.nodes.doc.create(null, [
para(seq('figure')), para(seq('table')), para(seq('figure')), para(seq('table')), para(seq('figure')),
]);
expect(sequenceFields(doc).map((f) => `${f.node.attrs.category}${f.number}`))
.toEqual(['figure1', 'table1', 'figure2', 'table2', 'figure3']);
});
it('numbers two fields in one paragraph left to right', () => {
const doc = schema.nodes.doc.create(null, [para(seq('figure'), seq('figure'))]);
expect(sequenceFields(doc).map((f) => f.number)).toEqual([1, 2]);
});
it('reports the stale cached number, which is what triggers a renumber', () => {
const doc = schema.nodes.doc.create(null, [para(seq('figure', 7))]);
const [only] = sequenceFields(doc);
expect(only.number).toBe(1);
expect(only.node.attrs.number).toBe(7);
});
});
describe('category names', () => {
it('reads both spellings of the picture counter', () => {
expect(ODF_SEQ_CATEGORY.Illustration).toBe('figure'); // LibreOffice's
expect(ODF_SEQ_CATEGORY.Figure).toBe('figure'); // Word's
expect(ODF_SEQ_CATEGORY.Table).toBe('table');
expect(ODF_SEQ_CATEGORY.Drawing).toBeUndefined();
});
it('falls back to the picture category', () => {
expect(seqCategoryOf('table')).toBe('table');
expect(seqCategoryOf(null)).toBe('figure');
expect(seqCategoryOf('nonsense')).toBe('figure');
});
});