mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 04:56:58 -06:00
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user