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:
1 parent
46ae0211af
commit
78e35cef2c
22 files changed
+1131
-42
No files matched your search
@@ -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).
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user