diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -9,7 +9,7 @@
 
 **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (OpenType SVG colour fonts draw and export as vector art; previously: COLR colour fonts (v0 and v1) draw and export as vector art; previously: G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously: colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (OpenType SVG colour fonts draw and export as vector art; previously: COLR colour fonts (v0 and v1) draw and export as vector art; previously: G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously: colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed, and the Character panel’s Ruby section applies it from the UI; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
 
 VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
 on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -133,6 +133,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
 - 2026-10-23: COLR colour fonts (v0 and v1, as in Noto Color Emoji's vector build, Segoe UI Emoji, Nabla) draw as vector art: their layers' colours, gradients, clips and blend modes on the canvas and in SVG, EPS, legacy `.ai` and PDF, and as editable paths with Create Outlines; glyphs using sweep gradients or compositing modes are drawn as images of the glyph.
 - 2026-10-22: The Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step. Filter thumbnails and a preview area inside the dialog remain ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Raster effects 82% → 85% (estimated); headline numbers unchanged.
 - 2026-10-22: Colour emoji fonts with bitmap glyphs draw and export: `sbix` (Apple Color Emoji, macOS's emoji font) and `CBDT` (bitmap Noto Color Emoji) glyphs show as their pictures on the canvas, whatever the type's fill, scale and turn with the type, and export to PDF (in the font's Type 3 glyphs, or as images), SVG (images when type is outlined; live text leaves emoji to the viewer), EPS and legacy `.ai` (images); Create Outlines turns them into embedded images. Fonts with bitmaps but no outlines, such as Noto Color Emoji, are now found among the installed fonts. COLR and OpenType SVG fonts remain; headline numbers unchanged.
+- 2026-10-22: Character panel gets a Ruby section: the reading over the selected characters (or the selected type) is typed in and applied with its position, alignment, size and overhang, or removed, all through `type.ruby` in one undo step each; it follows the Type tool's selection and the ruby there. The engine, layout, save and SVG/PDF support landed in #3448 (merged). Headline numbers unchanged.
 
 - 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h).
 - 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -5446,6 +5446,9 @@ axis	Y Axis	Y ос
 	Packaged {n} file(s) in {folder}	Пакетирани са {n} файла в {folder}
 	Packaged {n} file(s) {note}	Пакетирани са {n} файла {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} файл(а) в {folder}. Показване на папката на пакета?
+	Reading	Четене
+	Overhang	Надвес
+	applied	Приложено
 	Clockwise	По часовниковата стрелка
 	Dither	Дитъринг
 	Fine noise hides banding in the gradient fill	Фин шум скрива ивиците в градиентната запълка
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -5507,6 +5507,9 @@ axis	Y Axis	osa Y
 	Packaged {n} file(s) in {folder}	Zabaleno souborů: {n} do {folder}
 	Packaged {n} file(s) {note}	Zabaleno souborů: {n} {note}
 	{count} file(s) in {folder}. Show the package folder?	Souborů: {count} v {folder}. Zobrazit složku balíčku?
+	Reading	Čtení
+	Overhang	Přesah
+	applied	Použito
 	Clockwise	Ve směru hodinových ručiček
 	Dither	Rozptýlení
 	Fine noise hides banding in the gradient fill	Jemný šum skryje pruhy ve výplni přechodem
diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv
--- a/crates/ui-egui/src/i18n/da.tsv
+++ b/crates/ui-egui/src/i18n/da.tsv
@@ -5461,6 +5461,9 @@ axis	Y Axis	Y-akse
 	Packaged {n} file(s) in {folder}	Pakkede {n} fil(er) i {folder}
 	Packaged {n} file(s) {note}	Pakkede {n} fil(er) {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} fil(er) i {folder}. Vis pakkemappen?
+	Reading	Læsning
+	Overhang	Overhæng
+	applied	Anvendt
 	Clockwise	Med uret
 	Dither	Dither
 	Fine noise hides banding in the gradient fill	Fin støj skjuler striber i gradientfyldet
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -5088,6 +5088,9 @@ axis	Y Axis	Y-Achse
 	Packaged {n} file(s) in {folder}	{n} Datei(en) in {folder} verpackt
 	Packaged {n} file(s) {note}	{n} Datei(en) verpackt {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} Datei(en) in {folder}. Paketordner anzeigen?
+	Reading	Lesung
+	Overhang	Überhang
+	applied	Angewendet
 	Clockwise	Im Uhrzeigersinn
 	Dither	Dithering
 	Fine noise hides banding in the gradient fill	Feines Rauschen verbirgt Streifen in der Verlaufsfläche
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -5459,6 +5459,9 @@ axis	Y Axis	Άξονας Y
 	Packaged {n} file(s) in {folder}	Συσκευάστηκαν {n} αρχεία στον φάκελο {folder}
 	Packaged {n} file(s) {note}	Συσκευάστηκαν {n} αρχεία {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} αρχεία στον φάκελο {folder}. Να εμφανιστεί ο φάκελος συσκευασίας;
+	Reading	Ανάγνωση
+	Overhang	Προεξοχή
+	applied	Εφαρμόστηκε
 	Clockwise	Δεξιόστροφα
 	Dither	Πρόσθεση θορύβου
 	Fine noise hides banding in the gradient fill	Ο λεπτός θόρυβος κρύβει τις λωρίδες στο γέμισμα ντεγκραντέ
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -5449,6 +5449,9 @@ axis	Y Axis	Eje Y
 	Packaged {n} file(s) in {folder}	Se empaquetaron {n} archivo(s) en {folder}
 	Packaged {n} file(s) {note}	Se empaquetaron {n} archivo(s) {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} archivo(s) en {folder}. ¿Mostrar la carpeta del paquete?
+	Reading	Lectura
+	Overhang	Desbordamiento
+	applied	Aplicado
 	Clockwise	En sentido horario
 	Dither	Tramado
 	Fine noise hides banding in the gradient fill	Un ruido fino oculta las bandas del relleno de degradado
diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -5538,6 +5538,9 @@ axis	Y Axis	Y-akseli
 	Packaged {n} file(s) in {folder}	Paketoitiin {n} tiedosto(a) kansioon {folder}
 	Packaged {n} file(s) {note}	Paketoitiin {n} tiedosto(a) {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} tiedosto(a) kansiossa {folder}. Näytetäänkö pakettikansio?
+	Reading	Lukutapa
+	Overhang	Ylitys
+	applied	Käytetty
 	Clockwise	Myötäpäivään
 	Dither	Rasterointi
 	Fine noise hides banding in the gradient fill	Hieno kohina piilottaa raidat liukuväritäytöstä
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -5506,6 +5506,9 @@ axis	Y Axis	Axe Y
 	Packaged {n} file(s) in {folder}	{n} fichier(s) assemblé(s) dans {folder}
 	Packaged {n} file(s) {note}	{n} fichier(s) assemblé(s) {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} fichier(s) dans {folder}. Afficher le dossier de l’assemblage ?
+	Reading	Lecture
+	Overhang	Dépassement
+	applied	Appliqué
 	Clockwise	Sens horaire
 	Dither	Simulation
 	Fine noise hides banding in the gradient fill	Un bruit fin masque les bandes du fond en dégradé
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -5493,6 +5493,9 @@ axis	Y Axis	Y tengely
 	Packaged {n} file(s) in {folder}	Becsomagolt fájlok: {n}, mappa: {folder}
 	Packaged {n} file(s) {note}	Becsomagolt fájlok: {n} {note}
 	{count} file(s) in {folder}. Show the package folder?	Fájlok: {count}, mappa: {folder}. Megnyitod a csomag mappáját?
+	Reading	Olvasat
+	Overhang	Túlnyúlás
+	applied	Alkalmazva
 	Clockwise	Óramutató járásával megegyező
 	Dither	Árnyalatkeverés
 	Fine noise hides banding in the gradient fill	A finom zaj elrejti a sávokat a színátmenetes kitöltésben
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -5461,6 +5461,9 @@ axis	Y Axis	Sumbu Y
 	Packaged {n} file(s) in {folder}	Berhasil mengemas {n} file di {folder}
 	Packaged {n} file(s) {note}	Berhasil mengemas {n} file {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} file dalam {folder}. Tampilkan folder paket?
+	Reading	Bacaan
+	Overhang	Tonjolan
+	applied	Diterapkan
 	Clockwise	Searah jarum jam
 	Dither	Dither
 	Fine noise hides banding in the gradient fill	Derau halus menyembunyikan pita warna pada isian gradien
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -5122,6 +5122,9 @@ axis	Y Axis	Asse Y
 	Packaged {n} file(s) in {folder}	Pacchetto creato con {n} file in {folder}
 	Packaged {n} file(s) {note}	Pacchetto creato con {n} file {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} file in {folder}. Vuoi mostrare la cartella del pacchetto?
+	Reading	Lettura
+	Overhang	Sporgenza
+	applied	Applicato
 	Clockwise	In senso orario
 	Dither	Dithering
 	Fine noise hides banding in the gradient fill	Un rumore fine nasconde le bande nel riempimento sfumato
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -5184,6 +5184,9 @@ axis	Y Axis	Y 軸
 	Packaged {n} file(s) in {folder}	{n} 個のファイルを {folder} にパッケージしました
 	Packaged {n} file(s) {note}	{n} 個のファイルをパッケージしました {note}
 	{count} file(s) in {folder}. Show the package folder?	{folder} に {count} 個のファイル。パッケージフォルダーを表示しますか？
+	Reading	読み
+	Overhang	はみ出し
+	applied	適用済み
 	Clockwise	時計回り
 	Dither	ディザ
 	Fine noise hides banding in the gradient fill	細かいノイズでグラデーションの塗りの縞を目立たなくします
diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv
--- a/crates/ui-egui/src/i18n/ka.tsv
+++ b/crates/ui-egui/src/i18n/ka.tsv
@@ -5423,6 +5423,9 @@ axis	Y Axis	Y ღერძი
 @msg	this kind of brush has no options dialog yet	ამ ტიპის ფუნჯს პარამეტრების დიალოგი ჯერ არ აქვს
 	Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required.	გადმოათრიეთ აქ გრაფიკა, ფერები და ტექსტის სტილები, რომ სხვა დოკუმენტებშიც გამოიყენოთ. ბიბლიოთეკები ამ კომპიუტერზე ინახება — ანგარიში არ არის საჭირო.
 	Choose a filter	ფილტრის არჩევა
+	Reading	კითხვა
+	Overhang	გადაკიდება
+	applied	გამოყენებულია
 	Clockwise	საათის ისრის მიმართულებით
 	Dither	დიზერინგი
 	Fine noise hides banding in the gradient fill	წვრილი ხმაური მალავს ზოლებს გრადიენტულ შევსებაში
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -5085,6 +5085,9 @@ axis	Y Axis	Y축
 	Packaged {n} file(s) in {folder}	{folder}에 파일 {n}개를 패키지했습니다
 	Packaged {n} file(s) {note}	파일 {n}개를 패키지했습니다 {note}
 	{count} file(s) in {folder}. Show the package folder?	{folder}에 파일 {count}개. 패키지 폴더를 표시하시겠습니까?
+	Reading	읽기
+	Overhang	오버행
+	applied	적용됨
 	Clockwise	시계 방향
 	Dither	디더
 	Fine noise hides banding in the gradient fill	미세한 노이즈로 그레이디언트 칠의 띠를 감춥니다
diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv
--- a/crates/ui-egui/src/i18n/nl.tsv
+++ b/crates/ui-egui/src/i18n/nl.tsv
@@ -5505,6 +5505,9 @@ axis	Y Axis	Y-as
 	Packaged {n} file(s) in {folder}	{n} bestand(en) verpakt in {folder}
 	Packaged {n} file(s) {note}	{n} bestand(en) verpakt {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} bestand(en) in {folder}. De pakketmap openen?
+	Reading	Leeswijze
+	Overhang	Overhang
+	applied	Toegepast
 	Clockwise	Met de klok mee
 	Dither	Dither
 	Fine noise hides banding in the gradient fill	Fijne ruis verbergt banden in de verloopvulling
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -5359,6 +5359,9 @@ axis	Y Axis	Oś Y
 	Packaged {n} file(s) in {folder}	Spakowane pliki: {n} w {folder}
 	Packaged {n} file(s) {note}	Spakowane pliki: {n} {note}
 	{count} file(s) in {folder}. Show the package folder?	Pliki: {count} w {folder}. Pokazać folder pakietu?
+	Reading	Odczyt
+	Overhang	Wystawanie
+	applied	Zastosowano
 	Clockwise	Zgodnie z ruchem wskazówek zegara
 	Dither	Rozpraszanie
 	Fine noise hides banding in the gradient fill	Drobny szum ukrywa pasy w wypełnieniu gradientem
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -5424,6 +5424,9 @@ axis	Y Axis	Eixo Y
 	Packaged {n} file(s) in {folder}	{n} arquivo(s) empacotado(s) em {folder}
 	Packaged {n} file(s) {note}	{n} arquivo(s) empacotado(s) {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} arquivo(s) em {folder}. Mostrar a pasta do pacote?
+	Reading	Leitura
+	Overhang	Transbordamento
+	applied	Aplicado
 	Clockwise	Sentido horário
 	Dither	Dither
 	Fine noise hides banding in the gradient fill	Um ruído fino esconde as faixas do preenchimento em gradiente
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -5118,6 +5118,9 @@ axis	Y Axis	Ось Y
 	Packaged {n} file(s) in {folder}	Упаковано файлов: {n} в {folder}
 	Packaged {n} file(s) {note}	Упаковано файлов: {n} {note}
 	{count} file(s) in {folder}. Show the package folder?	Файлов: {count} в {folder}. Показать папку пакета?
+	Reading	Чтение
+	Overhang	Выступ
+	applied	Применено
 	Clockwise	По часовой стрелке
 	Dither	Дизеринг
 	Fine noise hides banding in the gradient fill	Мелкий шум скрывает полосы в градиентной заливке
diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv
--- a/crates/ui-egui/src/i18n/sv.tsv
+++ b/crates/ui-egui/src/i18n/sv.tsv
@@ -5531,6 +5531,9 @@ axis	Y Axis	Y-axel
 	Packaged {n} file(s) in {folder}	Paketerade {n} fil(er) i {folder}
 	Packaged {n} file(s) {note}	Paketerade {n} fil(er) {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} fil(er) i {folder}. Visa paketmappen?
+	Reading	Läsning
+	Overhang	Överhäng
+	applied	Använd
 	Clockwise	Medurs
 	Dither	Dithering
 	Fine noise hides banding in the gradient fill	Fint brus döljer bandningen i övertoningsfyllningen
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -5460,6 +5460,9 @@ axis	Y Axis	Y ekseni
 	Packaged {n} file(s) in {folder}	{folder} biçiminde paketlenmiş {n} dosyası/dosyaları
 	Packaged {n} file(s) {note}	Paketlenmiş {n} dosyası/dosyaları {note}
 	{count} file(s) in {folder}. Show the package folder?	{folder} içindeki {count} dosyası/dosyaları. Paket klasörü gösterilsin mi?
+	Reading	Okuma
+	Overhang	Taşma
+	applied	Uygulandı
 	Clockwise	Saat yönünde
 	Dither	Titreme
 	Fine noise hides banding in the gradient fill	İnce gürültü, degrade dolgudaki bantları gizler
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -5102,6 +5102,9 @@ axis	Y Axis	Вісь Y
 	Packaged {n} file(s) in {folder}	Упаковано файли: {n} {folder}
 	Packaged {n} file(s) {note}	Упаковано файли: {n} {note}
 	{count} file(s) in {folder}. Show the package folder?	файлів: {count} {folder}. Показати папку пакета?
+	Reading	Читання
+	Overhang	Виступ
+	applied	Застосовано
 	Clockwise	За годинниковою стрілкою
 	Dither	Дизеринг
 	Fine noise hides banding in the gradient fill	Дрібний шум приховує смуги в градієнтній заливці
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -5102,6 +5102,9 @@ axis	Y Axis	Trục Y
 	Packaged {n} file(s) in {folder}	Đã đóng gói {n} tệp trong {folder}
 	Packaged {n} file(s) {note}	Đã đóng gói {n} tệp {note}
 	{count} file(s) in {folder}. Show the package folder?	{count} tệp trong {folder}. Hiện thư mục gói?
+	Reading	Cách đọc
+	Overhang	Độ nhô
+	applied	Đã áp dụng
 	Clockwise	Theo chiều kim đồng hồ
 	Dither	Dither
 	Fine noise hides banding in the gradient fill	Nhiễu mịn che dải màu trong vùng tô chuyển sắc
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -5346,6 +5346,9 @@ axis	Y Axis	Y 轴
 	Packaged {n} file(s) in {folder}	已将 {n} 个文件打包到 {folder}
 	Packaged {n} file(s) {note}	已打包 {n} 个文件 {note}
 	{count} file(s) in {folder}. Show the package folder?	{folder} 中有 {count} 个文件。要显示打包文件夹吗？
+	Reading	读音
+	Overhang	悬挂
+	applied	已应用
 	Clockwise	顺时针
 	Dither	仿色
 	Fine noise hides banding in the gradient fill	细微杂色可隐藏渐变填色中的色带
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -5141,6 +5141,9 @@ axis	Y Axis	Y 軸
 	Packaged {n} file(s) in {folder}	已將 {n} 個檔案封裝到 {folder}
 	Packaged {n} file(s) {note}	已封裝 {n} 個檔案 {note}
 	{count} file(s) in {folder}. Show the package folder?	{folder} 中有 {count} 個檔案。要顯示封裝資料夾嗎？
+	Reading	讀音
+	Overhang	懸掛
+	applied	已套用
 	Clockwise	順時針
 	Dither	仿色
 	Fine noise hides banding in the gradient fill	細微雜色可隱藏漸層填色中的色帶
diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs
--- a/crates/ui-egui/src/panels/character.rs
+++ b/crates/ui-egui/src/panels/character.rs
@@ -6,7 +6,7 @@ use std::sync::{Mutex, OnceLock};
 
 use egui::{Key, Ui, vec2};
 use serde_json::{Value, json};
-use vectorcraft_doc::{CharStyle, NodeId, NodeKind};
+use vectorcraft_doc::{CharStyle, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject};
 use vectorcraft_tools::{Mods, ToolKey};
 
 use super::{pstate, set_pstate};
@@ -722,6 +722,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
     ui.add_space(2.0);
     let hidden = pstate::<bool>(ui.ctx(), "ch-hide-options");
     metrics_grid(app, ui, "ch-grid", &s, w, !hidden);
+    // Ruby (ふりがな): the reading over the selected characters, its placement and options.
+    ruby_section(app, ui);
     if hidden {
         return;
     }
@@ -979,5 +981,204 @@ fn reset_panel(app: &mut VectorcraftApp) {
     );
 }
 
+// ---------- Ruby (ふりがな, phonetic guide) ----------
+
+/// The Ruby section's fields, kept between frames; `synced` is the selection and ruby they were
+/// read from, so they reload when either changes.
+#[derive(Clone, Debug, PartialEq)]
+struct RubyFields {
+    reading: String,
+    /// "above" or "below" (right/left of the column in vertical type).
+    position: String,
+    /// "start", "center" or "end".
+    align: String,
+    /// Percentage of the base text's size, 1–100.
+    size: f64,
+    overhang: bool,
+    synced: String,
+}
+
+impl Default for RubyFields {
+    fn default() -> Self {
+        Self { reading: String::new(), position: "above".into(), align: "center".into(), size: 50.0, overhang: true, synced: String::new() }
+    }
+}
+
+impl RubyFields {
+    /// The fields of `span`, or the defaults (no reading, above, centred, half size, overhanging).
+    fn from_span(span: Option<&RubySpan>) -> Self {
+        let mut f = Self::default();
+        if let Some(r) = span {
+            f.reading.clone_from(&r.text);
+            f.position = match r.position {
+                RubyPosition::Above => "above",
+                RubyPosition::Below => "below",
+            }
+            .into();
+            f.align = match r.align {
+                RubyAlign::Center => "center",
+                RubyAlign::Start => "start",
+                RubyAlign::End => "end",
+            }
+            .into();
+            f.size = r.safe_size_percent();
+            f.overhang = r.overhang;
+        }
+        f
+    }
+}
+
+/// The text the Ruby section acts on: the Type tool's selected characters when it edits a range,
+/// else the first selected text object's whole text.
+fn ruby_target(app: &VectorcraftApp) -> Option<(NodeId, usize, usize)> {
+    if let Some((id, a, b)) = text_editing(app) {
+        return (b > a).then_some((id, a, b));
+    }
+    let st = app.session.active()?;
+    for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
+        let mut target: Option<(NodeId, usize)> = None;
+        node.walk(&mut |n| {
+            if target.is_none()
+                && let NodeKind::Text(t) = &n.kind
+            {
+                target = Some((n.id, t.plain_text().len()));
+            }
+        });
+        if let Some((id, len)) = target {
+            return (len > 0).then_some((id, 0, len));
+        }
+    }
+    None
+}
+
+/// The ruby over `a..b` of `t`: the first span touching the range.
+fn ruby_in(t: &TextObject, a: usize, b: usize) -> Option<&RubySpan> {
+    t.ruby_in(a, b).next()
+}
+
+/// Whether `a..b` is exactly one ruby span: an option edit can re-apply it in place, without
+/// dropping another reading under the selection or changing the span's range. Apply replaces the
+/// ruby over the selection when it is not.
+fn ruby_covers(t: &TextObject, a: usize, b: usize) -> bool {
+    let mut spans = t.ruby_in(a, b);
+    spans.next().is_some_and(|r| r.start == a && r.end == b) && spans.next().is_none()
+}
+
+/// The selection and ruby the section's fields were built from.
+fn ruby_sync_key(id: NodeId, a: usize, b: usize, span: Option<&RubySpan>) -> String {
+    let ruby = span.map_or_else(String::new, |r| format!("{}|{:?}|{:?}|{}|{}", r.text, r.position, r.align, r.size_percent, r.overhang));
+    format!("{}:{a}:{b}:{ruby}", id.0)
+}
+
+/// The `type.ruby` params of the section: set the reading over `a..b`, or `remove` it.
+fn ruby_params(id: NodeId, a: usize, b: usize, f: &RubyFields, remove: bool) -> Value {
+    let mut p = json!({"id": id.0, "start": a, "end": b});
+    if remove {
+        p["remove"] = json!(true);
+        return p;
+    }
+    p["text"] = json!(f.reading.trim());
+    p["position"] = json!(f.position);
+    p["align"] = json!(f.align);
+    p["size"] = json!(f.size);
+    p["overhang"] = json!(f.overhang);
+    p
+}
+
+/// Draw the Ruby section: the reading over the selected characters, its position, alignment,
+/// size and overhang, with Apply and Remove. Every action goes through `type.ruby` in one undo
+/// step; the fields follow the selection and the ruby it covers. An option edit re-applies the
+/// ruby only when it is the one span over exactly the selection; Apply replaces the ruby over
+/// the selection.
+fn ruby_section(app: &mut VectorcraftApp, ui: &mut Ui) {
+    let Some((id, a, b)) = ruby_target(app) else { return };
+    let (span, auto_apply) = app.session.active().and_then(|st| st.doc.node(id)).map_or((None, false), |n| match &n.kind {
+        NodeKind::Text(t) => (ruby_in(t, a, b).cloned(), ruby_covers(t, a, b)),
+        _ => (None, false),
+    });
+    let key = ruby_sync_key(id, a, b, span.as_ref());
+    let mut f = pstate::<RubyFields>(ui.ctx(), "ruby-fields");
+    if f.synced != key {
+        f = RubyFields::from_span(span.as_ref());
+        f.synced.clone_from(&key);
+    }
+    ui.add_space(4.0);
+    ui.separator();
+    ui.horizontal(|ui| {
+        ui.label(egui::RichText::new(tl!("Ruby")).strong());
+        if span.is_some() {
+            crate::widgets::dim_label(ui, tl!("applied"));
+        }
+    });
+    let mut apply = false;
+    ui.horizontal(|ui| {
+        crate::widgets::dim_label(ui, &format!("{}:", tl!("Reading")));
+        ui.add(egui::TextEdit::singleline(&mut f.reading).hint_text(tl!("Reading")).desired_width(f32::INFINITY));
+    });
+    ui.horizontal(|ui| {
+        crate::widgets::dim_label(ui, &format!("{}:", tl!("Position")));
+        let position = tl!(match f.position.as_str() {
+            "below" => "Below",
+            _ => "Above",
+        });
+        egui::ComboBox::from_id_salt("ruby-position").selected_text(position).width(64.0).show_ui(ui, |ui| {
+            for (value, label) in [("above", "Above"), ("below", "Below")] {
+                if ui.selectable_label(f.position == value, tl!(label)).clicked() && f.position != value {
+                    f.position = value.into();
+                    apply = auto_apply;
+                }
+            }
+        });
+        crate::widgets::dim_label(ui, &format!("{}:", tl!("Align")));
+        let align = tl!(match f.align.as_str() {
+            "start" => "Start",
+            "end" => "End",
+            _ => "Center",
+        });
+        egui::ComboBox::from_id_salt("ruby-align").selected_text(align).width(64.0).show_ui(ui, |ui| {
+            for (value, label) in [("start", "Start"), ("center", "Center"), ("end", "End")] {
+                if ui.selectable_label(f.align == value, tl!(label)).clicked() && f.align != value {
+                    f.align = value.into();
+                    apply = auto_apply;
+                }
+            }
+        });
+    });
+    ui.horizontal(|ui| {
+        crate::widgets::dim_label(ui, &format!("{}:", tl!("Size")));
+        let size = ui.add(egui::DragValue::new(&mut f.size).range(1.0..=100.0).speed(1.0).suffix("%"));
+        // Dragging sets the value as it goes and ends here; typed, it commits on Enter or focus
+        // loss. Apply then, unless it is already the ruby's size.
+        if (size.drag_stopped() || size.lost_focus()) && auto_apply && span.as_ref().is_some_and(|r| r.safe_size_percent() != f.size) {
+            apply = true;
+        }
+        if ui.checkbox(&mut f.overhang, tl!("Overhang")).changed() && auto_apply {
+            apply = true;
+        }
+    });
+    let remove = ui
+        .horizontal(|ui| {
+            let mut remove = false;
+            if ui.add_enabled(!f.reading.trim().is_empty(), egui::Button::new(tl!("Apply"))).clicked() {
+                apply = true;
+            }
+            if ui.add_enabled(span.is_some(), egui::Button::new(tl!("Remove"))).clicked() {
+                remove = true;
+            }
+            remove
+        })
+        .inner;
+    if remove || apply {
+        // A typing session is its own undo step; the ruby command is the next one.
+        end_typing(app);
+        if remove {
+            app.run("type.ruby", ruby_params(id, a, b, &f, true)).ok();
+        } else {
+            app.run("type.ruby", ruby_params(id, a, b, &f, false)).ok();
+        }
+    }
+    set_pstate(ui.ctx(), "ruby-fields", f);
+}
+
 #[cfg(test)]
 mod tests;
diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs
--- a/crates/ui-egui/src/panels/character/tests.rs
+++ b/crates/ui-egui/src/panels/character/tests.rs
@@ -654,3 +654,166 @@ fn reset_panel_resets_every_attribute_but_the_font() {
     reset_panel(&mut app);
     assert_eq!(runs(&app), [("One ".to_string(), styled), ("12".to_string(), plain)]);
 }
+
+#[test]
+fn ruby_params_set_and_remove_the_reading() {
+    let id = NodeId(7);
+    let f = RubyFields {
+        reading: " かんじ ".into(),
+        position: "below".into(),
+        align: "end".into(),
+        size: 40.0,
+        overhang: false,
+        ..RubyFields::default()
+    };
+    let p = ruby_params(id, 3, 9, &f, false);
+    assert_eq!(p["id"], 7);
+    assert_eq!((p["start"].as_u64(), p["end"].as_u64()), (Some(3), Some(9)));
+    assert_eq!(p["text"], "かんじ", "the reading is trimmed");
+    assert_eq!(p["position"], "below");
+    assert_eq!(p["align"], "end");
+    assert_eq!(p["size"], 40.0);
+    assert_eq!(p["overhang"], false);
+    let r = ruby_params(id, 3, 9, &f, true);
+    assert_eq!(r["remove"], true);
+    assert!(r.get("text").is_none());
+}
+
+#[test]
+fn ruby_fields_load_the_ruby_under_the_selection() {
+    let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "漢字です", CharStyle::default());
+    let span =
+        RubySpan { position: RubyPosition::Below, align: RubyAlign::End, size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") };
+    t.ruby = vec![span];
+    let found = ruby_in(&t, 0, 6).expect("the ruby under the selection");
+    let f = RubyFields::from_span(Some(found));
+    assert_eq!(f.reading, "かんじ");
+    assert_eq!((f.position.as_str(), f.align.as_str()), ("below", "end"));
+    assert!((f.size - 40.0).abs() < 1e-9 && !f.overhang);
+    assert!(ruby_covers(&t, 0, 6), "the only span over exactly the selection");
+    // A selection outside it has none; no span is the defaults.
+    assert!(ruby_in(&t, 9, 12).is_none() && !ruby_covers(&t, 9, 12));
+    assert_eq!(RubyFields::from_span(None), RubyFields::default());
+    // The fields reload when the selection or its ruby changes.
+    assert_ne!(ruby_sync_key(NodeId(1), 0, 6, Some(found)), ruby_sync_key(NodeId(1), 0, 3, Some(found)));
+    assert_ne!(ruby_sync_key(NodeId(1), 0, 6, Some(found)), ruby_sync_key(NodeId(1), 0, 6, None));
+    // A partial selection and a second reading under it are not re-applied in place.
+    assert!(!ruby_covers(&t, 0, 3), "the selection is only part of the span");
+    t.ruby.push(RubySpan::new(3, 6, "じ"));
+    assert!(!ruby_covers(&t, 0, 6), "two readings under the selection");
+}
+
+/// The reading typed into the panel is applied to the selected text through `type.ruby`, shown
+/// as applied, and removable again; Undo brings it back (one step per action).
+#[test]
+fn ruby_panel_applies_and_removes_the_reading() {
+    use super::super::tests_appearance::{click, frame_events, text_rect};
+    let mut app = VectorcraftApp::new(Session::new(), Default::default());
+    app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
+    let id = NodeId(app.run("text.create", json!({"x": 20, "y": 50, "text": "漢字です", "size": 12})).unwrap()["id"].as_u64().unwrap());
+    app.run("select.set", json!({"ids": [id.0]})).unwrap();
+    let ctx = egui::Context::default();
+    let texts = frame_events(&ctx, &mut app, vec![], show);
+    let labels: Vec<&str> = texts.iter().map(|(s, _)| s.as_str()).collect();
+    for label in ["Ruby", "Reading:", "Position:", "Align:", "Size:", "Overhang", "Apply", "Remove"] {
+        assert!(labels.contains(&label), "`{label}` not drawn: {labels:?}");
+    }
+    assert!(labels.contains(&"Above") && labels.contains(&"Center"), "the defaults: {labels:?}");
+    // Type the reading into its field, then Apply.
+    let row = text_rect(&texts, "Reading:");
+    click(&ctx, &mut app, row.right_center() + egui::vec2(30.0, 0.0), show);
+    frame_events(&ctx, &mut app, vec![egui::Event::Text("かんじ".into())], show);
+    let texts = frame_events(&ctx, &mut app, vec![], show);
+    assert!(texts.iter().any(|(s, _)| s == "かんじ"), "the field shows the typed reading: {texts:?}");
+    click(&ctx, &mut app, text_rect(&texts, "Apply").center(), show);
+    let ruby = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(id).unwrap().kind {
+        NodeKind::Text(t) => t.ruby.clone(),
+        _ => panic!("text"),
+    };
+    let r = ruby(&app);
+    assert_eq!((r.len(), r[0].start, r[0].end, r[0].text.as_str()), (1, 0, 12, "かんじ"), "the whole selected text (12 bytes)");
+    assert_eq!(r[0].position, RubyPosition::Above);
+    assert!(r[0].overhang);
+    // The panel shows it as applied, and Remove takes it off; Undo puts it back.
+    let texts = frame_events(&ctx, &mut app, vec![], show);
+    assert!(texts.iter().any(|(s, _)| s == "かんじ"), "the reading stays in the field: {texts:?}");
+    click(&ctx, &mut app, text_rect(&texts, "Remove").center(), show);
+    assert!(ruby(&app).is_empty());
+    app.run("edit.undo", json!({})).unwrap();
+    assert_eq!(ruby(&app).len(), 1);
+}
+
+/// With the Type tool, the section acts on the selected characters only, not the whole text.
+#[test]
+fn ruby_panel_acts_on_the_type_tools_selection() {
+    use super::super::tests_appearance::{click, frame_events, text_rect};
+    let mut app = VectorcraftApp::new(Session::new(), Default::default());
+    app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
+    app.session.select_tool("type", Default::default()).unwrap();
+    let id = NodeId(app.run("text.create", json!({"x": 20, "y": 50, "text": "漢字です", "size": 12})).unwrap()["id"].as_u64().unwrap());
+    app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
+    app.session.set_tool_option("select", &json!({"start": 0, "end": 3}));
+    let ctx = egui::Context::default();
+    let texts = frame_events(&ctx, &mut app, vec![], show);
+    let row = text_rect(&texts, "Reading:");
+    click(&ctx, &mut app, row.right_center() + egui::vec2(30.0, 0.0), show);
+    frame_events(&ctx, &mut app, vec![egui::Event::Text("かん".into())], show);
+    let texts = frame_events(&ctx, &mut app, vec![], show);
+    click(&ctx, &mut app, text_rect(&texts, "Apply").center(), show);
+    let NodeKind::Text(t) = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("text") };
+    assert_eq!(t.ruby.len(), 1);
+    assert_eq!((t.ruby[0].start, t.ruby[0].end, t.ruby[0].text.as_str()), (0, 3, "かん"), "only the selected character");
+}
+
+/// A typed size commits on Enter and re-applies the ruby, like dragging the value does.
+#[test]
+fn ruby_panel_typing_a_size_reapplies_the_ruby() {
+    use super::super::tests_appearance::{click, frame_events, text_rect};
+    let mut app = VectorcraftApp::new(Session::new(), Default::default());
+    app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
+    let id = NodeId(app.run("text.create", json!({"x": 20, "y": 50, "text": "漢字", "size": 12})).unwrap()["id"].as_u64().unwrap());
+    app.run("select.set", json!({"ids": [id.0]})).unwrap();
+    app.run("type.ruby", json!({"text": "かんじ"})).unwrap();
+    let ctx = egui::Context::default();
+    let texts = frame_events(&ctx, &mut app, vec![], show);
+    click(&ctx, &mut app, text_rect(&texts, "Size:").right_center() + egui::vec2(30.0, 0.0), show);
+    let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
+    frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("40".into())], show);
+    frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, Default::default())], show);
+    let NodeKind::Text(t) = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("text") };
+    assert_eq!(t.ruby.len(), 1);
+    assert_eq!(t.ruby[0].text, "かんじ");
+    assert!((t.ruby[0].size_percent - 40.0).abs() < 1e-9, "the typed size is applied: {:?}", t.ruby[0]);
+}
+
+/// Option edits re-apply only the one ruby over exactly the selection: over two readings,
+/// changing Position leaves them alone until Apply replaces them over the selection.
+#[test]
+fn ruby_panel_option_edits_do_not_merge_overlapping_readings() {
+    use super::super::tests_appearance::{click, frame_events, text_rect};
+    let mut app = VectorcraftApp::new(Session::new(), Default::default());
+    app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
+    let id = NodeId(app.run("text.create", json!({"x": 20, "y": 50, "text": "漢字", "size": 12})).unwrap()["id"].as_u64().unwrap());
+    app.run("select.set", json!({"ids": [id.0]})).unwrap();
+    app.run("type.ruby", json!({"start": 0, "end": 3, "text": "かん"})).unwrap();
+    app.run("type.ruby", json!({"start": 3, "end": 6, "text": "じ"})).unwrap();
+    let readings = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(id).unwrap().kind {
+        NodeKind::Text(t) => t.ruby.iter().map(|r| (r.start, r.end, r.text.clone(), r.position)).collect::<Vec<_>>(),
+        _ => panic!("text"),
+    };
+    let ctx = egui::Context::default();
+    let texts = frame_events(&ctx, &mut app, vec![], show);
+    // Open Position and pick Below: the field shows it, but both readings stay.
+    click(&ctx, &mut app, text_rect(&texts, "Above").center(), show);
+    let texts = frame_events(&ctx, &mut app, vec![], show);
+    click(&ctx, &mut app, text_rect(&texts, "Below").center(), show);
+    assert_eq!(
+        readings(&app),
+        [(0, 3, "かん".to_string(), RubyPosition::Above), (3, 6, "じ".to_string(), RubyPosition::Above)],
+        "no option edit collapses the readings"
+    );
+    // Apply still replaces the ruby over the whole selection.
+    let texts = frame_events(&ctx, &mut app, vec![], show);
+    click(&ctx, &mut app, text_rect(&texts, "Apply").center(), show);
+    assert_eq!(readings(&app), [(0, 6, "かん".to_string(), RubyPosition::Below)]);
+}
diff --git a/docs/type-parity.md b/docs/type-parity.md
--- a/docs/type-parity.md
+++ b/docs/type-parity.md
@@ -1,6 +1,6 @@
 # Type parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (threading ports: a double-click on a threaded port breaks the thread, `text.thread.break`; previously: Ruby: Create Outlines, EPS, EMF/WMF and legacy `.ai` keep the reading where the canvas draws it; previously: OpenType SVG: images in glyph documents draw, export and outline with the glyph; previously: bitmap glyphs (emoji) go with type that effects reshape, into Expand Appearance, envelope Expand and EMF/WMF export, as images; previously: threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, #2572; scores unchanged; font menu: Font Technology › Variable lists only variable font families, G4; previously:; colour glyphs (COLR, SVG) go with type that effects reshape, into Expand Appearance and EMF/WMF export; previously:; OpenType SVG glyphs follow the `SVG ` table's coordinate rules: a root size without a `viewBox` sets up the em, a `viewBox` that isn't square is centred in it, and a glyph element nested in others is drawn on its own; previously: OpenType SVG colour fonts draw and export as vector art; hanging punctuation: Glyph Scaling leaves the hung marks at their width, Standard burasagari counts a hung line start as room, G4; a paragraph's Auto Leading also drives the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; scores unchanged; COLR colour fonts (v0 and v1) draw and export as vector art; previously: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; scores unchanged; previously: #3515: recent-font groups in font pickers; manual Tate-chu-yoko marks characters across one em of a vertical column; System Layout: Character panel menu sets whole-point advances for every text object in the document (off by default); ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), start/centre/end aligned, sized and overhanging or squeezed; saved natively, laid out with its line growing, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores unchanged; previously:; Fractional Widths: Character panel menu turns whole-point advances on or off per type object, on by default; scores unchanged; type on a path threads, mixed with area type in one thread; scores unchanged; No Break keeps marked characters on one line; scores unchanged; a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (threading ports: a double-click on a threaded port breaks the thread, `text.thread.break`; previously: Ruby: Create Outlines, EPS, EMF/WMF and legacy `.ai` keep the reading where the canvas draws it; previously: OpenType SVG: images in glyph documents draw, export and outline with the glyph; previously: bitmap glyphs (emoji) go with type that effects reshape, into Expand Appearance, envelope Expand and EMF/WMF export, as images; previously: threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, #2572; scores unchanged; font menu: Font Technology › Variable lists only variable font families, G4; previously:; colour glyphs (COLR, SVG) go with type that effects reshape, into Expand Appearance and EMF/WMF export; previously:; OpenType SVG glyphs follow the `SVG ` table's coordinate rules: a root size without a `viewBox` sets up the em, a `viewBox` that isn't square is centred in it, and a glyph element nested in others is drawn on its own; previously: OpenType SVG colour fonts draw and export as vector art; hanging punctuation: Glyph Scaling leaves the hung marks at their width, Standard burasagari counts a hung line start as room, G4; a paragraph's Auto Leading also drives the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; scores unchanged; COLR colour fonts (v0 and v1) draw and export as vector art; previously: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; scores unchanged; previously: #3515: recent-font groups in font pickers; manual Tate-chu-yoko marks characters across one em of a vertical column; System Layout: Character panel menu sets whole-point advances for every text object in the document (off by default); ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), start/centre/end aligned, sized and overhanging or squeezed, and the Character panel’s Ruby section applies it from the UI; saved natively, laid out with its line growing, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores unchanged; previously:; Fractional Widths: Character panel menu turns whole-point advances on or off per type object, on by default; scores unchanged; type on a path threads, mixed with area type in one thread; scores unchanged; No Break keeps marked characters on one line; scores unchanged; a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
 
 Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
 [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -58,9 +58,8 @@ against Illustrator's public documentation; **estimated** unless noted.
 | CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing |
 | CJK: Proportional Metrics (`palt` in horizontal type, `vpal` in vertical type) | done | #966 |
 | CJK: manual tate-chu-yoko | done | Character panel menu › Tate-chu-yoko (`text.setFormat` / `text.setRangeStyle {tateChuYoko}`), with Show East Asian Options: marked characters stand side by side across one em of a vertical column, squeezed to the em when wider; two or three half-width digits still do so without the mark; horizontal type keeps the mark and does not move them. Up/Down and Left/Right settings remain. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on` |
-| CJK: ruby, manual tate-chu-yoko | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`); manual tate-chu-yoko missing | #633 |
+| CJK: ruby, manual tate-chu-yoko | partial | ruby (ふりがな) for live text (`type.ruby`, and the Character panel's Ruby section for the selected characters: reading, Apply/Remove, position, alignment, size, overhang): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`); manual tate-chu-yoko missing | #633 |
 | No Break | done | Character panel menu (`text.setFormat` / `text.setRangeStyle {noBreak}`): the marked characters stay on one line, including across a space; a run that does not fit after earlier text moves together, and a run longer than the line stays together. Evidence: `layout::wrapping_tests::no_break_keeps_marked_characters_on_one_line`, `tests::no_break_keeps_marked_words_on_one_line`, `typecmd::no_break_is_a_character_attribute_saved_only_when_on` |
-| CJK: ruby | missing | #633 |
 | Inline symbols in text | beyond Illustrator | not scored |
 | Align Stroke (inside, outside) on live type's character stroke | beyond Illustrator | not scored; `stroke.set {align}` and `text.setRangeStyle {strokeOptions: {align}}`; saved natively, drawn around the glyphs on the canvas and in PDF, EPS and SVG (live text, stroke on the glyph outlines); visual bounds grow with it (#2294) |
 
@@ -87,6 +86,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
 | 2026-10-21 | minor | Manual Tate-chu-yoko: Character panel menu marks characters that stand across one em of a vertical column; ruby and the up/down settings remain; scores unchanged |
 | 2026-10-21 | minor | System Layout: Character panel menu sets whole-point advances for every text object in the document (off by default); scores and hours unchanged |
 | 2026-10-22 | minor | Ruby (ふりがな) for live text: `type.ruby` sets/removes a reading over a byte range, above or below (right/left of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores unchanged |
+| 2026-10-22 | minor | Character panel: a Ruby section applies the reading to the selected characters (or the selected type), with position, alignment, size and overhang, Apply and Remove, all through `type.ruby` in one undo step; scores unchanged |
 | 2026-10-21 | minor | Fractional Widths: Character panel menu turns whole-point advances on or off per type object (on by default); System Layout remains a stub; scores and hours unchanged |
 | 2026-10-21 | minor | Type on a path threads, mixed with area type in one thread: its text that doesn't fit flows on into the next frame, and its red out port, just past its end bracket, loads the Selection tool with that text as area type's does; a new frame threaded from it at a point is type on a path of its shape; scores unchanged |
 | 2026-10-21 | minor | No Break: marked characters stay on one line; scores unchanged |
