diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 03943029e..6406fcf5b 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -930,8 +930,8 @@ impl CharChange { /// Type › Enable Missing Glyph Protection: after a font change, the characters of `runs` their new /// font has no glyph for, but their font `before` the change had, keep that font: its family, -/// style, version and Variable Font settings. -pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec) { +/// style, version and Variable Font settings. Returns the byte ranges that kept their font, in order. +pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec) -> Vec<(usize, usize)> { fn chars(runs: &[TextRun]) -> impl Iterator { runs.iter() .scan(0, |at, r| { @@ -963,6 +963,7 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec _ => keep.push((at, end, old)), } } + let kept = keep.iter().map(|(a, b, _)| (*a, *b)).collect(); for (a, b, old) in keep { edit::style_range(runs, a, b, |st| { st.font_family.clone_from(&old.font_family); @@ -971,6 +972,30 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec st.font_axes.clone_from(&old.font_axes); }); } + kept +} + +/// The runs of `runs` from byte `a` to `b`, leaving out the ranges `kept` (in order) that Missing +/// Glyph Protection gave their earlier font: the characters a font command set its font on. +pub(crate) fn unprotected_runs(runs: &[TextRun], a: usize, b: usize, kept: &[(usize, usize)]) -> Vec { + let mut out = vec![]; + let mut at = a; + for &(k0, k1) in kept { + if k1 <= at { + continue; + } + if k0 >= b { + break; + } + if k0 > at { + out.extend(edit::slice_runs(runs, at, k0)); + } + at = k1; + } + if at < b { + out.extend(edit::slice_runs(runs, at, b)); + } + out } fn set_range_style(s: &mut Session, p: &Value) -> Result { @@ -1021,12 +1046,10 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result { } let before = protect.then(|| t.runs.clone()); edit::style_range(&mut t.runs, a, b, |st| change.apply(st)); - if let Some(before) = before { - protect_missing_glyphs(&before, &mut t.runs); - } + let kept = before.map(|before| protect_missing_glyphs(&before, &mut t.runs)).unwrap_or_default(); if sets_font { - // Empty text has one run, which the empty range styles. - let styled = if a == b { t.runs.clone() } else { edit::slice_runs(&t.runs, a, b) }; + // Empty text has one run, which the empty range styles. Characters that kept their font aren't the command's. + let styled = if a == b { t.runs.clone() } else { unprotected_runs(&t.runs, a, b, &kept) }; set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); } refresh_bounds(t); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index d4a58c34d..a24be3252 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -874,16 +874,16 @@ fn set_style(s: &mut Session, p: &Value) -> Result { st.features = super::textedit::change_features(&st.features, c); } }); - if let Some(before) = before { - super::textedit::protect_missing_glyphs(&before, &mut t.runs); - } + let kept = before.map(|before| super::textedit::protect_missing_glyphs(&before, &mut t.runs)).unwrap_or_default(); if font.is_some() || style.is_some() { - // All the text: every run, the one run of empty text included. + // All the text: every run, the one run of empty text included. Characters that kept their font aren't the + // command's. let styled = match range { - None => t.runs.clone(), + None if kept.is_empty() => t.runs.clone(), + None => super::textedit::unprotected_runs(&t.runs, 0, vectorcraft_text::edit::runs_len(&t.runs), &kept), Some(r) => { let (a, b) = r.bytes(t); - vectorcraft_text::edit::slice_runs(&t.runs, a, b) + super::textedit::unprotected_runs(&t.runs, a, b, &kept) } }; set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index b590e3c28..87b943d6c 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -1474,6 +1474,29 @@ fn missing_glyph_protection_keeps_the_version_of_the_font_kept() { assert_eq!(versions, [moved.clone(), kept, moved]); } +/// The font warning of `text.setStyle` and `text.setRangeStyle` names only the fonts the command +/// set: a character that keeps an unavailable font through Missing Glyph Protection isn't reported. +#[test] +fn missing_glyph_protection_leaves_the_font_kept_out_of_the_warning() { + let (from, to, c) = ("No Such Font Family", "JetBrains Mono", '\u{180}'); + let db = vectorcraft_text::FontDb::global(); + // The unavailable family is drawn in the fallback font, which has the character; the new font is installed and lacks it. + let installed = db.resolve(to, "Regular").is_some_and(|(_, m)| m == vectorcraft_text::FontMatch::Exact); + if !installed || !db.face(from, "Regular").is_some_and(|f| f.covers(c)) || db.face(to, "Regular").is_some_and(|f| f.covers(c)) { + return; // The fonts installed here cover other characters. + } + let mut s = new_doc(); + let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "a\u{180}c", "font": from})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + for change in [json!({"font": to}), json!({"id": id.0, "start": 0, "end": 4, "font": to})] { + let cmd = if change.get("id").is_some() { "text.setRangeStyle" } else { "text.setStyle" }; + let out = s.execute(cmd, &change).unwrap(); + assert_eq!(style_at(&s, id, 1).font_family, from, "{cmd}: the character keeps its font"); + assert_eq!(out.get("warning"), None, "{cmd}: {out}"); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + // ---------- #394, fourth batch ---------- /// Type › Type Object Selection by Path Only (#394): on, a click among point type's glyphs no