mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
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:
32 files changed
+1030
-17
No files matched your search
+3
-1
@@ -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 |
|
||||
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())));
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user