From 53aacd9f7772647c931384b60f5ce54d3d2758a9 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 14 Aug 2026 06:21:30 +0200 Subject: [PATCH] feat: captions that number themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 3 +- src/lib/components/CaptionDialog.svelte | 161 ++++++++++++++++++ src/lib/components/ribbon/icons.ts | 1 + .../ribbon/tabs/ReferencesTab.svelte | 12 ++ src/lib/editor/extensions.ts | 4 + src/lib/editor/extensions/CLAUDE.md | 1 + src/lib/editor/extensions/caption.ts | 160 +++++++++++++++++ src/lib/export/CLAUDE.md | 4 +- src/lib/export/docx.ts | 20 ++- src/lib/export/odt.ts | 70 +++++++- src/lib/i18n/locales/de.ts | 12 ++ src/lib/i18n/locales/en.ts | 12 ++ src/lib/import/docx.ts | 57 +++++-- src/lib/import/odt.ts | 33 ++++ src/lib/styles/styleSheet.ts | 6 +- tests/roundtrip.test.ts | 48 ++++++ tests/unit/caption.test.ts | 58 +++++++ 17 files changed, 644 insertions(+), 18 deletions(-) create mode 100644 src/lib/components/CaptionDialog.svelte create mode 100644 src/lib/editor/extensions/caption.ts create mode 100644 tests/unit/caption.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca0203..0b517f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` (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 diff --git a/src/lib/components/CaptionDialog.svelte b/src/lib/components/CaptionDialog.svelte new file mode 100644 index 0000000..b951de5 --- /dev/null +++ b/src/lib/components/CaptionDialog.svelte @@ -0,0 +1,161 @@ + + + (open = false)} + onclick={(e) => e.target === dialogEl && (open = false)} + aria-label={t().caption.title} +> +
+

{t().caption.title}

+ + + + + + + + + + + +

{shownLabel} 1{separator}{text}

+ +
+ + + +
+
+
+ + diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index fc42a40..8a8cbe5 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -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'], diff --git a/src/lib/components/ribbon/tabs/ReferencesTab.svelte b/src/lib/components/ribbon/tabs/ReferencesTab.svelte index f0dcb72..c44b4b1 100644 --- a/src/lib/components/ribbon/tabs/ReferencesTab.svelte +++ b/src/lib/components/ribbon/tabs/ReferencesTab.svelte @@ -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 @@ {/if} + (captionOpen = true)} + /> + + / and DOCX DATE/TIME fields. DateTimeField, + // A caption's running number, one counter per category in document order. Round-trips + // to an ODF 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 and DOCX REF/PAGEREF fields. CrossReference, diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 5a465f7..f97d91e 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -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. diff --git a/src/lib/editor/extensions/caption.ts b/src/lib/editor/extensions/caption.ts new file mode 100644 index 0000000..90e3da4 --- /dev/null +++ b/src/lib/editor/extensions/caption.ts @@ -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 = { figure: 'Illustration', table: 'Table' }; +export const DOCX_SEQ_NAME: Record = { 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 = { + 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(); + 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 { + 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[] = []; + 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'); +} diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 1cc88de..12ada43 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -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 `` 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 ``/``/``. 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. - **`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. @@ -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). diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 40f0397..137e5ec 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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 = { + '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) { diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 769dc68..1d1da71 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 at the anchor. U+E017. const FNT = '\uE017'; +// Sentinel wrapping a caption's sequence-field index (SEQ{i}SEQ); applySequenceFields +// rewrites it to . U+E01A. +const SEQ = '\uE01A'; + const EXT_BY_MIME: Record = { '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 . The formula is LibreOffice's own +// "ooow:+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 `${display}`; + }); + + // 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) => ``).join(''); + content = //.test(content) + ? content.replace('', `${decls}`) + : content.replace('', `${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); diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index d1ca89d..f2032f8 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index c14e682..5dc1eb6 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 666825f..26d3d08 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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 = { + 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. diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index c3c0443..132ed51 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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) } }; } +// → 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 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); diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 6f888e0..0a84fc3 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -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. diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 6e7ab17..eeced41 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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(' { + 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: 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(/]*>/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 ')); + }); +}); diff --git a/tests/unit/caption.test.ts b/tests/unit/caption.test.ts new file mode 100644 index 0000000..24a64e7 --- /dev/null +++ b/tests/unit/caption.test.ts @@ -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[]) => 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'); + }); +});