diff --git a/docs/architecture/formatting.md b/docs/architecture/formatting.md index 0ccc97f..1411573 100644 --- a/docs/architecture/formatting.md +++ b/docs/architecture/formatting.md @@ -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 ``/`` 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 `'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 `
` 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 `
    ` 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 `` 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` (``/`` 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 ``/DATE field stays plain text). **ODF**: `replaceDateTimeFields`/`applyDateTimeFields` (sentinel U+E00A, mirrors images) emit ``/`` + 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 ``, 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 ``, `` and ``. **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). diff --git a/src/lib/components/BookmarkDialog.svelte b/src/lib/components/BookmarkDialog.svelte new file mode 100644 index 0000000..fa6c47f --- /dev/null +++ b/src/lib/components/BookmarkDialog.svelte @@ -0,0 +1,153 @@ + + +{#if open} + +{/if} + + diff --git a/src/lib/components/CrossRefDialog.svelte b/src/lib/components/CrossRefDialog.svelte new file mode 100644 index 0000000..bc3f02e --- /dev/null +++ b/src/lib/components/CrossRefDialog.svelte @@ -0,0 +1,99 @@ + + +{#if open} + + +{/if} + + diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 1c78dc0..bd1ec57 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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; }, diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index d7623ca..46e6be3 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -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)} /> +
    diff --git a/src/lib/editor/contextMenuItems.ts b/src/lib/editor/contextMenuItems.ts index 06d7e3e..03ef2f6 100644 --- a/src/lib/editor/contextMenuItems.ts +++ b/src/lib/editor/contextMenuItems.ts @@ -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' }, { diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 0b4c6f4..c4bda53 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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 // / 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 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, diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 354cf16..850fdbb 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -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). diff --git a/src/lib/editor/extensions/bookmark.ts b/src/lib/editor/extensions/bookmark.ts new file mode 100644 index 0000000..1887095 --- /dev/null +++ b/src/lib/editor/extensions/bookmark.ts @@ -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 { + 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; + }, + }; + }, +}); diff --git a/src/lib/editor/extensions/crossReference.ts b/src/lib/editor/extensions/crossReference.ts new file mode 100644 index 0000000..df052fa --- /dev/null +++ b/src/lib/editor/extensions/crossReference.ts @@ -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 { + 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); + } +} diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index acd13d9..0affc7e 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -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; } diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index 1b00206..22a42dc 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -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 { diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index b76a552..f041150 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -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 `` 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 ``/``/`` (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 `` 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 `'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 `` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `` + preset `` (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 `` 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 `` (or `` 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 ``/``/``. 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 `` 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). diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index bc6d89d..4e797c9 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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') { diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index d1074d7..d65d7c5 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -147,6 +147,16 @@ const LEAD = '\uE011'; // / and writes the embedded formula object. U+E012. const MTH = '\uE012'; +// Sentinels bracketing a bookmark's text (BMSBMS \u2026 BMEBME), spliced into +// the run text by replaceBookmarks so they ride every odf-kit path; applyBookmarks +// rewrites them to /. U+E013/U+E014. +const BMS = '\uE013'; +const BME = '\uE014'; + +// Sentinel wrapping a cross-reference's index and its display text +// (XRF{i}XRFXRF); applyBookmarks rewrites it to . U+E015. +const XRF = '\uE015'; + const EXT_BY_MIME: Record = { '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 TOC{i}TOC to its // and mint the Contents_20_* paragraph styles it references. +// BMS/BME/XRF sentinels → , and +// . All three are legal anywhere in paragraph content, so a plain +// replace works wherever odf-kit put the run — inside a 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) => ``) + .replace(new RegExp(`${BME}([^${BME}]*)${BME}`, 'g'), (_m, name: string) => ``) + .replace(new RegExp(`${XRF}(\\d+)${XRF}([^${XRF}]*)${XRF}`, 'g'), (_m, idx: string, shown: string) => { + const ref = refs[Number(idx)]; + if (!ref) return shown; + return `${shown}`; + }); + + 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); diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index d698287..9796c54 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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: { diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 3adf08a..1a762f9 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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: { diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index ad3c2c0..6caca5b 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -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`): `` 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 `` whose `` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `` 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`. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 3c3fd82..c2d4410 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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; }; // ---- 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. diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 059716c..e8eb6c8 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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; }; // 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 + // () 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': diff --git a/tests/lo-roundtrip.test.ts b/tests/lo-roundtrip.test.ts index 5769082..795cd7f 100644 --- a/tests/lo-roundtrip.test.ts +++ b/tests/lo-roundtrip.test.ts @@ -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(/]*>/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); + }); }); diff --git a/tests/unit/bookmarks-roundtrip.test.ts b/tests/unit/bookmarks-roundtrip.test.ts new file mode 100644 index 0000000..3edb2e9 --- /dev/null +++ b/tests/unit/bookmarks-roundtrip.test.ts @@ -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(''); + expect(content).toContain(''); + expect(content).toContain('Figure 1'); + expect(content).toContain('1'); + 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(' 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); + }); +});