Phonetic Guide: ruby text (furigana, pinyin) over base text (#296)

* 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 <noreply@anthropic.com>

* Merge fix: keep both sides' tests

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-11 13:12:45 -04:00
1 parent dba6e66d53
commit 3042e5251e
30 files changed
+757 -27

No files matched your search

+1
View File
@@ -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 |
+71
View File
@@ -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> {
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<CharProps>,
},
/// 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,
_ => "",
}
+15
View File
@@ -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
+56 -9
View File
@@ -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<CharProps>) {
let mut text = String::new();
let mut first = None;
for r in k.child(name).map(|e| e.children("w:r").collect::<Vec<_>>()).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) {
+32
View File
@@ -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);
+37
View File
@@ -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#"<w:rubyAlign w:val="distributeSpace"/><w:hps w:val="11"/><w:hpsRaise w:val="20"/><w:hpsBaseText w:val="22"/><w:lid w:val="ja-JP"/>"#,
"<w:rt><w:r>",
">かんじ</w:t>",
"<w:rubyBase><w:r><w:rPr><w:b/>",
">漢字</w:t>",
] {
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]
+2
View File
@@ -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());
+193
View File
@@ -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<CommandSpec> {
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<Pos> {
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());
}
}
+29
View File
@@ -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);
+92 -2
View File
@@ -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<std::ops::Range<usize>>,
/// 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<crate::math::MathLayout>)>,
/// Ruby (Phonetic Guide) text over base-text clusters, in cluster order.
pub rubies: Vec<RubyLayout>,
/// 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<ResolvedChar>,
ruby_rc: &Arc<ResolvedChar>,
) -> Option<RubyLayout> {
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<Cluster> = 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);
+43
View File
@@ -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]
+72
View File
@@ -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:"));
+9 -1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+8
View File
@@ -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):
Can't render this file because it contains an unexpected character in line 421 and column 13.
+9 -1
View File
@@ -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 ユーザー設定
Can't render this file because it contains an unexpected character in line 504 and column 13.
+9 -1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+8
View File
@@ -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 Сверху и снизу
Can't render this file because it contains an unexpected character in line 7 and column 25.
+9 -1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+9 -1
View File
@@ -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 Прилагођавање
Can't render this file because it contains an unexpected character in line 11 and column 25.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 430 and column 13.
+9 -1
View File
@@ -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 Настроювання
Can't render this file because it contains an unexpected character in line 6 and column 31.
+9 -1
View File
@@ -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 自定义
Can't render this file because it contains an unexpected character in line 7 and column 25.
+9 -1
View File
@@ -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 自訂
Can't render this file because it contains an unexpected character in line 505 and column 13.
+5
View File
@@ -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)]);
+1
View File
@@ -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| {
+3 -1
View File
@@ -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) |
+2 -1
View File
@@ -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 |
+2 -1
View File
@@ -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) |
+2 -3
View File
@@ -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`)
+3 -2
View File
@@ -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 |