Enclose Characters: put a character in a circle, square, triangle or diamond (#301)

* Home › Font › Enclose Characters: circle, square, triangle or diamond around a character (#297)

New command format.enclose {"shape": "circle|square|triangle|diamond" | null,
"style"?: "shrink|enlarge", "text"?: string} puts one full-width or up to two
half-width characters in an enclosure, changes its shape, or (null) takes the
text back out. It is stored the way ECMA-376 describes it: an EQ field with the
overstrike switch, eq \o\ac(○,字), with the text as its plain-text result.

Layout now draws EQ \o fields: the arguments overstruck as one cluster
(centred, \al or \ar). An enclosure symbol among them is drawn as a vector
shape around the text in the text's colour, so it never depends on the font
having ○ □ △ ◇; "shrink text" scales the text into a one-em shape, "enlarge
symbol" grows the shape around text at its own size. Screen and PDF both get
it through the display list. Imported Word documents with enclosed characters
used to show nothing (the field has no result); they now render.

.docx: written as Word stores enclosed characters (begin, the code split so the
symbol and the text have their own run sizes, end, no result); the reader
recovers the style from those sizes. The style lives in the model as a trailing
\* enlarge on the code, never written to a file.

Home › Font gets an Enclose Characters button that opens a dialog (Style:
None / Shrink text / Enlarge symbol, Text, Enclosure list, a drawn preview);
scripts with a shape never see it. Strings in every interface catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge fix: keep both sides (enclosures and ruby; tests); regenerate parity checklist

---------

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:20:45 -04:00
1 parent 3042e5251e
commit a5b17eb3a4
32 files changed
+1030 -17

No files matched your search

+3 -1
View File
@@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **402 / 434 (92.6%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| Ribbon/menu catalog coverage | **403 / 434 (92.9%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
@@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field |
| 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) |
@@ -155,6 +156,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) |
| 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) |
| 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) |
| 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 |
+311
View File
@@ -0,0 +1,311 @@
//! The EQ field's overstrike switch (ECMA-376 Part 1 §17.16.5.20, `\o`): its arguments are drawn
//! on top of one another, centred (`\ac`, the default), left-aligned (`\al`) or right-aligned
//! (`\ar`). Home › Font › Enclose Characters stores a character in a circle, square, triangle or
//! diamond as one: ` eq \o\ac(○,字)`.
//!
//! Which of the two enclosure styles a field has is kept in the document model as a trailing
//! `\* enlarge` (WordCraft's own; nothing is written for the default "shrink text" style). In a
//! `.docx` the style is Word's: the font sizes of the field code's runs (the text smaller than the
//! symbol, or the symbol larger than the text), so the marker never reaches a file.
/// How the overstruck arguments line up.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OAlign {
Left,
#[default]
Center,
Right,
}
/// An `EQ \o(…)` field: the arguments' text (nested switches flattened to their text).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Overstrike {
pub align: OAlign,
pub args: Vec<String>,
}
/// Most arguments read from one field (the rest are ignored).
const MAX_ARGS: usize = 16;
/// Parse an `EQ \o` field code; `None` for any other field (or EQ switch).
pub fn parse_overstrike(instr: &str) -> Option<Overstrike> {
let t = instr.trim_start();
let name_end = t.find(|c: char| c.is_whitespace() || c == '\\').unwrap_or(t.len());
if !t.get(..name_end)?.eq_ignore_ascii_case("eq") {
return None;
}
let mut rest = t.get(name_end..)?.trim_start();
// `\o`, then its alignment options, then the argument list.
rest = rest.strip_prefix('\\')?;
let o = rest.chars().next()?;
if !o.eq_ignore_ascii_case(&'o') {
return None;
}
rest = rest.get(1..)?;
let mut align = OAlign::Center;
loop {
rest = rest.trim_start();
if let Some(r) = rest.strip_prefix('(') {
rest = r;
break;
}
let r = rest.strip_prefix('\\')?;
let sw = r.get(..2)?;
align = match sw.to_ascii_lowercase().as_str() {
"al" => OAlign::Left,
"ac" => OAlign::Center,
"ar" => OAlign::Right,
_ => return None,
};
rest = r.get(2..)?;
}
// Arguments up to the matching `)`, split at top-level `,` or `;` (the list separator).
let mut args = Vec::new();
let mut cur = String::new();
let mut depth = 0usize;
let mut chars = rest.chars();
let mut closed = false;
while let Some(c) = chars.next() {
match c {
'\\' => {
cur.push(c);
if let Some(n) = chars.next() {
cur.push(n);
}
}
'(' => {
depth = depth.saturating_add(1);
cur.push(c);
}
')' if depth == 0 => {
closed = true;
break;
}
')' => {
depth -= 1;
cur.push(c);
}
',' | ';' if depth == 0 => {
if args.len() < MAX_ARGS {
args.push(arg_text(&cur));
}
cur.clear();
}
c => cur.push(c),
}
}
if !closed {
return None;
}
if args.len() < MAX_ARGS {
args.push(arg_text(&cur));
}
Some(Overstrike { align, args })
}
/// An argument's text: escapes (`\,` `\(` `\)` `\\`) become the character, nested switches
/// (`\s\up4(…)`) and their brackets are dropped, leaving what they show.
fn arg_text(s: &str) -> String {
let mut out = String::new();
let mut it = s.chars().peekable();
while let Some(c) = it.next() {
match c {
'\\' => match it.peek().copied() {
Some(n @ (',' | '(' | ')' | '\\' | ';')) => {
out.push(n);
it.next();
}
// A switch: its letters, then an optional (signed) number.
Some(_) => {
while it.next_if(|n| n.is_ascii_alphabetic()).is_some() {}
while it.next_if(|n| *n == ' ').is_some() {}
while it.next_if(|n| n.is_ascii_digit() || *n == '-' || *n == '.').is_some() {}
}
None => {}
},
'(' | ')' => {}
c => out.push(c),
}
}
out
}
/// Escape text for an EQ argument.
fn escape_arg(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, ',' | '(' | ')' | '\\' | ';') {
out.push('\\');
}
out.push(c);
}
out
}
/// The enclosure drawn around enclosed characters.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum EncloseShape {
Circle,
Square,
Triangle,
Diamond,
}
impl EncloseShape {
pub const ALL: [EncloseShape; 4] = [EncloseShape::Circle, EncloseShape::Square, EncloseShape::Triangle, EncloseShape::Diamond];
/// The symbol in the field code.
pub fn symbol(self) -> char {
match self {
EncloseShape::Circle => '○',
EncloseShape::Square => '□',
EncloseShape::Triangle => '△',
EncloseShape::Diamond => '◇',
}
}
/// The shape a field-code symbol stands for (the usual symbols and their large or small
/// variants).
pub fn from_symbol(s: &str) -> Option<EncloseShape> {
let mut it = s.trim().chars();
let c = it.next()?;
if it.next().is_some() {
return None;
}
Some(match c {
'○' | '◯' | '⭘' | '◦' | '〇' => EncloseShape::Circle,
'□' | '◻' | '⬜' | '▢' | '◽' => EncloseShape::Square,
'△' | '▵' | '⊿' => EncloseShape::Triangle,
'◇' | '◊' | '⬦' | '⋄' => EncloseShape::Diamond,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
EncloseShape::Circle => "circle",
EncloseShape::Square => "square",
EncloseShape::Triangle => "triangle",
EncloseShape::Diamond => "diamond",
}
}
pub fn parse(s: &str) -> Option<EncloseShape> {
EncloseShape::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s.trim()))
}
}
/// Shrink the text to fit the shape, or enlarge the shape around the text.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum EncloseStyle {
#[default]
Shrink,
Enlarge,
}
impl EncloseStyle {
pub fn name(self) -> &'static str {
match self {
EncloseStyle::Shrink => "shrink",
EncloseStyle::Enlarge => "enlarge",
}
}
pub fn parse(s: &str) -> Option<EncloseStyle> {
match s.trim().to_ascii_lowercase().as_str() {
"shrink" => Some(EncloseStyle::Shrink),
"enlarge" => Some(EncloseStyle::Enlarge),
_ => None,
}
}
}
/// Enclosed characters: an `EQ \o` field with an enclosure symbol and the text.
#[derive(Clone, Debug, PartialEq)]
pub struct Enclosure {
pub shape: EncloseShape,
pub style: EncloseStyle,
pub text: String,
}
/// The model's marker for the "enlarge symbol" style (see the module docs).
const ENLARGE: &str = "\\* enlarge";
impl Enclosure {
/// Enclosed characters from a field code: `\o` with exactly two arguments, one of them an
/// enclosure symbol and the other non-empty text.
pub fn parse(instr: &str) -> Option<Enclosure> {
let o = parse_overstrike(instr)?;
let [a, b] = o.args.as_slice() else { return None };
let (shape, text) = match (EncloseShape::from_symbol(a), EncloseShape::from_symbol(b)) {
(Some(s), _) => (s, b),
(None, Some(s)) => (s, a),
_ => return None,
};
if text.trim().is_empty() {
return None;
}
let close = instr.rfind(')')?;
let tail = instr.get(close + 1..).unwrap_or("").trim();
let style = if tail.eq_ignore_ascii_case(ENLARGE) { EncloseStyle::Enlarge } else { EncloseStyle::Shrink };
Some(Enclosure { shape, style, text: text.clone() })
}
/// The field code in the model (with the style marker; see [`Enclosure::word_instr`]).
pub fn instr(&self) -> String {
match self.style {
EncloseStyle::Shrink => self.word_instr(),
EncloseStyle::Enlarge => format!("{} {ENLARGE}", self.word_instr()),
}
}
/// The field code as Word writes it: `eq \o\ac(○,字)`.
pub fn word_instr(&self) -> String {
format!("eq \\o\\ac({},{})", self.shape.symbol(), escape_arg(&self.text))
}
}
/// Characters Word counts as full width (East Asian wide or fullwidth); two half-width characters
/// or one full-width character fit in an enclosure.
pub fn is_full_width(c: char) -> bool {
matches!(c as u32,
0x1100..=0x115F
| 0x2E80..=0x303E
| 0x3041..=0x33FF
| 0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xA000..=0xA4CF
| 0xAC00..=0xD7A3
| 0xF900..=0xFAFF
| 0xFE30..=0xFE4F
| 0xFF00..=0xFF60
| 0xFFE0..=0xFFE6
| 0x20000..=0x3FFFD)
}
/// Whether `text` fits in an enclosure: one full-width character or one or two half-width ones.
pub fn fits_enclosure(text: &str) -> bool {
let n = text.chars().count();
let wide = text.chars().filter(|c| is_full_width(*c)).count();
!text.chars().any(|c| c.is_control() || c == crate::para::OBJ) && (n == 1 || (n == 2 && wide == 0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_word_enclosures_and_general_overstrike() {
let e = Enclosure::parse(" eq \\o\\ac(○,字)").unwrap();
assert_eq!((e.shape, e.style, e.text.as_str()), (EncloseShape::Circle, EncloseStyle::Shrink, "字"));
// Nested switches flatten to their text; the list separator may be `;`.
let e = Enclosure::parse("EQ \\o\\ac(□;\\s\\up 1(12))").unwrap();
assert_eq!((e.shape, e.text.as_str()), (EncloseShape::Square, "12"));
let e = Enclosure { shape: EncloseShape::Diamond, style: EncloseStyle::Enlarge, text: "a,".into() };
assert_eq!(Enclosure::parse(&e.instr()), Some(e.clone()));
assert_eq!(e.word_instr(), "eq \\o\\ac(◇,a\\,)");
let o = parse_overstrike("eq \\o \\al(=,/)").unwrap();
assert_eq!((o.align, o.args), (OAlign::Left, vec!["=".to_string(), "/".to_string()]));
assert!(parse_overstrike("eq \\f(1,2)").is_none());
assert!(parse_overstrike("PAGE").is_none());
assert!(parse_overstrike("eq \\o(unclosed").is_none());
assert!(Enclosure::parse("eq \\o(a,b)").is_none());
assert!(fits_enclosure("字") && fits_enclosure("12") && !fits_enclosure("字字") && !fits_enclosure("123") && !fits_enclosure(""));
}
}
+1
View File
@@ -14,6 +14,7 @@ pub mod bidi;
pub mod edit;
pub mod effects;
pub mod encoding;
pub mod eq;
pub mod fields;
pub mod freeform;
pub mod graphic;
+31 -2
View File
@@ -53,6 +53,27 @@ struct FieldState {
/// Kept as a range field: its result flows into the document as ordinary content between
/// `FieldStart` and `FieldEnd` markers instead of being buffered.
range: bool,
/// The font size of each piece of the code (where it starts in `instr`): an enclosure's
/// style is the size of its symbol and text (see [`wordcraft_doc::eq`]).
sizes: Vec<(usize, Option<f32>)>,
}
/// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word
/// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text
/// (smaller than the symbol).
fn enclosure(f: &FieldState) -> Option<wordcraft_doc::eq::Enclosure> {
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
let mut e = Enclosure::parse(&f.instr)?;
let size_at = |at: usize| f.sizes.iter().rev().find(|(start, _)| *start <= at).and_then(|(_, s)| *s);
let sym = f.instr.char_indices().find(|(_, c)| EncloseShape::from_symbol(&c.to_string()) == Some(e.shape)).map(|(i, _)| i)?;
let txt = e.text.chars().next().and_then(|c| f.instr.get(sym..)?.find(c).map(|i| i + sym)).unwrap_or(sym);
let base = f.props.size;
e.style = match (size_at(sym), size_at(txt)) {
(Some(s), Some(t)) if s > t + 0.01 && base.is_none_or(|b| t >= b - 0.01) => EncloseStyle::Enlarge,
(Some(s), None) if base.is_none_or(|b| s > b + 0.01) => EncloseStyle::Enlarge,
_ => EncloseStyle::Shrink,
};
Some(e)
}
/// Fields kept as ranges: those whose result another program writes, with formatting and often
@@ -457,6 +478,9 @@ impl Reader<'_> {
&& !f.spilled
&& f.instr.len() < MAX_INSTR
{
if f.sizes.len() < 64 {
f.sizes.push((f.instr.len(), props.size));
}
f.instr.push_str(&k.text());
}
}
@@ -608,7 +632,7 @@ impl Reader<'_> {
return;
}
// Fields nested deeper than the cap are ignored (their content flows to the enclosing field).
sc.fields.push(FieldState { instr, phase: Phase::Instr, spilled: false, buf: Vec::new(), props, locked, range: false });
sc.fields.push(FieldState { instr, phase: Phase::Instr, spilled: false, buf: Vec::new(), props, locked, range: false, sizes: Vec::new() });
}
/// Whether the field below the innermost one is still collecting its code (a field nested
@@ -663,7 +687,12 @@ impl Reader<'_> {
Item::Obj(o) => result.push_str(o.plain_text()),
}
}
let obj = InlineObject::Field { instr: f.instr.trim().to_string(), result, locked: f.locked, code: false };
let (instr, result) = match enclosure(&f) {
// Word writes no result for enclosed characters: the text is their plain text.
Some(e) => (e.instr(), if result.is_empty() { e.text } else { result }),
None => (f.instr.trim().to_string(), result),
};
let obj = InlineObject::Field { instr, result, locked: f.locked, code: false };
if let Some(parent) = sc.fields.last_mut()
&& !parent.spilled
&& parent.phase == Phase::Instr
+52
View File
@@ -601,6 +601,12 @@ impl Writer<'_> {
self.toc_field = Some((instr.to_string(), locked, props.clone()));
return;
}
if let Some(e) = wordcraft_doc::eq::Enclosure::parse(instr) {
self.rev_open(w, props);
self.enclosure(w, &e, locked, props);
self.rev_close(w, props);
return;
}
let del = self.is_del(props);
let run = |w: &mut W, f: &dyn Fn(&mut W)| {
w.open("w:r", &[]);
@@ -640,6 +646,52 @@ impl Writer<'_> {
self.rev_close(w, props);
}
/// Enclosed characters as Word stores them: an `eq \o\ac(○,字)` field with no result, whose
/// code is split so the symbol and the text get their own sizes: "shrink text" makes the text
/// smaller (raised to sit in the middle), "enlarge symbol" makes the symbol bigger (lowered).
fn enclosure(&self, w: &mut W, e: &wordcraft_doc::eq::Enclosure, locked: bool, props: &CharProps) {
use wordcraft_doc::eq::EncloseStyle;
let del = self.is_del(props);
let tag = if del { "w:delInstrText" } else { "w:instrText" };
let base = props.size.unwrap_or_else(|| self.doc.styles.resolve_char(None, props).size).clamp(1.0, 1638.0);
let half = |pt: f32| (pt * 2.0).round() / 2.0;
let (mut sym, mut txt) = (props.clone(), props.clone());
match e.style {
EncloseStyle::Shrink => {
txt.size = Some(half(base * 0.7).max(1.0));
txt.position = Some(half(base * 0.1));
}
EncloseStyle::Enlarge => {
sym.size = Some(half(base * 1.5));
sym.position = Some(-half(base * 0.2));
}
}
let run = |w: &mut W, p: &CharProps, f: &dyn Fn(&mut W)| {
w.open("w:r", &[]);
rpr(w, p);
f(w);
w.close("w:r");
};
run(w, props, &|w| {
if locked {
w.empty("w:fldChar", &[("w:fldCharType", "begin"), ("w:fldLock", "1")])
} else {
w.empty("w:fldChar", &[("w:fldCharType", "begin")])
}
});
let code = e.word_instr();
// `eq \o\ac(` + symbol, `,`, the text, `)`.
let open = code.find('(').map(|i| i + 1).unwrap_or(0);
let comma = open + e.shape.symbol().len_utf8();
let (head, rest) = code.split_at(comma.min(code.len()));
let text = rest.get(1..rest.len().saturating_sub(1)).unwrap_or("");
run(w, &sym, &|w| w.leaf(tag, &[("xml:space", "preserve")], &format!(" {head}")));
run(w, props, &|w| w.leaf(tag, &[("xml:space", "preserve")], ","));
run(w, &txt, &|w| w.leaf(tag, &[("xml:space", "preserve")], text));
run(w, props, &|w| w.leaf(tag, &[("xml:space", "preserve")], ")"));
run(w, props, &|w| w.empty("w:fldChar", &[("w:fldCharType", "end")]));
}
/// A field's begin, instruction and separate runs.
fn field_start(&self, w: &mut W, instr: &str, locked: bool, props: &CharProps) {
let del = self.is_del(props);
+42
View File
@@ -1504,6 +1504,48 @@ fn wrap_polygons_round_trip() {
assert_eq!(floats[1].wrap_polygon.as_deref(), ellipse.as_ref());
}
/// Enclosed characters (#297) are written as Word stores them — an `eq \o\ac(○,字)` field with no
/// result, the symbol and the text in runs of their own sizes — and read back with their style,
/// whether the field's size is set on the run or comes from the styles.
#[test]
fn enclosed_characters_round_trip_in_words_structure() {
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
let mut p = Paragraph::new();
let cases = [
(EncloseShape::Circle, EncloseStyle::Shrink, Some(11.0)),
(EncloseShape::Triangle, EncloseStyle::Enlarge, Some(11.0)),
(EncloseShape::Square, EncloseStyle::Shrink, None),
(EncloseShape::Diamond, EncloseStyle::Enlarge, None),
];
for (shape, style, size) in cases {
let e = Enclosure { shape, style, text: "字".into() };
let off = p.len();
p.insert_object(
off,
InlineObject::Field { instr: e.instr(), result: "字".into(), locked: false, code: false },
&CharProps { size, ..Default::default() },
)
.unwrap();
}
let doc = doc_with(vec![p]);
let bytes = wordcraft_docx::write(&doc).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<w:instrText xml:space="preserve"> eq \o\ac(○</w:instrText>"#), "{xml}");
assert!(!xml.contains("separate") && !xml.contains("enlarge"), "{xml}");
// Shrink text at 11 pt: the character at 7.5 pt (half-points 15), raised.
assert!(xml.contains(r#"<w:sz w:val="15"/>"#), "{xml}");
let back = wordcraft_docx::read(&bytes).unwrap();
let got: Vec<Enclosure> = paras(&back)[0]
.objects
.iter()
.filter_map(|o| if let InlineObject::Field { instr, result, .. } = o { Enclosure::parse(instr).filter(|_| result == "字") } else { None })
.collect();
let want: Vec<Enclosure> = cases.iter().map(|(shape, style, _)| Enclosure { shape: *shape, style: *style, text: "字".into() }).collect();
assert_eq!(got, want);
}
#[test]
fn ruby_phonetic_guide_round_trips_as_w_ruby() {
use wordcraft_doc::para::RubyAlign;
+103
View File
@@ -2,6 +2,7 @@
use serde_json::{Value, json};
use wordcraft_doc::Pos;
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
use wordcraft_doc::props::{Border, CharProps, Highlight, Rgb, TextColor, Underline, VertAlign};
use wordcraft_doc::resolve::ResolvedChar;
@@ -141,6 +142,8 @@ pub fn specs() -> Vec<CommandSpec> {
})
.key("Mod+D")
.pure(),
CommandSpec::new("format.enclose", "Enclose Characters", "Home › Font", enclose)
.params(r#"{"shape": "circle|square|triangle|diamond" | null, "style"?: "shrink|enlarge", "text"?: string}"#),
CommandSpec::new("format.state", "Formatting at Selection", "Home › Font", |s, _| Ok(state(s))).pure(),
]
}
@@ -519,3 +522,103 @@ mod tests {
assert_eq!(convert("straße", "upper", true), "STRASSE");
}
}
/// What Enclose Characters works on: the selected text (one full-width or up to two half-width
/// characters), or enclosed characters already there (selected, or beside the caret).
#[derive(Clone, Debug, PartialEq)]
pub struct EncloseTarget {
/// The range to replace.
pub from: Pos,
pub to: Pos,
pub text: String,
pub props: CharProps,
/// The enclosure there now.
pub current: Option<Enclosure>,
}
/// The selection as Enclose Characters sees it (an error says why it can't be enclosed).
pub fn enclose_target(s: &Session) -> Result<EncloseTarget, CmdError> {
use wordcraft_doc::para::OBJ;
let (a, b) = s.sel.ordered();
if a.story != b.story || a.path != b.path {
return Err(CmdError::Failed("select characters within one paragraph".into()));
}
let para = s.doc.para_at(&a).ok_or_else(|| CmdError::Failed("no paragraph at the selection".into()))?;
let enclosed = |off: usize| -> Option<EncloseTarget> {
let Some(wordcraft_doc::InlineObject::Field { instr, .. }) = para.object_at(off) else { return None };
let e = Enclosure::parse(instr)?;
let end = off + OBJ.len_utf8();
Some(EncloseTarget {
from: Pos { off, ..a.clone() },
to: Pos { off: end, ..a.clone() },
text: e.text.clone(),
props: para.props_at(end).clone(),
current: Some(e),
})
};
if a.off == b.off {
// A caret just after (or before) enclosed characters.
let before = para.text.get(..a.off).and_then(|t| t.chars().next_back()).filter(|c| *c == OBJ).map(|c| a.off - c.len_utf8());
return before.and_then(enclosed).or_else(|| enclosed(a.off)).ok_or_else(|| CmdError::Failed("select the characters to enclose".into()));
}
let text = para.text.get(a.off..b.off).ok_or_else(|| CmdError::Failed("bad selection".into()))?;
if text == OBJ.to_string()
&& let Some(t) = enclosed(a.off)
{
return Ok(t);
}
if !wordcraft_doc::eq::fits_enclosure(text) {
return Err(CmdError::Failed("select one full-width character or up to two half-width characters".into()));
}
let first = text.chars().next().map(char::len_utf8).unwrap_or(0);
Ok(EncloseTarget { from: a.clone(), to: b, text: text.to_string(), props: para.props_at(a.off + first).clone(), current: None })
}
/// Home › Font › Enclose Characters: put the selected character(s) in a circle, square, triangle
/// or diamond (an `EQ \o` field, see [`wordcraft_doc::eq`]), or (`shape: null`) take them out.
fn enclose(s: &mut Session, v: &Value) -> CmdResult {
let shape = match v.get("shape") {
None => return Err(CmdError::Params("`shape` is required: circle, square, triangle, diamond, or null to remove".into())),
Some(Value::Null) => None,
Some(x) => {
Some(x.as_str().and_then(EncloseShape::parse).ok_or_else(|| CmdError::Params("shape: circle|square|triangle|diamond|null".into()))?)
}
};
let style = match p::str(v, "style") {
Some(x) => Some(EncloseStyle::parse(x).ok_or_else(|| CmdError::Params("style: shrink|enlarge".into()))?),
None => None,
};
// Text typed in the dialog replaces the selection's (or goes in at the caret).
let given = match p::str(v, "text") {
Some(t) if shape.is_some() && !wordcraft_doc::eq::fits_enclosure(t) => {
return Err(CmdError::Params("text: one full-width character or up to two half-width characters".into()));
}
Some(t) if shape.is_some() => Some(t.to_string()),
_ => None,
};
let t = match (enclose_target(s), given) {
(Ok(t), None) => t,
(Ok(t), Some(text)) => EncloseTarget { text, ..t },
(Err(_), Some(text)) if s.sel.anchor == s.sel.focus => {
let at = s.sel.focus.clone();
EncloseTarget { from: at.clone(), to: at, text, props: s.typing_props(), current: None }
}
(Err(e), _) => return Err(e),
};
if shape.is_none() && t.current.is_none() {
return Err(CmdError::Failed("no enclosed characters at the selection".into()));
}
s.sel = crate::Selection { anchor: t.from.clone(), focus: t.to.clone() };
let at = super::delete_selection(s)?;
let end = match shape {
Some(shape) => {
let style = style.or(t.current.as_ref().map(|e| e.style)).unwrap_or_default();
let e = Enclosure { shape, style, text: t.text.clone() };
let obj = wordcraft_doc::InlineObject::Field { instr: e.instr(), result: t.text, locked: false, code: false };
s.doc.insert_object(&at, obj, &t.props)?
}
None => s.doc.insert_text(&at, &t.text, &t.props)?,
};
s.sel = crate::Selection { anchor: at, focus: end };
sel_result(s)
}
+64
View File
@@ -2807,3 +2807,67 @@ fn deleting_the_last_cell_column_never_leaves_a_table_without_rows() {
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.body.iter().any(|b| b.as_table().is_some_and(|t| !t.rows.is_empty())));
}
/// Home › Font › Enclose Characters (#297): the selection becomes an `EQ \o\ac` field, saved the
/// way Word stores it (the style in the code's run sizes) and read back; null takes the text out
/// again, and each step undoes.
#[test]
fn enclose_characters_make_an_eq_field_and_come_back_out() {
use wordcraft_doc::InlineObject;
let field = |s: &Session| {
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
p.objects.iter().find_map(|o| match o {
InlineObject::Field { instr, result, .. } => Some((instr.clone(), result.clone())),
_ => None,
})
};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "No 12 and 字 here"}));
run(&mut s, "select.text", json!({"text": "No 12"}));
assert!(s.run("format.enclose", &json!({"shape": "circle"})).is_err(), "five characters don't fit");
run(&mut s, "select.text", json!({"text": "字"}));
assert!(s.run("format.enclose", &json!({})).is_err(), "a script must say which shape");
run(&mut s, "format.enclose", json!({"shape": "circle"}));
assert_eq!(field(&s), Some(("eq \\o\\ac(○,字)".to_string(), "字".to_string())));
assert_eq!(text(&s), "No 12 and 字 here", "the field's text reads as the character");
run(&mut s, "select.text", json!({"text": "12"}));
run(&mut s, "format.enclose", json!({"shape": "diamond", "style": "enlarge"}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let codes: Vec<String> =
p.objects.iter().filter_map(|o| if let InlineObject::Field { instr, .. } = o { Some(instr.clone()) } else { None }).collect();
assert_eq!(codes, ["eq \\o\\ac(◇,12) \\* enlarge", "eq \\o\\ac(○,字)"]);
// Word's structure in the .docx: no result, the symbol and the text in runs of their own
// sizes; reading it back gives the same fields, styles included.
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let back = wordcraft_docx::read(&bytes).unwrap();
let bp = back.para_at(&Pos::body(0, 0)).unwrap();
let back_codes: Vec<(String, String)> = bp
.objects
.iter()
.filter_map(|o| if let InlineObject::Field { instr, result, .. } = o { Some((instr.clone(), result.clone())) } else { None })
.collect();
assert_eq!(back_codes, [("eq \\o\\ac(◇,12) \\* enlarge".to_string(), "12".to_string()), ("eq \\o\\ac(○,字)".to_string(), "字".to_string())]);
// Change the shape of the enclosed characters at the caret, then remove it.
let e = s.doc.para_at(&Pos::body(0, 0)).unwrap().text.find('\u{FFFC}').unwrap();
s.sel = crate::Selection::caret(Pos::body(0, e + 3));
run(&mut s, "format.enclose", json!({"shape": "square"}));
assert!(field(&s).unwrap().0.starts_with("eq \\o\\ac(□,12) \\* enlarge"), "the style stays");
run(&mut s, "format.enclose", json!({"shape": null}));
assert_eq!(text(&s), "No 12 and 字 here");
assert_eq!(s.selected_text(), "12");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(field(&s).unwrap().0.starts_with("eq \\o\\ac(◇,12)"));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(field(&s), None, "back to plain text");
assert!(s.run("format.enclose", &json!({"shape": null})).is_err(), "nothing to remove");
// Text given (the dialog's Text box) goes in at a caret.
run(&mut s, "caret.docEnd", json!({}));
assert!(s.run("format.enclose", &json!({"shape": "triangle", "text": "ABC"})).is_err());
run(&mut s, "format.enclose", json!({"shape": "triangle", "text": "A"}));
assert_eq!(text(&s), "No 12 and 字 hereA");
assert_eq!(field(&s), Some(("eq \\o\\ac(△,A)".to_string(), "A".to_string())));
}
+17
View File
@@ -809,6 +809,23 @@ fn lines(
_ => {}
}
}
// Enclosed characters' shapes, in the text's colour.
for (k, e) in &pl.enclosures {
if *k < line.c0 || *k >= line.c1 {
continue;
}
let (Some(c), Some(left)) = (pl.clusters.get(*k), line.cl_left(*k)) else { continue };
let Some(st) = pl.styles.get(c.style as usize) else { continue };
let rect = Rect::new(x + left + e.x, base - st.shift - e.top, e.w, e.h);
out.push(Draw::Shape {
rect,
kind: e.kind,
fill: None,
stroke: Some(text_color(&st.rc.color, None)),
stroke_width: e.stroke,
effects: Default::default(),
});
}
// 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 };
+163 -3
View File
@@ -276,6 +276,21 @@ pub struct ParaLayout {
/// equations) rather than for the paragraph's own text, by cluster index, sorted, with each of
/// the cluster's glyphs' byte range in that text (a ligature's glyph spans its letters).
pub shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)>,
/// Shapes drawn around overstruck field text (enclosed characters, `EQ \o`), by cluster index.
pub enclosures: Vec<(usize, Enclosed)>,
}
/// A shape drawn over a cluster (an enclosed character's circle, square, triangle or diamond),
/// relative to the cluster's left edge and baseline, in the cluster's text colour.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Enclosed {
pub kind: wordcraft_doc::para::ShapeKind,
/// Left edge from the cluster's x; top above the baseline (up is positive), points.
pub x: f32,
pub top: f32,
pub w: f32,
pub h: f32,
pub stroke: f32,
}
/// What a table style gives the text of a cell: its paragraph and run formatting, with the
@@ -621,6 +636,144 @@ impl<'a> Builder<'a> {
self.shown.push((self.clusters.len(), text.to_string(), ranges));
self.clusters.push(Cluster { start, end, adv: x, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, obj_d: 0.0, dot: false });
}
/// Shape `texts` on top of one another as one cluster for `start..end`: each aligned within
/// the cluster (at least `width` wide) and raised `dy`. Returns the cluster's index.
#[allow(clippy::too_many_arguments)]
fn shape_overstruck(
&mut self,
texts: &[&str],
start: usize,
end: usize,
rc: &Arc<ResolvedChar>,
align: wordcraft_doc::eq::OAlign,
width: f32,
dy: f32,
) -> Option<usize> {
use wordcraft_doc::eq::OAlign;
let (c0, s0) = (self.clusters.len(), self.shown.len());
for t in texts {
self.shape_atomic(t, start, end, rc);
}
let added: Vec<Cluster> = self.clusters.drain(c0..).collect();
let shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)> = self.shown.drain(s0..).collect();
let adv = added.iter().filter(|c| c.kind == ClKind::Text).map(|c| c.adv).fold(width.max(0.0), f32::max);
let (mut text, mut ranges) = (String::new(), Vec::new());
let (mut g0, mut g1) = (u32::MAX, 0u32);
for (ci, t, r) in &shown {
let Some(c) = added.get(ci.saturating_sub(c0)) else { continue };
let dx = match align {
OAlign::Left => 0.0,
OAlign::Center => (adv - c.adv) / 2.0,
OAlign::Right => adv - c.adv,
};
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
g.dx += dx;
g.dy += dy;
}
let off = text.len();
ranges.extend(r.iter().map(|x| x.start + off..x.end + off));
text.push_str(t);
g0 = g0.min(c.g0);
g1 = g1.max(c.g1);
}
let g = self.glyphs.len() as u32;
let (g0, g1) = if g0 <= g1 { (g0, g1) } else { (g, g) };
let style = match added.iter().find(|c| c.kind == ClKind::Text) {
Some(c) => c.style,
None => self.style(rc, None, false),
};
let k = self.clusters.len();
if !text.is_empty() {
self.shown.push((k, text, ranges));
}
self.clusters.push(Cluster { start, end, adv, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, obj_d: 0.0, dot: false });
Some(k)
}
}
/// Lay out an `EQ \o` field (ECMA-376 Part 1 §17.16.5.20): its arguments drawn on top of one
/// another, as one cluster. An enclosure symbol among them (○ □ △ ◇, see
/// [`wordcraft_doc::eq::EncloseShape`]) is drawn as a shape around the text rather than as a
/// glyph, so it never depends on the font: "shrink text" scales the text to fit inside a shape
/// one em high, "enlarge symbol" grows the shape around text at its own size.
fn overstrike(
b: &mut Builder,
o: &wordcraft_doc::eq::Overstrike,
instr: &str,
start: usize,
end: usize,
rc: &Arc<ResolvedChar>,
enclosures: &mut Vec<(usize, Enclosed)>,
) {
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
use wordcraft_doc::para::ShapeKind;
let shapes: Vec<EncloseShape> = o.args.iter().filter_map(|a| EncloseShape::from_symbol(a)).collect();
let texts: Vec<&str> = o.args.iter().filter(|a| !a.is_empty() && EncloseShape::from_symbol(a).is_none()).map(String::as_str).collect();
let Some(shape) = shapes.first().copied() else {
b.shape_overstruck(&texts, start, end, rc, o.align, 0.0, 0.0);
return;
};
let style = Enclosure::parse(instr).map(|e| e.style).unwrap_or_default();
let s = rc.size.clamp(1.0, 1638.0);
// The widest text at the run's own size.
let (c0, g0, s0) = (b.clusters.len(), b.glyphs.len(), b.shown.len());
for t in &texts {
b.shape_atomic(t, start, end, rc);
}
let w0 = b.clusters.get(c0..).map(|cs| cs.iter().map(|c| c.adv).fold(0.0f32, f32::max)).unwrap_or(0.0).max(0.0);
b.clusters.truncate(c0);
b.glyphs.truncate(g0);
b.shown.truncate(s0);
// The shape's box (`bw` × `bh`, centred `cy` above the baseline) and the text's size.
let cy = s * 0.34;
let (bw, bh, size) = match style {
EncloseStyle::Shrink => {
// Room for the text inside each shape: (height, width) as fractions of the box.
let (fh, fw) = match shape {
EncloseShape::Circle => (0.62, 0.7),
EncloseShape::Square => (0.68, 0.78),
EncloseShape::Triangle => (0.45, 0.5),
EncloseShape::Diamond => (0.5, 0.55),
};
let bh = s * 1.05;
let mut t = s * fh;
if w0 > 0.0 && w0 * t / s > bh * fw {
t = bh * fw * s / w0;
}
(bh, bh, t.max(s * 0.2))
}
EncloseStyle::Enlarge => {
let (kh, kw) = match shape {
EncloseShape::Circle => (1.4, 1.4),
EncloseShape::Square => (1.3, 1.3),
EncloseShape::Triangle => (1.75, 2.0),
EncloseShape::Diamond => (1.7, 1.8),
};
let bh = s * kh;
(bh.max(w0 * kw), bh, s)
}
};
// A triangle's middle is low; the text sits there.
let ty = if shape == EncloseShape::Triangle { cy - bh * 0.13 } else { cy };
let trc = if (size - rc.size).abs() > 0.01 {
let mut r = (**rc).clone();
r.size = size;
Arc::new(r)
} else {
rc.clone()
};
let pad = s * 0.05;
let Some(k) = b.shape_overstruck(&texts, start, end, &trc, o.align, bw + 2.0 * pad, ty - size * 0.34) else { return };
let adv = b.clusters.get(k).map(|c| c.adv).unwrap_or(bw);
let kind = match shape {
EncloseShape::Circle => ShapeKind::Ellipse,
EncloseShape::Square => ShapeKind::Rectangle,
EncloseShape::Triangle => ShapeKind::Triangle,
EncloseShape::Diamond => ShapeKind::Diamond,
};
let stroke = (s * 0.06).clamp(0.4, 4.0);
enclosures.push((k, Enclosed { kind, x: (adv - bw) / 2.0, top: cy + bh / 2.0, w: bw, h: bh, stroke }));
}
impl Builder<'_> {
@@ -722,6 +875,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 enclosures = Vec::new();
let mut rubies = Vec::new();
let mut displays = Vec::new();
let mut eq_counter = env.eq_number;
@@ -844,9 +998,14 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
}
b.shape_atomic(&field_code_text(instr), start, end, &Arc::new(shaded));
} else {
let (t, page_dep) = field_text(instr, result, env.fields);
has_page_fields |= page_dep;
b.shape_atomic(&t, start, end, &rc);
match wordcraft_doc::eq::parse_overstrike(instr) {
Some(o) => overstrike(&mut b, &o, instr, start, end, &rc, &mut enclosures),
None => {
let (t, page_dep) = field_text(instr, result, env.fields);
has_page_fields |= page_dep;
b.shape_atomic(&t, start, end, &rc);
}
}
}
}
Some(InlineObject::NoteRef { kind, id, custom }) => {
@@ -1010,6 +1169,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
rubies,
displays,
left_out: Vec::new(),
enclosures,
};
pl.hyph_after = hyphenation_points(&text, to_para, &pl, env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens);
pl.left_out = left;
+57
View File
@@ -3079,6 +3079,63 @@ fn contour_wrap_turns_with_the_object() {
assert_eq!(first_row(Wrap::Square, 0.0, false)[0].0, 209.0);
}
/// Enclosed characters (#297, an `EQ \o\ac` field): the shape is drawn as a vector around the text,
/// whatever the font has; "shrink text" draws the text smaller inside a one-em shape, "enlarge
/// symbol" a bigger shape around text at its own size. Plain `\o` overstrikes its arguments.
#[test]
fn enclosed_characters_draw_a_shape_around_the_text() {
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
let draw = |shape: EncloseShape, style: EncloseStyle| {
let mut d = Document::from_text("a b");
let e = Enclosure { shape, style, text: "A".into() };
d.insert_object(
&Pos::body(0, 2),
InlineObject::Field { instr: e.instr(), result: "A".into(), locked: false, code: false },
&Default::default(),
)
.unwrap();
let l = lay(&d);
let items = display::page_display(&d, &l.pages[0], &Default::default());
let shape = items
.iter()
.find_map(|i| if let display::Draw::Shape { rect, kind, stroke, .. } = i { Some((*rect, *kind, stroke.is_some())) } else { None });
// Each glyph's character, size and x.
let mut chars = Vec::new();
for i in &items {
if let display::Draw::Glyphs { text, size, glyphs, ranges, .. } = i {
for (g, r) in glyphs.iter().zip(ranges) {
chars.push((text.get(r.clone()).unwrap_or("").to_string(), *size, g.1));
}
}
}
let at = |c: &str| chars.iter().find(|x| x.0 == c).map(|x| (x.1, x.2)).unwrap_or_else(|| panic!("{c}: {items:?}"));
(shape.expect("a shape"), at("A"), at("b"))
};
let ((rect, kind, stroked), (size, gx), (base, bx)) = draw(EncloseShape::Circle, EncloseStyle::Shrink);
assert_eq!(kind, wordcraft_doc::para::ShapeKind::Ellipse);
assert!(stroked);
assert!(size < base * 0.8, "the text shrinks: {size} vs {base}");
assert!((rect.h - base * 1.05).abs() < 0.1 && (rect.w - rect.h).abs() < 0.1, "{rect:?}");
assert!(gx > rect.x && gx < rect.x + rect.w, "the text is inside: {gx} {rect:?}");
assert!(bx >= rect.x + rect.w, "the next text follows the shape: {bx} {rect:?}");
let ((rect, kind, _), (size, _), (base, _)) = draw(EncloseShape::Diamond, EncloseStyle::Enlarge);
assert_eq!(kind, wordcraft_doc::para::ShapeKind::Diamond);
assert!((size - base).abs() < 0.01 && rect.h > base * 1.5, "the shape grows: {rect:?}");
// A general overstrike: both arguments at the same place, no shape.
let mut d = Document::from_text("x");
d.insert_object(
&Pos::body(0, 1),
InlineObject::Field { instr: "eq \\o(=,/)".into(), result: String::new(), locked: false, code: false },
&Default::default(),
)
.unwrap();
let l = lay(&d);
let items = display::page_display(&d, &l.pages[0], &Default::default());
assert!(!items.iter().any(|i| matches!(i, display::Draw::Shape { .. })));
assert!(items.iter().any(|i| matches!(i, display::Draw::Glyphs { text, .. } if text.contains("=/"))), "{items:?}");
}
#[test]
fn ruby_text_sits_over_its_base_and_the_line_grows_to_fit() {
use wordcraft_doc::para::RubyAlign;
+92 -1
View File
@@ -1,6 +1,6 @@
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
//! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Table
//! Properties, Command search, Paste Special, About, Save Changes, the password to open a
//! Properties, Enclose Characters, Command search, Paste Special, About, Save Changes, the password to open a
//! document and Encrypt with Password, and the mail-merge Recipient List, Insert Merge Field, Find
//! Recipient, merge rules, Match Fields and Check for Errors (Customize Keyboard is in
//! `dialogs_keyboard`, Customize Ribbon in `dialogs_ribbon`). Every dialog ends by running a
@@ -274,6 +274,13 @@ pub enum Dialog {
CustomizeRibbon {
form: Box<crate::dialogs_ribbon::RibbonForm>,
},
/// Home › Font › Enclose Characters: the style (`none` takes the enclosure away, `shrink`,
/// `enlarge`), the text and the shape (`circle`, `square`, `triangle`, `diamond`).
Enclose {
style: String,
text: String,
shape: String,
},
}
/// The Table Properties dialog's fields. Lengths are in the interface unit
@@ -684,6 +691,7 @@ impl Dialog {
Dialog::TrackOptions { .. } => "trackChangesOptions",
Dialog::CustomizeKeyboard { .. } => "customizeKeyboard",
Dialog::CustomizeRibbon { .. } => "customizeRibbon",
Dialog::Enclose { .. } => "enclose",
}
}
@@ -821,6 +829,16 @@ impl Dialog {
let form = Box::new(TableForm::read(app)?);
Dialog::TableProperties { basis: form.clone(), form }
}
"enclose" => {
// The selection's text and its enclosure, if it has one.
let t = wordcraft_engine::cmd::format::enclose_target(&app.session).ok();
let cur = t.as_ref().and_then(|t| t.current.clone());
Dialog::Enclose {
style: cur.as_ref().map_or("shrink", |e| e.style.name()).into(),
shape: cur.as_ref().map_or("circle", |e| e.shape.name()).into(),
text: t.map(|t| t.text).unwrap_or_default(),
}
}
"pasteSpecial" => Dialog::paste_special(app, &json!({})),
"about" => Dialog::About { tab: 0 },
"contributors" => Dialog::About { tab: 1 },
@@ -1031,6 +1049,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::TrackOptions { .. } => "Track Changes Options",
Dialog::CustomizeKeyboard { .. } => "Customize Keyboard",
Dialog::CustomizeRibbon { .. } => "Customize Ribbon",
Dialog::Enclose { .. } => "Enclose Characters",
};
let id = egui::Id::new(("dialog", title));
let shown = place_dialog(ctx, id, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false)).open(&mut open).show(ctx, |ui| {
@@ -1331,6 +1350,36 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
Dialog::Enclose { style, text, shape } => {
ui.label(egui::RichText::new(tl!("Style")).font(semibold(12.5)));
ui.horizontal(|ui| {
for (v, l) in [("none", "None"), ("shrink", "Shrink text"), ("enlarge", "Enlarge symbol")] {
ui.radio_value(style, v.to_string(), tl!(l));
}
});
ui.add_space(6.0);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.label(tl!("Text:"));
ui.add(egui::TextEdit::singleline(text).desired_width(110.0).char_limit(2));
ui.add_space(6.0);
enclose_preview(ui, style, text, shape);
});
ui.add_space(12.0);
ui.vertical(|ui| {
ui.label(tl!("Enclosure"));
for (v, l) in [("circle", "Circle"), ("square", "Square"), ("triangle", "Triangle"), ("diamond", "Diamond")] {
ui.add_enabled_ui(style != "none", |ui| ui.selectable_value(shape, v.to_string(), tl!(l)));
}
});
});
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let params = if style == "none" { json!({"shape": null}) } else { json!({"shape": shape, "style": style, "text": text}) };
let _ = app.run("format.enclose", params);
}
ok || cancel
}
Dialog::TableProperties { form, basis } => {
table_properties(ui, form);
let (ok, cancel) = buttons(ui, tl!("OK"));
@@ -2390,6 +2439,48 @@ fn describe(st: &Value) -> String {
parts.join(", ")
}
/// Enclose Characters' preview: the text in the chosen shape, shrunk or with the shape enlarged
/// (drawn, so it doesn't depend on the interface font having ○ □ △ ◇).
fn enclose_preview(ui: &mut Ui, style: &str, text: &str, shape: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(110.0, 64.0), Sense::hover());
let p = ui.painter_at(r);
p.rect_filled(r, 2.0, t.input);
p.rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
let ink = ui.visuals().text_color();
let c = r.center();
let (box_h, text_px) = match style {
"none" => (0.0, 26.0),
"enlarge" => (48.0, 26.0),
_ => (34.0, 20.0),
};
let h = box_h / 2.0;
let stroke = egui::Stroke::new(1.5, ink);
match (shape, box_h > 0.0) {
(_, false) => {}
("square", _) => {
p.rect_stroke(egui::Rect::from_center_size(c, vec2(box_h, box_h)), 0.0, stroke, egui::StrokeKind::Middle);
}
("triangle", _) => {
let pts = vec![egui::pos2(c.x, c.y - h), egui::pos2(c.x + h * 1.1, c.y + h), egui::pos2(c.x - h * 1.1, c.y + h)];
p.add(egui::Shape::closed_line(pts, stroke));
}
("diamond", _) => {
let pts = vec![egui::pos2(c.x, c.y - h), egui::pos2(c.x + h, c.y), egui::pos2(c.x, c.y + h), egui::pos2(c.x - h, c.y)];
p.add(egui::Shape::closed_line(pts, stroke));
}
_ => {
p.circle_stroke(c, h, stroke);
}
}
let px = match (style, shape) {
("shrink", "triangle" | "diamond") => text_px * 0.75,
_ => text_px,
};
let dy = if shape == "triangle" && box_h > 0.0 { h * 0.3 } else { 0.0 };
p.text(c + vec2(0.0, dy), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(px), ink);
}
#[cfg(test)]
mod tests {
use super::*;
+5
View File
@@ -1410,3 +1410,8 @@
New Group Nuevo grupo
Rename… Cambiar nombre…
Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido:
Enclose Characters Encerrar caracteres
Shrink text Reducir texto
Enlarge symbol Agrandar símbolo
Enclosure Contorno
Circle Círculo
Can't render this file because it contains an unexpected character in line 6 and column 25.
+5
View File
@@ -1396,6 +1396,11 @@
Ribbon Lint
Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba:
Ribbon tabs Lindi vahekaardid
Enclose Characters Ümbritsetud märgid
Shrink text Vähenda teksti
Enlarge symbol Suurenda sümbolit
Enclosure Ümbris
Circle Ring
Base text: Alustekst:
Distribute letters Jaota tähed
Distribute spacing Jaota vahed
Can't render this file because it contains an unexpected character in line 421 and column 13.
+5
View File
@@ -1394,3 +1394,8 @@
New Group 新しいグループ
Rename… 名前の変更…
Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー:
Enclose Characters 囲い文字
Shrink text 文字を縮小
Enlarge symbol 記号を拡大
Enclosure 囲い
Circle 円
Can't render this file because it contains an unexpected character in line 504 and column 13.
+5
View File
@@ -1406,3 +1406,8 @@
New Group Novo Grupo
Rename… Renomear…
Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido:
Enclose Characters Envolver caracteres
Shrink text Reduzir texto
Enlarge symbol Ampliar símbolo
Enclosure Contorno
Circle Círculo
Can't render this file because it contains an unexpected character in line 6 and column 25.
+5
View File
@@ -1767,6 +1767,11 @@ preview Title Пример
New Group Создать группу
Rename… Переименовать…
Ribbon and Quick Access Toolbar: Лента и панель быстрого доступа:
Enclose Characters Обрамление символов
Shrink text Уменьшить текст
Enlarge symbol Увеличить символ
Enclosure Обрамление
Circle Круг
Base text: Основной текст:
Distribute letters Распределить буквы
Distribute spacing Распределить интервал
Can't render this file because it contains an unexpected character in line 7 and column 25.
+5
View File
@@ -1404,3 +1404,8 @@
New Group Nova grupa
Rename… Preimenuj…
Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup:
Enclose Characters Uokviri znakove
Shrink text Smanji tekst
Enlarge symbol Uvećaj simbol
Enclosure Okvir
Circle Krug
Can't render this file because it contains an unexpected character in line 6 and column 25.
+5
View File
@@ -1408,3 +1408,8 @@
New Group Нова група
Rename… Преименуј…
Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ:
Enclose Characters Уоквири знакове
Shrink text Смањи текст
Enlarge symbol Увећај симбол
Enclosure Оквир
Circle Круг
Can't render this file because it contains an unexpected character in line 11 and column 25.
+6 -1
View File
@@ -1384,6 +1384,12 @@
New Group Yeni Grup
Rename… Yeniden Adlandır…
Ribbon and Quick Access Toolbar: Şerit ve Hızlı Erişim Araç Çubuğu:
Enclose Characters Karakterleri Çevrele
Shrink text Metni küçült
Enlarge symbol Simgeyi büyüt
Enclosure Çerçeve
Circle Daire
Square Kare
Base text: Ana metin:
Distribute letters Harfleri dağıt
Distribute spacing Aralığı dağıt
@@ -1393,7 +1399,6 @@
Ruby text: Ruby metni:
Size (pt): Boyut (pt):
In Line with Text Metinle Aynı Hizada
Square Kare
Through Geçişli
Top and Bottom Üst ve Alt
Behind Text Metnin Arkasında
Can't render this file because it contains an unexpected character in line 430 and column 13.
+5
View File
@@ -1406,3 +1406,8 @@
New Group Нова група
Rename… Перейменувати…
Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу:
Enclose Characters Обведення символів
Shrink text Зменшити текст
Enlarge symbol Збільшити символ
Enclosure Обведення
Circle Коло
Can't render this file because it contains an unexpected character in line 6 and column 31.
+5
View File
@@ -1406,3 +1406,8 @@
New Group 新建组
Rename… 重命名…
Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏:
Enclose Characters 带圈字符
Shrink text 缩小文字
Enlarge symbol 增大圈号
Enclosure 圈号
Circle 圆形
Can't render this file because it contains an unexpected character in line 7 and column 25.
+5
View File
@@ -1395,3 +1395,8 @@
New Group 新增群組
Rename… 重新命名…
Ribbon and Quick Access Toolbar: 功能區和快速存取工具列:
Enclose Characters 圍繞字元
Shrink text 縮小文字
Enlarge symbol 放大符號
Enclosure 圍繞符號
Circle 圓形
Can't render this file because it contains an unexpected character in line 505 and column 13.
+4
View File
@@ -120,6 +120,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
pen.rect(3.0, 3.0, 17.0, 17.0, a);
pen.text(10.0, 6.0, 9.0, "A", c, false);
}
"enclose" => {
pen.circle(10.0, 10.5, 7.5, a);
pen.text(10.0, 10.5, 9.0, "A", c, false);
}
"subscript" => {
pen.text(8.0, 9.0, 13.0, "x", c, false);
pen.text(15.0, 14.5, 8.0, "2", a, true);
+21 -1
View File
@@ -1124,7 +1124,7 @@ fn keeps_everything(name: &str) -> bool {
/// the command's own error or default instead): Select Recipients and Edit Recipient List without
/// data, Insert Merge Field without a field, Find Recipient without text, the If and Skip
/// Record If rules (or Rules with no rule at all) without a field, Table Properties without
/// settings, and Symbol and Field without a character or code.
/// settings, Symbol and Field without a character or code, and Enclose Characters without a shape.
fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
let has = |k: &str| params.get(k).is_some_and(|v| !v.is_null());
let rule = params.get("rule").and_then(Value::as_str).map(str::to_ascii_uppercase);
@@ -1157,6 +1157,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
"insert.bookmark" if !has("name") => Some("bookmark"),
// Word Count from the keyboard or command search shows its dialog, as the ribbon does.
"review.wordCount" if params.as_object().is_none_or(|m| m.is_empty()) => Some("wordCount"),
// Enclose Characters without a shape (null is one: it takes the enclosure away).
"format.enclose" if params.get("shape").is_none() => Some("enclose"),
_ => None,
}
}
@@ -1268,6 +1270,24 @@ mod tests {
assert_eq!(dialogs::TableForm::read(&a).unwrap().changes(&f), json!({}), "the dialog reopens with the new values");
}
/// Home › Font › Enclose Characters (#297): the button opens the dialog on the selected
/// character; the dialog's OK is `format.enclose`, and it reopens showing the enclosure.
#[test]
fn enclose_characters_button_opens_the_dialog() {
let mut a = app();
a.run("text.insert", json!({"text": "a 字 b"})).unwrap();
a.run("select.text", json!({"text": "字"})).unwrap();
a.run("format.enclose", json!({})).unwrap();
let Some(dialogs::Dialog::Enclose { style, text, shape }) = a.dialog.clone() else { panic!("no dialog: {:?}", a.dialog) };
assert_eq!((style.as_str(), text.as_str(), shape.as_str()), ("shrink", "字", "circle"));
a.dialog = None;
a.run("format.enclose", json!({"shape": "square", "style": "enlarge", "text": "字"})).unwrap();
assert!(a.dialog.is_none(), "a shape applies without the dialog");
a.run("format.enclose", json!({})).unwrap();
let Some(dialogs::Dialog::Enclose { style, shape, .. }) = a.dialog.clone() else { panic!("no dialog") };
assert_eq!((style.as_str(), shape.as_str()), ("enlarge", "square"));
}
/// Issue #139: Ctrl+wheel over the page didn't zoom.
#[test]
fn ctrl_wheel_over_canvas_zooms() {
+1
View File
@@ -390,6 +390,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
small(ui, app, "subscript", None, "Subscript", "format.subscript", json!({}), flag("subscript"));
small(ui, app, "superscript", None, "Superscript", "format.superscript", json!({}), flag("superscript"));
small(ui, app, "charborder", None, "Character Border", "format.border", json!({}), flag("border"));
small(ui, app, "enclose", None, "Enclose Characters", "format.enclose", json!({}), false);
ui.add_space(4.0);
menu_button(ui, app, "effects", None, "Text Effects and Typography", false, |ui, app| {
mi(ui, app, "Outline", "format.outline", json!({}));
+2 -1
View File
@@ -52,7 +52,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 |
| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF/ODT ignore them |
| Move tracking (`w:moveFrom`/`w:moveTo`) | 🟡 | ❌ | Read as plain insert/delete |
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result |
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297) |
| Content controls (`w:sdt`) | 🟡 | ❌ | Content kept, the control (type, binding, placeholder, lock) dropped |
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through |
@@ -89,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 | Enclosed characters (`EQ \o\ac`) read, drawn and written (#297) |
| 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) |
+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, 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) |
| 17 | **East Asian typography**: vertical text, document grid, Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 16–24 | [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 | Enclose Characters landed (#297); removed from the East Asian typography gap |
| 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) |
+1
View File
@@ -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 | `EQ \o` fields overstrike their arguments; enclosed characters draw their circle, square, triangle or diamond as vectors (#297) |
| 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) |
+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.
**419 of 448 catalog features have commands (93.5%).**
**420 of 448 catalog features have commands (93.8%).**
| Tab | Live | Total |
|---|---|---|
| File | 20 | 20 |
| Quick Access | 4 | 4 |
| Home | 100 | 102 |
| Home | 101 | 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 › Enclose Characters (`format.enclose`)
- Voice › Dictate (`tools.dictate`)
### Insert
+3 -2
View File
@@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **402 / 434 (92.6%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Ribbon/menu catalog coverage | **403 / 434 (92.9%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated |
| Feature depth (weighted by use, table below) | **~68%** | estimated |
| **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) |
@@ -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, Phonetic Guide (#288). Missing: 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), Enclose Characters (#297). Missing: 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). |
@@ -249,6 +249,7 @@ can be used without committing Word output.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 402/434 → 403/434 (92.9%) |
| 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 |
+2 -1
View File
@@ -16,7 +16,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 |
| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 |
| Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 |
| Modal dialogs | **36** (`Dialog` enum: Font, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors) plus panes and menus for the rest | ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~40% | 20–30 |
| Modal dialogs | **37** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors) plus panes and menus for the rest | ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~40% | 20–30 |
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility, Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); objects and tables partial | rich per context | 55% | 3–5 |
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268) | | 68% | 4–6 |
@@ -48,6 +48,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| 2026-10-10 | trivial | Tabs dialog (Paragraph › Tabs…, double-click a ruler tab) and Borders and Shading with Borders, Page Border and Shading tabs (#320) |
| 2026-10-10 | trivial | Columns (presets, unequal widths, line between, apply to), Symbol (font coverage grid by Unicode subset, recently used, character code, Special Characters) and Field (categories, options, field codes) dialogs (#321) |
| 2026-10-10 | trivial | Define New Multilevel List (all nine levels, live preview, `list.define`) and Track Changes Options (show, balloons, insertion/deletion marks and colours, changed-line bars; `review.trackingOptions`, saved per user) (#328) |
| 2026-10-11 | trivial | Enclose Characters dialog and Home › Font button (#297) |
| 2026-10-10 | trivial | Shortcuts: Alt+Shift+Up/Down move paragraphs, Alt+Shift+Left/Right promote/demote (⌃⇧ arrows on macOS), Shift+F5 Go Back, Alt+X Toggle Unicode (#343) |
| 2026-10-10 | trivial | Rotation handle on pictures, shapes and text boxes; Rotate menu (90° and flips) and a Rotation box in Picture/Shape Format › Size (#332) |
| 2026-10-10 | trivial | System interface theme keeps following OS appearance changes (no longer pins the macOS window); Dark page no longer darkens the interface (#311, #312) |