mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
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:
30 files changed
+757
-27
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
_ => "",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:"));
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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)]);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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,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`)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user