From 20e6dcad0ece7cf3ae7bad16a65e8d39200e0c0e Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 15 Aug 2026 10:40:52 +0200 Subject: [PATCH] feat: a reading can be printed over its base text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruby, as both products' phonetic guide writes it: one element holding a base and its reading, so the editor keeps it as one inline atom and the browser sets it as a real . ODF gets plus a minted ruby-family style (centred, above — probed: LibreOffice writes that style back unchanged); Word a run from a post-pack pass, the package having none. Both legs verified through a LibreOffice re-save, our .docx to .odt included. --- CHANGELOG.md | 3 +- src/lib/components/RubyDialog.svelte | 134 ++++++++++++++++++ src/lib/components/ribbon/icons.ts | 2 + .../components/ribbon/tabs/InsertTab.svelte | 12 ++ src/lib/editor/extensions.ts | 3 + src/lib/editor/extensions/CLAUDE.md | 1 + src/lib/editor/extensions/ruby.ts | 70 +++++++++ src/lib/export/CLAUDE.md | 4 +- src/lib/export/docx.ts | 43 +++++- src/lib/export/odt.ts | 52 ++++++- src/lib/i18n/locales/de.ts | 5 + src/lib/i18n/locales/en.ts | 5 + src/lib/import/CLAUDE.md | 3 +- src/lib/import/docx.ts | 8 ++ src/lib/import/odt.ts | 7 + src/styles/editor.css | 10 ++ tests/unit/ruby.test.ts | 49 +++++++ 17 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 src/lib/components/RubyDialog.svelte create mode 100644 src/lib/editor/extensions/ruby.ts create mode 100644 tests/unit/ruby.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a08bb65..e24996b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ - AutoCorrect while typing (LibreOffice's Tools ▸ AutoCorrect Options, one checkbox per rule): typographic quotes per document language (`"…"` / `„…“`), its dash matrix (`A - B` → `A – B`, `A--B` → `A—B`), its replacement table (`-->` → `→`, `(C)` → `©`, `...` → `…`), capitalize the first letter of a sentence — with an abbreviation exception list — and TWo INitial CApitals. URL recognition and auto-list ride the same switches - Word completion (the same dialog's own section, as LibreOffice keeps it): every word of at least eight letters is remembered as it is typed, and typing its first three letters offers the rest in grey after the caret — Enter takes the offer, Esc drops it. It is offered while typing only, so an Enter meant to split a paragraph never completes a word instead, and the offer stays outside the document until it is accepted. Minimum length, an appended space and clearing the collected words are all settable - AutoText (LibreOffice's Tools ▸ AutoText, Word's Insert ▸ Quick Parts): keep a selected block of text under a name and a shortcut, then insert it from the library or by typing the shortcut and pressing F3 — LibreOffice's own key. An entry keeps its formatting, and the library belongs to the app rather than to a document, as it does in both products +- Ruby annotations (Insert ▸ Phonetic guide — LibreOffice's Format ▸ Asian Phonetic Guide, Word's Phonetic Guide): the reading printed over its base text, the selected text proposed as the base. Round-trips as ODF `text:ruby` with its ruby-family style and as Word's `w:ruby` run — verified through LibreOffice both ways, including our `.docx` converted to `.odt` by it - Automatic hyphenation for the whole document (Layout ▸ Hyphenation): the browser hyphenates in the document's own language, which shortens a justified paragraph the way LibreOffice does. Round-trips as ODF `fo:hyphenate` on the base style — where LibreOffice keeps it, in its *text* properties — and Word's `w:autoHyphenation` - Manual line breaks (Shift+Enter) @@ -163,7 +164,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14. - Find & Replace by font, size or colour — the weight/slant/underline and paragraph-style half is implemented, but LibreOffice's Format dialog is the whole character dialog - Multi-document management: one document is open at a time, so there is no window list and no side-by-side compare - Thesaurus and grammar check -- Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby) +- Vertical writing modes (ODF `tb-rl`), and a ruby annotation's own alignment and position — both products' defaults are what we write - Password-protected ODT/DOCX; digital signatures - Split view: two views of one document, scrolled independently - A committed corpus of documents we author ourselves, so CI can test against real files diff --git a/src/lib/components/RubyDialog.svelte b/src/lib/components/RubyDialog.svelte new file mode 100644 index 0000000..3875718 --- /dev/null +++ b/src/lib/components/RubyDialog.svelte @@ -0,0 +1,134 @@ + + + (open = false)} + onclick={(e) => e.target === dialogEl && (open = false)} + aria-label={t().ruby.title} +> +
+

{t().ruby.title}

+ + + + + {#if base.trim()} +

{base}{reading}

+ {/if} + +
+ + + +
+
+
+ + diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index f720e7b..5cd88f8 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -137,6 +137,8 @@ export const STROKED = { footer: ['M3.5 1.75h9v12.5h-9z', 'M5.4 4h5.2M5.4 6.1h5.2M5.4 8.2h3.1'], pageNumber: ['M3.25 1.75h9.5v12.5h-9.5z', 'M6.9 6.4 8 5.3v5.2', 'M6.75 10.5h2.5'], pageBreak: ['M2 2.5h12M2 13.5h12', 'M8 5.5v5.2', 'M6.2 8.9 8 10.7l1.8-1.8'], + // A big glyph with its small reading over it: what the annotation prints. + ruby: ['M4.5 13.2V6.6M4.5 6.6h4.2M8.7 6.6v6.6', 'M11 4.6h3.2M12.6 4.6v-2.4'], // A page with one saved block picked out of its text: the stored entry. quickParts: ['M3 1.75h10v12.5H3z', 'M5.2 4.4h5.6v3H5.2z', 'M5.2 9.9h5.6M5.2 12h3.4'], } as const; diff --git a/src/lib/components/ribbon/tabs/InsertTab.svelte b/src/lib/components/ribbon/tabs/InsertTab.svelte index 850eea4..e09d2c1 100644 --- a/src/lib/components/ribbon/tabs/InsertTab.svelte +++ b/src/lib/components/ribbon/tabs/InsertTab.svelte @@ -9,6 +9,7 @@ import BookmarkDialog from '../../BookmarkDialog.svelte'; import CrossRefDialog from '../../CrossRefDialog.svelte'; import FormulaDialog from '../../FormulaDialog.svelte'; + import RubyDialog from '../../RubyDialog.svelte'; import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte'; import { OPEN_LINK_DIALOG_EVENT } from '../../../editor/extensions/link'; import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames, findBookmark } from '../../../editor/extensions/bookmark'; @@ -37,6 +38,8 @@ onAutoText?: () => void; } = $props(); + let rubyOpen = $state(false); + let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty); let isLink = $derived(tick >= 0 && !!editor?.isActive('link')); let bmNames = $derived(tick >= 0 && editor && !hfActive ? bookmarkNames(editor.state.doc) : []); @@ -264,6 +267,14 @@ disabled={!editor || !onAutoText} onclick={() => onAutoText?.()} /> + (rubyOpen = true)} + />
+