Merge PR #512 from NikitkaRa: Home: Replace Fonts dialog and font.replace command

# Conflicts:
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/ribbon.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:34:06 -04:00
9 changed files with 299 additions and 1 deletions
+78
View File
@@ -13,6 +13,16 @@ use crate::{DocState, Result, Selection, Session, Target, TextSel};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "font.used", "Fonts in Presentation", [], None, "{} → [family] (theme fonts resolved)", has_doc, fonts_used),
cmd!(
"font.replace",
"Replace Fonts",
["Home", "Editing", "Replace"],
None,
"{from, to} — every explicit use of family `from`, theme fonts included → {replaced}",
has_doc,
replace_font
),
cmd!(noundo "text.edit", "Edit Text", [], Some("Enter"), "{id?, at?: [paragraph, char], end?: bool, cell?: [row, col], notes?: bool}", has_slide, edit),
cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit),
cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert),
@@ -964,3 +974,71 @@ pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
}
ok()
}
fn fonts_used(s: &mut Session, _: &Value) -> Result<Value> {
let mut v = crate::fonts::used_fonts(&s.doc()?.doc);
v.sort_by_key(|f| f.to_lowercase());
Ok(json!(v))
}
fn replace_font(s: &mut Session, p: &Value) -> Result<Value> {
let from = str_param(p, "from").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `from`"))?.to_string();
let to = str_param(p, "to").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `to`"))?.to_string();
if to.len() > 256 || to.chars().any(char::is_control) {
return Err(bad("font.replace", "`to` is not a font name"));
}
// Count on a copy first so a replace that finds nothing leaves no undo step.
let mut probe = (*s.doc()?.doc).clone();
if crate::fonts::replace_font(&mut probe, &from, &to) == 0 {
return Ok(json!({"replaced": 0}));
}
let n = s.edit(|doc, _| Ok(crate::fonts::replace_font(doc, &from, &to)))?;
let st = s.doc_mut()?;
st.missing_fonts = crate::fonts::missing_fonts(&st.doc);
Ok(json!({"replaced": n}))
}
#[cfg(test)]
mod replace_font_tests {
use serde_json::json;
use crate::Session;
fn fonts(s: &mut Session) -> Vec<String> {
serde_json::from_value(s.execute("font.used", &json!({})).unwrap()).unwrap()
}
#[test]
fn replaces_runs_tables_notes_and_theme_and_undoes() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
let theme = s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin.clone();
s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "Hello"})).unwrap();
s.execute("text.exit", &json!({})).unwrap();
s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap();
assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue"), "{:?}", fonts(&mut s));
let r = s.execute("font.replace", &json!({"from": "comic neue", "to": "Lato"})).unwrap();
assert!(r["replaced"].as_u64().unwrap() >= 1, "{r}");
let used = fonts(&mut s);
assert!(!used.iter().any(|f| f == "Comic Neue") && used.iter().any(|f| f == "Lato"), "{used:?}");
// The theme's body font is replaced too, so placeholders follow.
s.execute("font.replace", &json!({"from": theme, "to": "Lato"})).unwrap();
assert_eq!(s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin, "Lato");
assert!(s.doc().unwrap().doc.validate().is_empty());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue"));
}
#[test]
fn nothing_to_replace_is_no_undo_step_and_bad_names_fail() {
let mut s = Session::with_new();
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("font.replace", &json!({"from": "Nope Sans", "to": "Inter"})).unwrap()["replaced"], 0);
assert_eq!(s.execute("font.replace", &json!({"from": "+mn-lt", "to": "Inter"})).unwrap()["replaced"], 0);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert!(s.execute("font.replace", &json!({"from": "", "to": "Inter"})).is_err());
assert!(s.execute("font.replace", &json!({"from": "Inter", "to": "a\nb"})).is_err());
assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err());
}
}
+89 -1
View File
@@ -36,7 +36,7 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {
}
/// The families the slides' text runs and bullets are drawn in.
fn used_fonts(p: &Presentation) -> Vec<String> {
pub fn used_fonts(p: &Presentation) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut add = |f: String| {
let f = f.trim().to_string();
@@ -78,3 +78,91 @@ fn body_fonts(ctx: &Ctx, s: &Shape, body: &TextBody, add: &mut dyn FnMut(String)
}
}
}
/// Replace the font family `from` with `to` everywhere it is named explicitly: text runs (and
/// their east Asian, complex script and symbol faces), bullet fonts, paragraph end marks, list
/// styles of shapes, layouts and masters, the master text styles, the deck default style, speaker
/// notes, tables and the theme fonts. Names compare case-insensitively; theme references
/// (`+mn-lt`) are left alone. Returns how many names changed.
pub fn replace_font(p: &mut Presentation, from: &str, to: &str) -> usize {
use std::sync::Arc;
let from = from.trim();
let to = to.trim();
if from.is_empty() || to.is_empty() || from.starts_with('+') || from == to {
return 0;
}
let mut n = 0usize;
let mut fix = |f: &mut Option<String>| {
if f.as_deref().is_some_and(|x| x.trim().eq_ignore_ascii_case(from)) {
*f = Some(to.to_string());
n = n.saturating_add(1);
}
};
replace_list(&mut p.default_text_style, &mut fix);
for slide in &mut p.slides {
let sl = Arc::make_mut(slide);
replace_shapes(&mut sl.shapes, &mut fix);
replace_body(&mut sl.notes, &mut fix);
}
let mut theme_hits = 0usize;
let masters = p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut());
for m in masters {
let m = Arc::make_mut(m);
replace_shapes(&mut m.shapes, &mut fix);
for l in [&mut m.title_style, &mut m.body_style, &mut m.other_style] {
replace_list(l, &mut fix);
}
for lay in &mut m.layouts {
replace_shapes(&mut lay.shapes, &mut fix);
}
for set in [&mut m.theme.fonts.major, &mut m.theme.fonts.minor] {
for face in [&mut set.latin, &mut set.ea, &mut set.cs].into_iter().chain(set.scripts.iter_mut().map(|s| &mut s.typeface)) {
if face.trim().eq_ignore_ascii_case(from) {
*face = to.to_string();
theme_hits = theme_hits.saturating_add(1);
}
}
}
}
n.saturating_add(theme_hits)
}
fn replace_shapes(shapes: &mut [Shape], fix: &mut dyn FnMut(&mut Option<String>)) {
for s in shapes {
if let Some(body) = &mut s.text {
replace_body(body, fix);
}
if let ShapeKind::Table(t) = &mut s.kind {
for cell in t.rows.iter_mut().flat_map(|r| &mut r.cells) {
replace_body(&mut cell.text, fix);
}
}
if let Some(children) = s.children_mut() {
replace_shapes(children, fix);
}
}
}
fn replace_body(body: &mut TextBody, fix: &mut dyn FnMut(&mut Option<String>)) {
replace_list(&mut body.list_style, fix);
for para in &mut body.paragraphs {
fix(&mut para.props.bullet_font);
replace_run(&mut para.end_props, fix);
for run in &mut para.runs {
replace_run(&mut run.props, fix);
}
}
}
fn replace_list(l: &mut deckcraft_model::text::ListStyle, fix: &mut dyn FnMut(&mut Option<String>)) {
for lvl in l.levels.iter_mut().flatten() {
fix(&mut lvl.para.bullet_font);
replace_run(&mut lvl.run, fix);
}
}
fn replace_run(r: &mut deckcraft_model::text::RunProps, fix: &mut dyn FnMut(&mut Option<String>)) {
for f in [&mut r.font, &mut r.font_ea, &mut r.font_cs, &mut r.font_sym] {
fix(f);
}
}