From 95b3fbfec956f8047d512faa103c86b966cefc03 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 04:03:37 -0800 Subject: [PATCH] Type: the font warning leaves out characters Missing Glyph Protection kept text.setStyle and text.setRangeStyle collected the fonts for their warning after Missing Glyph Protection had run. A character the new font has no glyph for keeps its earlier font, and when that font was not available the warning named it, although the command did not set it. protect_missing_glyphs now returns the byte ranges that kept their font, and both commands leave those ranges out of the warning's fonts. --- crates/engine/src/cmd/textedit.rs | 37 +++++++++++++++++++++++++------ crates/engine/src/cmd/typecmd.rs | 12 +++++----- crates/engine/src/tests/prefs.rs | 23 +++++++++++++++++++ 3 files changed, 59 insertions(+), 13 deletions(-) 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