From 3042e5251e64e3ba984acdbb0ba1947313ea8df5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 19:12:45 +0200 Subject: [PATCH] Phonetic Guide: ruby text (furigana, pinyin) over base text (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Phonetic Guide: ruby text over base text (#288) Home › Font › Phonetic Guide (format.phonetic) sets small ruby text (furigana, pinyin) over the selected text. The ruby is one inline object anchored at U+FFFC whose plain text is the base, so selection and the caret treat it as one unit and exports read the base text. - Model: InlineObject::Ruby with base, ruby, alignment (ECMA-376 ST_RubyAlign), ruby size, raise, base size, language and the ruby's own formatting. - Layout: the base is drawn in its run's formatting, the ruby above it by alignment (centre, distribute letters/space, left, right); the wider of the two sets the width and the line grows to fit the ruby. Drawn in the shared display list, so screen and PDF both show it. - DOCX: w:ruby read and written with w:rubyPr (rubyAlign, hps, hpsRaise, hpsBaseText, lid), w:rt and w:rubyBase runs with their formatting. Previously only the base text was kept. - Command format.phonetic {"ruby", "align"?, "size"?, "offset"?}: sets, edits or (null/"") removes the ruby, undoable; without "ruby" it opens the dialog and returns the selection's phonetic guide. - UI: Phonetic Guide button in Home › Font and a dialog (base text, ruby text, alignment, offset, size, Remove), strings in every catalog. Co-Authored-By: Claude Opus 5.5 * Merge fix: keep both sides' tests --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/doc/src/para.rs | 71 ++++++++++ crates/doc/src/props.rs | 15 +++ crates/docx/src/read/story.rs | 65 ++++++++-- crates/docx/src/write/story.rs | 32 +++++ crates/docx/tests/roundtrip.rs | 37 ++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/phonetic.rs | 193 ++++++++++++++++++++++++++++ crates/layout/src/display.rs | 29 +++++ crates/layout/src/para.rs | 94 +++++++++++++- crates/layout/src/tests.rs | 43 +++++++ crates/ui-egui/src/dialogs.rs | 72 +++++++++++ crates/ui-egui/src/i18n/es.tsv | 10 +- crates/ui-egui/src/i18n/et.tsv | 8 ++ crates/ui-egui/src/i18n/ja.tsv | 10 +- crates/ui-egui/src/i18n/pt-br.tsv | 10 +- crates/ui-egui/src/i18n/ru.tsv | 8 ++ crates/ui-egui/src/i18n/sr-latn.tsv | 10 +- crates/ui-egui/src/i18n/sr.tsv | 10 +- crates/ui-egui/src/i18n/tr.tsv | 8 ++ crates/ui-egui/src/i18n/uk.tsv | 10 +- crates/ui-egui/src/i18n/zh-hans.tsv | 10 +- crates/ui-egui/src/i18n/zh-hant.tsv | 10 +- crates/ui-egui/src/icons.rs | 5 + crates/ui-egui/src/ribbon.rs | 1 + docs/file-format-parity.md | 4 +- docs/gaps.md | 3 +- docs/layout-parity.md | 3 +- docs/parity-checklist.md | 5 +- docs/target-app-parity.md | 5 +- 30 files changed, 757 insertions(+), 27 deletions(-) create mode 100644 crates/engine/src/cmd/phonetic.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1b94f93..45c46ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip | | 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) | | 2026-10-11 | Customize Keyboard: assign, remove and reset keyboard shortcuts; custom keys override built-ins and are kept between runs (#368) | | 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields | diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 8385924..218497a 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -269,6 +269,55 @@ pub enum NoteKind { Endnote, } +/// How ruby (phonetic guide) text lines up over its base text (ECMA-376 `ST_RubyAlign`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum RubyAlign { + #[default] + Center, + /// Letters spread evenly from edge to edge. + DistributeLetter, + /// Letters spread evenly with half a gap at each edge. + DistributeSpace, + Left, + Right, + /// Right-aligned beside vertical text (laid out like `Right` in horizontal text). + RightVertical, +} + +impl RubyAlign { + pub const ALL: [RubyAlign; 6] = + [RubyAlign::Center, RubyAlign::DistributeLetter, RubyAlign::DistributeSpace, RubyAlign::Left, RubyAlign::Right, RubyAlign::RightVertical]; + + /// The OOXML name (`center`, `distributeLetter`…), also used by commands. + pub fn ooxml(self) -> &'static str { + match self { + RubyAlign::Center => "center", + RubyAlign::DistributeLetter => "distributeLetter", + RubyAlign::DistributeSpace => "distributeSpace", + RubyAlign::Left => "left", + RubyAlign::Right => "right", + RubyAlign::RightVertical => "rightVertical", + } + } + + pub fn from_ooxml(v: &str) -> Option { + RubyAlign::ALL.into_iter().find(|a| a.ooxml() == v) + } +} + +/// Phonetic Guide's Offset for a ruby `raise` (baseline to baseline, points): the gap from the top +/// of the base text to the bottom of the ruby text, taking letters as rising 0.8 em above their +/// baseline and dropping 0.2 em below it. +pub fn ruby_offset(raise: f32, base_size: f32, size: f32) -> f32 { + raise - 0.8 * base_size - 0.2 * size +} + +/// The ruby raise (baseline to baseline, points) for a Phonetic Guide Offset (see [`ruby_offset`]). +pub fn ruby_raise(offset: f32, base_size: f32, size: f32) -> f32 { + offset + 0.8 * base_size + 0.2 * size +} + /// An inline object anchored at a U+FFFC in the paragraph text. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -390,6 +439,27 @@ pub enum InlineObject { #[serde(default, skip_serializing_if = "crate::math::Math::is_empty")] math: crate::math::Math, }, + /// Ruby (Phonetic Guide): small `ruby` text (furigana, pinyin…) set over `base` text. The base + /// text takes the formatting of the object's run; it reads as the paragraph's text. + Ruby { + base: String, + ruby: String, + #[serde(default)] + align: RubyAlign, + /// Ruby text size, points (`w:hps`). + size: f32, + /// From the base text's baseline up to the ruby text's baseline, points (`w:hpsRaise`). + raise: f32, + /// The base text size the ruby was set for, points (`w:hpsBaseText`). + #[serde(default)] + base_size: f32, + /// Language of the ruby (`w:lid`, e.g. `ja-JP`); empty = unspecified. + #[serde(default, skip_serializing_if = "String::is_empty")] + lang: String, + /// Formatting of the ruby text over the run's (font, colour…); its size is `size`. + #[serde(default, skip_serializing_if = "CharProps::is_empty")] + props: Box, + }, /// Something we don't model, kept for round-trip (raw XML of the source format). Opaque { format: String, @@ -527,6 +597,7 @@ impl InlineObject { match self { InlineObject::Field { result, .. } => result, InlineObject::Equation { linear, .. } => linear, + InlineObject::Ruby { base, .. } => base, InlineObject::Opaque { text, .. } => text, _ => "", } diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index e1bcd11..60e10dc 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -354,6 +354,12 @@ macro_rules! overlay_fields { }; } +macro_rules! drop_same_fields { + ($dst:ident, $base:ident; $($f:ident),*) => { + $( if $dst.$f == $base.$f { $dst.$f = None; } )* + }; +} + impl CharProps { /// The same formatting outside a hyperlink: no link target, and no "Hyperlink" character /// style that came with it. What a paragraph mark or text typed next to a link gets. @@ -365,6 +371,15 @@ impl CharProps { c } + /// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when + /// overlaid on `base`. + pub fn minus(mut self, base: &CharProps) -> CharProps { + drop_same_fields!(self, base; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, + shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, + cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del); + self + } + /// Apply every `Some` field of `patch` on top of `self`. /// /// Size, bold and italic come in pairs with their complex-script values (Word's own buttons diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index e3b1996..b56e254 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -533,15 +533,7 @@ impl Reader<'_> { "w:pgNum" => { self.emit_obj(sc, pb, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false }, props) } - "w:ruby" => { - if let Some(base) = k.child("w:rubyBase") { - for r in base.children("w:r") { - for c in r.els() { - self.read_run_child(sc, pb, c, props, rels, note, depth + 1); - } - } - } - } + "w:ruby" => self.read_ruby(sc, pb, k, props), "mc:AlternateContent" => { if let Some(c) = children_of_choice(k) { for x in c.els() { @@ -553,6 +545,61 @@ impl Reader<'_> { } } + /// `w:ruby` (ECMA-376 §17.3.3.25): ruby text (`w:rt`) over base text (`w:rubyBase`), kept as + /// one [`InlineObject::Ruby`] in the base text's formatting (the first base run's). + fn read_ruby(&mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, props: &CharProps) { + const MAX: usize = 4096; + // The text of a part's runs and the formatting of its first run. + let part = |name: &str| -> (String, Option) { + let mut text = String::new(); + let mut first = None; + for r in k.child(name).map(|e| e.children("w:r").collect::>()).unwrap_or_default() { + if first.is_none() { + first = Some(r.child("w:rPr").map(|p| self.pc.rpr(p)).unwrap_or_default()); + } + for t in r.els().filter(|t| matches!(t.name.as_str(), "w:t" | "w:delText")) { + if text.len() < MAX { + text.push_str(&clean_text(&t.text()).replace('\t', " ")); + } + } + } + (text.chars().take(MAX).collect(), first) + }; + let (base, base_props) = part("w:rubyBase"); + let (ruby, ruby_props) = part("w:rt"); + let mut bp = base_props.unwrap_or_default(); + (bp.link, bp.ins, bp.del) = (props.link.clone(), props.ins, props.del); + if base.is_empty() { + return; + } + if ruby.is_empty() { + self.emit_text(sc, pb, &base, &bp); + return; + } + let pr = k.child("w:rubyPr"); + let val = |n: &str| pr.and_then(|p| p.child(n)).and_then(|e| e.attr("w:val")); + let pt = |n: &str| val(n).and_then(|v| measure(v, 2.0)).filter(|v| v.is_finite() && *v >= 0.0); + let mut rp = ruby_props.unwrap_or_default(); + let base_size = pt("w:hpsBaseText").filter(|v| *v > 0.0).or(bp.size).unwrap_or(0.0).min(1584.0); + let size = pt("w:hps").filter(|v| *v > 0.0).or(rp.size).unwrap_or(if base_size > 0.0 { base_size / 2.0 } else { 5.0 }).clamp(1.0, 1584.0); + let raise = pt("w:hpsRaise").unwrap_or_else(|| wordcraft_doc::para::ruby_raise(0.0, base_size.max(size), size)).min(1584.0); + // The ruby's size is `size`; its run keeps what it sets beyond the base text's formatting + // (writers repeat the base's). + (rp.size, rp.size_cs) = (None, None); + let rp = rp.minus(&bp); + let obj = InlineObject::Ruby { + base, + ruby, + align: val("w:rubyAlign").and_then(wordcraft_doc::para::RubyAlign::from_ooxml).unwrap_or_default(), + size, + raise, + base_size, + lang: val("w:lid").map(|l| l.chars().take(32).collect()).unwrap_or_default(), + props: Box::new(rp), + }; + self.emit_obj(sc, pb, obj, &bp); + } + // ---- fields ---- fn begin_field(&mut self, sc: &mut StoryCtx, props: CharProps, instr: String, locked: bool) { diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 6210f44..b25cf0b 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -547,6 +547,38 @@ impl Writer<'_> { w.close("w:r"); self.rev_close(w, props); } + InlineObject::Ruby { base, ruby, align, size, raise, base_size, lang, props: ruby_props } => { + // ECMA-376 §17.3.3.25: a run holding w:ruby (properties, ruby text, base text). + let t = if self.is_del(props) { "w:delText" } else { "w:t" }; + let fin = |v: f32, d: f32| if v.is_finite() { v } else { d }; + let hps = |v: f32, min: f32| n(wordcraft_geom::to_half_points(v.clamp(min, 1584.0))); + let size = fin(*size, 5.0).clamp(1.0, 1584.0); + let base_size = if base_size.is_finite() && *base_size > 0.0 { *base_size } else { props.size.unwrap_or(10.5) }; + self.rev_open(w, props); + w.open("w:r", &[]); + rpr(w, props); + w.open("w:ruby", &[]); + w.open("w:rubyPr", &[]); + w.val("w:rubyAlign", align.ooxml()); + w.val("w:hps", &hps(size, 1.0)); + w.val("w:hpsRaise", &hps(fin(*raise, 0.0), 0.0)); + w.val("w:hpsBaseText", &hps(fin(base_size, 10.5), 1.0)); + w.val("w:lid", if lang.is_empty() { "ja-JP" } else { lang }); + w.close("w:rubyPr"); + let mut rt_props = props.clone().overlaid(ruby_props); + rt_props.size = Some(size); + for (tag, p, text) in [("w:rt", &rt_props, ruby), ("w:rubyBase", props, base)] { + w.open(tag, &[]); + w.open("w:r", &[]); + rpr(w, p); + w.leaf(t, &[("xml:space", "preserve")], text); + w.close("w:r"); + w.close(tag); + } + w.close("w:ruby"); + w.close("w:r"); + self.rev_close(w, props); + } InlineObject::Opaque { format, xml, text } => { if format == "docx" && crate::xml::parse(xml.as_bytes()).is_ok() { w.raw(xml); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 3c483e4..f31556d 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -1504,6 +1504,43 @@ fn wrap_polygons_round_trip() { assert_eq!(floats[1].wrap_polygon.as_deref(), ellipse.as_ref()); } +#[test] +fn ruby_phonetic_guide_round_trips_as_w_ruby() { + use wordcraft_doc::para::RubyAlign; + let mut p = Paragraph::with_text("Read daily.", CharProps::default()); + let bold = CharProps { bold: Some(true), ..Default::default() }; + let ruby = InlineObject::Ruby { + base: "漢字".into(), + ruby: "かんじ".into(), + align: RubyAlign::DistributeSpace, + size: 5.5, + raise: 10.0, + base_size: 11.0, + lang: "ja-JP".into(), + props: Box::new(CharProps { font: Some("Noto Sans CJK JP".into()), ..Default::default() }), + }; + p.insert_object(5, ruby.clone(), &bold).unwrap(); + let d = doc_with(vec![p]); + let bytes = wordcraft_docx::write(&d).expect("write"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); + for part in [ + r#""#, + "", + ">かんじ", + "", + ">漢字", + ] { + assert!(xml.contains(part), "missing {part}: {xml}"); + } + let r = wordcraft_docx::read(&bytes).expect("read"); + let q = paras(&r)[0]; + assert_eq!(q.plain_text(), "Read 漢字 daily."); + assert_eq!(q.objects, vec![ruby]); + assert_eq!(q.props_of_char(5).bold, Some(true)); +} + /// #354: the sides text wraps on (`@wrapText`) are read and written for Square, Tight and /// Through; both sides is the default. #[test] diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 53d7d34..f104b52 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ pub mod objects; pub mod page; pub mod para; pub mod paste; +pub mod phonetic; pub mod references; pub mod review; pub mod speech; @@ -44,6 +45,7 @@ pub fn registry() -> Registry { v.extend(edit::specs()); v.extend(paste::specs()); v.extend(format::specs()); + v.extend(phonetic::specs()); v.extend(para::specs()); v.extend(lists::specs()); v.extend(inspector::specs()); diff --git a/crates/engine/src/cmd/phonetic.rs b/crates/engine/src/cmd/phonetic.rs new file mode 100644 index 0000000..4ac13fe --- /dev/null +++ b/crates/engine/src/cmd/phonetic.rs @@ -0,0 +1,193 @@ +//! Home › Font › Phonetic Guide: ruby text (furigana, pinyin…) over base text, kept as one +//! [`InlineObject::Ruby`] in the paragraph. + +use serde_json::{Value, json}; +use wordcraft_doc::para::{OBJ, RubyAlign, ruby_offset, ruby_raise}; +use wordcraft_doc::{InlineObject, Pos}; + +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +/// Longest base or ruby text, characters. +const MAX_CHARS: usize = 255; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("format.phonetic", "Phonetic Guide", "Home › Font", phonetic).params( + r#"{"ruby"?: string | null, "align"?: "center|distributeLetter|distributeSpace|left|right|rightVertical", "size"?: number, "offset"?: number}"#, + ), + ] +} + +/// Sets the selected text's ruby (one group over the whole selection, or the phonetic guide the +/// selection is or the caret touches); `null` or `""` removes it. Without `ruby`, opens the +/// dialog and returns the selection's phonetic guide (`base`, `ruby`, `align`, `size`, `offset`). +fn phonetic(s: &mut Session, v: &Value) -> CmdResult { + let Some(rv) = v.get("ruby") else { + s.ui_requests.push(json!({"open": "phonetic"})); + return Ok(state(s)); + }; + let text: String = match rv { + Value::Null => String::new(), + Value::String(t) => t.chars().filter(|c| !c.is_control() && *c != OBJ).take(MAX_CHARS).collect(), + _ => return Err(CmdError::Params("`ruby` must be a string or null".into())), + }; + let text = text.trim().to_string(); + let align = match p::str(v, "align") { + Some(a) => Some(RubyAlign::from_ooxml(a).ok_or_else(|| CmdError::Params(format!("unknown alignment `{a}`")))?), + None => None, + }; + let size = p::f32(v, "size").map(|x| x.clamp(1.0, 1584.0)); + let offset = p::f32(v, "offset").map(|x| x.clamp(-1584.0, 1584.0)); + if let Some(at) = ruby_at(s) { + let end = Pos { off: at.off + OBJ.len_utf8(), ..at.clone() }; + let bsz = base_size(s, &at); + let para = s.doc.para_mut(at.story, &at.path)?; + let props = para.props_of_char(at.off).clone(); + match para.object_at_mut(at.off) { + Some(InlineObject::Ruby { base, .. }) if text.is_empty() => { + // Remove: the base text stays, in the ruby's formatting. + let base = base.clone(); + s.doc.delete_range(&at, &end)?; + let stop = s.doc.insert_text(&at, &base, &props)?; + s.sel = Selection { anchor: at, focus: stop }; + return Ok(state(s)); + } + Some(InlineObject::Ruby { ruby, align: al, size: sz, raise, base_size, .. }) => { + let bsz = if *base_size > 0.0 { *base_size } else { bsz }; + let keep = ruby_offset(*raise, bsz, *sz); + *ruby = text; + *al = align.unwrap_or(*al); + *sz = size.unwrap_or(*sz); + *raise = ruby_raise(offset.unwrap_or(keep), bsz, *sz).clamp(0.0, 1584.0); + *base_size = bsz; + } + _ => return Err(CmdError::Failed("no phonetic guide at the selection".into())), + } + para.touch(); + s.sel = Selection { anchor: at, focus: end }; + return Ok(state(s)); + } + if text.is_empty() { + return Err(CmdError::Failed("no phonetic guide at the selection".into())); + } + let (a, b) = s.sel.ordered(); + if a == b || a.story != b.story || a.path != b.path { + return Err(CmdError::Failed("select the text, within one paragraph, to set a phonetic guide over".into())); + } + let para = s.doc.para_at(&a).ok_or_else(|| CmdError::Failed("no paragraph at the selection".into()))?; + let base = para.text.get(a.off..b.off).unwrap_or("").to_string(); + if base.is_empty() || base.chars().any(|c| c == OBJ || c.is_control()) { + return Err(CmdError::Failed("a phonetic guide goes over plain text (no objects, tabs or breaks)".into())); + } + if base.chars().count() > MAX_CHARS { + return Err(CmdError::Failed(format!("a phonetic guide covers at most {MAX_CHARS} characters"))); + } + let props = para.props_of_char(a.off).clone(); + let bsz = base_size(s, &a); + let size = size.unwrap_or_else(|| default_size(bsz)); + let raise = ruby_raise(offset.unwrap_or(0.0), bsz, size).clamp(0.0, 1584.0); + let obj = InlineObject::Ruby { + base, + ruby: text, + align: align.unwrap_or_default(), + size, + raise, + base_size: bsz, + lang: String::new(), + props: Default::default(), + }; + s.doc.delete_range(&a, &b)?; + let end = s.doc.insert_object(&a, obj, &props)?; + s.sel = Selection { anchor: a, focus: end }; + Ok(state(s)) +} + +/// The phonetic guide the selection is (exactly one ruby object) or the caret is next to. +fn ruby_at(s: &Session) -> Option { + let (a, b) = s.sel.ordered(); + let para = s.doc.para_at(&a)?; + let ruby = |off: usize| matches!(para.object_at(off), Some(InlineObject::Ruby { .. })).then(|| Pos { off, ..a.clone() }); + let w = OBJ.len_utf8(); + if a == b { + return ruby(a.off).or_else(|| a.off.checked_sub(w).and_then(ruby)); + } + (a.story == b.story && a.path == b.path && b.off == a.off.saturating_add(w)).then(|| ruby(a.off)).flatten() +} + +/// The resolved font size of the character at `at`, points. +fn base_size(s: &Session, at: &Pos) -> f32 { + let Some(para) = s.doc.para_at(at) else { return 10.5 }; + let size = s.doc.styles.resolve_char(para.props.style.as_deref(), para.props_of_char(at.off)).size; + if size.is_finite() { size.clamp(1.0, 1584.0) } else { 10.5 } +} + +/// Ruby half the base size, rounded down to a half point (10.5 pt text gets 5 pt ruby). +fn default_size(base: f32) -> f32 { + (base.floor() / 2.0).max(1.0) +} + +/// What the Phonetic Guide shows for the selection: its ruby, or the selected text with defaults. +pub fn state(s: &Session) -> Value { + if let Some(at) = ruby_at(s) + && let Some(InlineObject::Ruby { base, ruby, align, size, raise, base_size: bs, .. }) = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) + { + let bs = if *bs > 0.0 { *bs } else { base_size(s, &at) }; + // `+ 0.0` turns a rounded -0 into 0. + let offset = (ruby_offset(*raise, bs, *size) * 10.0).round() / 10.0 + 0.0; + return json!({"base": base, "ruby": ruby, "align": align.ooxml(), "size": size, "offset": offset, "existing": true}); + } + let (a, b) = s.sel.ordered(); + let base = if a.story == b.story && a.path == b.path { s.selected_text() } else { String::new() }; + let base: String = base.chars().filter(|c| !c.is_control() && *c != OBJ).take(MAX_CHARS).collect(); + json!({"base": base, "ruby": "", "align": "center", "size": default_size(base_size(s, &a)), "offset": 0.0, "existing": false}) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_doc::{Document, InlineObject, Paragraph, StoryRef}; + + use crate::Session; + + fn session(text: &str) -> Session { + let mut d = Document::new(); + d.body = vec![wordcraft_doc::para_block(Paragraph::with_text(text, Default::default()))]; + Session::new(d) + } + + fn para(s: &Session) -> Paragraph { + s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).cloned().unwrap() + } + + #[test] + fn phonetic_guide_applies_edits_removes_and_undoes() { + let mut s = session("I read 漢字 daily."); + s.run("select.text", &json!({"text": "漢字"})).unwrap(); + let r = s.run("format.phonetic", &json!({"ruby": "かんじ", "align": "distributeSpace"})).unwrap(); + assert_eq!((r["base"].as_str(), r["ruby"].as_str(), r["existing"].as_bool()), (Some("漢字"), Some("かんじ"), Some(true))); + let p = para(&s); + assert_eq!(p.plain_text(), "I read 漢字 daily."); + let Some(InlineObject::Ruby { ruby, size, align, .. }) = p.objects.first() else { panic!("{:?}", p.objects) }; + assert_eq!((ruby.as_str(), *size, align.ooxml()), ("かんじ", 6.0, "distributeSpace")); + // The selection is the ruby: editing it keeps the base, changes the reading and offset. + let r = s.run("format.phonetic", &json!({"ruby": "カンジ", "offset": 2})).unwrap(); + assert_eq!((r["ruby"].as_str(), r["offset"].as_f64()), (Some("カンジ"), Some(2.0))); + assert_eq!(para(&s).objects.len(), 1); + // Remove: the base text is plain text again, selected. + s.run("format.phonetic", &json!({"ruby": null})).unwrap(); + assert!(para(&s).objects.is_empty()); + assert_eq!(s.selected_text(), "漢字"); + // Undo brings the edited ruby back, then the first one, then the plain text. + s.run("edit.undo", &json!({})).unwrap(); + assert!(matches!(para(&s).objects.first(), Some(InlineObject::Ruby { ruby, .. }) if ruby == "カンジ")); + s.run("edit.undo", &json!({})).unwrap(); + s.run("edit.undo", &json!({})).unwrap(); + assert!(para(&s).objects.is_empty()); + assert_eq!(para(&s).plain_text(), "I read 漢字 daily."); + // Without `ruby` it reports the selection (and asks the UI for the dialog). + s.run("select.text", &json!({"text": "read"})).unwrap(); + let r = s.run("format.phonetic", &json!({})).unwrap(); + assert_eq!((r["base"].as_str(), r["existing"].as_bool()), (Some("read"), Some(false))); + assert!(s.run("format.phonetic", &json!({"ruby": "x", "align": "sideways"})).is_err()); + } +} diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 85a23df..6389c35 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -809,6 +809,35 @@ fn lines( _ => {} } } + // Ruby (Phonetic Guide) text over its base text, in runs of one style. + for r in pl.rubies.iter().filter(|r| (line.c0..line.c1).contains(&r.cluster)) { + let Some(bst) = pl.clusters.get(r.cluster).and_then(|c| pl.styles.get(c.style as usize)) else { continue }; + if bst.rc.del.is_some() && !opts.markup { + continue; + } + let cx = x + line.cl_left(r.cluster).unwrap_or(0.0); + let y = base - bst.shift - r.raise; + let mut i = 0; + while let Some(&(style, _)) = r.glyphs.get(i) { + let n = r.glyphs.get(i..).map_or(0, |g| g.iter().take_while(|(s, _)| *s == style).count()).max(1); + let run = r.glyphs.get(i..i + n).unwrap_or(&[]); + let ranges = r.ranges.get(i..i + n).map(<[_]>::to_vec).unwrap_or_default(); + i += n; + let Some(st) = pl.styles.get(style as usize) else { continue }; + out.push(Draw::Glyphs { + face: st.face, + size: st.size, + glyphs: run.iter().map(|(_, g)| (g.gid, cx + g.dx, y - g.dy)).collect(), + color: text_color(&st.rc.color, st.rc.shading.or(st.rc.highlight)), + alpha, + synth_bold: st.synth_bold, + synth_italic: st.synth_italic, + text: r.text.clone(), + link: None, + ranges, + }); + } + } // Formatting marks. if opts.marks { let msize = pl.styles.first().map(|s| s.size).unwrap_or(11.0); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 7ffe514..85d0051 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use wordcraft_doc::math::MathJc; use wordcraft_doc::numbering::{Level, LevelSuffix}; -use wordcraft_doc::para::{COLUMN_BREAK, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, SOFT_HYPHEN, WrapText}; +use wordcraft_doc::para::{COLUMN_BREAK, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, RubyAlign, SOFT_HYPHEN, WrapText}; use wordcraft_doc::props::{Align, CharProps, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop}; use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara}; use wordcraft_doc::{Document, Paragraph}; @@ -221,6 +221,21 @@ pub struct Label { pub width: f32, } +/// Ruby text laid out over its base text's cluster. +#[derive(Clone, Debug)] +pub struct RubyLayout { + /// The base text's cluster (the one cluster standing for an [`InlineObject::Ruby`]). + pub cluster: usize, + /// Glyphs with their style (index into `ParaLayout::styles`); `dx` is from the cluster's left + /// edge. + pub glyphs: Vec<(u16, Glyph)>, + /// The ruby text and each glyph's byte range in it. + pub text: String, + pub ranges: Vec>, + /// The ruby's baseline above the base text's, points. + pub raise: f32, +} + /// A laid-out paragraph. #[derive(Clone, Debug)] pub struct ParaLayout { @@ -250,6 +265,8 @@ pub struct ParaLayout { pub drop_cap_w: f32, /// Laid-out equations by object index. pub maths: Vec<(usize, Arc)>, + /// Ruby (Phonetic Guide) text over base-text clusters, in cluster order. + pub rubies: Vec, /// Display equations (on lines of their own): cluster index and justification. pub displays: Vec<(usize, wordcraft_doc::math::MathJc)>, /// Byte ranges of the text laid out as nothing ([`left_out`]): hidden text that is not shown, @@ -606,6 +623,66 @@ impl<'a> Builder<'a> { } } +impl Builder<'_> { + /// One cluster for ruby `base` text (an [`InlineObject::Ruby`] at `start..end`), as wide as the + /// wider of the base and the ruby, with the ruby text laid out over it by `align`. The line + /// grows to fit the ruby through the cluster's `obj_h`. + #[allow(clippy::too_many_arguments)] + fn ruby( + &mut self, + base: &str, + ruby: &str, + align: RubyAlign, + raise: f32, + start: usize, + end: usize, + rc: &Arc, + ruby_rc: &Arc, + ) -> Option { + self.shape_atomic(base, start, end, rc); + let ci = self.clusters.len().checked_sub(1)?; + let (bc, bg) = (self.clusters.len(), self.glyphs.len()); + self.shape_with(ruby, 0, ruby_rc, None, false); + let cl: Vec = self.clusters.drain(bc..).collect(); + let ruby_w: f32 = cl.iter().map(|c| c.adv).sum(); + let base_w = self.clusters.get(ci).map_or(0.0, |c| c.adv); + let w = base_w.max(ruby_w); + let n = cl.len().max(1) as f32; + // Where the narrower text starts, and the extra gap between its letters (ruby only). + let lead = |room: f32| match align { + RubyAlign::Left => 0.0, + RubyAlign::Right | RubyAlign::RightVertical => room, + _ => room / 2.0, + }; + let (x0, gap) = match align { + RubyAlign::DistributeLetter if cl.len() > 1 => (0.0, (w - ruby_w) / (n - 1.0)), + RubyAlign::DistributeSpace => ((w - ruby_w) / n / 2.0, (w - ruby_w) / n), + _ => (lead(w - ruby_w), 0.0), + }; + let mut glyphs = Vec::new(); + let mut ranges = Vec::new(); + let mut x = x0; + for c in &cl { + for g in self.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() { + glyphs.push((c.style, Glyph { dx: g.dx + x, ..*g })); + ranges.push(c.start..c.end); + } + x += c.adv + gap; + } + self.glyphs.truncate(bg); + // The base text centres (or aligns) under a wider ruby. + let shift = lead(w - base_w); + let ascent = cl.iter().filter_map(|c| self.styles.get(c.style as usize)).map(|s| s.ascent).fold(0.0f32, f32::max); + let c = self.clusters.get_mut(ci)?; + for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() { + g.dx += shift; + } + c.adv = w; + c.obj_h = raise + ascent; + Some(RubyLayout { cluster: ci, glyphs, text: ruby.to_string(), ranges, raise }) + } +} + /// Whether text with this formatting is left out of the layout: hidden text unless it is shown, and /// tracked deletions in the final text. It takes no room, draws nothing and is no place to break. pub(crate) fn left_out(rc: &ResolvedChar, show_hidden: bool, hide_deleted: bool) -> bool { @@ -645,6 +722,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let mut drop_cap = None; let mut drop_cap_w = 0.0; let mut maths = Vec::new(); + let mut rubies = Vec::new(); let mut displays = Vec::new(); let mut eq_counter = env.eq_number; let math_props = doc.settings.math.clone().unwrap_or_default(); @@ -810,6 +888,16 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { } maths.push((k, Arc::new(ml))); } + Some(InlineObject::Ruby { base, ruby, align, size, raise, props: ruby_props, .. }) => { + let mut rr = (*resolve(&props.clone().overlaid(ruby_props))).clone(); + let size = if size.is_finite() { size.clamp(1.0, 1584.0) } else { rc.size * 0.5 }; + (rr.size, rr.size_cs, rr.position) = (size, size, 0.0); + rr.vert_align = wordcraft_doc::props::VertAlign::Baseline; + let raise = if raise.is_finite() { raise.clamp(0.0, 1584.0) } else { rc.size }; + if let Some(r) = b.ruby(base, ruby, *align, raise, start, end, &rc, &Arc::new(rr)) { + rubies.push(r); + } + } Some(InlineObject::Opaque { text, .. }) => b.shape_atomic(text, start, end, &rc), _ => push(&mut b, ClKind::Marker, 0.0, 0.0), } @@ -919,6 +1007,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { bidi_levels: b.levels, drop_cap_w, maths, + rubies, displays, left_out: Vec::new(), }; @@ -1277,7 +1366,8 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio (st.ascent, st.descent) } } else { - (st.ascent + st.shift.max(0.0), st.descent + (-st.shift).max(0.0)) + // `obj_h` is set on text clusters only under ruby text, which the line grows to fit. + ((st.ascent + st.shift.max(0.0)).max(c.obj_h + st.shift), st.descent + (-st.shift).max(0.0)) }; asc = asc.max(a); desc = desc.max(d); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 1c60af4..f72ef56 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -3079,6 +3079,49 @@ fn contour_wrap_turns_with_the_object() { assert_eq!(first_row(Wrap::Square, 0.0, false)[0].0, 209.0); } +#[test] +fn ruby_text_sits_over_its_base_and_the_line_grows_to_fit() { + use wordcraft_doc::para::RubyAlign; + let plain = Document::from_text("Plain words here"); + let mut d = Document::from_text("Plain here"); + let ruby = InlineObject::Ruby { + base: "words".into(), + ruby: "a much longer reading".into(), + align: RubyAlign::Center, + size: 6.0, + raise: 12.0, + base_size: 12.0, + lang: String::new(), + props: Default::default(), + }; + d.insert_object(&Pos::body(0, 6), ruby, &Default::default()).unwrap(); + let height = |l: &DocLayout| { + l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.lines.first().map(|x| x.height) } else { None }).unwrap() + }; + let (lp, lr) = (lay(&plain), lay(&d)); + assert!(height(&lr) > height(&lp) + 3.0, "ruby line {} vs plain {}", height(&lr), height(&lp)); + let items = display::page_display(&d, &lr.pages[0], &Default::default()); + let glyphs_of = |want: &str| { + items.iter().find_map(|i| match i { + display::Draw::Glyphs { text, glyphs, size, .. } if text.contains(want) => Some((*size, glyphs.clone())), + _ => None, + }) + }; + let (_, line) = glyphs_of("Plain words").expect("base text drawn"); + let (size, over) = glyphs_of("a much longer reading").expect("ruby text drawn"); + let w = line[6]; // "Plain " then the base's first letter + // Smaller, 12 pt above the base text's baseline, and (being wider) centred over it: it starts + // at the base's slot, the base letters move in. + assert_eq!(size, 6.0); + assert!((w.2 - over[0].2 - 12.0).abs() < 0.01, "{w:?} {over:?}"); + let slot = lr.caret(&Pos::body(0, 6)).unwrap().x; + assert!((over[0].1 - slot).abs() < 0.5 && w.1 > slot + 1.0, "{slot} {w:?} {over:?}"); + // The plain text reads the base, and the caret steps over the ruby as one character. + assert_eq!(d.body[0].as_para().unwrap().plain_text(), "Plain words here"); + let (a, b) = (lr.caret(&Pos::body(0, 6)).unwrap(), lr.caret(&Pos::body(0, 9)).unwrap()); + assert!(b.x - a.x > 30.0, "{a:?} {b:?}"); +} + /// #354: `wrapText`. Left: text only to the left of a centred object (Square or Tight); Right: /// only to its right; Largest: only on its wider side. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 01a7d39..14358a8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -107,6 +107,17 @@ pub enum Dialog { text: String, diagonal: bool, }, + /// Home › Font › Phonetic Guide: ruby text over the selected text (`format.phonetic`). + Phonetic { + base: String, + ruby: String, + /// OOXML alignment name (`center`, `distributeLetter`…). + align: String, + size: f32, + offset: f32, + /// The selection already has a phonetic guide (Remove is offered). + existing: bool, + }, NewStyle { name: String, based_on: String, @@ -646,6 +657,7 @@ impl Dialog { Dialog::WordCount { .. } => "wordCount", Dialog::Zoom { .. } => "zoom", Dialog::Watermark { .. } => "watermark", + Dialog::Phonetic { .. } => "phonetic", Dialog::NewStyle { .. } => "newStyle", Dialog::ModifyStyle { .. } => "modifyStyle", Dialog::ManageStyles { .. } => "manageStyles", @@ -775,6 +787,19 @@ impl Dialog { "wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() }, "zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() }, "watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true }, + "phonetic" => { + let v = wordcraft_engine::cmd::phonetic::state(&app.session); + let f = |k: &str| v.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + Dialog::Phonetic { + base: s("base"), + ruby: s("ruby"), + align: s("align"), + size: f("size"), + offset: f("offset"), + existing: v.get("existing").and_then(Value::as_bool).unwrap_or(false), + } + } "newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into(), back: false }, "manageStyles" => Dialog::ManageStyles { alphabetical: false, selected: s("style") }, "newTableStyle" => { @@ -979,6 +1004,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::WordCount { .. } => "Word Count", Dialog::Zoom { .. } => "Zoom", Dialog::Watermark { .. } => "Custom Watermark", + Dialog::Phonetic { .. } => "Phonetic Guide", Dialog::NewStyle { .. } => "Create New Style", Dialog::ModifyStyle { .. } => "Modify Style", Dialog::ManageStyles { .. } => "Manage Styles", @@ -1459,6 +1485,52 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + Dialog::Phonetic { base, ruby, align, size, offset, existing } => { + // Base and ruby text are usually Chinese or Japanese: fonts for them load from the next frame. + app.want_system_cjk = true; + const ALIGNS: [(&str, &str); 6] = [ + ("center", "Center"), + ("distributeLetter", "Distribute letters"), + ("distributeSpace", "Distribute spacing"), + ("left", "Left"), + ("right", "Right"), + ("rightVertical", "Right (vertical)"), + ]; + egui::Grid::new("phonetic").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Base text:")); + ui.label(egui::RichText::new(base.as_str()).strong()); + ui.end_row(); + ui.label(tl!("Ruby text:")); + ui.text_edit_singleline(ruby); + ui.end_row(); + ui.label(tl!("Alignment:")); + let shown = ALIGNS.iter().find(|(k, _)| k == align).map_or("Center", |(_, l)| l); + egui::ComboBox::from_id_salt("phonetic_align").selected_text(tl!(shown)).show_ui(ui, |ui| { + for (k, l) in ALIGNS { + ui.selectable_value(align, k.to_string(), tl!(l)); + } + }); + ui.end_row(); + ui.label(tl!("Offset (pt):")); + ui.add(egui::DragValue::new(offset).speed(0.5).range(-100.0..=100.0)); + ui.end_row(); + ui.label(tl!("Size (pt):")); + ui.add(egui::DragValue::new(size).speed(0.5).range(1.0..=200.0)); + ui.end_row(); + }); + let mut remove = false; + if *existing { + ui.add_space(4.0); + remove = ui.button(tl!("Remove")).clicked(); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if remove { + let _ = app.run("format.phonetic", json!({"ruby": null})); + } else if ok { + let _ = app.run("format.phonetic", json!({"ruby": ruby, "align": align, "size": *size, "offset": *offset})); + } + ok || cancel || remove + } Dialog::NewStyle { name, based_on, back } => { egui::Grid::new("ns").num_columns(2).show(ui, |ui| { ui.label(tl!("Name:")); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4163060..1655819 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -110,6 +110,7 @@ Backspace Retroceso Banded Columns Columnas con bandas Banded Rows Filas con bandas + Base text: Texto base: Before: Antes: Bibliography Bibliografía Binary assets added Recursos binarios añadidos @@ -275,7 +276,9 @@ Display Mostrar Display for Review Mostrar para revisión Distribute Columns Distribuir columnas + Distribute letters Distribuir letras Distribute Rows Distribuir filas + Distribute spacing Distribuir espacio Distributed Distribuido Do Not Show in TOC No mostrar en la tabla de contenido Document End Fin del documento @@ -540,6 +543,7 @@ Number of columns: Número de columnas: Number of rows: Número de filas: Numbering Numeración + Offset (pt): Desplazamiento (pt): OK Aceptar Object Objeto Odd Page Página impar @@ -606,6 +610,7 @@ Paste: Merge Formatting Pegar: combinar formato Pen Pluma Pencil Lápiz + Phonetic Guide Guía fonética Picture Border Borde de imagen Picture Format Formato de imagen Picture Styles Estilos de imagen @@ -649,6 +654,7 @@ Reject All Changes Rechazar todos los cambios Reject Change Rechazar cambio Relaxed Relajado + Remove Quitar Remove Background Quitar fondo Remove Footer Quitar pie de página Remove Header Quitar encabezado @@ -686,6 +692,7 @@ Rich Text Format (*.rtf) Formato de texto enriquecido (*.rtf) Rich Text Format (RTF) Formato de texto enriquecido (RTF) Right Derecha + Right (vertical) Derecha (vertical) Right Border Borde derecho Right Margin Margen derecho Right-to-Left Text Direction Dirección de texto de derecha a izquierda @@ -694,6 +701,7 @@ Rotate Girar Rounded Rectangle Rectángulo redondeado Rows & Columns Filas y columnas + Ruby text: Texto ruby: Ruler Regla Rules Reglas Résumé Currículum @@ -761,6 +769,7 @@ Single Sencillo Single Spacing Interlineado sencillo Size Tamaño + Size (pt): Tamaño (pt): Size: Tamaño: Small Caps Versalitas Small caps Versalitas @@ -1377,7 +1386,6 @@ Not used by any command Ningún comando lo usa Remove every custom shortcut and restore the built-in keys? ¿Quitar todos los métodos abreviados personalizados y restaurar las teclas integradas? Assign Asignar - Remove Quitar Reset All… Restablecer todo… Press a key combination Pulse una combinación de teclas Customize Personalizar diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 034d20d..b07adea 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1396,3 +1396,11 @@ Ribbon Lint Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba: Ribbon tabs Lindi vahekaardid + Base text: Alustekst: + Distribute letters Jaota tähed + Distribute spacing Jaota vahed + Offset (pt): Nihe (pt): + Phonetic Guide Foneetiline juhend + Right (vertical) Paremal (vertikaalne) + Ruby text: Ruby-tekst: + Size (pt): Suurus (pt): diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 237c52e..24358f4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -103,6 +103,14 @@ Restart list after: リストを振り直すレベル: Right border 右罫線 Right Margin 右余白 + Base text: 親文字: + Distribute letters 文字を均等に配置 + Distribute spacing 間隔を均等に配置 + Offset (pt): オフセット (pt): + Phonetic Guide ルビ + Remove 削除 + Right (vertical) 右 (縦書き) + Ruby text: ルビ文字: Search for templates テンプレートを検索 Show all revisions inline すべての変更履歴を本文中に表示 Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 表示 › モードの切り替えと同様に、文書の色を反転して表示します。保存、印刷、PDF は変わりません。 @@ -739,6 +747,7 @@ Single 一重線 Single Spacing 1 行 Size サイズ + Size (pt): サイズ (pt): Size: サイズ: Small Caps 小型英大文字 Small caps 小型英大文字 @@ -1361,7 +1370,6 @@ Not used by any command どのコマンドにも割り当てられていません Remove every custom shortcut and restore the built-in keys? すべてのカスタム ショートカットを削除し、既定のキーに戻しますか? Assign 割り当て - Remove 削除 Reset All… すべてリセット… Press a key combination キーの組み合わせを押してください Customize ユーザー設定 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e47edb9..1572891 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -106,6 +106,7 @@ Backspace Backspace Banded Columns Colunas listradas Banded Rows Linhas listradas + Base text: Texto base: Before: Antes: Bibliography Bibliografia Binary assets added Recursos binários adicionados @@ -265,6 +266,13 @@ No templates match your search. Nenhum modelo corresponde à pesquisa. Reopen Reabrir Right Margin Margem direita + Distribute letters Distribuir letras + Distribute spacing Distribuir espaçamento + Offset (pt): Deslocamento (pt): + Phonetic Guide Guia fonético + Remove Remover + Right (vertical) Direita (vertical) + Ruby text: Texto ruby: Search for templates Pesquisar modelos development build versão de desenvolvimento Diagonal Diagonal @@ -740,6 +748,7 @@ Single Simples Single Spacing Espaçamento simples Size Tamanho + Size (pt): Tamanho (pt): Size: Tamanho: Small Caps Versaletes Small caps Versaletes @@ -1373,7 +1382,6 @@ Not used by any command Nenhum comando usa Remove every custom shortcut and restore the built-in keys? Remover todos os atalhos personalizados e restaurar as teclas padrão? Assign Atribuir - Remove Remover Reset All… Redefinir tudo… Press a key combination Pressione uma combinação de teclas Customize Personalizar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e6f9ba9..2941b57 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1767,6 +1767,14 @@ preview Title Пример New Group Создать группу Rename… Переименовать… Ribbon and Quick Access Toolbar: Лента и панель быстрого доступа: + Base text: Основной текст: + Distribute letters Распределить буквы + Distribute spacing Распределить интервал + Offset (pt): Смещение (пт): + Phonetic Guide Фонетическое руководство + Right (vertical) Справа (вертикально) + Ruby text: Текст фуригана: + Size (pt): Размер (пт): In Line with Text В тексте Through Сквозное Top and Bottom Сверху и снизу diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index e5c7b1d..c23cabe 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -162,6 +162,14 @@ Shadow, glow and soft edges Senka, sjaj i meke ivice Shape Effects Efekti oblika Show all revisions inline Prikaži sve izmene u tekstu + Base text: Osnovni tekst: + Distribute letters Rasporedi slova + Distribute spacing Rasporedi razmak + Offset (pt): Pomak (pt): + Phonetic Guide Fonetski vodič + Remove Ukloni + Right (vertical) Desno (vertikalno) + Ruby text: Rubi tekst: Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Prikaži dokumente sa invertovanim bojama, kao kod Prikaz › Promeni režim. Čuvanje, štampanje i PDF nisu pogođeni. AutoSave saves every change to the file (needs a saved document) Automatsko čuvanje upisuje svaku izmenu u fajl (dokument mora biti već sačuvan) AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Automatsko čuvanje nije dostupno u pregledaču: ne može da upisuje u vaše fajlove. Čuvanje preuzima kopiju. @@ -789,6 +797,7 @@ Single Jednostruko Single Spacing Jednostruko proređenje Size Veličina papira + Size (pt): Veličina (pt): Size: Veličina: Small Caps Kapitelke Small caps kapitelke @@ -1371,7 +1380,6 @@ Not used by any command Nijedna komanda je ne koristi Remove every custom shortcut and restore the built-in keys? Ukloniti sve prilagođene prečice i vratiti ugrađene tastere? Assign Dodeli - Remove Ukloni Reset All… Resetuj sve… Press a key combination Pritisnite kombinaciju tastera Customize Prilagođavanje diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 05818e7..47911ff 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -166,6 +166,14 @@ Shadow, glow and soft edges Сенка, сјај и меке ивице Shape Effects Ефекти облика Show all revisions inline Прикажи све измене у тексту + Base text: Основни текст: + Distribute letters Распореди слова + Distribute spacing Распореди размак + Offset (pt): Помак (пт): + Phonetic Guide Фонетски водич + Remove Уклони + Right (vertical) Десно (вертикално) + Ruby text: Руби текст: Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Прикажи документе са инвертованим бојама, као код Приказ › Промени режим. Чување, штампање и PDF нису погођени. AutoSave saves every change to the file (needs a saved document) Аутоматско чување уписује сваку измену у фајл (документ мора бити већ сачуван) AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Аутоматско чување није доступно у прегледачу: не може да уписује у ваше фајлове. Чување преузима копију. @@ -793,6 +801,7 @@ Single Једноструко Single Spacing Једноструко проређење Size Величина папира + Size (pt): Величина (пт): Size: Величина: Small Caps Капителке Small caps капителке @@ -1375,7 +1384,6 @@ Not used by any command Ниједна команда је не користи Remove every custom shortcut and restore the built-in keys? Уклонити све прилагођене пречице и вратити уграђене тастере? Assign Додели - Remove Уклони Reset All… Ресетуј све… Press a key combination Притисните комбинацију тастера Customize Прилагођавање diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 04743e9..fb57dde 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1384,6 +1384,14 @@ New Group Yeni Grup Rename… Yeniden Adlandır… Ribbon and Quick Access Toolbar: Şerit ve Hızlı Erişim Araç Çubuğu: + Base text: Ana metin: + Distribute letters Harfleri dağıt + Distribute spacing Aralığı dağıt + Offset (pt): Kaydırma (pt): + Phonetic Guide Fonetik Kılavuz + Right (vertical) Sağ (dikey) + Ruby text: Ruby metni: + Size (pt): Boyut (pt): In Line with Text Metinle Aynı Hizada Square Kare Through Geçişli diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a0b7fd8..99f2e31 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -108,6 +108,7 @@ Backspace Видалити попередній символ Banded Columns Смугасті стовпці Banded Rows Смугасті рядки + Base text: Основний текст: Before: Перед: Bibliography Бібліографія Binary assets added Додано двійкових ресурсів @@ -272,6 +273,13 @@ No templates match your search. Немає шаблонів, що відповідають пошуку. Reopen Відкрити знову Right Margin Праве поле + Distribute letters Розподілити літери + Distribute spacing Розподілити інтервали + Offset (pt): Зсув (пункти): + Phonetic Guide Фонетична підказка + Remove Прибрати + Right (vertical) Праворуч (вертикально) + Ruby text: Текст рубі: Search for templates Пошук шаблонів development build Збірка для розробки Diagonal Діагональ @@ -763,6 +771,7 @@ Single Одинарний Single Spacing Одинарний інтервал Size Розмір + Size (pt): Розмір (пункти): Size: Розмір: Small Caps Малі великі літери Small caps Малі великі літери @@ -1373,7 +1382,6 @@ Not used by any command Не використовується жодною командою Remove every custom shortcut and restore the built-in keys? Видалити всі власні сполучення клавіш і відновити вбудовані? Assign Призначити - Remove Видалити Reset All… Скинути все… Press a key combination Натисніть сполучення клавіш Customize Настроювання diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index cfdc917..bd50584 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -115,6 +115,14 @@ Restart list after: 重新开始列表的级别: Right border 右框线 Right Margin 右边距 + Base text: 基准文字: + Distribute letters 字母均匀分布 + Distribute spacing 间距均匀分布 + Offset (pt): 偏移量(磅): + Phonetic Guide 拼音指南 + Remove 删除 + Right (vertical) 右对齐(竖排) + Ruby text: 拼音文字: Search for templates 搜索模板 Show all revisions inline 以嵌入方式显示所有修订 Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 像“视图 › 切换模式”一样以反转的颜色显示文档。保存、打印和 PDF 不受影响。 @@ -751,6 +759,7 @@ Single 单线 Single Spacing 单倍行距 Size 纸张大小 + Size (pt): 字号(磅): Size: 字号: Small Caps 小型大写字母 Small caps 小型大写字母 @@ -1373,7 +1382,6 @@ Not used by any command 未被任何命令使用 Remove every custom shortcut and restore the built-in keys? 删除所有自定义快捷键并恢复内置按键? Assign 指定 - Remove 删除 Reset All… 全部重置… Press a key combination 请按组合键 Customize 自定义 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5cf2f21..494ebd8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -104,6 +104,14 @@ Restart list after: 重新開始清單的層級: Right border 右框線 Right Margin 右邊界 + Base text: 基準文字: + Distribute letters 字母平均分散 + Distribute spacing 間距平均分散 + Offset (pt): 位移(點): + Phonetic Guide 注音標示 + Remove 移除 + Right (vertical) 靠右(直書) + Ruby text: 注音文字: Search for templates 搜尋範本 Show all revisions inline 以內嵌方式顯示所有修訂 Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 像「檢視 › 切換模式」一樣以反轉的色彩顯示文件。儲存、列印和 PDF 不受影響。 @@ -740,6 +748,7 @@ Single 單線 Single Spacing 單行間距 Size 大小 + Size (pt): 大小(點): Size: 大小: Small Caps 小型大寫字 Small caps 小型大寫字 @@ -1362,7 +1371,6 @@ Not used by any command 未被任何命令使用 Remove every custom shortcut and restore the built-in keys? 要移除所有自訂快速鍵並還原內建按鍵嗎? Assign 指定 - Remove 移除 Reset All… 全部重設… Press a key combination 請按組合鍵 Customize 自訂 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index bbc5d56..a6a3071 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -141,6 +141,11 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.text(8.0, 9.0, 13.0, "A", c, false); pen.fill(&[(10.0, 15.0), (14.0, 11.0), (18.0, 15.0), (14.0, 19.0)], Color32::from_rgb(0xE8, 0x6F, 0xA0)); } + // Small letters over a large one: text with a reading above it. + "phonetic" => { + pen.text(10.0, 5.0, 7.5, "abc", a, false); + pen.text(10.0, 13.5, 12.5, "A", c, false); + } "highlight" => { pen.fill(&[(5.0, 12.0), (11.0, 4.0), (14.0, 7.0), (8.0, 15.0)], c); pen.line(&[(5.0, 12.0), (3.5, 15.0), (8.0, 15.0)]); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 8c446be..dbac0c1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -366,6 +366,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "tOGGLE cASE", "format.changeCase", json!({"mode": "toggle"})); }); small(ui, app, "clear", None, "Clear All Formatting", "format.clear", json!({}), false); + small(ui, app, "phonetic", None, "Phonetic Guide", "format.phonetic", json!({}), false); }); ui.add_space(3.0); crate::widgets::row(ui, |ui| { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index da5a9ed..cf1e954 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -66,7 +66,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) | | Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read | | Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom properties round-trip; bibliography sources pending (#169) | -| Ruby, `w:eastAsianLayout`, `w:fitText`, document grid (`w:docGrid`) | ❌ | ❌ | East Asian layout | +| Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | +| `w:eastAsianLayout`, `w:fitText`, document grid (`w:docGrid`) | ❌ | ❌ | East Asian layout | | Glossary document (building blocks), `w:altChunk`, sub-documents | ❌ | ❌ | | | Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard | @@ -88,6 +89,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) | | 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed | | 2026-10-10 | trivial | Charts, SmartArt and OLE objects written back on save with their parts (#319); chart/SmartArt reading (#292) recorded | +| 2026-10-11 | trivial | Ruby (`w:ruby`) read and written (#288) | | 2026-10-10 | trivial | `wrapText` on square, tight and through wraps read and written (#354) | | 2026-10-10 | trivial | Wrap polygons for Tight and Through wrapping read and written (#354) | | 2026-10-10 | trivial | DrawingML rotation and flips read and written (#332) | diff --git a/docs/gaps.md b/docs/gaps.md index 1513cc5..34d12c1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -27,7 +27,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 14 | **`.doc` can't be written; metafile pictures and Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: RTL sections and tables, kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 10–15 | [layout-parity.md](layout-parity.md) | -| 17 | **East Asian typography**: vertical text, ruby/Phonetic Guide, Enclose Characters, document grid, Asian Typography options | | Catalog misses `format.phonetic`, `format.enclose`, `para.asianTypography` | Chinese, Japanese and Korean documents | 20–30 | [layout-parity.md](layout-parity.md) | +| 17 | **East Asian typography**: vertical text, Enclose Characters, document grid, Asian Typography options (Phonetic Guide landed, #288) | | Catalog misses `format.enclose`, `para.asianTypography` | Chinese, Japanese and Korean documents | 18–27 | [layout-parity.md](layout-parity.md) | | 18 | **Screen-reader access** to the document canvas untested | | AccessKit enabled; no tests | Blind and low-vision users; public-sector procurement | 10–15 | [ui-parity.md](ui-parity.md) | | 19 | **RTF and ODT depth**: notes, comments, revisions, sections, RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | @@ -77,6 +77,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-10 | trivial | #2 narrowed: charts and SmartArt drawn (#292), and with OLE objects kept on save (#319); group shapes landed (#267); ink and drawing canvases remain | | 2026-10-10 | trivial | #4: long footnotes continue onto the next page (#352) | | 2026-10-10 | trivial | Define New Multilevel List and Track Changes Options dialogs landed (#328); removed from the dialog-depth list | +| 2026-10-11 | trivial | Phonetic Guide (ruby) landed (#288): gap 17 narrowed | | 2026-10-10 | trivial | #10: wrap side (both sides, left, right, largest) for Square, Tight and Through objects (#354) | | 2026-10-10 | trivial | #10: Tight and Through wrapping follow the object's outline (#354) | | 2026-10-10 | trivial | #10: free rotation and flips for pictures, shapes and text boxes, with a rotation handle (#332); text boxes rotate their frame; their text stays upright | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 7b476cd..1e87419 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -47,7 +47,7 @@ font can't match Word's metrics. | Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins; book fold missing | 2–3 | | Document grid (`w:docGrid`, lines per page, characters per line) | ❌ | East Asian documents paginate differently without it | 4–6 | | Vertical text (`tbRl`) in sections and text boxes; cell text direction | 🟡 | cell text direction landed (#245); page-level and text-box vertical text missing | 8–12 | -| Ruby / phonetic guide, enclose characters, combined characters | ❌ | | 4–6 | +| Ruby / phonetic guide, enclose characters, combined characters | 🟡 | ruby (Phonetic Guide) landed (#288): ruby text over its base by alignment, line grows to fit, in PDF; enclose and combined characters missing; ruby not yet compared with Word page by page | 2–4 | | Bidirectional paragraphs, mixed-direction lines | 🟡 | #207; RTL sections and tables (`w:bidiVisual`) missing | 4–6 | | Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — | | Hidden text excluded from breaking and hyphenation | ✅ | #173 | — | @@ -70,6 +70,7 @@ font can't match Word's metrics. |---|---|---| | 2026-10-11 | trivial | Group and Ungroup keep turned members' orientation and placement (#441, #443); Size resizes a group (#442); the eraser hits turned ink where it is drawn (#445) | | 2026-10-10 | trivial | Long footnotes continue onto the next page with a continuation separator (#352) | +| 2026-10-11 | trivial | Ruby (Phonetic Guide) layout landed (#288) | | 2026-10-10 | trivial | Wrap side: text on both sides, left only, right only or the largest side of an object (#354) | | 2026-10-10 | trivial | Tight and Through wrapping follow the object's outline (#354) | | 2026-10-10 | trivial | Rotated and flipped objects laid out and drawn (#332) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9fd8ee4..aabb0bf 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,13 +2,13 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**418 of 448 catalog features have commands (93.3%).** +**419 of 448 catalog features have commands (93.5%).** | Tab | Live | Total | |---|---|---| | File | 20 | 20 | | Quick Access | 4 | 4 | -| Home | 99 | 102 | +| Home | 100 | 102 | | Insert | 43 | 53 | | Draw | 5 | 11 | | Design | 10 | 10 | @@ -30,7 +30,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- ### Home -- Font › Phonetic Guide (`format.phonetic`) - Font › Enclose Characters (`format.enclose`) - Voice › Dictate (`tools.dictate`) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 44f8965..e0bf644 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -57,7 +57,7 @@ full parity for the area. | Area | Weight | Parity | Was (2026-10-06) | Hours | Evidence and what's missing | |---|---|---|---|---|---| | Typing, selection, clipboard, undo, find/replace | 12 | 85% | 85% | 8–12 | Typing parity pinned by `tests_typing.rs` ([typing-parity.md](typing-parity.md)). Paste Special (#235), Advanced Find (#234) and Column Selection (#237) landed; Clipboard pane still missing (PR #264 open). | -| Character and paragraph formatting | 11 | 82% | 85% | 8–12 | Nearly every property, Font/Paragraph dialogs, character border. Missing: Phonetic Guide, Enclose Characters, Asian Typography, kerning/ligature/stylistic-set options in the Font dialog's Advanced tab. | +| Character and paragraph formatting | 11 | 82% | 85% | 8–12 | Nearly every property, Font/Paragraph dialogs, character border, Phonetic Guide (#288). Missing: Enclose Characters, Asian Typography, kerning/ligature/stylistic-set options in the Font dialog's Advanced tab. | | Styles and themes | 8 | 74% | 75% | 8–12 | Gallery, pane, create/modify, style sets, themes. Style Inspector landed (#236). Missing: Manage Styles (#270 open), style separators, linked-style edge cases; Word ships 11 Quick Style sets plus dozens of themes, we ship our own smaller set. | | Lists | 6 | 78% | 75% | 5–8 | Bullets, numbering, multilevel, restart, `w:lvlOverride` levels (#134), Word's list AutoFormat and Enter/Backspace behaviour. Missing: Define New Multilevel List dialog depth, list styles, legal numbering edge cases. | | Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303). Missing: splitting or erasing around vertically merged cells, nested-table polish, interactive column resize (#49 open, #217). Cell text direction landed (#245). | @@ -74,7 +74,7 @@ full parity for the area. | View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane. Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Outline view depth, Immersive Reader depth. | | Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. | | Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. | -| East Asian typography | 1 | 15% | not rated | 20–30 | IME input works (winit fixes pending, #155–#164). No vertical text, ruby/phonetic guide, document grid, `w:eastAsianLayout`, enclose characters, Asian line-break options. | +| East Asian typography | 1 | 15% | not rated | 18–27 | IME input works (winit fixes pending, #155–#164). Ruby/Phonetic Guide with DOCX round trip (#288). No vertical text, document grid, `w:eastAsianLayout`, enclose characters, Asian line-break options. | | Accessibility | 1 | 35% | not rated | 15–25 | AccessKit enabled in eframe; an accessibility checker exists. Screen-reader access to the document canvas is untested; no alt-text workflow polish, no read-order tools. | | Agent control (CLI, MCP, control channel, macro record/playback) | 0 | beyond Word | 100%+ | — | Every action is a command id usable from CLI, MCP and a keyed control port. Word has VBA and Office.js instead (see Ecosystem). | @@ -249,6 +249,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Phonetic Guide (ruby) landed (#288): catalog +1 feature | | 2026-10-11 | trivial | Shortcut count 101 → 125 (#330) | | 2026-10-11 | trivial | Equation fixes: removing numbers keeps every row (#444), caret moves keep imported OMML (#446), build-up on a caret move is an undoable edit (#447), LaTeX `\text{}` keeps its spaces (#448); no percentage change | | 2026-10-11 | trivial | English spelling (#520): common modern words ("desktop", "download", "email"…) recognised via our supplement list; Proofing row updated, no percentage change |