mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-06 10:38:39 +00:00
Merge pull request #821 from attisvalentin/fix/rescan-and-load-fonts
M4: fonts installed while the app runs: text.rescanFonts, text.loadFont, text.refit
This commit is contained in:
11 files changed
+360
-15
No files matched your search
@@ -481,5 +481,40 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.rescanFonts",
|
||||
"Rescan installed fonts",
|
||||
"{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}",
|
||||
true,
|
||||
|s, _| {
|
||||
let rescan = figcraft_text::rescan_system_fonts();
|
||||
let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed));
|
||||
Ok(json!({"added": rescan.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; 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 _;
|
||||
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
|
||||
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
|
||||
} else if let Some(path) = string(p, "path") {
|
||||
super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)?
|
||||
} else {
|
||||
return Err(bad(cmd, "pass `path` or `data` (base64)"));
|
||||
};
|
||||
let families = figcraft_text::load_font_bytes(bytes);
|
||||
if families.is_empty() {
|
||||
return Err(bad(cmd, "not a font file"));
|
||||
}
|
||||
let refitted = crate::fonts::refit_open(s, None, Some(&families));
|
||||
Ok(json!({"families": families, "refitted": refitted}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
+70
-10
@@ -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,20 +81,78 @@ fn string_values(vars: &Variables, id: &str) -> Vec<String> {
|
||||
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<NodeId> {
|
||||
let want: Option<BTreeSet<String>> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect());
|
||||
let scope: Option<BTreeSet<NodeId>> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect());
|
||||
let ids: Vec<NodeId> = 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<String>| {
|
||||
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<NodeId> {
|
||||
match s.doc_mut() {
|
||||
Ok(doc) => refit(doc, under, families),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the
|
||||
/// whole document.
|
||||
fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let under = match get(p, "ids") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(req_targets("text.refit", s, p)?),
|
||||
};
|
||||
s.doc()?;
|
||||
Ok(json!({"refitted": refit_open(s, under.as_deref(), None)}))
|
||||
}
|
||||
|
||||
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
|
||||
s.request_ui("dialog.missingFonts", json!({}));
|
||||
Ok(json!({"families": families}))
|
||||
}
|
||||
|
||||
/// The font commands: `text.missingFonts` and `text.refit`.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
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)]
|
||||
|
||||
@@ -124,3 +124,92 @@ 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
|
||||
}
|
||||
|
||||
/// The width of layer `id`.
|
||||
fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 {
|
||||
s.doc().unwrap().node(id).unwrap().width
|
||||
}
|
||||
|
||||
/// The ids a refit reports.
|
||||
fn refitted(r: &Value) -> Vec<u64> {
|
||||
r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs.
|
||||
#[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());
|
||||
// An `ids` that isn't a list is refused, not taken as the whole document.
|
||||
stale(&mut s, a, 9.0);
|
||||
assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err());
|
||||
assert_eq!(width(&s, a), want_a + 9.0);
|
||||
assert!(Session::empty().execute("text.refit", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Text inside an instance is refitted too.
|
||||
#[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);
|
||||
}
|
||||
|
||||
/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added.
|
||||
#[test]
|
||||
fn fonts_load_from_files_and_rescans_report_new_families() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("text.loadFont", &json!({})).is_err());
|
||||
assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file");
|
||||
assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err());
|
||||
assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err());
|
||||
if let Some(f) = figcraft_text::CRAFT_FONTS.first() {
|
||||
use base64::Engine as _;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(f.bytes);
|
||||
let r = s.execute("text.loadFont", &json!({"data": data})).unwrap();
|
||||
assert_eq!(r["families"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
assert!(r["added"].is_array());
|
||||
}
|
||||
@@ -376,6 +376,7 @@ fn pointer_draw_move_resize() {
|
||||
assert_eq!(s.selection(), vec![r]);
|
||||
}
|
||||
|
||||
/// No command panics on junk parameters.
|
||||
#[test]
|
||||
fn every_command_survives_junk_params() {
|
||||
// A junk-parameter sweep: no command may panic (guard turns panics into Internal errors,
|
||||
@@ -437,6 +438,8 @@ fn every_command_survives_junk_params() {
|
||||
| "file.placeImage"
|
||||
| "image.place"
|
||||
| "file.revert"
|
||||
// Takes no params and walks the font folders: one walk per junk is slow.
|
||||
| "text.rescanFonts"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -782,6 +782,7 @@ mod tests {
|
||||
assert!(r.is_null() || r["axes"].is_array());
|
||||
}
|
||||
|
||||
/// No text command panics on junk parameters while a text is being edited.
|
||||
#[test]
|
||||
fn text_commands_survive_junk_while_editing() {
|
||||
let junk = [
|
||||
@@ -793,7 +794,8 @@ mod tests {
|
||||
json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}),
|
||||
json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}),
|
||||
];
|
||||
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) {
|
||||
// `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow.
|
||||
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") {
|
||||
for j in &junk {
|
||||
let (mut s, id) = setup("Hi there\nyou");
|
||||
begin(&mut s, id, Some(2), false).unwrap();
|
||||
|
||||
@@ -444,9 +444,22 @@ pub struct FontDb {
|
||||
families: BTreeMap<String, (String, Vec<usize>)>,
|
||||
/// System faces known by name but not loaded (lower-case family → entries).
|
||||
pending: BTreeMap<String, (String, Vec<Pending>)>,
|
||||
/// System faces read from disk (file, face index), so a rescan only offers new ones.
|
||||
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>>,
|
||||
system_scanned: bool,
|
||||
}
|
||||
|
||||
/// What a later walk of the font folders found ([`rescan_system_fonts`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Rescan {
|
||||
/// Families that were unknown before.
|
||||
pub added: Vec<String>,
|
||||
/// Every family that gained faces: the new ones and those with styles installed since.
|
||||
pub changed: Vec<String>,
|
||||
}
|
||||
|
||||
static DB: OnceLock<RwLock<FontDb>> = OnceLock::new();
|
||||
/// Set by [`scan_system_fonts_on_demand`].
|
||||
static ON_DEMAND: AtomicBool = AtomicBool::new(false);
|
||||
@@ -580,6 +593,7 @@ impl FontDb {
|
||||
};
|
||||
if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) {
|
||||
self.add(face);
|
||||
self.loaded_files.insert((e.path, e.index));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -670,6 +684,35 @@ impl FontDb {
|
||||
self.faces.len()
|
||||
}
|
||||
|
||||
/// Offer the families of a later walk of the font folders: new families become pending, a
|
||||
/// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets
|
||||
/// the files it hasn't read (styles installed since) as pending, read when it is next used.
|
||||
fn merge_rescan(&mut self, found: BTreeMap<String, (String, Vec<Pending>)>) -> Rescan {
|
||||
let mut out = Rescan::default();
|
||||
for (k, (display, entries)) in found {
|
||||
if self.families.contains_key(&k) {
|
||||
let waiting = self.pending.get(&k).map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
let new: Vec<Pending> = entries
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
!self.loaded_files.contains(&(e.path.clone(), e.index)) && !waiting.iter().any(|w| w.path == e.path && w.index == e.index)
|
||||
})
|
||||
.collect();
|
||||
if !new.is_empty() {
|
||||
out.changed.push(display.clone());
|
||||
self.pending.entry(k).or_insert_with(|| (display, Vec::new())).1.extend(new);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !self.pending.contains_key(&k) {
|
||||
out.added.push(display.clone());
|
||||
}
|
||||
out.changed.push(display.clone());
|
||||
self.pending.insert(k, (display, entries));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Index the system font directories (desktop only): family names are read now, faces loaded
|
||||
/// when first used. Safe to call more than once.
|
||||
pub fn scan_system(&mut self) {
|
||||
@@ -776,13 +819,19 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
|
||||
// Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the
|
||||
// system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed
|
||||
// buffer, so only those parts are ever touched.
|
||||
let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 256 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue };
|
||||
if meta.len() > MAX_FONT_FILE {
|
||||
continue;
|
||||
}
|
||||
let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue };
|
||||
for (index, family, style) in faces_in(&bytes) {
|
||||
out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB).
|
||||
pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// The parts of a font file the scan reads: headers, table directories and `name` tables, at their
|
||||
/// offsets in a zeroed buffer of the file's length (reading whole files was most of the scan).
|
||||
fn index_bytes(path: &std::path::Path, len: usize) -> Option<Vec<u8>> {
|
||||
@@ -887,6 +936,55 @@ pub fn scan_system_fonts() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Walk the system font folders again, for fonts installed while the app runs (the first scan is
|
||||
/// once per process): the families that were unknown before, and every family that gained faces.
|
||||
/// Before the first scan this is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS`.
|
||||
pub fn rescan_system_fonts() -> Rescan {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !system_fonts_opted_out() {
|
||||
if SCANNED.get().is_none() {
|
||||
let before: std::collections::BTreeSet<String> =
|
||||
raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families().into_iter().collect();
|
||||
scan_system_fonts();
|
||||
let added: Vec<String> = family_names().into_iter().filter(|f| !before.contains(f)).collect();
|
||||
return Rescan { changed: added.clone(), added };
|
||||
}
|
||||
let mut found: BTreeMap<String, (String, Vec<Pending>)> = BTreeMap::new();
|
||||
for dir in system_font_dirs() {
|
||||
scan_dir(&dir, 0, &mut found);
|
||||
}
|
||||
let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found);
|
||||
if !rescan.changed.is_empty() {
|
||||
crate::clear_layout_cache();
|
||||
}
|
||||
return rescan;
|
||||
}
|
||||
Rescan::default()
|
||||
}
|
||||
|
||||
/// Load font file bytes (every face of a collection) into the global db, for a font that isn't
|
||||
/// installed. Returns the families of its faces (none: not a font file); the same file again
|
||||
/// returns them without adding its faces twice.
|
||||
pub fn load_font_bytes(bytes: Vec<u8>) -> Vec<String> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
bytes.hash(&mut h);
|
||||
let key = (h.finish(), bytes.len());
|
||||
let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(families) = db.loaded_bytes.get(&key) {
|
||||
return families.clone();
|
||||
}
|
||||
let faces = db.add_bytes(bytes);
|
||||
let mut families: Vec<String> = faces.iter().map(|f| f.family.clone()).collect();
|
||||
families.dedup();
|
||||
if !families.is_empty() {
|
||||
db.loaded_bytes.insert(key, families.clone());
|
||||
drop(db);
|
||||
crate::clear_layout_cache();
|
||||
}
|
||||
families
|
||||
}
|
||||
|
||||
/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_fonts_opted_out() -> bool {
|
||||
|
||||
@@ -217,3 +217,49 @@ fn user_font_dirs_preserve_non_utf8_paths() {
|
||||
assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts");
|
||||
assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts");
|
||||
}
|
||||
|
||||
/// A rescan offers new families and the styles installed since, and never a file twice.
|
||||
#[test]
|
||||
fn a_rescan_offers_fonts_installed_since() {
|
||||
let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() };
|
||||
let mut db = pending(&["Regular"]);
|
||||
let found = |fams: &[(&str, &[&str])]| -> BTreeMap<String, (String, Vec<Pending>)> {
|
||||
fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect()
|
||||
};
|
||||
// A new family is added; a pending one takes the walk's files (a new style); both changed.
|
||||
let r = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])]));
|
||||
assert_eq!(r.added, ["Brand Sans"]);
|
||||
assert_eq!(r.changed, ["Brand Sans", "CJK UI"]);
|
||||
assert!(db.has_family("brand sans"));
|
||||
assert_eq!(left(&db), ["Regular", "Bold"]);
|
||||
// A loaded family keeps its faces and gets only the files it hasn't read, once.
|
||||
db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new()));
|
||||
db.pending.remove("brand sans");
|
||||
db.loaded_files.insert(("no-such-dir/Regular.ttf".into(), 0));
|
||||
let same = db.merge_rescan(found(&[("Brand Sans", &["Regular"])]));
|
||||
assert_eq!(same, Rescan::default());
|
||||
assert!(!db.pending.contains_key("brand sans"));
|
||||
let bold = db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])]));
|
||||
assert_eq!((bold.added.len(), bold.changed), (0, vec!["Brand Sans".to_string()]));
|
||||
let waiting = |db: &FontDb| db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
assert_eq!(waiting(&db), ["Black"]);
|
||||
assert_eq!(db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])), Rescan::default());
|
||||
assert_eq!(waiting(&db), ["Black"]);
|
||||
}
|
||||
|
||||
/// Font file bytes add their faces once; junk adds nothing.
|
||||
#[test]
|
||||
fn font_bytes_load_into_the_db() {
|
||||
assert!(load_font_bytes(b"not a font".to_vec()).is_empty());
|
||||
assert!(load_font_bytes(Vec::new()).is_empty());
|
||||
// With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added,
|
||||
// and loading the same file again returns it without adding its faces twice.
|
||||
if let Some(f) = CRAFT_FONTS.first() {
|
||||
let families = load_font_bytes(f.bytes.to_vec());
|
||||
assert_eq!(families.len(), 1);
|
||||
assert!(families.iter().all(|f| has_family(f)));
|
||||
let faces = db().read().unwrap().face_count();
|
||||
assert_eq!(load_font_bytes(f.bytes.to_vec()), families);
|
||||
assert_eq!(db().read().unwrap().face_count(), faces);
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,9 @@ mod onpath;
|
||||
pub use breaks::grapheme_step;
|
||||
pub use color::{ColorBitmap, ColorInk, ColorLayer};
|
||||
pub use fontdb::{
|
||||
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family,
|
||||
installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,
|
||||
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face,
|
||||
has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand,
|
||||
system_fonts_scanned,
|
||||
};
|
||||
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
|
||||
pub use onpath::OnPath;
|
||||
|
||||
@@ -2157,6 +2157,7 @@ status = "draft — needs native review"
|
||||
"text.listIndent" = "Aumentar sangría de la lista"
|
||||
"text.listOutdent" = "Reducir sangría de la lista"
|
||||
"text.listStyle" = "Estilo de lista"
|
||||
"text.loadFont" = "Cargar archivo de fuente"
|
||||
"text.maxLines" = "Máximo de líneas"
|
||||
"text.missingFonts" = "Fuentes que faltan"
|
||||
"text.numberList" = "Lista numerada"
|
||||
@@ -2165,7 +2166,9 @@ status = "draft — needs native review"
|
||||
"text.paragraphSpacing" = "Espacio entre párrafos"
|
||||
"text.pathOptions" = "Opciones de texto en trazado"
|
||||
"text.preview" = "Previsualizar cambio de texto"
|
||||
"text.refit" = "Ajustar texto a las fuentes"
|
||||
"text.removeFromPath" = "Quitar texto del trazado"
|
||||
"text.rescanFonts" = "Volver a buscar fuentes instaladas"
|
||||
"text.set" = "Establecer texto"
|
||||
"text.setFeatures" = "Funciones OpenType"
|
||||
"text.setVariations" = "Ejes de fuente variable"
|
||||
|
||||
@@ -2145,6 +2145,7 @@ status = "draft — needs native review"
|
||||
"text.listIndent" = "Sisennä luettelo"
|
||||
"text.listOutdent" = "Vähennä luettelon sisennystä"
|
||||
"text.listStyle" = "Luettelotyyli"
|
||||
"text.loadFont" = "Lataa fonttitiedosto"
|
||||
"text.maxLines" = "Rivien enimmäismäärä"
|
||||
"text.missingFonts" = "Puuttuvat fontit"
|
||||
"text.numberList" = "Numeroitu luettelo"
|
||||
@@ -2153,7 +2154,9 @@ status = "draft — needs native review"
|
||||
"text.paragraphSpacing" = "Kappaleväli"
|
||||
"text.pathOptions" = "Tekstipolun asetukset"
|
||||
"text.preview" = "Esikatsele tekstimuutosta"
|
||||
"text.refit" = "Sovita teksti fontteihin"
|
||||
"text.removeFromPath" = "Poista teksti polulta"
|
||||
"text.rescanFonts" = "Etsi asennetut fontit uudelleen"
|
||||
"text.set" = "Aseta teksti"
|
||||
"text.setFeatures" = "OpenType-ominaisuudet"
|
||||
"text.setVariations" = "Muuttuvan fontin akselit"
|
||||
|
||||
+6
-1
@@ -234,7 +234,12 @@ Or in a project's `.mcp.json`:
|
||||
`text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}`
|
||||
lists a font's axes and named instances (named instances are also valid `fontStyle` values).
|
||||
`text.missingFonts` lists the families the document names that aren't installed
|
||||
(`{families: [{family, layers}]}`); their text draws in a fallback font.
|
||||
(`{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
|
||||
|
||||
Reference in new issue
Block a user