Files
wordcraft/crates/engine/src/cmd/format.rs
T
dab5092e83 Font dialog: separate Asian and Latin text fonts (#505) (#536)
* Font list: installed Chinese fonts are listed, by their Chinese names in a Chinese interface (#505)

The font database read only the English family name of each font, so a
Chinese interface listed SimSun, Microsoft YaHei and WenQuanYi Zen Hei
by their English names and never as 宋体, 微软雅黑 or 文泉驿正黑, and a
document naming a font by its Chinese name fell back to another font.

- fonts: keep each face's localized family names (name IDs 16 and 1,
  every face of a collection, read during the scan without loading the
  file); FontDb::families_in(lang) lists them in place of the English
  names for a matching interface language (zh-hans, zh-hant, ja, ko);
  face, has_family and styles accept either name.
- ui: the ribbon and mini toolbar font boxes show the interface
  language's names and store the English name.
- East Asian font: choosing a Chinese, Japanese or Korean font also sets
  it as the East Asian font (w:eastAsia), read and written in DOCX;
  layout draws CJK text in a run's installed East Asian font.

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

* Font dialog: separate Asian and Latin text fonts (#505)

Word's Font dialog sets the font of Chinese, Japanese and Korean text
apart from the font of Latin text; WordCraft's had one font box.

- Font dialog: an "Asian text font" box (the installed Chinese, Japanese
  and Korean fonts, by their interface-language names, type to find)
  above the "Latin text font" box, shown for a Chinese or Japanese
  interface, East Asian text in the selection or a selection with an
  Asian font. OK sets only the fonts changed, in one undo step.
- format.font takes "eastAsia" (w:eastAsia) besides "name"; either or
  both; format.state reports fontEastAsia.
- fonts: the scan reads each font's OS/2 code pages, so
  FontDb::east_asian_families lists CJK fonts without loading them.
- Tests: dialog sets each font on its own; layout sets CJK text in the
  run's Asian font and Latin text in its Latin font (skipped without an
  installed CJK font); command and undo; the scan finds a CJK font.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:42:56 -04:00

722 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Character formatting (Home › Font).
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;
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Word's font size list.
pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| {
// Like Word's button, for complex-script (Persian, Arabic) text too: an unset `bold_cs`
// follows `bold`.
toggle(
s,
v,
|r| r.bold,
|c, on| {
c.bold = Some(on);
c.bold_cs = None;
},
)
})
.key("Mod+B")
.params(r#"{"value"?: bool}"#),
CommandSpec::new("format.italic", "Italic", "Home › Font", |s, v| {
toggle(
s,
v,
|r| r.italic,
|c, on| {
c.italic = Some(on);
c.italic_cs = None;
},
)
})
.key("Mod+I")
.params(r#"{"value"?: bool}"#),
CommandSpec::new("format.underline", "Underline", "Home › Font", underline)
.key("Mod+U")
.params(r#"{"value"?: bool, "style"?: "single|double|thick|dotted|dash|dotDash|dotDotDash|wave|words"}"#),
CommandSpec::new("format.doubleUnderline", "Double Underline", "Home › Font › Underline", |s, _| {
underline(s, &json!({"style": "double"}))
})
.key("Mod+Shift+D"),
CommandSpec::new("format.wordUnderline", "Underline Words Only", "Home › Font › Underline", |s, _| {
underline(s, &json!({"style": "words"}))
})
.key("Mod+Shift+W"),
CommandSpec::new("format.strikethrough", "Strikethrough", "Home › Font", |s, v| toggle(s, v, |r| r.strike, |c, on| c.strike = Some(on)))
.params(r#"{"value"?: bool}"#),
// Note: off clears the direct property only; a border inherited from a character style stays on.
CommandSpec::new("format.border", "Character Border", "Home › Font", |s, v| {
toggle(s, v, |r| r.border.is_some(), |c, on| c.border = on.then(|| Border::single(0.5)))
})
.params(r#"{"value"?: bool}"#),
CommandSpec::new("format.doubleStrikethrough", "Double Strikethrough", "Home › Font › Font", |s, v| {
toggle(s, v, |r| r.double_strike, |c, on| c.double_strike = Some(on))
}),
CommandSpec::new("format.subscript", "Subscript", "Home › Font", |s, v| {
toggle(
s,
v,
|r| r.vert_align == VertAlign::Subscript,
|c, on| c.vert_align = Some(if on { VertAlign::Subscript } else { VertAlign::Baseline }),
)
})
.key("Mod+="),
CommandSpec::new("format.superscript", "Superscript", "Home › Font", |s, v| {
toggle(
s,
v,
|r| r.vert_align == VertAlign::Superscript,
|c, on| c.vert_align = Some(if on { VertAlign::Superscript } else { VertAlign::Baseline }),
)
})
.key("Mod+Shift+="),
CommandSpec::new("format.allCaps", "All Caps", "Home › Font › Font", |s, v| toggle(s, v, |r| r.caps, |c, on| c.caps = Some(on)))
.key("Mod+Shift+A"),
CommandSpec::new("format.smallCaps", "Small Caps", "Home › Font › Font", |s, v| {
toggle(s, v, |r| r.small_caps, |c, on| c.small_caps = Some(on))
})
.key("Mod+Shift+K"),
CommandSpec::new("format.hidden", "Hidden", "Home › Font › Font", |s, v| toggle(s, v, |r| r.hidden, |c, on| c.hidden = Some(on)))
.key("Mod+Shift+H"),
CommandSpec::new("format.outline", "Outline", "Home › Font › Text Effects", |s, v| {
toggle(s, v, |r| r.outline, |c, on| c.outline = Some(on))
}),
CommandSpec::new("format.shadow", "Shadow", "Home › Font › Text Effects", |s, v| toggle(s, v, |r| r.shadow, |c, on| c.shadow = Some(on))),
CommandSpec::new("format.emboss", "Emboss", "Home › Font › Font", |s, v| toggle(s, v, |r| r.emboss, |c, on| c.emboss = Some(on))),
CommandSpec::new("format.engrave", "Engrave", "Home › Font › Font", |s, v| toggle(s, v, |r| r.engrave, |c, on| c.engrave = Some(on))),
CommandSpec::new("format.font", "Font", "Home › Font", font).key("Mod+Shift+F").params(r#"{"name"?: string, "eastAsia"?: string}"#),
CommandSpec::new("format.size", "Font Size", "Home › Font", size).key("Mod+Shift+P").params(r#"{"size": number}"#),
CommandSpec::new("format.growFont", "Increase Font Size", "Home › Font", |s, _| step_size(s, 1)).key("Mod+Shift+. / Mod+Shift+>"),
CommandSpec::new("format.shrinkFont", "Decrease Font Size", "Home › Font", |s, _| step_size(s, -1)).key("Mod+Shift+, / Mod+Shift+<"),
CommandSpec::new("format.growFont1", "Grow Font 1 Point", "Home › Font", |s, _| nudge_size(s, 1.0)).key("Mod+]"),
CommandSpec::new("format.shrinkFont1", "Shrink Font 1 Point", "Home › Font", |s, _| nudge_size(s, -1.0)).key("Mod+["),
CommandSpec::new("format.color", "Font Color", "Home › Font", color).params(r#"{"color": "RRGGBB" | "auto"}"#),
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight)
.params(
r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#,
)
// ⌘⌥H hides other apps on macOS.
.key(super::non_mac("Mod+Alt+H")),
CommandSpec::new("format.shading", "Character Shading", "Home › Font", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse);
apply(s, &|x| x.shading = c)
})
.params(r#"{"color": "RRGGBB" | null}"#),
CommandSpec::new("format.changeCase", "Change Case", "Home › Font", change_case)
.key("Shift+F3")
.params(r#"{"mode"?: "sentence|lower|upper|title|toggle"}"#),
CommandSpec::new("format.clear", "Clear All Formatting", "Home › Font", clear),
// Word's Ctrl+Space; ⌘Space is Spotlight, so Word for Mac uses ⌃Space.
CommandSpec::new("format.resetChar", "Reset Character Formatting", "Home › Font", reset_char).key(super::mac_or("Ctrl+Space", "Mod+Space")),
CommandSpec::new("format.spacing", "Character Spacing", "Home › Font › Font › Advanced", |s, v| {
let x = p::req_f32(v, "points")?.clamp(-100.0, 100.0);
apply(s, &|c| c.spacing = Some(x))
})
.params(r#"{"points": number}"#),
CommandSpec::new("format.scale", "Character Scale", "Home › Font › Font › Advanced", |s, v| {
let x = p::req_f32(v, "percent")?.clamp(1.0, 600.0);
apply(s, &|c| c.scale = Some(x))
})
.params(r#"{"percent": number}"#),
CommandSpec::new("format.position", "Character Position", "Home › Font › Font › Advanced", |s, v| {
let x = p::req_f32(v, "points")?.clamp(-1584.0, 1584.0);
apply(s, &|c| c.position = Some(x))
})
.params(r#"{"points": number}"#),
CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#),
CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#),
CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| {
s.ui_requests.push(json!({"open": "font"}));
sel_result(s)
})
.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(),
]
}
/// The selection to format: the selection, or the word around a collapsed caret (Word
/// formats the whole word when the caret is inside one). `None` = caret between words.
fn target(s: &Session) -> Option<(Pos, Pos)> {
let (a, b) = s.sel.ordered();
if a != b {
return Some((a, b));
}
let p = s.doc.para_at(&a)?;
let is_w = |c: char| c.is_alphanumeric() || c == '\'' || c == '_';
let before = p.text.get(..a.off)?.chars().next_back();
let after = p.text.get(a.off..)?.chars().next();
if before.is_some_and(is_w) && after.is_some_and(is_w) {
let (x, y) = p.word_at(a.off);
// word_at includes trailing spaces; Word formats just the word.
let word_end = p.text.get(x..y).map(|w| x + w.trim_end().len()).unwrap_or(y);
return Some((Pos { off: x, ..a.clone() }, Pos { off: word_end, ..a }));
}
None
}
/// The non-empty rows of a column selection, if one is active.
fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
let segs = s.column_segments()?;
let rows: Vec<(Pos, Pos)> = segs
.iter()
.map(|(a, b)| (s.doc.clamp(a), s.doc.clamp(b)))
.filter(|(a, b)| a != b && a.path == b.path && a.story == b.story)
.map(|(a, b)| if a <= b { (a, b) } else { (b, a) })
.collect();
// An empty block (column mode just started) formats like a caret.
(!rows.is_empty()).then_some(rows)
}
/// Apply a change to the selection (or the pending caret formatting).
pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
// A column selection formats each row's piece.
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
s.doc.format_range(&a, &b, f)?;
}
return sel_result(s);
}
match target(s) {
Some((a, b)) => {
s.doc.format_range(&a, &b, f)?;
}
None => {
let mut pend = s.typing_props();
f(&mut pend);
s.pending = Some(pend);
// An empty paragraph's mark takes the formatting too (so it shows the new size).
let fpos = s.sel.focus.clone();
if s.doc.para_at(&fpos).is_some_and(|p| p.is_empty())
&& let Ok(para) = s.doc.para_mut(fpos.story, &fpos.path)
{
f(&mut para.mark);
para.touch();
}
}
}
sel_result(s)
}
/// Resolved formatting of every character in the target (or the caret).
fn resolved(s: &Session) -> Vec<ResolvedChar> {
let mut out = Vec::new();
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
let Some(p) = s.doc.para_at(&a) else { continue };
for (r, c) in p.run_ranges() {
if r.end > a.off && r.start < b.off {
out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), c));
}
}
if out.len() > 10_000 {
break;
}
}
if !out.is_empty() {
return out;
}
}
let Some((a, b)) = target(s) else {
let f = &s.sel.focus;
let style = s.doc.para_at(f).and_then(|p| p.props.style.clone());
out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()));
return out;
};
for path in s.doc.paths_between(&a, &b) {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
for (r, c) in p.run_ranges() {
if r.end > from && r.start < to {
out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), c));
}
}
if out.len() > 10_000 {
break;
}
}
if out.is_empty() {
let style = s.doc.para_at(&a).and_then(|p| p.props.style.clone());
out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()));
}
out
}
fn toggle(s: &mut Session, v: &Value, get: fn(&ResolvedChar) -> bool, set: fn(&mut CharProps, bool)) -> CmdResult {
let on = match p::bool(v, "value") {
Some(b) => b,
None => !resolved(s).iter().all(get),
};
apply(s, &|c| set(c, on))
}
fn underline(s: &mut Session, v: &Value) -> CmdResult {
let style = p::str(v, "style").map(Underline::from_ooxml);
let on = match (p::bool(v, "value"), style) {
(Some(b), _) => b,
(None, Some(_)) => true,
(None, None) => !resolved(s).iter().all(|r| r.underline != Underline::None),
};
let u = if on { style.unwrap_or(Underline::Single) } else { Underline::None };
apply(s, &|c| c.underline = Some(u))
}
/// `format.font`: `name` is the font (Latin text, and Persian/Arabic text too); `eastAsia` sets the
/// East Asian text's font on its own, as the Font dialog's Asian text font box does (#505).
fn font(s: &mut Session, v: &Value) -> CmdResult {
let arg = |k: &str| -> Result<Option<String>, CmdError> {
let Some(n) = v.get(k) else { return Ok(None) };
let n = n.as_str().map(str::trim).unwrap_or("");
if n.is_empty() || n.len() > 128 {
return Err(CmdError::Params(format!("bad font name `{k}`")));
}
Ok(Some(n.to_string()))
};
let (name, east_asia) = (arg("name")?, arg("eastAsia")?);
if name.is_none() && east_asia.is_none() {
return Err(CmdError::Params("`name` or `eastAsia` (string) is required".into()));
}
// The font applies to Persian/Arabic text in the selection too (its complex-script font). A
// Chinese, Japanese or Korean font is the East Asian text's font as well (`w:eastAsia`), as in
// Word; choosing a Latin font leaves that text's font as it was.
let ea = east_asia.or_else(|| {
name.clone().filter(|n| {
let r = wordcraft_fonts::word::resolve(n, false, false);
!r.substituted && r.face.is_east_asian()
})
});
apply(s, &|c| {
if let Some(n) = &name {
c.font = Some(n.clone());
c.font_cs = Some(n.clone());
}
if let Some(n) = &ea {
c.font_ea = Some(n.clone());
}
})
}
fn size(s: &mut Session, v: &Value) -> CmdResult {
let sz = p::req_f32(v, "size")?;
if !(1.0..=1638.0).contains(&sz) {
return Err(CmdError::Params("size must be between 1 and 1638".into()));
}
// Word rounds to half points.
let sz = (sz * 2.0).round() / 2.0;
apply(s, &|c| {
c.size = Some(sz);
c.size_cs = None;
})
}
fn current_size(s: &Session) -> f32 {
resolved(s).first().map(|r| r.size).unwrap_or(12.0)
}
fn step_size(s: &mut Session, dir: i32) -> CmdResult {
let cur = current_size(s);
let next = if dir > 0 {
SIZES.iter().copied().find(|x| *x > cur + 0.01).unwrap_or(((cur / 10.0).floor() + 1.0) * 10.0)
} else {
SIZES.iter().rev().copied().find(|x| *x < cur - 0.01).unwrap_or(1.0)
};
let next = next.clamp(1.0, 1638.0);
// Each run steps from its own size when the selection mixes sizes.
apply(s, &|c| {
c.size = Some(next);
c.size_cs = None;
})
}
fn nudge_size(s: &mut Session, d: f32) -> CmdResult {
let next = (current_size(s) + d).clamp(1.0, 1638.0);
apply(s, &|c| {
c.size = Some(next);
c.size_cs = None;
})
}
fn color(s: &mut Session, v: &Value) -> CmdResult {
let c = p::req_str(v, "color")?;
let tc = if c.eq_ignore_ascii_case("auto") || c.eq_ignore_ascii_case("automatic") {
TextColor::Auto
} else {
TextColor::Rgb(Rgb::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))?)
};
apply(s, &|x| x.color = Some(tc))
}
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
let c = p::str(v, "color").unwrap_or("yellow");
let h = Highlight::ALL
.iter()
.copied()
.find(|h| {
h.ooxml().eq_ignore_ascii_case(c)
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|| format!("{h:?}").eq_ignore_ascii_case(c)
})
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?;
apply(s, &|x| x.highlight = Some(h))
}
fn change_case(s: &mut Session, v: &Value) -> CmdResult {
let (a, b) = s.sel.ordered();
let (a, b) = if a == b { target(s).unwrap_or((a, b)) } else { (a, b) };
if a == b {
return sel_result(s);
}
let text = s.doc.copy_range(&a, &b).plain_text();
let mode = p::str(v, "mode").map(str::to_string).unwrap_or_else(|| {
// Shift+F3 cycles: lower → UPPER → Title → lower.
if text.chars().any(char::is_lowercase) && text.chars().any(char::is_uppercase) && !is_title(&text) {
"lower".into()
} else if !text.chars().any(char::is_uppercase) {
"upper".into()
} else if text.chars().any(char::is_lowercase) {
"lower".into()
} else {
"title".into()
}
});
for path in s.doc.paths_between(&a, &b) {
// Changed stretches of one run each: (start, end, replacement), in text order.
let groups = {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
let Some(src) = p.text.get(from..to) else { continue };
case_groups(p, from, src, &convert_chars(src, &mode, from == 0))
};
if groups.is_empty() {
continue;
}
// Each stretch keeps its own run's formatting (#421); right to left so offsets hold.
let para = s.doc.para_mut(a.story, &path)?;
for (start, end, new) in groups.iter().rev() {
if new.len() == end - start {
if para.text.get(*start..*end).is_some() {
para.text.replace_range(*start..*end, new);
}
} else {
let props = para.props_of_char(*start).clone();
para.delete(*start, *end)?;
para.insert_text(*start, new, &props)?;
}
}
para.touch();
// The selection keeps covering the converted text when its length changed (#420).
let spans: Vec<(usize, usize, usize)> = groups.iter().map(|(x, y, n)| (*x, *y, n.len())).collect();
for end in [&mut s.sel.anchor, &mut s.sel.focus] {
if end.story == a.story && end.path == path {
end.off = map_offset(&spans, end.off);
}
}
}
sel_result(s)
}
/// Group the characters of `src` (at `from` in `p`) whose converted form `conv` differs into
/// stretches that stay inside one run: `(start, end, replacement)`, in text order.
fn case_groups(p: &wordcraft_doc::Paragraph, from: usize, src: &str, conv: &[String]) -> Vec<(usize, usize, String)> {
let runs: Vec<std::ops::Range<usize>> = p.run_ranges().map(|(r, _)| r).collect();
let run_of = |off: usize| runs.iter().position(|r| r.contains(&off));
let mut out: Vec<(usize, usize, String, Option<usize>)> = Vec::new();
for ((i, c), new) in src.char_indices().zip(conv) {
let (start, end) = (from + i, from + i + c.len_utf8());
let mut buf = [0u8; 4];
if new.as_str() == c.encode_utf8(&mut buf) {
continue;
}
let run = run_of(start);
match out.last_mut() {
Some(g) if g.1 == start && g.3 == run => {
g.1 = end;
g.2.push_str(new);
}
_ => out.push((start, end, new.clone(), run)),
}
}
out.into_iter().map(|(a, b, n, _)| (a, b, n)).collect()
}
/// Where `off` lands after the stretches `(start, end, new_len)` (text order) were replaced;
/// an offset inside a stretch moves to its end.
fn map_offset(spans: &[(usize, usize, usize)], off: usize) -> usize {
let mut out = off;
for &(start, end, new_len) in spans {
if end <= off {
out = out.saturating_sub(end.saturating_sub(start)).saturating_add(new_len);
} else if start < off {
out = out.saturating_sub(off - start).saturating_add(new_len);
}
}
out
}
fn is_title(t: &str) -> bool {
t.split_whitespace().all(|w| w.chars().next().is_some_and(char::is_uppercase))
}
#[cfg(test)]
fn convert(src: &str, mode: &str, para_start: bool) -> String {
convert_chars(src, mode, para_start).concat()
}
/// The case-converted form of each character of `src`, in order (one entry per char, so a
/// change of length such as ß → SS or İ → i̇ can be applied run by run).
fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> {
let chars: Vec<char> = src.chars().collect();
// Greek capital sigma lowers to the final form at the end of a word (as `str::to_lowercase`).
let lower = |i: usize, c: char| -> String {
let prev = i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_alphabetic());
let next = chars.get(i + 1).is_some_and(|n| n.is_alphabetic());
if c == 'Σ' && prev && !next { "ς".to_string() } else { c.to_lowercase().collect() }
};
// Title case capitalises the first word of any selection; sentence case only at a paragraph start.
let mut up = mode == "title" || para_start;
let mut out = Vec::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() {
let new = match mode {
"upper" => c.to_uppercase().collect(),
"lower" => lower(i, c),
"toggle" => {
if c.is_uppercase() {
lower(i, c)
} else {
c.to_uppercase().collect()
}
}
"title" => {
let n = if up && c.is_alphabetic() {
up = false;
c.to_uppercase().collect()
} else {
lower(i, c)
};
if c.is_whitespace() {
up = true;
}
n
}
_ => {
// Sentence case.
let n = if up && c.is_alphabetic() {
up = false;
c.to_uppercase().collect()
} else {
lower(i, c)
};
if matches!(c, '.' | '!' | '?') {
up = true;
}
n
}
};
out.push(new);
}
out
}
fn clear(s: &mut Session, _: &Value) -> CmdResult {
match target(s) {
Some((a, b)) => {
s.doc.format_range(&a, &b, &|c| *c = c.cleared())?;
// Clear Formatting also resets the paragraph to Normal.
s.doc.format_paragraphs(&a, &b, &|p| {
let num = p.numbering;
*p = Default::default();
p.numbering = num.filter(|_| false);
})?;
}
None => {
s.pending = Some(CharProps::default());
let f = s.sel.focus.clone();
s.doc.format_paragraphs(&f, &f, &|p| *p = Default::default())?;
}
}
sel_result(s)
}
/// Remove direct character formatting, keeping the paragraph and any character style.
fn reset_char(s: &mut Session, _: &Value) -> CmdResult {
match target(s) {
Some((a, b)) => s.doc.format_range(&a, &b, &|c| *c = CharProps { style: c.style.clone(), ..c.cleared() })?,
None => s.pending = Some(CharProps::default()),
}
sel_result(s)
}
fn char_style(s: &mut Session, v: &Value) -> CmdResult {
let st = p::req_str(v, "style")?;
let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no style `{st}`")))?;
apply(s, &|c| c.style = Some(id.clone()))
}
fn set(s: &mut Session, v: &Value) -> CmdResult {
let props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
apply(s, &|c| c.overlay(&props))
}
/// What the ribbon shows: the formatting at the selection (mixed values are null).
pub fn state(s: &Session) -> Value {
let all = resolved(s);
let first = all.first().cloned();
let same = |f: &dyn Fn(&ResolvedChar) -> Value| -> Value {
let Some(r) = &first else { return Value::Null };
let v0 = f(r);
if all.iter().all(|r| f(r) == v0) { v0 } else { Value::Null }
};
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into());
let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone());
json!({
"font": same(&|r| json!(r.font)),
"fontEastAsia": same(&|r| json!(r.font_ea)),
"size": same(&|r| json!(r.size)),
"bold": all.iter().all(|r| r.bold),
"italic": all.iter().all(|r| r.italic),
"underline": all.iter().all(|r| r.underline != Underline::None),
"strike": all.iter().all(|r| r.strike),
"border": all.iter().all(|r| r.border.is_some()),
"subscript": all.iter().all(|r| r.vert_align == VertAlign::Subscript),
"superscript": all.iter().all(|r| r.vert_align == VertAlign::Superscript),
"smallCaps": all.iter().all(|r| r.small_caps),
"caps": all.iter().all(|r| r.caps),
"hidden": all.iter().all(|r| r.hidden),
"spacing": same(&|r| json!(r.spacing)),
"color": same(&|r| json!(match r.color { TextColor::Auto => "auto".to_string(), TextColor::Rgb(c) => c.hex() })),
"highlight": same(&|r| json!(r.highlight.map(|c| c.hex()))),
"style": style,
"styleName": style_name,
})
}
#[cfg(test)]
mod tests {
use super::convert;
#[test]
fn case_conversions() {
assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again");
assert_eq!(convert("hello WORLD", "title", true), "Hello World");
assert_eq!(convert("Hello", "toggle", true), "hELLO");
assert_eq!(convert("straße", "upper", true), "STRASSE");
assert_eq!(convert("hello world", "title", false), "Hello World");
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
}
}
/// 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)
}