From ba4c502ada1809c3a27a7795a312d0303528e0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20Valentin=20N=C3=A9meth?= <61153253+attisvalentin@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:35:31 +0200 Subject: [PATCH] M4: text.refit: auto-sizing text measures its box again once its font is there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A text box keeps the size it was measured at, so text laid out while its family was missing kept the fallback font's box after the font arrived (a hugging chip clipped its label). - text.refit {ids?} measures auto-sizing text again (whole document by default) → {refitted} - text.rescanFonts and text.loadFont refit the open document's text in the families they add, as one undo step Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 19 +++++--- crates/engine/src/fonts.rs | 77 +++++++++++++++++++++++++++----- crates/engine/src/fonts_tests.rs | 63 ++++++++++++++++++++++++++ docs/mcp.md | 3 ++ 4 files changed, 145 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 129c8ead1..fbc413344 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -477,16 +477,20 @@ pub fn specs() -> Vec { spec!( "text.rescanFonts", "Rescan installed fonts", - "{} walks the system font folders again, for fonts installed while the app runs → {added: [families that were unknown]}", - false, - |_, _| Ok(json!({"added": figcraft_text::rescan_system_fonts()})) + "{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in those families fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}", + true, + |s, _| { + let added = figcraft_text::rescan_system_fonts(); + let refitted = crate::fonts::refit_open(s, None, Some(&added)); + Ok(json!({"added": added, "refitted": refitted})) + } ), spec!( "text.loadFont", "Load font file", - "{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it → {families}", - false, - |_, p| { + "{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}", + true, + |s, p| { let cmd = "text.loadFont"; let bytes = if let Some(b64) = string(p, "data") { use base64::Engine as _; @@ -501,7 +505,8 @@ pub fn specs() -> Vec { if families.is_empty() { return Err(bad(cmd, "not a font file")); } - Ok(json!({"families": families})) + let refitted = crate::fonts::refit_open(s, None, Some(&families)); + Ok(json!({"families": families, "refitted": refitted})) } ), ] diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 3548124d5..42cc8a569 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,13 +1,15 @@ //! Fonts a document names that this machine doesn't have (#752): text layers, their styled //! ranges, text styles and the string variables bound to a font family. Text in such a family -//! is drawn in a fallback font, so the user is told which ones are missing. +//! is drawn in a fallback font, so the user is told which ones are missing. Once such a font is +//! there, [`refit`] lets auto-sizing text measure its box in it again. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables}; +use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; +use crate::params::{get, req_targets}; use crate::{Result, Session, spec}; /// A family the document names that no installed or built-in font provides. @@ -79,6 +81,52 @@ fn string_values(vars: &Variables, id: &str) -> Vec { out } +/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing +/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under +/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the +/// layers whose size changed. +pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec { + let want: Option> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect()); + let scope: Option> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect()); + let ids: Vec = doc + .nodes() + .filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id))) + .filter(|n| { + let NodeKind::Text(t) = &n.kind else { return false }; + let uses = |w: &BTreeSet| { + w.contains(&t.style.font_family.trim().to_lowercase()) + || t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase())) + }; + want.as_ref().is_none_or(uses) + && figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) + }) + .map(|n| n.id) + .collect(); + if !ids.is_empty() { + let before = doc.clone(); + doc.mark_dirty(ids.iter().copied()); + crate::normalize(doc, &before); + } + ids +} + +/// [`refit`] in the open document, if there is one. +pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec { + match s.doc_mut() { + Ok(doc) => refit(doc, under, families), + Err(_) => Vec::new(), + } +} + +fn refit_text(s: &mut Session, p: &Value) -> Result { + let under = match get(p, "ids") { + Some(Value::Array(_)) => Some(req_targets("text.refit", s, p)?), + _ => None, + }; + s.doc()?; + Ok(json!({"refitted": refit_open(s, under.as_deref(), None)})) +} + fn missing_fonts(s: &mut Session, _: &Value) -> Result { let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); s.request_ui("dialog.missingFonts", json!({})); @@ -86,13 +134,22 @@ fn missing_fonts(s: &mut Session, _: &Value) -> Result { } pub fn specs() -> Vec { - vec![spec!( - "text.missingFonts", - "Missing fonts", - "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", - false, - missing_fonts - )] + vec![ + spec!( + "text.missingFonts", + "Missing fonts", + "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", + false, + missing_fonts + ), + spec!( + "text.refit", + "Fit text to fonts", + "{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}", + true, + refit_text + ), + ] } #[cfg(test)] diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index a6e2d213f..c947773d2 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -124,3 +124,66 @@ fn needs_an_open_document() { let mut s = Session::empty(); assert!(s.execute("text.missingFonts", &json!({})).is_err()); } + +/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content. +fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 { + let doc = s.doc_mut().unwrap(); + let n = doc.get_mut(id).unwrap(); + let want = figcraft_text::auto_size(n).unwrap().0; + n.width = want + dw; + doc.take_dirty(); + want +} + +fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 { + s.doc().unwrap().node(id).unwrap().width +} + +fn refitted(r: &Value) -> Vec { + r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() +} + +#[test] +fn refit_measures_stale_text_boxes_again() { + let mut s = Session::new(); + let a = text(&mut s, "FigCraft Refit A"); + let b = text(&mut s, "FigCraft Refit B"); + let fixed = text(&mut s, "FigCraft Refit A"); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind { + t.auto_resize = figcraft_doc::TextAutoResize::None; + } + s.execute("select.none", &json!({})).unwrap(); + let want_a = stale(&mut s, a, -30.0); + let want_b = stale(&mut s, b, 25.0); + s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0; + s.doc_mut().unwrap().take_dirty(); + // Only the families asked for. + let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()])); + assert_eq!(only_a, vec![a]); + assert_eq!(width(&s, a), want_a); + assert_eq!(width(&s, b), want_b + 25.0); + // Only the layers asked for, then everything; a fixed box keeps its size. + stale(&mut s, a, 12.0); + assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]); + assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b)); + assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]); + assert_eq!(width(&s, a), want_a); + assert_eq!(width(&s, fixed), 7.0); + // Nothing stale: nothing changes. + assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty()); + assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err()); + assert!(Session::empty().execute("text.refit", &json!({})).is_err()); +} + +#[test] +fn refit_reaches_text_in_instances() { + let mut s = Session::new(); + let t = text(&mut s, "FigCraft Refit C"); + let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap(); + let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap(); + let copy = s.doc().unwrap().node(inst).unwrap().children[0]; + let want = stale(&mut s, copy, -20.0); + let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap(); + assert_eq!(refitted(&r), vec![copy.0]); + assert_eq!(width(&s, copy), want); +} diff --git a/docs/mcp.md b/docs/mcp.md index 787f0c82c..63ff7dd0b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -237,6 +237,9 @@ Or in a project's `.mcp.json`: (`{families: [{family, layers}]}`); their text draws in a fallback font. Fonts are indexed once per process: after installing one, `text.rescanFonts` picks it up (`{added: [families]}`), and `text.loadFont {path | data (base64)}` adds a font file without installing it (`{families}`). + Text boxes keep the size they were measured at: both refit the open document's auto-sizing text + in the new families (`refitted: [ids]`), and `text.refit {ids?}` does it for any text, e.g. a + file saved while its fonts were missing. OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline