feat: a reference finds the text it points at, and follows it

Bookmarks are a mark, not a point: both formats store a range, and only a
range lets a REF recompute its text. A cross-reference caches its display
like a Word field and resolves live on each pagination settle — the
bookmark's text, or the page it renders on — keeping the cached value when
the target is gone. Ctrl+click jumps to it, internal #name links too.

ODF splices its sentinels into the run text so cells and custom paragraphs
need no separate emitter; DOCX needs no post-pass at all.
This commit is contained in:
Steffen Becker committed 2026-08-09 13:56:22 +00:00
1 parent 46ae0211af
commit 78e35cef2c
22 files changed
+1131 -42

No files matched your search

+2 -1
View File
@@ -1,7 +1,7 @@
# Run and paragraph formatting
Covers the `src/lib/editor/extensions/` files carrying character effects, indents, tab stops,
list markers, paragraph boxes and date/time fields.
list markers, paragraph boxes, date/time fields and cross-references.
- **`textEffects.ts`** (`TextEffects`, `UnderlineStyled`, `StrikeStyled`) — the character effects beyond bold/italic that both formats carry and CSS can draw: `caps` (`uppercase`/`lowercase`/`capitalize`/`smallCaps` → `text-transform`/`font-variant-caps`) and `textPosition` (pt above the baseline → `vertical-align`, independent of sub/superscript) as TextStyle attrs, plus `lineStyle`/`lineColor` on the **underline** mark and `lineStyle` on **strike** (the `<u>`/`<s>` element draws the line, so its shape belongs there). Replaces TipTap's bare Underline/Strike in `extensions.ts` **and** `hfExtensions`. `caps` is also a `TextProps` key, so a named style can carry it. **DOCX**: `w:caps`/`w:smallCaps` (toggles — a run may switch a style's off), `w:u w:val`+`w:color`, `w:dstrike`, `w:position` (`ST_SignedHpsMeasure`: a bare number is half-points, else a measure with its unit). **ODF**: `fo:text-transform`/`fo:font-variant`, `style:text-underline-style`/`-type`/`-color`, `style:text-line-through-type`, `style:text-position` (a percentage of the font size, so the pt value is converted against the run's own size). odf-kit's run formatting has a field for none of them, so `markTextEffects` prefixes the `TEF` sentinel (U+E00F) on the run and `applyTextEffects` folds the attributes into that run's automatic text style — before `applyCharacterStyles`, which then clones the style that already carries them. `tests/lo-roundtrip.test.ts` is what proves LibreOffice reads each one back.
- **`paragraphBox.ts`** (`ParagraphBox`) — paragraph/heading **background** (`backgroundColor`, "colored field") + per-side **borders** (`borderTop/Right/Bottom/Left`, "rule line"), as in Word/LibreOffice letterheads. Borders reuse table cells' canonical `'<W>pt solid #RRGGBB'` (from `tableCellBorders.ts`), but here `null`/absent = no border. Commands `setParagraphBackground`/`setParagraphBorders(preset, spec)`; `activeParagraphBorderPresets` drives the picker states. An input rule turns a paragraph typed as `---`/`___`/`===` into an empty bottom-rule line (the border-line AutoCorrect); attrs are `keepOnSplit: false` so the box doesn't cascade onto the next paragraph on Enter. In both `extensions.ts` (body) and `hfExtensions` (header/footer). **ODF**: import reads `fo:background-color`/`fo:border-*` in `blockAttrs` (body) and merges them across the collapsed header/footer paragraphs (`convertHfZone`, first/last heuristic — bottom rule from the last line); export emits them via `ParaStyle`/`paraStyleProps` for cells/lists, a `PBX`-sentinel post-process (`applyParagraphBoxes`, odf-kit has no such options) for top-level body paragraphs, and `applyHfPostProcess` for the Header/Footer styles. **DOCX**: `w:shd`/`w:pBdr` (import `readParaBox`, export `paraShadingOf`/`paraBordersOf`). UI: a paragraph-shading `ColorPicker` + `ParagraphBorderPicker.svelte` in `ToolbarExpanded.svelte` (route via `activeEditor`, so body and the active HF zone).
@@ -11,3 +11,4 @@ list markers, paragraph boxes and date/time fields.
A **run of tabs wider than the line** continues on the next one with the grid starting over, as LibreOffice lays it out; Chromium instead hangs the leftover tabs and starts the next line at the margin. `runBreaks` walks the run analytically from the pen *before* it — the one thing the fix can't move, so the answer is the same whether or not the break is already in place — and emits a `<br>` widget where the line runs out. Only runs of two or more are considered: reading a block's geometry forces a reflow, and doing it for every tab in a 48-page document slowed pagination enough to skew the parity harness.
- **`listMarker.ts`** (`ListMarker`) — the marker's **family, weight, slant, size and color**, taken from the item's **first text portion** (`markerFormat`): a mark lives inside the item's paragraph and never reaches `::marker`, which only inherits the `li`'s own font, so a bold numbered heading would keep a light number. A **character style** on that portion is resolved through the registry (`charStyleProps`, handed in by the caller: the plugin from its `sheet` option, the exporters from their `exportSheet`); what a **named paragraph style** provides reaches the marker through `styleCss`'s `li:has(> …)` rule instead. First portion, not the whole line, is LibreOffice's own numbering rule (verified by probe: bold-then-plain gets a bold number, plain-then-bold does not; Word instead takes the paragraph mark). **Rendering**: a node decoration puts `--marker-family/-weight/-style/-size/-color` on **every** `listItem` — they inherit, so an item without its own format resets them to `initial` (the guaranteed-invalid value, so the `var(…, inherit)` in `editor.css` falls back; `inherit` would pull the parent item's value in), and `editor.css` has one rule reading them on the item block's `::before` — which is what **draws** the marker: `::marker` cannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (`LIST_HANGING_CM` 0.635cm, what both exports write). The pseudo is **absolutely positioned** at that indent, because in flow it grew every list line by a pixel. `list-style: none` drops only the marker box, so the `list-item` counter and `<ol start>` keep numbering, and `content: counter(list-item, …)`/`counters(list-item, '.')` per `data-eff-list-style` replaces `list-style-type`; a `bulletChar` rides `--bullet` (an `attr()` inside the pseudo would read the block, not the list). `export/pdf.ts` switches the pseudo off and injects the same marker as a real span, since html2canvas cannot paint counters. The bullet shim keeps the symbol font first and takes `--marker-family` as its fallback. The pass also re-runs on `FORCE_PAGE_RECALC`, so editing a character style repaints the markers. **Export**: both formats carry marker formatting **per level**, so `listMarkerFormat` returns one only when *every* item of the list agrees — ODF mints a **named** `style:family="text"` style **in styles.xml** and points the level definition at it via `text:style-name` (`collectListMarkerFormats`/`applyListMarkerFormats`) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level's `w:rPr` (`markerRunProps` → `ILevelsOptions.style.run`). A **mixed** list gets no level format on purpose: LibreOffice then applies its first-portion rule and matches the editor, Word falls back to the paragraph mark (which only carries the size). Import needs nothing — the runs already carry the formatting. Nested lists that odf-kit restyles (`applyNestedListTypes`) and in-cell lists keep a plain marker. A character style **inside a list item** is handled by an ODF pre-pass (`bakeListCharStyles`) instead: odf-kit's list builder reads marks itself and knows nothing about `charStyle`, and `applyRuns` never sees a list paragraph, so the pre-pass does both of its jobs — bake the resolved props into direct marks (which is what gets the run a `<text:span>` at all) and prefix the `CST` sentinel, so `applyCharacterStyles` re-points that span at the named style like everywhere else. Formatting and name both round-trip. `tests/unit/list-marker.test.ts` covers the four LibreOffice cases, the reset, and both export legs. **Label alignment** (`markerAlign` on the list node, Word's `w:lvlJc`, ODF's `fo:text-align` on the level properties): `right` sets the label's *far* end against the hanging indent, so a wide number — the Roman numerals Word's built-in numberings use it for — grows into the margin instead of over the text. `tests/unit/marker-align.test.ts` round-trips it.
- **`dateTimeField.ts`** (`DateTimeField`) — inline atom for an inserted date/time field (Word's "Insert Date and Time"). Attrs: `kind` (`'date'|'time'`), `format` (a catalog key), `fixed`, `value` (ISO local datetime captured at insert). A **fixed** field renders its stored `value`; an **auto** field renders the *current* moment on every load (`renderHTML`/`renderText` via `dateTimeFieldText`) — no plugin, Word/LibreOffice likewise only refresh on open. As an inline atom with no children it can't inherit the surrounding run's font from a sibling span, so it **carries that run's marks** on the node: insert adopts the cursor's stored marks, and both importers attach the field run's resolved marks (else it falls back to the editor default font). Command `insertDateTimeField({kind,format,fixed})`. Formats live in `utils/dateTime.ts` as a **token model** (one `Token[]` per format) so the three consumers stay in agreement: `renderFormat` (locale display via `Intl`), `odfNumberStyle` (`<number:date-style>`/`<number:time-style>` body), `docxPicture` (Word field switch). `DateTimePicker.svelte` (a dropdown next to `SpecialCharPicker` in `ToolbarExpanded.svelte`) lists live samples + an "update automatically" checkbox. Not in the header/footer schema (there a `<text:date>`/DATE field stays plain text). **ODF**: `replaceDateTimeFields`/`applyDateTimeFields` (sentinel U+E00A, mirrors images) emit `<text:date text:date-value text:fixed style:data-style-name>`/`<text:time text:time-value …>` + a minted per-format number style (language from the export doc language; `ensureNumberNamespace` adds `xmlns:number`); import matches the referenced number style back to a format via `parseNumberStyleTokens` + `matchFormat` (unknown ⇒ keep the shown text). **DOCX**: auto → `SimpleField` `DATE`/`TIME \@ "picture"` with a cached run; fixed → a plain text run (Word has no fixed-date field); import maps a `DATE`/`TIME` `fldSimple`/complex field's picture back to a format (`dateTimeFieldFromInstr`).
- **`bookmark.ts`** (`Bookmark`) + **`crossReference.ts`** (`CrossReference`) — a named range of text and the references pointing at it. A bookmark is a **mark**, not a point node: both formats store a range, and only a range lets a `REF` recompute its text. It carries one `name` (`data-bookmark`), is `inclusive: false`, and names are sanitized to Word's rules on insert (`sanitizeBookmarkName`: `[A-Za-z0-9_.]`, no leading digit, 40 chars) — which also keeps the ODF sentinel pass free of XML escaping. A mark type excludes itself, so **overlapping ranges collapse to the outermost** and both importers keep the first. Word's `_GoBack` and every point bookmark (ODF `<text:bookmark/>`, a zero-length Word range) are dropped — there is no range to mark. `bookmarks(doc)`/`findBookmark`/`bookmarkNames` are the shared lookups (dialogs, node view, `Editor.svelte`'s click handler). A cross-reference is an inline atom with `name`, `format` (`'text'|'page'`) and `text`, the last being the **cached display** exactly as a Word field caches its result. Its node view resolves live on each `pm-pagecount` like `TocView` does — the bookmark's own text, or its page via `pageOfElement`/`readVerticalMargins` (both from `pageBreaks.ts`, shared with the TOC) — and **leaves the cached text alone when the target is gone**, which is what a reference to a deleted bookmark shows. Ctrl/Cmd+click on a reference or on an internal `#name` link jumps to the bookmark and selects it. Not in the header/footer schema. **ODF**: `replaceBookmarks` splices the `BMS`/`BME` sentinels (U+E013/U+E014) into the run text and swaps each `crossRef` for an `XRF` run (U+E015) — splicing into the text, not into a run emitter, is what makes them ride every odf-kit path, cells and custom paragraphs included; `applyBookmarks` rewrites all three to `<text:bookmark-start/>`, `<text:bookmark-end/>` and `<text:bookmark-ref text:reference-format>`. **DOCX**: the `docx` package covers both natively — `Bookmark` around the marked runs, `SimpleField` `REF`/`PAGEREF … \h` with the cached result, `InternalHyperlink` for a `#name` href. A range spanning paragraphs is emitted per paragraph (Word tolerates the repeated name). Import tracks open ranges in `Ctx` across both the block and the inline walk (Word writes a `w:bookmarkStart` beside paragraphs as well as beside runs) and reads `REF`/`PAGEREF` out of the `fldChar` state machine and `w:fldSimple` alike, including a field Word never resolved (no `w:separate`, so no cached result — the node view fills it in).
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import { t } from '../i18n/i18n.svelte';
// Popover to name a bookmark for the current selection, and to jump to or delete the
// ones the document already has. The parent owns `open` and applies the name.
let {
open,
names = [],
initialName = '',
onApply,
onRemove,
onGoTo,
onClose,
}: {
open: boolean;
names?: string[];
initialName?: string;
onApply: (name: string) => void;
onRemove: (name: string) => void;
onGoTo: (name: string) => void;
onClose: () => void;
} = $props();
let name = $state('');
let input = $state<HTMLInputElement | null>(null);
$effect(() => {
if (open) {
name = initialName;
queueMicrotask(() => input?.focus());
queueMicrotask(() => input?.select());
}
});
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); onApply(name); }
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
}
</script>
{#if open}
<div class="bm-dialog" role="dialog" aria-label={t().bookmark.dialogLabel}>
<input
bind:this={input}
bind:value={name}
type="text"
placeholder={t().bookmark.namePlaceholder}
onkeydown={onKeydown}
spellcheck="false"
autocomplete="off"
/>
<div class="bm-list-label">{t().bookmark.existing}</div>
<div class="bm-list">
{#each names as n (n)}
<div class="bm-row">
<button class="bm-name" onclick={() => onGoTo(n)} title={n}>{n}</button>
<button class="bm-drop" onclick={() => onRemove(n)} aria-label={`${t().common.remove} ${n}`}>×</button>
</div>
{:else}
<div class="bm-empty">{t().bookmark.none}</div>
{/each}
</div>
<div class="bm-actions">
<span class="bm-spacer"></span>
<button class="bm-cancel" onclick={onClose}>{t().common.cancel}</button>
<button class="bm-apply" onclick={() => onApply(name)} disabled={!name.trim()}>{t().common.apply}</button>
</div>
</div>
{/if}
<style>
.bm-dialog {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 300;
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 18rem;
padding: 0.6rem;
background: var(--color-toolbar-bg, #fff);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
}
input {
width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
font-size: 0.85rem;
}
.bm-list-label { font-size: 0.72rem; opacity: 0.7; }
.bm-list {
max-height: 9rem;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius);
}
.bm-row { display: flex; align-items: center; }
.bm-empty { padding: 0.35rem 0.5rem; font-size: 0.8rem; opacity: 0.6; }
.bm-name {
flex: 1;
padding: 0.3rem 0.5rem;
border: none;
background: transparent;
color: var(--color-text);
font-size: 0.8rem;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.bm-drop {
border: none;
background: transparent;
color: #c0392b;
font-size: 0.95rem;
line-height: 1;
padding: 0 0.45rem;
cursor: pointer;
}
.bm-row:hover { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
.bm-actions { display: flex; align-items: center; gap: 0.4rem; }
.bm-spacer { flex: 1; }
.bm-actions button {
height: 1.8rem;
padding: 0 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
font-size: 0.8rem;
cursor: pointer;
}
.bm-actions button:hover:not(:disabled) { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
.bm-apply { border-color: var(--color-accent, #1a56db) !important; color: var(--color-accent, #1a56db); }
.bm-actions button:disabled { opacity: 0.5; cursor: default; }
</style>
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import { t } from '../i18n/i18n.svelte';
// Popover to insert a reference to one of the document's bookmarks, showing either its
// text or the page it sits on.
let {
open,
names = [],
onInsert,
onClose,
}: {
open: boolean;
names?: string[];
onInsert: (name: string, format: 'text' | 'page') => void;
onClose: () => void;
} = $props();
let name = $state('');
let format = $state<'text' | 'page'>('text');
let select = $state<HTMLSelectElement | null>(null);
$effect(() => {
if (open) {
name = names[0] ?? '';
queueMicrotask(() => select?.focus());
}
});
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); if (name) onInsert(name, format); }
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
}
</script>
{#if open}
<!-- tabindex so the whole popover can own Enter/Escape, wherever focus sits inside it -->
<div class="xr-dialog" role="dialog" tabindex="-1" aria-label={t().bookmark.crossRefLabel} onkeydown={onKeydown}>
<select bind:this={select} bind:value={name} size={Math.min(6, Math.max(2, names.length))}>
{#each names as n (n)}
<option value={n}>{n}</option>
{/each}
</select>
<label><input type="radio" bind:group={format} value="text" />{t().bookmark.formatText}</label>
<label><input type="radio" bind:group={format} value="page" />{t().bookmark.formatPage}</label>
<div class="xr-actions">
<span class="xr-spacer"></span>
<button class="xr-cancel" onclick={onClose}>{t().common.cancel}</button>
<button class="xr-apply" onclick={() => onInsert(name, format)} disabled={!name}>{t().common.insert}</button>
</div>
</div>
{/if}
<style>
.xr-dialog {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 300;
display: flex;
flex-direction: column;
gap: 0.45rem;
width: 18rem;
padding: 0.6rem;
background: var(--color-toolbar-bg, #fff);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
}
select {
width: 100%;
box-sizing: border-box;
padding: 0.25rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
font-size: 0.8rem;
}
label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; }
.xr-actions { display: flex; align-items: center; gap: 0.4rem; }
.xr-spacer { flex: 1; }
.xr-actions button {
height: 1.8rem;
padding: 0 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
font-size: 0.8rem;
cursor: pointer;
}
.xr-actions button:hover:not(:disabled) { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
.xr-apply { border-color: var(--color-accent, #1a56db) !important; color: var(--color-accent, #1a56db); }
.xr-actions button:disabled { opacity: 0.5; cursor: default; }
</style>
+9 -1
View File
@@ -24,6 +24,7 @@
import { applyPageSizeVars, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_HF_DISTANCES, hfIsEmpty, type HfDoc, type HfZone, type HfDistances, type HfSet } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { findBookmark } from '../editor/extensions/bookmark';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
import { wheelZoomFactor } from '../utils/zoom';
import { styleCss } from '../styles/styleSheet';
@@ -705,11 +706,18 @@
// they don't double up.
attributes: { spellcheck: 'false' },
// Ctrl/Cmd+click opens a hyperlink (a plain click just places the cursor).
handleClick: (_view, _pos, event) => {
handleClick: (view, _pos, event) => {
if (!(event.metaKey || event.ctrlKey)) return false;
const a = (event.target as HTMLElement | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
const href = a?.getAttribute('href');
if (!href) return false;
// An internal href targets a bookmark in this document, not a URL.
if (href.startsWith('#')) {
const found = findBookmark(view.state.doc, href.slice(1));
if (!found) return true;
editor?.chain().focus().setTextSelection({ from: found.from, to: found.to }).scrollIntoView().run();
return true;
}
window.open(href, '_blank', 'noopener,noreferrer');
return true;
},
+102
View File
@@ -7,8 +7,12 @@
import SpecialCharPicker from './SpecialCharPicker.svelte';
import DateTimePicker from './DateTimePicker.svelte';
import LinkDialog from './LinkDialog.svelte';
import BookmarkDialog from './BookmarkDialog.svelte';
import CrossRefDialog from './CrossRefDialog.svelte';
import FormulaDialog from './FormulaDialog.svelte';
import { OPEN_LINK_DIALOG_EVENT } from '../editor/extensions/link';
import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames, findBookmark } from '../editor/extensions/bookmark';
import { OPEN_CROSS_REF_DIALOG_EVENT } from '../editor/extensions/crossReference';
import { EDIT_FORMULA_EVENT } from '../editor/extensions/formula';
import {
detectInstalledFonts,
@@ -953,6 +957,63 @@
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
// --- Bookmarks + cross-references (bookmark.ts / crossReference.ts) ---
let bookmarkOpen = $state(false);
let crossRefOpen = $state(false);
let bmNames = $derived(tick >= 0 && editor && !hfActive ? bookmarkNames(editor.state.doc) : []);
// A bookmark covers a range, so there has to be one selected.
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
function openBookmarkDialog() {
if (!editor || hfActive || !hasSelection) return;
bookmarkOpen = true;
crossRefOpen = false;
}
function applyBookmark(name: string) {
bookmarkOpen = false;
editor?.chain().focus().setBookmark(name).run();
}
function goToBookmark(name: string) {
const found = editor && findBookmark(editor.state.doc, name);
if (!editor || !found) return;
bookmarkOpen = false;
editor.chain().focus().setTextSelection({ from: found.from, to: found.to }).scrollIntoView().run();
}
function openCrossRefDialog() {
if (!editor || hfActive || !bmNames.length) return;
crossRefOpen = true;
bookmarkOpen = false;
}
function insertCrossRef(name: string, format: 'text' | 'page') {
crossRefOpen = false;
editor?.chain().focus().insertCrossRef({ name, format }).run();
}
function bookmarkClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (node.contains(e.target as Node)) return;
bookmarkOpen = false;
crossRefOpen = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
$effect(() => {
const openBm = () => openBookmarkDialog();
const openXr = () => openCrossRefDialog();
window.addEventListener(OPEN_BOOKMARK_DIALOG_EVENT, openBm);
window.addEventListener(OPEN_CROSS_REF_DIALOG_EVENT, openXr);
return () => {
window.removeEventListener(OPEN_BOOKMARK_DIALOG_EVENT, openBm);
window.removeEventListener(OPEN_CROSS_REF_DIALOG_EVENT, openXr);
};
});
// Ctrl/Cmd+K (from the Link extension) opens the dialog.
$effect(() => {
const open = () => openLinkDialog();
@@ -1689,6 +1750,47 @@
onClose={() => (linkDialogOpen = false)}
/>
</div>
<div class="link-wrap" use:bookmarkClickOutside>
<button
onclick={openBookmarkDialog}
disabled={!!hfActive || !hasSelection}
title={hfActive ? t().toolbarExpanded.bookmarkNotInHf : hasSelection ? t().toolbarExpanded.insertBookmark : t().toolbarExpanded.bookmarkNeedsSelection}
aria-label={t().toolbarExpanded.insertBookmark}
aria-haspopup="dialog"
aria-expanded={bookmarkOpen}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 2.5h8v11l-4-3-4 3v-11z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
</svg>
</button>
<BookmarkDialog
open={bookmarkOpen}
names={bmNames}
onApply={applyBookmark}
onRemove={(n) => editor?.chain().focus().removeBookmark(n).run()}
onGoTo={goToBookmark}
onClose={() => (bookmarkOpen = false)}
/>
<button
onclick={openCrossRefDialog}
disabled={!!hfActive || !bmNames.length}
title={hfActive ? t().toolbarExpanded.bookmarkNotInHf : bmNames.length ? t().toolbarExpanded.insertCrossRef : t().toolbarExpanded.crossRefNeedsBookmark}
aria-label={t().toolbarExpanded.insertCrossRef}
aria-haspopup="dialog"
aria-expanded={crossRefOpen}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2.5 4.5h5M2.5 8h5M2.5 11.5h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M9.5 8h4M11.5 6l2 2-2 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<CrossRefDialog
open={crossRefOpen}
names={bmNames}
onInsert={insertCrossRef}
onClose={() => (crossRefOpen = false)}
/>
</div>
</div>
<div class="toolbar-separator"></div>
+15
View File
@@ -3,6 +3,8 @@ import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
import { shortcutHint } from './shortcuts';
import { OPEN_LINK_DIALOG_EVENT } from './extensions/link';
import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames } from './extensions/bookmark';
import { OPEN_CROSS_REF_DIALOG_EVENT } from './extensions/crossReference';
// The right-click menu's contents, the usual text menu mapped onto this editor. Pure
// data + closures — ContextMenu.svelte only renders it.
@@ -90,6 +92,19 @@ export function buildContextMenu(editor: Editor, opts: { spell?: SpellSection }
});
}
entries.push({
kind: 'item',
label: m.insertBookmark,
disabled: !hasSelection,
run: () => window.dispatchEvent(new CustomEvent(OPEN_BOOKMARK_DIALOG_EVENT)),
});
entries.push({
kind: 'item',
label: m.insertCrossRef,
disabled: !bookmarkNames(editor.state.doc).length,
run: () => window.dispatchEvent(new CustomEvent(OPEN_CROSS_REF_DIALOG_EVENT)),
});
entries.push(
{ kind: 'sep' },
{
+8
View File
@@ -8,6 +8,7 @@ import Subscript from '@tiptap/extension-subscript';
import Superscript from '@tiptap/extension-superscript';
import Highlight from '@tiptap/extension-highlight';
import { Link } from './extensions/link';
import { Bookmark } from './extensions/bookmark';
import { TextStyle, FontFamily, FontSize } from '@tiptap/extension-text-style';
import { FontWeight } from './extensions/fontWeight';
import { FontColor } from './extensions/fontColor';
@@ -47,6 +48,7 @@ import { TableStyle } from './extensions/tableStyle';
import { TrailingNode } from './extensions/trailingNode';
import { Image } from './extensions/image';
import { DateTimeField } from './extensions/dateTimeField';
import { CrossReference } from './extensions/crossReference';
import { Formula } from './extensions/formula';
import { TextBox } from './extensions/textBox';
import { Columns } from './extensions/columns';
@@ -66,6 +68,9 @@ export const extensions = [
UnderlineStyled,
StrikeStyled,
Link,
// Named range of text, the target of a cross-reference or an internal link;
// round-trips to ODF text:bookmark-start/-end and DOCX w:bookmarkStart/End.
Bookmark,
Subscript,
Superscript,
TextStyle,
@@ -88,6 +93,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,
// 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,
// Mathematical formula; stores LaTeX, renders native MathML, round-trips to an ODF
// embedded formula object and DOCX OMML. See docs/architecture/formulas.md.
Formula,
+3 -2
View File
@@ -12,7 +12,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
| `pageBreaks.ts`, `columns.ts`, `columnsFlow.ts` | `docs/architecture/pagination.md` |
| `image.ts`, `textBox.ts` | `docs/architecture/frames.md` |
| `table*.ts`, `tableStyle.ts` | `docs/architecture/tables.md` |
| `textEffects.ts`, `indent.ts`, `tabStops.ts`, `listMarker.ts`, `paragraphBox.ts`, `dateTimeField.ts` | `docs/architecture/formatting.md` |
| `textEffects.ts`, `indent.ts`, `tabStops.ts`, `listMarker.ts`, `paragraphBox.ts`, `dateTimeField.ts`, `bookmark.ts`, `crossReference.ts` | `docs/architecture/formatting.md` |
| `formula.ts` | `docs/architecture/formulas.md` |
| `paragraphStyle.ts`, `characterStyle.ts` | `src/lib/styles/CLAUDE.md` |
@@ -26,7 +26,8 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`pageBreak.ts`** — the text-flow attrs of a paragraph/heading: `breakBefore: 'page'`, plus `keepNext`/`keepLines` (see `docs/architecture/pagination.md`). Owns `Mod-Enter`.
- **`formattingMarks.ts`** (`FormattingMarks`) — decorations marking spaces (`·`) and tabs (`→`) when `.paper.show-formatting-marks` is set.
- **`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.
- **`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.
- **`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.
- **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu.
- **`tableOfContents.ts`** — a generated TOC: block atom listing every heading (levels 1–5).
+119
View File
@@ -0,0 +1,119 @@
import { Mark, mergeAttributes } from '@tiptap/core';
import type { Node as PMNode, Mark as PMMark } from '@tiptap/pm/model';
// A bookmark: a named range of text, the target a cross-reference or an internal link
// points at. A mark, not a point node — both formats store a range, and a REF field
// needs that range to recompute its text. Round-trips to ODF text:bookmark-start/-end
// and DOCX w:bookmarkStart/w:bookmarkEnd.
export type BookmarkRef = { name: string; from: number; to: number; text: string };
// Fired by the context menu; ToolbarExpanded owns the dialog (as it does for links).
export const OPEN_BOOKMARK_DIALOG_EVENT = 'odf-open-bookmark-dialog';
// Bookmark names as Word writes them: letters, digits, underscore and dot, never
// leading with a digit, 40 chars at most. Sanitizing here also keeps the ODF export's
// sentinel pass free of XML escaping.
export function sanitizeBookmarkName(raw: string): string {
const cleaned = raw.trim().replace(/[^A-Za-z0-9_.]/g, '_').slice(0, 40);
if (!cleaned) return '';
return /^[0-9]/.test(cleaned) ? `_${cleaned}`.slice(0, 40) : cleaned;
}
export function bookmarkNameOf(marks: readonly PMMark[]): string | null {
const name = marks.find((m) => m.type.name === 'bookmark')?.attrs?.name;
return typeof name === 'string' && name ? name : null;
}
// Every bookmark range in document order. Adjacent text nodes sharing a name merge into
// one range; a name that reappears later (a paste, or across a paragraph boundary) gets
// its own entry and every lookup below takes the first.
// ponytail: no cross-paragraph merge — the DOCX exporter can't write one either.
export function bookmarks(doc: PMNode): BookmarkRef[] {
const out: BookmarkRef[] = [];
let last: BookmarkRef | null = null;
doc.descendants((node, pos) => {
if (!node.isText) return true;
const name = bookmarkNameOf(node.marks);
if (!name) { last = null; return false; }
if (last && last.name === name && last.to === pos) {
last.to = pos + node.nodeSize;
last.text += node.text ?? '';
return false;
}
last = { name, from: pos, to: pos + node.nodeSize, text: node.text ?? '' };
out.push(last);
return false;
});
return out;
}
export function findBookmark(doc: PMNode, name: string): BookmarkRef | null {
return bookmarks(doc).find((b) => b.name === name) ?? null;
}
// Unique names, document order — the pick list of both dialogs.
export function bookmarkNames(doc: PMNode): string[] {
return [...new Set(bookmarks(doc).map((b) => b.name))];
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
bookmark: {
setBookmark: (name: string) => ReturnType;
unsetBookmark: () => ReturnType;
removeBookmark: (name: string) => ReturnType;
};
}
}
export const Bookmark = Mark.create({
name: 'bookmark',
// The range is fixed: typing at either end stays outside it, as it does in Word.
inclusive: false,
addAttributes() {
return {
name: {
default: '',
parseHTML: (el) => el.getAttribute('data-bookmark') || '',
renderHTML: (attrs) => ({ 'data-bookmark': String(attrs.name ?? '') }),
},
};
},
parseHTML() {
return [{ tag: 'span[data-bookmark]' }];
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes), 0];
},
addCommands() {
return {
setBookmark:
(name) =>
({ commands }) => {
const clean = sanitizeBookmarkName(name);
return clean ? commands.setMark(this.name, { name: clean }) : false;
},
unsetBookmark:
() =>
({ commands }) =>
commands.unsetMark(this.name),
// Drop a bookmark by name from wherever it sits — the delete affordance in the
// dialog, which works without moving the selection there first.
removeBookmark:
(name) =>
({ tr, state, dispatch }) => {
const ranges = bookmarks(state.doc).filter((b) => b.name === name);
if (!ranges.length) return false;
if (dispatch) {
for (const r of ranges) tr.removeMark(r.from, r.to, state.schema.marks.bookmark);
}
return true;
},
};
},
});
+204
View File
@@ -0,0 +1,204 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { findBookmark } from './bookmark';
import { readVerticalMargins, pageOfElement } from './pageBreaks';
// A cross-reference: an inline atom showing either the text of a bookmark or the page it
// sits on, kept live by the node view the way the TOC keeps its page numbers. Round-trips
// to ODF text:bookmark-ref and DOCX REF/PAGEREF fields.
export type CrossRefFormat = 'text' | 'page';
// Fired by the context menu; ToolbarExpanded owns the dialog (as it does for links).
export const OPEN_CROSS_REF_DIALOG_EVENT = 'odf-open-cross-ref-dialog';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
crossReference: {
insertCrossRef: (opts: { name: string; format: CrossRefFormat }) => ReturnType;
};
}
}
export const CrossReference = Node.create({
name: 'crossRef',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
name: {
default: '',
parseHTML: (el) => el.getAttribute('data-cross-ref') || '',
renderHTML: (attrs) => ({ 'data-cross-ref': String(attrs.name ?? '') }),
},
format: {
default: 'text' as CrossRefFormat,
parseHTML: (el) => (el.getAttribute('data-format') === 'page' ? 'page' : 'text'),
renderHTML: (attrs) => ({ 'data-format': attrs.format === 'page' ? 'page' : 'text' }),
},
// The last resolved display text, persisted like a Word field's cached result: it
// is what a reference to a deleted bookmark keeps showing.
text: {
default: '',
parseHTML: (el) => el.getAttribute('data-text') ?? el.textContent ?? '',
renderHTML: (attrs) => ({ 'data-text': String(attrs.text ?? '') }),
},
};
},
parseHTML() {
return [{ tag: 'span[data-cross-ref]' }];
},
renderHTML({ node, HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes), String(node.attrs.text ?? '')];
},
renderText({ node }) {
return String(node.attrs.text ?? '');
},
addCommands() {
return {
insertCrossRef:
({ name, format }) =>
({ commands, state }) => {
if (!name) return false;
const found = findBookmark(state.doc, name);
// Adopt the cursor's marks so the atom renders like the surrounding text.
const marks = (state.storedMarks ?? state.selection.$to.marks())
.filter((m) => m.type.name !== 'bookmark')
.map((m) => ({ type: m.type.name, attrs: m.attrs }));
return commands.insertContent({
type: this.name,
attrs: { name, format, text: format === 'page' ? '1' : (found?.text ?? name) },
...(marks.length ? { marks } : {}),
});
},
};
},
addNodeView() {
return ({ editor, getPos }) => new CrossRefView(editor, getPos as () => number);
},
});
// Node view: repaints from the live bookmark on each pagination settle (pm-pagecount on
// the .paper ancestor) and on doc change, writing the resolved text back to the attr —
// guarded against a loop by comparing with what the node already holds.
class CrossRefView {
dom: HTMLElement;
private editor: Editor;
private getPos: () => number;
private scheduled = false;
private paper: HTMLElement | null = null;
private onPageCount = () => this.schedule();
constructor(editor: Editor, getPos: () => number) {
this.editor = editor;
this.getPos = getPos;
this.dom = document.createElement('span');
this.dom.className = 'cross-ref';
this.dom.setAttribute('contenteditable', 'false');
this.paint(this.node());
this.dom.addEventListener('mousedown', (ev) => {
if (!(ev.metaKey || ev.ctrlKey)) return;
ev.preventDefault();
ev.stopPropagation();
this.goTo();
});
requestAnimationFrame(() => {
this.paper = this.dom.closest('.paper') as HTMLElement | null;
this.paper?.addEventListener('pm-pagecount', this.onPageCount);
this.render();
});
}
private schedule(): void {
if (this.scheduled) return;
this.scheduled = true;
requestAnimationFrame(() => {
this.scheduled = false;
this.render();
});
}
private node(): PMNode | null {
const pos = this.getPos();
const node = typeof pos === 'number' ? this.editor.state.doc.nodeAt(pos) : null;
return node?.type.name === 'crossRef' ? node : null;
}
// The reference's text now: the bookmark's own text, or the page it renders on. A
// bookmark that no longer exists leaves the cached text alone (Word shows its cached
// result too until the field is updated).
private resolve(node: PMNode): string {
const found = findBookmark(this.editor.state.doc, String(node.attrs.name ?? ''));
if (!found) return String(node.attrs.text ?? '');
if (node.attrs.format !== 'page') return found.text;
const at = this.editor.view.domAtPos(found.from).node;
const el = (at.nodeType === 1 ? at : at.parentElement) as HTMLElement | null;
if (!el) return String(node.attrs.text ?? '');
const { cycle } = readVerticalMargins(this.editor.view.dom as HTMLElement);
return String(pageOfElement(this.editor.view, el, cycle));
}
// Mirror what renderHTML would emit, so the DOM reads the same with or without the view.
private paint(node: PMNode | null, text = String(node?.attrs?.text ?? '')): void {
this.dom.dataset.crossRef = String(node?.attrs?.name ?? '');
this.dom.dataset.format = node?.attrs?.format === 'page' ? 'page' : 'text';
this.dom.dataset.text = text;
this.dom.textContent = text;
}
private render(): void {
if (this.editor.isDestroyed || !this.dom.isConnected) return;
const node = this.node();
if (!node) return;
const text = this.resolve(node);
this.paint(node, text);
if (text === node.attrs.text) return;
const pos = this.getPos();
if (typeof pos !== 'number') return;
this.editor.view.dispatch(
this.editor.state.tr.setNodeAttribute(pos, 'text', text).setMeta('addToHistory', false),
);
}
// Scroll the bookmark into view and drop the cursor into it.
private goTo(): void {
const node = this.node();
const found = node && findBookmark(this.editor.state.doc, String(node.attrs.name ?? ''));
if (!found) return;
const at = this.editor.view.domAtPos(found.from).node;
const el = (at.nodeType === 1 ? at : at.parentElement) as HTMLElement | null;
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
this.editor.chain().focus().setTextSelection({ from: found.from, to: found.to }).run();
}
update(node: PMNode): boolean {
if (node.type.name !== 'crossRef') return false;
this.schedule();
return true;
}
// Own only the modifier-click that navigates; a plain click still selects the atom so
// it can be deleted.
stopEvent(event: Event): boolean {
const e = event as MouseEvent;
return event.type.startsWith('mouse') && (e.metaKey || e.ctrlKey);
}
ignoreMutation(): boolean {
return true;
}
destroy(): void {
this.paper?.removeEventListener('pm-pagecount', this.onPageCount);
}
}
+14
View File
@@ -158,6 +158,20 @@ export function readVerticalMargins(dom: HTMLElement): VMargins {
};
}
// Rendered page of an element: its offsetTop within the editor DOM (summed up the
// offsetParent chain, spacers included) divided by the page cycle. The TOC and every
// cross-reference resolve their page numbers with it.
export function pageOfElement(view: EditorView, el: HTMLElement, cycle: number): number {
const tiptap = view.dom as HTMLElement;
let top = 0;
let n: HTMLElement | null = el;
while (n && n !== tiptap) {
top += n.offsetTop;
n = n.offsetParent as HTMLElement | null;
}
return Math.max(1, Math.floor(top / cycle) + 1);
}
function pageContentStart(page: number, marginTop: number, cycle: number): number {
return (page - 1) * cycle + marginTop;
}
+3 -18
View File
@@ -2,6 +2,7 @@ import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { MAX_HEADING_LEVEL } from '../../export/odt';
import { readVerticalMargins, pageOfElement } from './pageBreaks';
// A generated table of contents: a block atom listing every heading (levels 1–5) with its
// live page number. The node view regenerates entries from the headings + pagination and
@@ -9,10 +10,6 @@ import { MAX_HEADING_LEVEL } from '../../export/odt';
export type TocEntry = { text: string; level: number; page: number };
// Mirrors pageBreaks.ts PAGE_GAP + Editor.svelte getCycle(): the rendered page of a
// heading is its offsetTop within .tiptap (spacers included) divided by the cycle.
const PAGE_GAP = 20;
const FALLBACK_PAGE_HEIGHT = 1123;
// The heading above the entries. An imported TOC keeps the one its file used
// ("Inhalt", "Sommaire", …); a freshly inserted one takes this.
const TITLE = 'Table of Contents';
@@ -129,25 +126,13 @@ class TocView {
}
private cycle(): number {
const tiptap = this.editor.view.dom as HTMLElement;
const ph = parseFloat(getComputedStyle(tiptap).getPropertyValue('--user-page-height'));
return (Number.isFinite(ph) ? ph : FALLBACK_PAGE_HEIGHT) + PAGE_GAP;
return readVerticalMargins(this.editor.view.dom as HTMLElement).cycle;
}
// Rendered page of a heading: sum offsetTop up the offsetParent chain to .tiptap (the
// live DOM already includes pagination spacers, so this is the on-page position),
// divided by the page cycle. Mirrors topWithin/getPageForY in pageBreaks.ts.
private pageOf(pos: number, cycle: number): number {
const tiptap = this.editor.view.dom as HTMLElement;
const el = this.editor.view.nodeDOM(pos) as HTMLElement | null;
if (!el || el.nodeType !== 1) return 1;
let top = 0;
let n: HTMLElement | null = el;
while (n && n !== tiptap) {
top += n.offsetTop;
n = n.offsetParent as HTMLElement | null;
}
return Math.max(1, Math.floor(top / cycle) + 1);
return pageOfElement(this.editor.view, el, cycle);
}
private render(): void {
+3 -1
View File
@@ -24,6 +24,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
- **Tables:** column widths come from the editor's per-column weights (`tableColumnWidthsCm`, summing exactly to the text width, with `table:align="margins"`). **Table margins:** a table's `marginLeft`/`marginRight` shrink that width and are written by `applyTableMargins` into the table's automatic style (`style:width` + `fo:margin-left`/`-right`; odf-kit names table styles `Table1`, `Table2`, … in document order, matching the descriptor list `exportTable` fills). odf-kit serializes each cell to a single `<text:p>` of runs; `exportTable` emits all of a cell's content into that paragraph separated by a `SEG` sentinel and records a `CellBlock[]` descriptor, then `applyCellBlocks` splits on `SEG` and rebuilds real `<text:h>`/`<text:p>`/`<text:list>` (incl. nested lists) with minted automatic paragraph styles. **Merged cells:** `exportTable` passes each cell's `colspan`/`rowspan` as odf-kit `CellOptions.colSpan`/`rowSpan`; odf-kit emits `table:number-columns/rows-spanned` and auto-generates the `<table:covered-table-cell>` placeholders (which never match the `applyCellBlocks` cell regex, so `cellBlocks` stays one-per-real-cell aligned). Import already reads spans/covered cells in `convertTable`. **Cell shading:** a cell's `backgroundColor` attr is passed as `CellOptions.backgroundColor` (normalized), which odf-kit mints into the cell style as `fo:background-color`. **Cell borders:** the per-side border attrs (tableCellBorders.ts) are passed as `CellOptions.borderTop/Right/Bottom/Left` (`'none'` or `'<W>pt solid #RRGGBB'`) overriding the table-level default border per side.
- **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a top-level `textBox` is **hoisted** — `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) swaps it for `TBX`-sentinel marker paragraphs (`S{i}`…`E{i}`, U+E008) bracketing its child blocks at top level, so every existing pass (custom attrs, list styles, inline sentinels, images) serializes them unchanged and document-order-indexed passes stay aligned. `applyTextBoxes` (after `applyImages`) wraps each serialized S…E region into `<draw:frame><draw:text-box fo:min-height>` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `<draw:custom-shape>` + preset `<draw:enhanced-geometry>` (roundRect/ellipse, static LibreOffice-style paths) inside a fresh anchor paragraph, minting a `TbxFr{n}` graphic style (fill/stroke/padding + the image wrap props; `draw:auto-grow-height="true"` for boxes, both auto-grows explicitly **false** for shapes or LibreOffice's autofit shrinks them to their text).
- **Formulas** (`replaceFormulas`/`applyFormulas`): an `MTH`-sentinel run becomes an as-char `<draw:frame><draw:object xlink:href="./Formula{n}"/>` plus a `Formula{n}/content.xml` sub-document (MathML root) and its two manifest entries. **No `svg:width`/`svg:height`** — a sized frame makes LibreOffice scale the object instead of typesetting it. DOCX emits `<m:oMath>` (or `<m:oMathPara>` when displayed) from the same post-pack pass as the text boxes. See `docs/architecture/formulas.md`.
- **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`.
- **`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.
@@ -43,4 +44,5 @@ Sentinel order matters — the passes assume it. Full list of sentinels in use:
`LBR`/`TAB` (inline), `IMG`, `SEG` (cell blocks), `TBX` U+E008 (text boxes),
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).
(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).
+42 -5
View File
@@ -1,5 +1,5 @@
import {
Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, Tab,
Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, InternalHyperlink, Bookmark, Tab,
TableOfContents,
Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
@@ -284,7 +284,7 @@ function textNodeToRuns(node: TiptapNode, force: TextProps): TextRun[] {
return [new TextRun({ children, ...props })];
}
type Inline = TextRun | ImageRun | ExternalHyperlink | SimpleField;
type Inline = TextRun | ImageRun | ExternalHyperlink | InternalHyperlink | SimpleField | Bookmark;
// A date/time field. A fixed field is plain text (Word has no fixed-date field); an
// auto field is a DATE/TIME field with the picture switch and a cached value.
@@ -302,15 +302,52 @@ function dateTimeRun(node: TiptapNode): Inline {
return new SimpleField(`${kind === 'time' ? 'TIME' : 'DATE'} \\@ "${docxPicture(fmt)}"`, text);
}
// 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 {
const a = node.attrs ?? {};
const verb = a.format === 'page' ? 'PAGEREF' : 'REF';
return new SimpleField(`${verb} ${String(a.name ?? '')} \\h`, String(a.text ?? ''));
}
const bookmarkNameOf = (node: TiptapNode): string | null => {
const name = node.marks?.find((m) => m.type === 'bookmark')?.attrs?.name;
return typeof name === 'string' && name ? name : null;
};
function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline[] {
const out: Inline[] = [];
// Consecutive nodes sharing a bookmark become one w:bookmarkStart/End pair.
// ponytail: a range spanning paragraphs is emitted per paragraph — Word tolerates the
// repeated name, and splitting it would need a second pass over the whole document.
let open: { name: string; children: Inline[] } | null = null;
const flush = () => {
if (!open) return;
out.push(new Bookmark({ id: open.name, children: open.children }));
open = null;
};
const emit = (runs: Inline[], name: string | null) => {
if (name !== (open?.name ?? null)) flush();
if (!name) { out.push(...runs); return; }
if (!open) open = { name, children: [] };
open.children.push(...runs);
};
for (const node of content) {
const bookmark = bookmarkNameOf(node);
if (node.type === 'text' && node.text) {
const runs = textNodeToRuns(node, force);
const href = node.marks?.find((m) => m.type === 'link')?.attrs?.href;
if (href) out.push(new ExternalHyperlink({ link: String(href), children: runs }));
else out.push(...runs);
} else if (node.type === 'hardBreak') {
const link = href ? String(href) : '';
// An internal href targets a bookmark in this document, not a URL.
if (link.startsWith('#')) emit([new InternalHyperlink({ anchor: link.slice(1), children: runs })], bookmark);
else if (link) emit([new ExternalHyperlink({ link, children: runs })], bookmark);
else emit(runs, bookmark);
continue;
}
if (node.type === 'crossRef') { emit([crossRefField(node)], bookmark); continue; }
flush();
if (node.type === 'hardBreak') {
// Carry the run's props so an empty line between two breaks keeps its font size.
out.push(new TextRun({ break: 1, ...runPropsFromMarks(node.marks) }));
} else if (node.type === 'image') {
+85 -2
View File
@@ -147,6 +147,16 @@ const LEAD = '\uE011';
// <draw:frame>/<draw:object> and writes the embedded formula object. U+E012.
const MTH = '\uE012';
// Sentinels bracketing a bookmark's text (BMS<name>BMS \u2026 BME<name>BME), spliced into
// the run text by replaceBookmarks so they ride every odf-kit path; applyBookmarks
// rewrites them to <text:bookmark-start/>/<text:bookmark-end/>. U+E013/U+E014.
const BMS = '\uE013';
const BME = '\uE014';
// Sentinel wrapping a cross-reference's index and its display text
// (XRF{i}XRF<text>XRF); applyBookmarks rewrites it to <text:bookmark-ref>. U+E015.
const XRF = '\uE015';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -502,6 +512,52 @@ function replaceDateTimeFields(node: TiptapNode, fields: DateTimeFieldExport[]):
return { ...node, content };
}
// One cross-reference, collected by replaceCrossRefs and emitted by applyBookmarks.
type CrossRefExport = { name: string; format: 'text' | 'page' };
const bookmarkNameOf = (node: TiptapNode): string =>
String(node.marks?.find((m) => m.type === 'bookmark')?.attrs?.name ?? '');
// Bracket each bookmark's text with the BMS/BME sentinels and replace every `crossRef`
// node with an XRF-sentinel run. Splicing into the run text (rather than into a run
// emitter) is what makes both ride every odf-kit path — cells and custom paragraphs
// included. applyBookmarks resolves them after serialization.
function replaceBookmarks(node: TiptapNode, refs: CrossRefExport[]): TiptapNode {
if (!node.content?.length) return node;
const content: TiptapNode[] = [];
// Index of the run the open bookmark started on, so its end sentinel lands on the
// last run of the range.
let open = '';
let lastOfRange = -1;
const closeRange = () => {
if (!open) return;
const run = content[lastOfRange];
content[lastOfRange] = { ...run, text: `${run.text ?? ''}${BME}${open}${BME}` };
open = '';
};
for (const child of node.content) {
if (child.type === 'crossRef') {
closeRange();
const a = child.attrs ?? {};
refs.push({ name: String(a.name ?? ''), format: a.format === 'page' ? 'page' : 'text' });
content.push({ type: 'text', text: `${XRF}${refs.length - 1}${XRF}${String(a.text ?? '')}${XRF}`, marks: child.marks });
continue;
}
const name = child.type === 'text' ? bookmarkNameOf(child) : '';
if (name !== open) closeRange();
if (name && !open) {
open = name;
content.push({ ...child, text: `${BMS}${name}${BMS}${child.text ?? ''}` });
lastOfRange = content.length - 1;
continue;
}
content.push(name ? child : replaceBookmarks(child, refs));
if (name) lastOfRange = content.length - 1;
}
closeRange();
return { ...node, content };
}
// One text box / shape, collected by replaceTextBoxes and emitted by applyTextBoxes.
type ShapeKind = 'textbox' | 'roundRect' | 'ellipse';
type TextBoxExport = {
@@ -3091,6 +3147,31 @@ function tocXml(toc: TocExport, index: number): string {
// Resolve TOC sentinels: rewrite each marker <text:p>TOC{i}TOC</text:p> to its
// <text:table-of-content> and mint the Contents_20_* paragraph styles it references.
// BMS/BME/XRF sentinels → <text:bookmark-start/>, <text:bookmark-end/> and
// <text:bookmark-ref>. All three are legal anywhere in paragraph content, so a plain
// replace works wherever odf-kit put the run — inside a <text:span> included. Bookmark
// names are sanitized on insert, so nothing here needs XML escaping.
function applyBookmarks(odtBytes: Uint8Array, refs: CrossRefExport[]): Uint8Array {
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
if (!content.includes(BMS) && !content.includes(XRF)) return odtBytes;
content = content
.replace(new RegExp(`${BMS}([^${BMS}]*)${BMS}`, 'g'), (_m, name: string) => `<text:bookmark-start text:name="${name}"/>`)
.replace(new RegExp(`${BME}([^${BME}]*)${BME}`, 'g'), (_m, name: string) => `<text:bookmark-end text:name="${name}"/>`)
.replace(new RegExp(`${XRF}(\\d+)${XRF}([^${XRF}]*)${XRF}`, 'g'), (_m, idx: string, shown: string) => {
const ref = refs[Number(idx)];
if (!ref) return shown;
return `<text:bookmark-ref text:reference-format="${ref.format}" text:ref-name="${ref.name}">${shown}</text:bookmark-ref>`;
});
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: number): Uint8Array {
if (!tocs.length) return odtBytes;
const files = unzipSync(odtBytes);
@@ -3156,7 +3237,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const columns: ColumnsExport[] = [];
const dateFields: DateTimeFieldExport[] = [];
const formulas: FormulaExport[] = [];
const sentinels = replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns)))), images), dateFields), formulas);
const crossRefs: CrossRefExport[] = [];
const sentinels = replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns)))), images), dateFields), formulas), crossRefs);
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;
@@ -3359,7 +3441,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withFormulas = applyFormulas(withDateFields, formulas);
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
const withColumns = applyColumns(withTextBoxes, columns);
const withToc = applyToc(withColumns, tocs, contentWidthCm);
const withBookmarks = applyBookmarks(withColumns, crossRefs);
const withToc = applyToc(withBookmarks, tocs, contentWidthCm);
const withPageBreaks = applyPageBreaks(withToc);
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
const withParaBoxes = applyParagraphBoxes(withEmptyFontSizes);
+18
View File
@@ -9,6 +9,7 @@ const de: Messages = {
cancel: 'Abbrechen',
close: 'Schließen',
apply: 'Übernehmen',
insert: 'Einfügen',
remove: 'Entfernen',
},
appearance: {
@@ -201,6 +202,11 @@ const de: Messages = {
tocNotInHf: 'Ein Inhaltsverzeichnis ist in Kopf-/Fußzeilen nicht verfügbar',
insertLink: 'Link einfügen',
linkNotInHf: 'Links sind in Kopf-/Fußzeilen nicht verfügbar',
insertBookmark: 'Lesezeichen',
bookmarkNeedsSelection: 'Markiere den Text, den das Lesezeichen umfassen soll',
bookmarkNotInHf: 'Lesezeichen sind in Kopf-/Fußzeilen nicht verfügbar',
insertCrossRef: 'Querverweis einfügen',
crossRefNeedsBookmark: 'Erst ein Lesezeichen setzen — ein Querverweis zeigt darauf',
clearFormatting: 'Formatierung entfernen',
},
image: {
@@ -228,6 +234,16 @@ const de: Messages = {
borderWidth: 'Rahmenbreite',
pt: (w) => `${w} pt`,
},
bookmark: {
dialogLabel: 'Lesezeichen',
namePlaceholder: 'Name des Lesezeichens',
existing: 'In diesem Dokument',
none: 'Noch keine Lesezeichen',
crossRefLabel: 'Querverweis einfügen',
formatText: 'Text des Lesezeichens',
formatPage: 'Seitenzahl',
openHint: (mod) => `${mod}+Klick, um zum Lesezeichen zu springen`,
},
link: {
dialogLabel: 'Link einfügen',
openHint: (mod) => `${mod}+Klick, um den Link zu öffnen`,
@@ -336,6 +352,8 @@ const de: Messages = {
insertLink: 'Link…',
editLink: 'Link bearbeiten…',
removeLink: 'Link entfernen',
insertBookmark: 'Lesezeichen…',
insertCrossRef: 'Querverweis…',
clearFormatting: 'Direkte Formatierung löschen',
},
about: {
+18
View File
@@ -10,6 +10,7 @@ const en = {
cancel: 'Cancel',
close: 'Close',
apply: 'Apply',
insert: 'Insert',
remove: 'Remove',
},
appearance: {
@@ -205,6 +206,11 @@ const en = {
tocNotInHf: 'A table of contents is not available in headers/footers',
insertLink: 'Insert link',
linkNotInHf: 'Links are not available in headers/footers',
insertBookmark: 'Bookmark',
bookmarkNeedsSelection: 'Select the text the bookmark should cover',
bookmarkNotInHf: 'Bookmarks are not available in headers/footers',
insertCrossRef: 'Insert cross-reference',
crossRefNeedsBookmark: 'Add a bookmark first — a cross-reference points at one',
clearFormatting: 'Clear formatting',
},
image: {
@@ -232,6 +238,16 @@ const en = {
borderWidth: 'Border width',
pt: (w: number) => `${w} pt`,
},
bookmark: {
dialogLabel: 'Bookmark',
namePlaceholder: 'Bookmark name',
existing: 'In this document',
none: 'No bookmarks yet',
crossRefLabel: 'Insert cross-reference',
formatText: 'Text of the bookmark',
formatPage: 'Page number',
openHint: (mod: string) => `${mod}+Click to jump to the bookmark`,
},
link: {
dialogLabel: 'Insert link',
openHint: (mod: string) => `${mod}+Click to open link`,
@@ -340,6 +356,8 @@ const en = {
insertLink: 'Link…',
editLink: 'Edit Link…',
removeLink: 'Remove Link',
insertBookmark: 'Bookmark…',
insertCrossRef: 'Cross-reference…',
clearFormatting: 'Clear Formatting',
},
about: {
+1
View File
@@ -16,6 +16,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **Charts (`import/chart.ts`, DOCX)**: `word/charts/chartN.xml` is parsed and **drawn as an SVG picture** of the frame's own size — series values from the `numCache` the producer left, colours from the named theme accent (`themeAccents`), title/axis titles/gridlines, and the value axis' own `c:scaling` bounds where the file fixes them. Bar (incl. stacked and `barDir="bar"`), line, area, scatter and pie; anything else keeps the placeholder. Read-only — the editor has no chart object, so a re-export carries the picture, not the chart. **ODF** charts go through the same drawing (`odfChartDataUrl`, `convertChartFrame`): `<chart:chart>` in the `draw:object`'s sub-document, series values read out of its own `local-table` by cell range, colours from the series' `draw:fill-color`, bounds from the y-axis style's `chart:maximum`/`-minimum`.
- **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (the thesis fixture lost every `[7]`-style reference and broke its lines one page short).
- **Formulas** (`convertFormulaFrame`): a `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`.
- **Bookmarks & cross-references**: `text:bookmark-start`/`-end` (ODF) and `w:bookmarkStart`/`End` (DOCX) become a `bookmark` **mark** on the text they cover; the open ranges live in `Ctx` because Word writes them beside paragraphs as well as beside runs, and a range may close in a later paragraph. A **point** bookmark has no range to mark and is dropped, `_GoBack` with it. `text:bookmark-ref` and `REF`/`PAGEREF` fields (the `fldChar` machine and `w:fldSimple` alike, including a field Word left unresolved) become `crossRef` nodes carrying the producer's cached text. A `w:hyperlink w:anchor` with no `r:id` is an internal link → `#name`. See `docs/architecture/formatting.md`.
- **Graceful degradation** (collected as `warnings`, surfaced once): footnotes/comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`).
`tests/roundtrip.test.ts` (export→import) and `tests/lo-roundtrip.test.ts` (export→LibreOffice re-save→import, needs `soffice`) verify the round trip; run with `npm test` / `npm run test:lo`.
+80 -7
View File
@@ -58,6 +58,9 @@ type Ctx = {
cellSpacing: ParaSpacing;
// theme1.xml's accent1..6, the colours a chart series names instead of an sRGB.
accents: string[];
// Bookmarks open at this point of the walk (w:id → name). A range may start beside a
// paragraph and end inside a later one, so the state outlives both walks.
openBookmarks: Map<string, string>;
};
// ---- units & editor defaults to suppress -----------------------------------
@@ -139,7 +142,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const sectPr = fc(body, 'sectPr');
const contentWidthCm = sectionContentWidthCm(sectPr);
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, accents: themeAccents(themeDoc) };
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, accents: themeAccents(themeDoc), openBookmarks: new Map() };
// Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares
// more than one column becomes a columns node (the trailing group is described
@@ -303,6 +306,33 @@ function tocHeading(content: Element | null): string | null {
return text && text.length <= 60 ? text : null;
}
// Word's own cursor bookkeeping, never a reference target.
const BOOKMARK_SKIP = new Set(['_GoBack']);
// w:bookmarkStart/w:bookmarkEnd sit beside runs, beside paragraphs, or split across
// both, so both walks feed the same open-range map. Returns true when the element was
// one of them.
function trackBookmark(el: Element, ctx: Ctx): boolean {
if (el.localName === 'bookmarkStart') {
const id = el.getAttributeNS(W, 'id');
const name = el.getAttributeNS(W, 'name');
if (id && name && !BOOKMARK_SKIP.has(name)) ctx.openBookmarks.set(id, name);
return true;
}
if (el.localName === 'bookmarkEnd') {
const id = el.getAttributeNS(W, 'id');
if (id) ctx.openBookmarks.delete(id);
return true;
}
return false;
}
// A mark can hold one bookmark, so overlapping ranges collapse to the outermost.
function openBookmarkMark(ctx: Ctx): Mark | null {
const name = ctx.openBookmarks.values().next().value;
return name ? { type: 'bookmark', attrs: { name } } : null;
}
function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
const out: Node[] = [];
const stack: { ilvl: number; numId: number; list: Node }[] = [];
@@ -349,6 +379,7 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
for (const el of children) {
if (el.namespaceURI !== W) continue;
if (trackBookmark(el, ctx)) continue;
if (el.localName === 'p') {
// A paragraph owned by a TOC field is skipped; the field emits one node.
const startedInToc = tocState.tocDepth >= 0;
@@ -917,11 +948,18 @@ 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.
let fieldCrossRef: Node | null = null;
let fieldCrossRefText = '';
const pushText = (text: string, marks: Mark[]) => {
if (!text) return;
// The one-paragraph header/footer schema has no bookmark mark.
const bookmark = hfFields ? null : openBookmarkMark(ctx);
const all = bookmark ? [...marks, bookmark] : marks;
const node: Node = { type: 'text', text };
if (marks.length) node.marks = marks;
if (all.length) node.marks = all;
out.push(node);
};
@@ -957,7 +995,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
const marks = runMarks(r, linkHref);
// Hide a field's cached result: always for a hf field, and for a recognized body
// date/time field (replaced by its live node).
const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime);
const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime || !!fieldCrossRef);
// Route a drawing/pict result: an image is inline, a text box a block node riding
// ctx.pendingBlocks. The one-paragraph header/footer zone takes as-char images only —
@@ -993,24 +1031,39 @@ 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; }
if (t === 'begin') { fieldMode = 'instr'; fieldInstr = ''; fieldDateTime = null; fieldCrossRef = null; fieldCrossRefText = ''; }
else if (t === 'separate') {
fieldMode = 'result';
if (!hfFields) {
fieldDateTime = dateTimeFieldFromInstr(fieldInstr);
fieldCrossRef = fieldDateTime ? null : crossRefFromInstr(fieldInstr);
fieldCrossRefText = '';
// Carry the field run's marks so the atom renders in the run's font.
if (fieldDateTime && marks.length) fieldDateTime.marks = marks;
const field = fieldDateTime ?? 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) {
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 emitField(out, fieldInstr, hfFields, marks);
fieldMode = 'none';
fieldDateTime = null;
fieldCrossRef = null;
}
break;
}
case 'instrText': if (fieldMode === 'instr') fieldInstr += child.textContent ?? ''; break;
case 't': if (!skipResult()) pushText(child.textContent ?? '', marks); break;
case 't':
if (fieldCrossRef && fieldMode === 'result') fieldCrossRefText += child.textContent ?? '';
else if (!skipResult()) pushText(child.textContent ?? '', marks);
break;
case 'tab': if (!skipResult()) pushText('\t', marks); break;
case 'br': out.push(child.getAttributeNS(W, 'type') === 'page' ? { type: PB_MARKER } : hardBreakNode(marks)); break;
case 'cr': out.push(hardBreakNode(marks)); break;
@@ -1029,17 +1082,28 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
continue;
}
if (el.namespaceURI !== W) continue;
if (trackBookmark(el, ctx)) continue;
switch (el.localName) {
case 'r': handleRun(el); break;
case 'hyperlink': {
const rid = el.getAttributeNS(R, 'id');
const href = rid ? ctx.rels.get(rid)?.target : undefined;
// No relationship id: an internal link to a bookmark in this document.
const anchor = el.getAttributeNS(W, 'anchor');
const href = rid ? ctx.rels.get(rid)?.target : anchor ? `#${anchor}` : undefined;
for (const r of fcAll(el, 'r')) handleRun(r, href);
break;
}
case 'fldSimple': {
const instr = el.getAttributeNS(W, 'instr') ?? '';
if (hfFields) { const first = fcAll(el, 'r')[0]; emitField(out, instr, true, first ? runMarks(first) : []); break; }
const xref = crossRefFromInstr(instr);
if (xref) {
const first = fcAll(el, 'r')[0];
const m = first ? runMarks(first) : [];
if (m.length) xref.marks = m;
out.push({ ...xref, attrs: { ...xref.attrs, text: el.textContent ?? '' } });
break;
}
const field = dateTimeFieldFromInstr(instr);
if (field) {
const first = fcAll(el, 'r')[0];
@@ -1081,6 +1145,15 @@ function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[]
else if (/\bPAGE\b/.test(instr)) push('pageNumber');
}
// A REF/PAGEREF field instruction → a cross-reference node; its cached result text is
// filled in by the caller when the field closes. Every other field (SEQ, CITATION, …)
// returns null and keeps showing the text the producer cached.
function crossRefFromInstr(instr: string): Node | null {
const m = /^\s*(PAGEREF|REF)\s+(\S+)/i.exec(instr);
if (!m) return null;
return { type: 'crossRef', attrs: { name: m[2], format: /^PAGEREF$/i.test(m[1]) ? 'page' : 'text', text: '' } };
}
// 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.
+38 -5
View File
@@ -97,6 +97,9 @@ type Ctx = {
contentWidthCm: number;
// Master pages the body switches to, in order — one section each past the first.
masterPages: string[];
// Bookmark ranges open at this point of the walk, outermost first; a range may end in
// a later paragraph than it started in, so the set outlives one convertInline call.
openBookmarks: Set<string>;
};
// Read a Pictures/ entry into a base64 data-URI; null when it's missing or in a format
@@ -520,7 +523,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
const contentWidthCm = geo
? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right
: pageDimsCm('A4', 'portrait').w - 2 * 2.12;
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [] };
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [], openBookmarks: new Set() };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm)));
@@ -1303,6 +1306,10 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
marks.push({ type: 'charStyle', attrs: { name: display } });
}
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
// A mark can hold one bookmark, so overlapping ranges collapse to the outermost.
// The one-paragraph header/footer schema has no bookmark mark.
const bookmark = hfFields ? undefined : ctx.openBookmarks.values().next().value;
if (bookmark) marks.push({ type: 'bookmark', attrs: { name: bookmark } });
const node: Node = { type: 'text', text: clean };
if (marks.length) node.marks = marks;
out.push(node);
@@ -1344,8 +1351,8 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
continue;
}
case 'a': {
// ODF hyperlink → link mark on the contained text (internal #bookmark
// links are kept as-is though the editor has no bookmark targets).
// ODF hyperlink → link mark on the contained text; an internal #bookmark
// href is kept as-is and resolved against the document's bookmarks.
const href = e.getAttributeNS(NS.xlink, 'href') ?? '';
walk(e, props, href || linkHref);
continue;
@@ -1353,9 +1360,35 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
case 'note':
ctx.warnings.add('Footnotes and endnotes were removed');
continue;
case 'bookmark':
case 'bookmark-start':
case 'bookmark-end':
case 'bookmark-end': {
// A named range → a bookmark mark on the text it covers. A point bookmark
// (<text:bookmark/>) has no range to mark, so it is dropped.
const name = e.getAttributeNS(NS.text, 'name');
if (name) {
if (e.localName === 'bookmark-start') ctx.openBookmarks.add(name);
else ctx.openBookmarks.delete(name);
}
continue;
}
case 'bookmark-ref': {
const name = e.getAttributeNS(NS.text, 'ref-name');
const fmt = e.getAttributeNS(NS.text, 'reference-format');
// Only the two formats the editor models; any other one (chapter, number,
// …) keeps the value the file cached.
// Same for the crossRef node: in a zone the reference stays its shown text.
if (!hfFields && name && (fmt === 'text' || fmt === 'page')) {
const field: Node = { type: 'crossRef', attrs: { name, format: fmt, text: e.textContent ?? '' } };
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;
}
if (e.textContent) pushText(e.textContent, props, linkHref);
continue;
}
case 'bookmark':
case 'soft-page-break':
case 'change':
case 'change-start':
+34
View File
@@ -292,4 +292,38 @@ describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () =
found[0]?.attrs?.display === false && found[1]?.attrs?.display === true,
found.map((f) => f.attrs?.display));
});
it('survives a `soffice` re-save of bookmarks and cross-references', { timeout: 180000 }, async () => {
const doc: N = { type: 'doc', content: [
P(null, T('Figure '), T('Figure 1', { type: 'bookmark', attrs: { name: 'Fig1' } }), T(' – a caption')),
P(null,
T('See '), { type: 'crossRef', attrs: { name: 'Fig1', format: 'text', text: 'Figure 1' } },
T(' on page '), { type: 'crossRef', attrs: { name: 'Fig1', format: 'page', text: '1' } },
T(' or '), T('jump', { type: 'link', attrs: { href: '#Fig1' } }), T('.'),
),
] };
mkdirSync('/tmp/lo-rt', { recursive: true });
writeFileSync('/tmp/lo-rt/bm.odt', await buildOdt(doc, margins, 'portrait'));
execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/bmout /tmp/lo-rt/bm.odt', { stdio: 'pipe', timeout: 120000 });
const resaved = new Uint8Array(readFileSync('/tmp/lo-rt/bmout/bm.odt'));
const xml = strFromU8(unzipSync(resaved)['content.xml']);
check('LO bookmarks: the range survives', xml.includes('text:bookmark-start text:name="Fig1"') && xml.includes('text:bookmark-end text:name="Fig1"'));
check('LO bookmarks: both references stay fields', (xml.match(/<text:bookmark-ref/g) ?? []).length === 2, xml.match(/<text:bookmark-ref[^>]*>/g));
const res = importOdt(resaved);
const marked: N[] = [];
const refs: N[] = [];
(function walk(n: N) {
if (n.type === 'crossRef') refs.push(n);
if ((n.marks ?? []).some((m: N) => m.type === 'bookmark')) marked.push(n);
for (const c of n.content ?? []) walk(c);
})(res.content);
check('LO bookmarks: the mark comes back on its text', marked[0]?.text === 'Figure 1', marked.map((m) => m.text));
check('LO bookmarks: both references come back',
refs.map((r) => `${r.attrs.name}/${r.attrs.format}`).join(',') === 'Fig1/text,Fig1/page',
refs.map((r) => r.attrs));
// LibreOffice re-evaluates the fields on load, so the shown values are its own.
check('LO bookmarks: the text reference resolves to the caption', refs[0]?.attrs?.text === 'Figure 1', refs[0]?.attrs?.text);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { importOdt } from '../../src/lib/import/odt';
import { importDocx } from '../../src/lib/import/docx';
import { DEFAULT_MARGINS } from '../../src/lib/storage/pageMargins';
type N = { type: string; attrs?: any; content?: N[]; marks?: any[]; text?: string };
const bm = (text: string, name: string): N => ({ type: 'text', text, marks: [{ type: 'bookmark', attrs: { name } }] });
const ref = (name: string, format: 'text' | 'page', text: string): N =>
({ type: 'crossRef', attrs: { name, format, text } });
const doc: N = {
type: 'doc',
content: [
{ type: 'paragraph', attrs: {}, content: [{ type: 'text', text: 'Figure ' }, bm('Figure 1', 'Fig1'), { type: 'text', text: ' – a caption' }] },
{
type: 'paragraph',
attrs: {},
content: [
{ type: 'text', text: 'See ' },
ref('Fig1', 'text', 'Figure 1'),
{ type: 'text', text: ' on page ' },
ref('Fig1', 'page', '1'),
{ type: 'text', text: ' or ' },
{ type: 'text', text: 'jump', marks: [{ type: 'link', attrs: { href: '#Fig1' } }] },
{ type: 'text', text: '.' },
],
},
],
};
function walk(node: N, type: string, out: N[] = []): N[] {
if (node.type === type) out.push(node);
for (const c of node.content ?? []) walk(c, type, out);
return out;
}
const texts = (node: N) => walk(node, 'text');
const markAttrs = (n: N, type: string) => (n.marks ?? []).find((m: any) => m.type === type)?.attrs;
describe('bookmarks and cross-references round trip', () => {
it('writes ODF bookmarks and reads them back', async () => {
const bytes = await buildOdt(doc as any, DEFAULT_MARGINS, 'portrait');
const content = strFromU8(unzipSync(bytes)['content.xml']);
expect(content).toContain('<text:bookmark-start text:name="Fig1"/>');
expect(content).toContain('<text:bookmark-end text:name="Fig1"/>');
expect(content).toContain('<text:bookmark-ref text:reference-format="text" text:ref-name="Fig1">Figure 1</text:bookmark-ref>');
expect(content).toContain('<text:bookmark-ref text:reference-format="page" text:ref-name="Fig1">1</text:bookmark-ref>');
expect(content).not.toMatch(/[-]/);
const back = importOdt(bytes).content as unknown as N;
expect(texts(back).find((t) => t.text === 'Figure 1' && markAttrs(t, 'bookmark'))?.marks?.[0].attrs.name).toBe('Fig1');
expect(walk(back, 'crossRef').map((r) => [r.attrs.name, r.attrs.format, r.attrs.text])).toEqual([
['Fig1', 'text', 'Figure 1'],
['Fig1', 'page', '1'],
]);
expect(texts(back).some((t) => markAttrs(t, 'link')?.href === '#Fig1')).toBe(true);
});
it('writes DOCX bookmarks and reads them back', async () => {
const bytes = await buildDocx(doc as any, DEFAULT_MARGINS, 'portrait');
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
expect(xml).toContain('w:name="Fig1"');
expect(xml).toContain('<w:bookmarkEnd');
expect(xml).toMatch(/w:instr=" ?REF Fig1 \\h ?"/);
expect(xml).toMatch(/w:instr=" ?PAGEREF Fig1 \\h ?"/);
expect(xml).toContain('w:anchor="Fig1"');
const back = importDocx(bytes).content as unknown as N;
expect(texts(back).find((t) => t.text === 'Figure 1' && markAttrs(t, 'bookmark'))?.marks?.[0].attrs.name).toBe('Fig1');
expect(walk(back, 'crossRef').map((r) => [r.attrs.name, r.attrs.format, r.attrs.text])).toEqual([
['Fig1', 'text', 'Figure 1'],
['Fig1', 'page', '1'],
]);
expect(texts(back).some((t) => markAttrs(t, 'link')?.href === '#Fig1')).toBe(true);
});
});