mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 08:51:11 +00:00
Merge pull request #2101 from TrevorBrown-dev/fix/loaded-font-wins-2092
Text: a font loaded with text.loadFont wins over the built-in and installed faces of its family and style (#2092)
This commit is contained in:
3 files changed
+68
-3
No files matched your search
@@ -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<String>>,
|
||||
/// 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<usize>,
|
||||
@@ -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<Face> {
|
||||
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<u8>) -> Vec<Arc<Face>> {
|
||||
self.add_bytes_with(bytes, false)
|
||||
}
|
||||
|
||||
fn add_bytes_with(&mut self, bytes: Vec<u8>, loaded: bool) -> Vec<Arc<Face>> {
|
||||
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,10 @@ 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 +1057,9 @@ impl FontDb {
|
||||
/// The loaded face of `family` nearest `style`.
|
||||
fn best(&self, family: &str, style: &str) -> Option<Arc<Face>> {
|
||||
let (_, idx) = self.families.get(&self.key(family))?;
|
||||
let faces: Vec<&Arc<Face>> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
|
||||
let mut faces: Vec<&Arc<Face>> = 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 +1619,7 @@ pub fn load_font_bytes(bytes: Vec<u8>) -> Vec<String> {
|
||||
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<String> = faces.iter().map(|f| f.family.clone()).collect();
|
||||
families.dedup();
|
||||
if !families.is_empty() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -188,6 +188,7 @@ scales and empty-style clears; they have not been checked in other software.
|
||||
| | 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 |
|
||||
@@ -196,6 +197,7 @@ scales and empty-style clears; they have not been checked in other software.
|
||||
|
||||
| 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-08 | minor | M6, #2097 follow-up: per-point vector corner radii take the scale of the row supplying `vectorData`, including nested instance rows; override and derived rows are not scaled twice. Network caching distinguishes the instance scale even when resizing gives two vectors the same final box. Synthetic tests cover missing/unit factors, half/double scale, derived size alone, overridden and derived vector data, nested scale and equal-size cache separation. Layer-radius fallback remains the separate #2115 change. Numbers unchanged. |
|
||||
| 2026-11-08 | patch | #2097 / #2100: scaled-instance lengths reset to the unscaled main value survive export/import as explicit overrides. Dimensionless values and zero do not gain scale overrides. Exported factors stay within the importer's 0.001–1000 range; larger or smaller ratios use explicit values instead. Effect/grid payload rows above an inherited style link take precedence at their own scale, including nested instances and empty-list clears, without detaching styles. Synthetic regressions cover corners, dashes/shadows, layout/grids and bounds, typography and styled runs, linked text and nested styles. Numbers unchanged. |
|
||||
| 2026-11-07 | minor | `.fig` import, #2091 item 1: an instance scaled with the Scale tool stores `symbolData.uniformScaleFactor`, which the import never read, so the layers of a 128 px instance of a 256 px component came in at 256 px with the main's radii, strokes and spacing. The layers of an instance now take the main's lengths times the factor (composed through nested instances, so 0.5 inside 0.5 is 0.25 for the inner layers), the lengths the Scale tool scales: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent. A value an override or derived row gives is taken as written, already at the instance's scale; the instance's own size is the file's; a missing, non-finite, non-positive or out-of-range factor (outside 0.001 to 1000) changes nothing. Not scaled: vector outline paths (a network's points follow the layer's size), per-point corner radii, fixed grid tracks, image tile scales and glass effects. Tests: `fig::tests::a_scaled_instance_draws_its_layers_at_the_factor` and `fig::scale_factor_tests`. Numbers unchanged |
|
||||
|
||||
Reference in new issue
Block a user