From 34c44e6063e4eb70f801bf3b8bffc6d6398faf6c Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 22:15:22 -0500 Subject: [PATCH 1/2] Text: a font loaded with text.loadFont wins over the built-in and installed faces of the same family and style (#2092) FigCraft chose the first face added for a family and style, so the embedded Inter 4.x kept Regular, SemiBold and Bold after Inter 3.19 was loaded and only Medium refitted. A loaded font is the counterpart of a font uploaded to the organization, which Figma's help center ranks first ("Manage conflicting fonts"). The test builds its faces from any embedded or installed font (skipped when there is none) and checks a loaded face beats a built-in one, the latest load beats earlier ones, and other styles resolve as before. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/text/src/fontdb.rs | 29 +++++++++++++++++++++--- crates/text/src/fontdb_tests.rs | 39 +++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 2 ++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 3c82cd261..5159a68d1 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -92,6 +92,10 @@ pub struct Face { pub italic: bool, pub bytes: FontBytes, pub index: u32, + /// 0 for a built-in or installed face; else the order it was loaded in with `text.loadFont` + /// ([`FontDb::add_loaded`]). A loaded face wins over any other of its family and style, the + /// latest load over earlier ones (#2092). + pub loaded: u32, pub upem: f64, /// Ascent and descent in font units, both positive. pub ascent: f64, @@ -207,6 +211,7 @@ impl Face { family, style, index, + loaded: 0, upem, ascent, descent, @@ -620,6 +625,8 @@ pub struct FontDb { loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>, /// Font files added as bytes (hash and length → families), so loading one again adds nothing. loaded_bytes: std::collections::HashMap<(u64, usize), Vec>, + /// Loads so far ([`Face::loaded`]). + load_seq: u32, system_scanned: bool, /// Browser fonts text asked for and not yet taken by the browser layer. wanted: Vec, @@ -716,8 +723,20 @@ impl FontDb { face } + /// [`add`](Self::add) a face loaded explicitly (`text.loadFont`): it wins over the other faces + /// of its family and style, and over those loaded before it. + pub fn add_loaded(&mut self, mut face: Face) -> Arc { + self.load_seq += 1; + face.loaded = self.load_seq; + self.add(face) + } + /// Add font file bytes (all faces of a collection). Returns the faces added. pub fn add_bytes(&mut self, bytes: Vec) -> Vec> { + self.add_bytes_with(bytes, false) + } + + fn add_bytes_with(&mut self, bytes: Vec, loaded: bool) -> Vec> { let shared = Arc::new(bytes); let count = match FileRef::new(shared.as_slice()) { // A collection's header may name any number of faces, each a few bytes pointing at @@ -727,7 +746,9 @@ impl FontDb { Ok(FileRef::Font(_)) => 1, Err(_) => 0, }; - (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)).map(|f| self.add(f)).collect() + (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)) + .map(|f| if loaded { self.add_loaded(f) } else { self.add(f) }) + .collect() } /// The family and style of the face whose PostScript name is `ps` (ignoring case), loaded or @@ -1035,7 +1056,9 @@ impl FontDb { /// The loaded face of `family` nearest `style`. fn best(&self, family: &str, style: &str) -> Option> { let (_, idx) = self.families.get(&self.key(family))?; - let faces: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect(); + let mut faces: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect(); + // A loaded font comes first (the latest load first): of faces as near, the first wins. + faces.sort_by_key(|f| std::cmp::Reverse(f.loaded)); let want = norm_style(style); if let Some(f) = faces.iter().find(|f| norm_style(&f.style) == want).or_else(|| faces.iter().find(|f| has_style(f, style))) { return Some((*f).clone()); @@ -1595,7 +1618,7 @@ pub fn load_font_bytes(bytes: Vec) -> Vec { if let Some(families) = db.loaded_bytes.get(&key) { return families.clone(); } - let faces = db.add_bytes(bytes); + let faces = db.add_bytes_with(bytes, true); let mut families: Vec = faces.iter().map(|f| f.family.clone()).collect(); families.dedup(); if !families.is_empty() { diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 38e04c790..a3dd15396 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -382,6 +382,45 @@ fn font_bytes_load_into_the_db() { } } +/// #2092: a font loaded with `text.loadFont` wins over the built-in and installed faces of its family +/// and style, the latest load over earlier ones; other styles resolve as before. +#[test] +fn a_loaded_face_wins_over_the_built_in_one_of_its_family_and_style() { + // Any embedded or installed font stands in for the faces (none without craft-fonts on CI). + let stand_in = CRAFT_FONTS.first().map(|f| f.bytes.to_vec()).or_else(|| { + let mut installed = BTreeMap::new(); + for dir in system_font_dirs() { + scan_dir(&dir, 0, &mut installed); + } + installed.values().flat_map(|(_, v)| v).find_map(|p| std::fs::read(&p.path).ok()) + }); + let Some(bytes) = stand_in.map(Arc::new) else { + eprintln!("skipping: no font to stand in"); + return; + }; + let face = |style: &str, name: &str| { + let mut f = Face::from_bytes(FontBytes::Shared(bytes.clone()), 0, Some("Brand Sans"), Some(style)).unwrap(); + f.instances.clear(); + f.postscript = Some(name.into()); + f + }; + let name = |db: &FontDb, style: &str| db.best("Brand Sans", style).and_then(|f| f.postscript.clone()); + let mut db = FontDb::default(); + db.add(face("Regular", "Built-in-Regular")); + db.add(face("Bold", "Built-in-Bold")); + assert_eq!(name(&db, "Regular").as_deref(), Some("Built-in-Regular")); + db.add_loaded(face("Regular", "Loaded-Regular")); + assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular")); + // Bold wasn't loaded: the built-in one stays. Nor does the loaded Regular stand in for it. + assert_eq!(name(&db, "Bold").as_deref(), Some("Built-in-Bold")); + // The latest load wins. + db.add_loaded(face("Regular", "Loaded-Regular-2")); + assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular-2")); + // A face added after a load, not loaded, doesn't displace it. + db.add(face("Regular", "Installed-Regular")); + assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular-2")); +} + /// Apple's system font answers to the names designs and CSS use for it (SF Pro, SF UI Text, /// system-ui, …), unless a font of that name is installed. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bce90b008..0aaf249ca 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -181,6 +181,7 @@ source paint coordinates. | | Figma | FigCraft | |---|---|---| | Local fonts | yes (font helper / desktop app) | yes (system scan) | +| Conflicting fonts | an uploaded organization font comes first, then fonts installed on the device, then Figma's web fonts (help center, "Manage conflicting fonts") | a font loaded with `text.loadFont` wins over the built-in and installed faces of the same family and style, the latest load over earlier ones (#2092); installed versus built-in order is unchanged, and browser local fonts count as installed | | Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) | | Variable fonts | yes | yes (axes, named instances, per-range axis values) | | Missing-font replacement on open | yes | listed, not replaced: in the desktop app the Missing fonts window opens after a file that uses them opens and from Text ▸ Missing fonts (`text.missingFonts`; `figcraft-cli info` prints `missing font` lines), and their text draws in a fallback font. A family the font store has (Google Fonts, Noto) downloads from the window once the user allows downloading fonts (`font.download`, #754): pinned, checksum-verified files into the Craft apps' shared font folder, after which the text draws in it. A family is found however a file names it: by the legacy name of one of its weights ("Family Medium") or by the typographic name of a family installed one family per weight, and spelt with other case, spaces or hyphens (#1292). No substitute picker | @@ -189,6 +190,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-11-07 | patch | Fonts, #2092: a font loaded with `text.loadFont` is the counterpart of a font uploaded to the organization, which Figma's help center ("Manage conflicting fonts") ranks first, ahead of installed fonts and the web fonts. FigCraft chose the first face added for a family and style, so the built-in Inter 4.x kept Regular, SemiBold and Bold after Inter 3.19 was loaded and only Medium refitted. A loaded face now wins over the other faces of its family and style, the latest load over earlier ones; style matching and fallbacks are unchanged, and `refitted` lists the text whose size changes. Installed versus built-in order is not changed here. Test: `fontdb::tests::a_loaded_face_wins_over_the_built_in_one_of_its_family_and_style`. Numbers unchanged. | | 2026-11-03 | patch | M6, #1909: `.jam` import: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER` (the end aims at the layer's middle) left the side its stale `relativePosition` named, the left one in the reporter's file, so the connectors looped behind their shape or crossed it. It now leaves the side facing the other end, as a board connector drawn here does (`Side::Auto`), for straight, elbowed and curved connectors alike; a named magnet (`TOP`, `RIGHT`, `BOTTOM`, `LEFT`) is unchanged, and an end without a magnet still takes the edge its point lies on. The Plugin API lists the magnets (`ConnectorEndpoint.magnet`: "Which side of the node it attaches to"; NONE, AUTO, TOP, LEFT, BOTTOM, RIGHT, CENTER; "Straight connectors may use only CENTER or NONE magnets"). Test: `connectors_with_an_automatic_side_leave_the_side_facing_the_target`. Numbers unchanged. | | 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). | | 2026-11-04 | minor | `.fig` and `.jam` import, #1910: an elbowed connector saved with its path keeps it. FigJam stores `connectorControlPoints`, one per run between the first and the last, each a point on its run (in the connector's parent's coordinates) with the run's axis; the first run leaves the start's side and the last arrives at the end's. They become the `ConnectorPath::Runs` a board connector reshaped by hand has (`figcraft_doc::route_along`), so a connector drawn around a layer in FigJam stays around it instead of being routed through it. Points that don't make such a path (off both axes, two runs in a row the same way, a first or last run sideways to its side or going back into its object, more runs than a hand could make) leave the connector to the router as before, and only connectors routed here count in the "follow FigCraft's own routing" note. A curved connector's `connectorBezierControlPoints` are still not read. Tests: `tail_tests::an_elbow_connector_saved_with_its_path_keeps_it`, `tail_tests::an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here`. | From a375ac06893ed41af6916508c18bb5896e1e527b Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 22:15:41 -0500 Subject: [PATCH 2/2] Format (#2092) Co-Authored-By: Claude Sonnet 5.5 --- crates/text/src/fontdb.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 5159a68d1..65df051e7 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -746,7 +746,8 @@ impl FontDb { Ok(FileRef::Font(_)) => 1, Err(_) => 0, }; - (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)) + (0..count) + .filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)) .map(|f| if loaded { self.add_loaded(f) } else { self.add(f) }) .collect() }