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:
1 parent
55f27da855
commit
53aacd9f77
17 files changed
+644
-18
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 '));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user