diff --git a/crates/engine/src/cmd/labels.rs b/crates/engine/src/cmd/labels.rs index 6e3ace0bb..ac4322bfd 100644 --- a/crates/engine/src/cmd/labels.rs +++ b/crates/engine/src/cmd/labels.rs @@ -33,6 +33,18 @@ pub fn commands() -> Vec { }) .view() .params(r#"{"on"?: bool}"#), + C::new("labels.fonts", "Label Fonts", "Style › Text Symbol", |_, p| { + // The families a font picker lists, those containing `search` (in any case) if given. + // While the app is still indexing them (commands run on its UI thread), none yet, + // and `indexing` says so: asking again later lists them. + let search = ps(p, "search").map(str::to_lowercase).unwrap_or_default(); + let Some(all) = crate::render::families::available() else { return Ok(json!({"fonts": [], "indexing": true})) }; + let fonts: Vec<&str> = + all.iter().filter(|f| f.listed && f.name.to_lowercase().contains(&search)).map(|f| f.name.as_str()).collect(); + Ok(json!({"fonts": fonts})) + }) + .view() + .params(r#"{"search"?: text in the family name} → {"fonts": [the installed font families, by name], "indexing"?: true (still being indexed: none listed yet)}"#), C::new("labels.convert", "Convert To Annotation", "View › Labeling", convert) .when(need_feature_layer) .params(r#"{"layer"?, "name"?: string}"#), diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 034e0f2a5..0de37f9b4 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -288,7 +288,13 @@ pub fn commands() -> Vec { let size = f(p, "size").map(|v| v.clamp(*LABEL_SIZE.start(), *LABEL_SIZE.end()) as f32); let color = s.get_color(p, "color")?; let (halo, bold, italic) = (b(p, "halo"), b(p, "bold"), b(p, "italic")); + // A family by name, whether or not this machine has it: the project may be drawn on + // one that does. Blank goes back to the label font. + let font = ps(p, "font").map(rustgis_map::symbology::font_name); super::labels::with_class(s, p, |c| { + if let Some(name) = font.clone() { + c.font = name; + } if let Some(v) = size { c.size = v; } @@ -307,7 +313,7 @@ pub fn commands() -> Vec { }) }) .when(need_feature_layer) - .params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "bold"?: bool, "italic"?: bool}"##), + .params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "bold"?: bool, "italic"?: bool, "font"?: installed family name (labels.fonts lists them; "" is the label font)}"##), C::new("layer.definitionQuery", "Definition Query", "Data › Definition Query", |s, p| { let q = ps(p, "where").map(str::trim).unwrap_or("").to_string(); let id = s.layer_id(p)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 033cefa8c..57e336f21 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1710,6 +1710,30 @@ fn label_range_placement_lock_and_convert() { assert!(!s.map().layer_by_name("Countries").unwrap().labels.enabled); } +/// A label class's font is set by its family name, installed here or not (the project may be +/// drawn on a machine that has it), and cleared with a blank one; `labels.fonts` lists the +/// installed families. +#[test] +fn labels_take_a_font_by_family_name() { + let mut s = world(); + let font = |s: &Session| s.map().layer_by_name("Cities").unwrap().labels.default.font.clone(); + s.run("labels.style", &json!({"layer": "Cities", "font": " Liberation Sans Narrow "})).unwrap(); + assert_eq!(font(&s).as_deref(), Some("Liberation Sans Narrow")); + // Drawn in the label font where it isn't installed, without fail. + s.run("labels.enable", &json!({"layer": "Cities", "on": true})).unwrap(); + let v = crate::render::View { width: 400.0, height: 300.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; + crate::render::render(s.map_mut(), &v).unwrap(); + s.run("labels.style", &json!({"layer": "Cities", "size": 12})).unwrap(); + assert!(font(&s).is_some(), "other settings leave it"); + s.run("labels.style", &json!({"layer": "Cities", "font": ""})).unwrap(); + assert_eq!(font(&s), None); + s.run("edit.undo", &json!({})).unwrap(); + assert!(font(&s).is_some(), "undoes"); + let listed = s.run("labels.fonts", &json!({"search": "zzz-no-such-family"})).unwrap(); + assert_eq!(listed["fonts"], json!([])); + assert!(s.run("labels.fonts", &json!({})).unwrap()["fonts"].is_array()); +} + /// A lock names each label by its class's place in the list: a change to the list (a class added, /// moved or removed) locks the labels again on the next frame, a change within a class keeps the /// lock (#732). diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 7868ba668..5bcf0fd6b 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -1272,6 +1272,37 @@ pub struct LabelClass { /// Slanted (an italic face where the font has one). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub italic: bool, + /// The installed font family the labels are drawn in, by name ("Liberation Sans Narrow"), as + /// QGIS names a text format's; none is the label font. A machine without the family draws + /// them in the label font too. + #[serde(default, deserialize_with = "de_font", skip_serializing_if = "Option::is_none")] + pub font: Option, +} + +/// The longest font family name kept (characters). +pub const MAX_FONT_NAME: usize = 128; + +/// A label font's family name as it is kept: trimmed, at most [`MAX_FONT_NAME`] characters, and +/// none when blank. +pub fn font_name(name: &str) -> Option { + let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_FONT_NAME).collect(); + let name = name.trim(); + (!name.is_empty()).then(|| name.to_string()) +} + +/// A label class's font as a project has it, kept as [`font_name`] keeps it: a name, or none for +/// anything else (a number, a list), so a damaged font doesn't make the project unreadable. +fn de_font<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum Font { + Name(String), + Other(serde::de::IgnoredAny), + } + Ok(match Option::::deserialize(d)? { + Some(Font::Name(n)) => font_name(&n), + Some(Font::Other(_)) | None => None, + }) } fn default_class_name() -> String { @@ -1415,6 +1446,7 @@ impl LabelClass { farthest: None, placement: LabelPlacement::Auto, italic: false, + font: None, } } @@ -1540,6 +1572,34 @@ mod tests { assert_eq!(serde_json::from_value::(v).unwrap(), with); } + /// A label font is kept by its family name, trimmed and bounded (a project file is hostile + /// input); a blank one is the label font, and isn't written. + #[test] + fn a_label_font_is_kept_by_name() { + let read = |font: serde_json::Value| { + serde_json::from_value::(serde_json::json!({ + "enabled": true, "field": "name", "size": 9.0, "color": [0, 0, 0, 255], "halo": false, "bold": false, "font": font + })) + .unwrap() + }; + let l = read(" Liberation Sans Narrow \n".into()); + assert_eq!(l.default.font.as_deref(), Some("Liberation Sans Narrow")); + assert_eq!(serde_json::to_value(&l).unwrap()["font"], "Liberation Sans Narrow"); + // Not a name (a number, a list, an object): no font, and the rest of the labels still read. + for blank in [ + serde_json::json!(" "), + serde_json::Value::Null, + serde_json::json!(7), + serde_json::json!(["Arial"]), + serde_json::json!({"a": 1}), + ] { + let l = read(blank); + assert_eq!(l.default.font, None); + assert!(serde_json::to_value(&l).unwrap().get("font").is_none()); + } + assert_eq!(read("x".repeat(10_000).into()).default.font.map(|f| f.chars().count()), Some(MAX_FONT_NAME)); + } + /// A project file is read with at most `MAX_LABEL_CLASSES` classes, and only that many count /// in a `Labeling` built with more. A class on /// a field the data doesn't have is pointed at Default's, and kept. diff --git a/crates/render/src/families.rs b/crates/render/src/families.rs new file mode 100644 index 000000000..91e9b9d4b --- /dev/null +++ b/crates/render/src/families.rs @@ -0,0 +1,489 @@ +//! Installed font families by name, so a label can be drawn in the family a project names (as +//! QGIS's text formats name theirs). The system's font folders are indexed once, from each +//! file's `name` and `OS/2` tables, read piecemeal so indexing never loads a whole file; a face +//! is read when it is first drawn, and kept. Nothing is installed or bundled: a family a machine +//! lacks is drawn in the label font. In the browser, and with `RUSTGIS_BUILTIN_FONTS` set (so +//! tests draw alike on every machine), there are none. + +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; + +/// One face of an installed family: its file, its index in a collection, its weight (100–900) +/// and whether it is an italic (or oblique) face. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstalledFace { + pub path: PathBuf, + pub index: u32, + pub weight: u16, + pub italic: bool, +} + +/// An installed family: its name, and its faces. A face is listed under its typographic family +/// ("Noto Sans") and under its legacy one ("Noto Sans Light") when they differ, so either name +/// finds it; only the first kind is `listed` in a font picker. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Family { + pub name: String, + pub listed: bool, + pub faces: Vec, +} + +/// The longest family name kept (characters): longer ones are from broken files. +pub const MAX_NAME: usize = 128; +/// Most font files indexed, folders walked deep and faces in one collection. +const MAX_FILES: usize = 50_000; +const MAX_DEPTH: usize = 8; +/// Most folders walked. +const MAX_DIRS: usize = 20_000; +const MAX_COLLECTION: u32 = 64; +/// Most tables a face's directory lists, and the most of its `name` table read. +const MAX_TABLES: u16 = 512; +const MAX_NAME_TABLE: u32 = 1 << 20; +/// Largest font file read, and the most face bytes kept in all: a few families' worth, or two or +/// three CJK collections (30–48 MB each). Kept bytes are never freed. +const MAX_FONT: u64 = 48 << 20; +const MAX_LOADED: usize = 128 << 20; + +/// The weight bold text is drawn at in a variable face whose default is lighter (a variable +/// Noto Sans is one file for every weight); a static face ignores it. +pub const BOLD_WEIGHT: f32 = 700.0; + +/// Whether bold text in `face` is drawn heavier than the face is, at [`BOLD_WEIGHT`]: the face +/// standing in for the bold one is lighter than semibold (a variable face, or a family with no +/// bold). The map view and the export must agree on this, so both ask here. +pub fn heavier(face: &InstalledFace, bold: bool) -> bool { + bold && face.weight < 600 +} + +static FAMILIES: OnceLock> = OnceLock::new(); +/// Whether [`warm_up`] has started indexing them. +static WARMING: AtomicBool = AtomicBool::new(false); + +/// The installed families, indexed once, by name (in any case). The first call walks the font +/// folders; the UI starts that early with [`warm_up`] and reads [`installed_now`]. +pub fn installed() -> &'static [Family] { + FAMILIES.get_or_init(|| if std::env::var_os("RUSTGIS_BUILTIN_FONTS").is_some() { Vec::new() } else { index(&font_dirs()) }) +} + +/// The installed families if they are indexed, without waiting: `None` while [`warm_up`]'s +/// indexing runs (a machine can have tens of thousands of font files). +pub fn installed_now() -> Option<&'static [Family]> { + FAMILIES.get().map(Vec::as_slice) +} + +/// Indexes the installed families on a thread of their own (once), so the UI never waits for +/// the folder walk; in the browser, where there are none, at once. +pub fn warm_up() { + if WARMING.swap(true, Ordering::Relaxed) { + return; + } + #[cfg(not(target_arch = "wasm32"))] + if std::thread::Builder::new() + .name("font-index".into()) + .spawn(|| { + installed(); + }) + .is_ok() + { + return; + } + installed(); +} + +/// The installed family named `name` (in any case, spaces trimmed): its place in [`installed`]. +pub fn find(name: &str) -> Option { + find_in(installed(), name) +} + +/// [`find`] for drawing. Where [`warm_up`] is indexing the families (the app, which draws the +/// map on its UI thread), `None` until the index is done rather than waiting for it, so a label +/// draws in the label font for a moment; elsewhere (a script, the command line, which never warm +/// up) the families are indexed now, so an export draws in the named family. +pub fn find_ready(name: &str) -> Option { + find_in(available()?, name) +} + +/// The installed families without waiting for [`warm_up`]'s index: `None` while it runs (the +/// app); where nothing warmed up (a script, the command line), indexed now. +pub fn available() -> Option<&'static [Family]> { + if WARMING.load(Ordering::Relaxed) { installed_now() } else { Some(installed()) } +} + +fn find_in(families: &[Family], name: &str) -> Option { + let name = name.trim(); + if name.is_empty() { + return None; + } + let i = families.binary_search_by(|f| cmp_names(&f.name, name)).ok()?; + u16::try_from(i).ok() +} + +/// Names compared as the index sorts them: in any case. +fn cmp_names(a: &str, b: &str) -> std::cmp::Ordering { + a.chars().flat_map(char::to_lowercase).cmp(b.chars().flat_map(char::to_lowercase)) +} + +/// The face of `family` that draws `bold` and `italic` text, and whether it has to be slanted by +/// hand (an italic asked of a family with no italic face): the nearest weight to 400, or 700 for +/// bold, among the faces of the right slope, else among the upright ones. +pub fn pick(family: &Family, bold: bool, italic: bool) -> Option<(&InstalledFace, bool)> { + let target: i32 = if bold { 700 } else { 400 }; + let best = |slope: bool| { + family.faces.iter().filter(|f| f.italic == slope).min_by_key(|f| { + let w = i32::from(f.weight); + // At a tie, a regular takes the lighter face and a bold the heavier, as CSS does. + ((w - target).abs(), if bold { w < target } else { w > target }) + }) + }; + match best(italic) { + Some(f) => Some((f, false)), + None => best(!italic).map(|f| (f, italic && !f.italic)), + } +} + +/// The bytes of `face`'s file, read once and kept for the rest of the run (faces are drawn from +/// borrowed bytes); `None` when it can't be read, is too large, or `face` isn't a font in it. A +/// collection's file is kept by its path and each face checked on its own, so one damaged face +/// doesn't hide its siblings. +pub fn bytes(face: &InstalledFace) -> Option<&'static [u8]> { + static LOADED: OnceLock>, usize)>> = OnceLock::new(); + let parses = |b: &[u8], index: u32| ab_glyph::FontRef::try_from_slice_and_index(b, index).is_ok(); + let lock = LOADED.get_or_init(|| Mutex::new((HashMap::new(), 0))); + let room = { + let Ok(loaded) = lock.lock() else { return None }; + if let Some(b) = loaded.0.get(&face.path) { + return b.filter(|b| parses(b, face.index)); + } + MAX_LOADED.saturating_sub(loaded.1) + }; + // Read with the lock let go: a large file takes a while, and other threads' faces are kept. + // Kept when this face or the file's first one is a font (a collection's other faces may be). + let read = std::fs::metadata(&face.path) + .ok() + .filter(|m| m.len() <= MAX_FONT && m.len() as usize <= room) + .and_then(|_| std::fs::read(&face.path).ok()) + .filter(|b| parses(b, face.index) || parses(b, 0)); + let Ok(mut loaded) = lock.lock() else { return None }; + // Another thread read it meanwhile: its copy is the one kept. + if let Some(b) = loaded.0.get(&face.path) { + return b.filter(|b| parses(b, face.index)); + } + let kept: Option<&'static [u8]> = read.filter(|b| loaded.1 + b.len() <= MAX_LOADED).map(|b| { + loaded.1 += b.len(); + &*Box::leak(b.into_boxed_slice()) + }); + loaded.0.insert(face.path.clone(), kept); + kept.filter(|b| parses(b, face.index)) +} + +/// The folders fonts are installed in on this system, the user's own included. +fn font_dirs() -> Vec { + let home = std::env::var_os("HOME").map(PathBuf::from); + let mut dirs: Vec = Vec::new(); + if cfg!(target_os = "windows") { + let windir = std::env::var_os("WINDIR").map_or_else(|| PathBuf::from("C:\\Windows"), PathBuf::from); + dirs.push(windir.join("Fonts")); + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(local).join("Microsoft").join("Windows").join("Fonts")); + } + } else if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(PathBuf::from)); + dirs.extend(home.iter().map(|h| h.join("Library").join("Fonts"))); + } else { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from)); + match std::env::var_os("XDG_DATA_HOME") { + Some(d) => dirs.push(PathBuf::from(d).join("fonts")), + None => dirs.extend(home.iter().map(|h| h.join(".local").join("share").join("fonts"))), + } + dirs.extend(home.iter().map(|h| h.join(".fonts"))); + } + dirs +} + +/// The families of the font files under `dirs`, sorted by name. +fn index(dirs: &[PathBuf]) -> Vec { + let mut files = Vec::new(); + let mut seen = HashSet::new(); + for d in dirs { + walk(d, 0, &mut files, &mut seen); + } + // In name order, so the same file wins a duplicate face on every machine. + files.sort(); + files.dedup(); + let mut by_name: HashMap = HashMap::new(); + for path in files { + for (index, face) in read_faces(&path).into_iter().enumerate() { + let Some(face) = face else { continue }; + let Ok(index) = u32::try_from(index) else { continue }; + let installed = InstalledFace { path: path.clone(), index, weight: face.weight, italic: face.italic }; + for (n, name) in face.names.iter().enumerate() { + let fam = + by_name.entry(name.to_lowercase()).or_insert_with(|| Family { name: name.clone(), listed: false, faces: Vec::new() }); + fam.listed |= n == 0; + // One face per weight and slope: the first file's. + if !fam.faces.iter().any(|f| f.weight == installed.weight && f.italic == installed.italic) { + fam.faces.push(installed.clone()); + } + } + } + } + let mut families: Vec = by_name.into_values().collect(); + families.sort_by(|a, b| cmp_names(&a.name, &b.name)); + families +} + +/// The font files under `dir`, at most [`MAX_DEPTH`] folders deep, until there are +/// [`MAX_FILES`]. A folder is walked once (`seen`, by its real path, at most [`MAX_DIRS`]), so a +/// link back up the tree, or two links to one folder, add nothing twice. +fn walk(dir: &Path, depth: usize, out: &mut Vec, seen: &mut HashSet) { + if depth > MAX_DEPTH || out.len() >= MAX_FILES || seen.len() >= MAX_DIRS { + return; + } + if !seen.insert(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf())) { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for e in entries.flatten() { + if out.len() >= MAX_FILES { + return; + } + let path = e.path(); + // (A link to a folder is followed as the folder, within the limits.) + if path.is_dir() { + walk(&path, depth + 1, out, seen); + } else if path + .extension() + .and_then(|x| x.to_str()) + .is_some_and(|x| ["ttf", "otf", "ttc", "otc"].iter().any(|k| x.eq_ignore_ascii_case(k))) + { + out.push(path); + } + } +} + +/// What indexing reads of a face: its family names (the typographic one first), weight and +/// slope. +#[derive(Debug, PartialEq, Eq)] +struct FaceInfo { + names: Vec, + weight: u16, + italic: bool, +} + +/// The faces of the font file at `path` (one, or a collection's), each `None` where it can't be +/// read. +fn read_faces(path: &Path) -> Vec> { + let Ok(mut file) = std::fs::File::open(path) else { return Vec::new() }; + faces_of(&mut file) +} + +/// The faces of a font file (a reader over it): see [`read_faces`]. Only the table directories, +/// the `name` table and the start of `OS/2` are read. +fn faces_of(r: &mut (impl Read + Seek)) -> Vec> { + let Some(head) = read_at(r, 0, 12) else { return Vec::new() }; + let starts: Vec = if head.get(0..4) == Some(b"ttcf") { + let n = u32_at(&head, 8).unwrap_or(0).min(MAX_COLLECTION); + let Some(offsets) = read_at(r, 12, n * 4) else { return Vec::new() }; + (0..n).filter_map(|i| u32_at(&offsets, i as usize * 4)).collect() + } else { + vec![0] + }; + starts.into_iter().map(|start| face_info(r, start)).collect() +} + +fn face_info(r: &mut (impl Read + Seek), start: u32) -> Option { + let dir = read_at(r, start, 12)?; + let n = u16_at(&dir, 4)?.min(MAX_TABLES); + let records = read_at(r, start.checked_add(12)?, u32::from(n) * 16)?; + let table = |tag: &[u8; 4]| { + records.as_chunks::<16>().0.iter().find(|t| t.get(0..4) == Some(tag)).and_then(|t| Some((u32_at(t, 8)?, u32_at(t, 12)?))) + }; + let (name_at, name_len) = table(b"name")?; + let names = read_at(r, name_at, name_len.min(MAX_NAME_TABLE))?; + let read_name = |id: u16| name(&names, id); + let family = read_name(1)?; + let typographic = read_name(16); + let style = read_name(17).or_else(|| read_name(2)).unwrap_or_default().to_lowercase(); + let (weight, selection) = match table(b"OS/2") { + Some((at, len)) => { + let os2 = read_at(r, at, len.min(64))?; + (u16_at(&os2, 4).unwrap_or(400), u16_at(&os2, 62).unwrap_or(0)) + } + None => (400, 0), + }; + // fsSelection's ITALIC or OBLIQUE bit, or a style that says so. + let italic = selection & 0x0201 != 0 || style.contains("italic") || style.contains("oblique"); + let mut names = Vec::with_capacity(2); + names.extend(typographic); + if !names.iter().any(|n: &String| n.eq_ignore_ascii_case(&family)) { + names.push(family); + } + Some(FaceInfo { names, weight: weight.clamp(1, 1000), italic }) +} + +/// Name `id` of a `name` table, in English where it has it: a Windows (UTF-16) record first, then +/// a Unicode one, then a Macintosh Roman one. Control characters are dropped; `None` when it is +/// missing or blank. +fn name(table: &[u8], id: u16) -> Option { + let count = u16_at(table, 2)?; + let strings = usize::from(u16_at(table, 4)?); + let mut best: Option<(u8, String)> = None; + for i in 0..usize::from(count) { + // A table cut short keeps the records before the cut (and what they matched). + let Some(rec) = table.get(6 + i * 12..6 + i * 12 + 12) else { break }; + let (platform, lang, name_id) = (u16_at(rec, 0)?, u16_at(rec, 4)?, u16_at(rec, 6)?); + if name_id != id { + continue; + } + let (len, at) = (usize::from(u16_at(rec, 8)?), usize::from(u16_at(rec, 10)?)); + let Some(raw) = table.get(strings + at..strings + at + len) else { continue }; + let (rank, text) = match platform { + 3 | 0 => { + let units: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); + let english = platform == 0 || lang == 0x0409; + (if english { 0 } else { 2 }, String::from_utf16_lossy(&units)) + } + // Macintosh Roman: its ASCII half is all a family name needs. + 1 => (if lang == 0 { 1 } else { 3 }, raw.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect()), + _ => continue, + }; + let text: String = text.chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); + let text = text.trim().to_string(); + if !text.is_empty() && best.as_ref().is_none_or(|(r, _)| rank < *r) { + best = Some((rank, text)); + } + } + best.map(|(_, t)| t) +} + +/// `len` bytes from `at` (`None` when the file ends before them). +fn read_at(r: &mut (impl Read + Seek), at: u32, len: u32) -> Option> { + r.seek(SeekFrom::Start(u64::from(at))).ok()?; + let mut buf = Vec::new(); + r.take(u64::from(len)).read_to_end(&mut buf).ok()?; + (buf.len() == len as usize).then_some(buf) +} + +fn u16_at(b: &[u8], i: usize) -> Option { + Some(u16::from_be_bytes(b.get(i..i + 2)?.try_into().ok()?)) +} + +fn u32_at(b: &[u8], i: usize) -> Option { + Some(u32::from_be_bytes(b.get(i..i + 4)?.try_into().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_installed_families_are_read_without_waiting_once_indexed() { + // No warm-up here (it would make every test in this process draw without waiting), so + // drawing finds a family as a command does: the families are indexed at once. + let all = installed(); + assert_eq!(installed_now().map(<[Family]>::len), Some(all.len())); + if let Some(f) = all.iter().find(|f| f.listed) { + assert_eq!(find_ready(&f.name), find(&f.name)); + } + assert!(find_ready("No Such Family").is_none()); + } + + #[test] + fn a_font_file_is_indexed_by_its_family_names_weight_and_slope() { + // egui's own faces, written into a folder as installed fonts would be. + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("ubuntu"); + std::fs::create_dir(&sub).unwrap(); + std::fs::write(sub.join("Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).unwrap(); + std::fs::write(dir.path().join("Hack-Regular.TTF"), epaint_default_fonts::HACK_REGULAR).unwrap(); + std::fs::write(dir.path().join("broken.otf"), b"OTTO\x00\x05garbage").unwrap(); + std::fs::write(dir.path().join("notes.txt"), b"not a font").unwrap(); + let fams = index(&[dir.path().to_path_buf()]); + let names: Vec<&str> = fams.iter().map(|f| f.name.as_str()).collect(); + assert!(names.contains(&"Hack"), "{names:?}"); + let ubuntu = fams.iter().find(|f| f.name.to_lowercase().starts_with("ubuntu")).expect("ubuntu indexed"); + assert_eq!(ubuntu.faces.len(), 1); + assert!(ubuntu.faces[0].weight < 400 && !ubuntu.faces[0].italic, "{ubuntu:?}"); + // Found in any case; the face it draws in, its bytes read once. + let i = find_in(&fams, &ubuntu.name.to_uppercase()).unwrap(); + let fam = &fams[i as usize]; + let (face, slanted) = pick(fam, false, true).unwrap(); + assert!(slanted, "no italic face: slanted by hand"); + // A light face standing in for bold is drawn heavier, by the same rule everywhere. + assert!(heavier(face, true) && !heavier(face, false)); + assert!(!heavier(&InstalledFace { weight: 700, ..face.clone() }, true)); + // A face the file hasn't (a damaged face of a collection) is none, and doesn't hide the + // file's good one. + assert!(bytes(&InstalledFace { index: 5, ..face.clone() }).is_none()); + let b = bytes(face).unwrap(); + assert!(std::ptr::eq(b, bytes(face).unwrap()), "kept, not read again"); + assert!(find_in(&fams, "No Such Family").is_none() && find_in(&fams, " ").is_none()); + } + + #[cfg(unix)] + #[test] + fn a_folder_is_walked_once_however_many_links_reach_it() { + // Links back up the tree, four to a folder: followed blindly, they'd fan out to 4^8 folders + // and list the one font thousands of times. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Hack-Regular.ttf"), epaint_default_fonts::HACK_REGULAR).unwrap(); + for i in 0..4 { + std::os::unix::fs::symlink(dir.path(), dir.path().join(format!("up{i}"))).unwrap(); + } + let mut files = Vec::new(); + let mut seen = HashSet::new(); + walk(dir.path(), 0, &mut files, &mut seen); + assert_eq!((files.len(), seen.len()), (1, 1), "{files:?}"); + } + + #[test] + fn a_name_table_cut_short_keeps_the_names_before_the_cut() { + // Two records listed, the second cut off: the first's family name still reads. + let text: Vec = "Sans".encode_utf16().flat_map(u16::to_be_bytes).collect(); + let strings = 6 + 12 + 4; + let mut table = vec![0, 0, 0, 2]; + table.extend((strings as u16).to_be_bytes()); + for v in [3u16, 1, 0x0409, 1, text.len() as u16, 0] { + table.extend(v.to_be_bytes()); + } + table.extend([0, 3, 0, 1]); + table.extend(&text); + assert_eq!(name(&table, 1).as_deref(), Some("Sans")); + } + + #[test] + fn a_family_draws_in_its_nearest_face() { + let face = |weight, italic| InstalledFace { path: PathBuf::from(format!("{weight}{italic}")), index: 0, weight, italic }; + let fam = + Family { name: "F".into(), listed: true, faces: vec![face(300, false), face(500, false), face(800, false), face(400, true)] }; + let w = |bold, italic| pick(&fam, bold, italic).map(|(f, s)| (f.weight, f.italic, s)).unwrap(); + // 300 and 500 are as near 400: the regular takes the lighter. + assert_eq!(w(false, false), (300, false, false)); + assert_eq!(w(true, false), (800, false, false)); + assert_eq!(w(false, true), (400, true, false)); + assert_eq!(w(true, true), (400, true, false), "the only italic, not a slanted bold"); + let upright = Family { faces: vec![face(400, false)], ..fam.clone() }; + assert_eq!(pick(&upright, false, true).map(|(f, s)| (f.weight, s)), Some((400, true))); + assert!(pick(&Family { faces: Vec::new(), ..fam }, false, false).is_none()); + } + + #[test] + fn hostile_font_files_are_skipped() { + use std::io::Cursor; + // Truncated headers, a collection claiming millions of faces, tables past the end. + let mut ttc = b"ttcf\x00\x01\x00\x00\xff\xff\xff\xff".to_vec(); + ttc.extend([0u8; 16]); + let mut dir = b"\x00\x01\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00".to_vec(); + dir.extend(b"name\x00\x00\x00\x00\xff\xff\xff\xf0\x7f\xff\xff\xff"); + for bytes in [&b""[..], b"\x00\x01", &ttc, &dir] { + let faces = faces_of(&mut Cursor::new(bytes.to_vec())); + assert!(faces.iter().all(Option::is_none), "{faces:?}"); + } + // A name table whose records point past its end. + let table = [0u8, 0, 0, 1, 0, 18, 0, 3, 0, 1, 4, 9, 0, 1, 0xff, 0xff, 0, 0]; + assert_eq!(name(&table, 1), None); + } +} diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 52626570a..db47502d3 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -203,7 +203,8 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O ElementKind::Text { text: t, size, color, bold, italic, align, letter_spacing, font } => { let first = layout.first_frame().and_then(frame); let s = expand(t, layout, info, first.and_then(|f| maps.get(f.1).map(|m| (m, f.2)))); - let face = text::Face { bold: *bold, italic: *italic, serif: *font == rustgis_map::layout::FontFamily::Serif }; + let face = + text::Face { bold: *bold, italic: *italic, serif: *font == rustgis_map::layout::FontFamily::Serif, family: None }; let style = TextStyle { face, align: *align, spacing: *letter_spacing as f32 }; text_block(&mut pm, r, &s, *size as f32, *color, style, ppp); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9f378a9ed..4c2f1c0fb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod effects; +pub mod families; pub mod fonts; pub mod gpu_scene; pub mod layout; @@ -187,6 +188,9 @@ pub struct PlacedLabel { /// upright with no spacing. pub italic: bool, pub spacing: f32, + /// The installed family it is drawn in, by its place in [`families::installed`] (a label + /// class's font); `None` is the label font. + pub font: Option, /// How the lines line up in their block (a text graphic's, #535); the block itself sits where /// `align` puts it. pub line_align: TextAlign, @@ -2531,7 +2535,10 @@ fn render_turned(map: &mut Map, view: &View, full_res_rasters: bool) -> Option) -> Option { let (w, h) = view.pixel_size(); let mut pm = Pixmap::new(w, h)?; @@ -3086,6 +3093,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac framed: selection.contains(&row), italic: rustgis_map::graphics::italic(ds_ptr, row), spacing: rustgis_map::graphics::letter_spacing(ds_ptr, row), + font: None, line_align: rustgis_map::graphics::align(ds_ptr, row), angle: None, collide: Default::default(), @@ -3103,6 +3111,10 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac }; if label_fields.iter().any(Option::is_some) { let label_factor = layer.size_factor(label_scale); + // Each class's font, where it is installed. An export (`full_res_rasters`: see + // `render_frame`'s contract) waits for the font index; a frame on screen doesn't. + let find = if full_res_rasters { families::find } else { families::find_ready }; + let class_fonts: Vec> = labels.in_order().map(|c| c.font.as_deref().and_then(find)).collect(); let classes = rustgis_map::LabelMatcher::new(labels, ds_ptr); let lid = layer.id; // Whether a class labels lines along them: those take the stretch of a line in @@ -3158,8 +3170,10 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // Placed as at its own size, then that much farther out: the gap between // the label and its feature grows with it too. let size = class.size * label_factor; + let font = class_fonts.get(ci).copied().flatten(); + let face = text::Face { bold: class.bold, italic: class.italic, serif: false, family: font }; let (dx, dy) = class.placement.offset( - text::block_width(&text, class.size, class.bold), + text::block_width(&text, class.size, face), text::block_height(&text, class.size), marker, is_point, @@ -3175,13 +3189,13 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let curved = class.placement == LabelPlacement::Curved && text.graphemes(true).nth(line_labels::MAX_CURVED_CHARS).is_none(); if !curved { - let width = text::block_width(&text, size, class.bold); + let width = text::block_width(&text, size, face); let Some((cx, cy, a)) = line_labels::parallel(line, width, overrun) else { continue }; (lx, ly, angle) = (cx, cy, Some(a)); } else { // Each character with its accents, drawn as one. let chars: Vec = text.graphemes(true).map(String::from).collect(); - let advances: Vec = chars.iter().map(|c| text::measure(c, size, class.bold)).collect(); + let advances: Vec = chars.iter().map(|c| text::measure_in(c, size, face)).collect(); let Some(((cx, cy), at)) = line_labels::curved(line, &advances, overrun) else { continue }; (lx, ly) = (cx, cy); glyphs = @@ -3206,6 +3220,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac framed: false, italic: class.italic, spacing: 0.0, + font, line_align: TextAlign::Left, angle, collide: Default::default(), @@ -3911,7 +3926,7 @@ fn turn_with(l: &mut PlacedLabel, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) /// as far from that end as it was from the start, by its own width rather than that of the /// character whose place it takes, a step along the nearest one's angle from it. fn read_back(l: &mut PlacedLabel) { - let widths: Vec = l.glyphs.iter().map(|g| text::measure(&g.text, l.size, l.bold)).collect(); + let widths: Vec = l.glyphs.iter().map(|g| text::measure_in(&g.text, l.size, text::label_face(l))).collect(); let total: f32 = widths.iter().sum(); // How far along the text each character's centre was. let mut centres = Vec::with_capacity(widths.len()); @@ -4171,6 +4186,7 @@ pub(crate) fn draw_attribution(pm: &mut Pixmap, attribution: &[String], ppp: f32 framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), @@ -4196,6 +4212,7 @@ mod tests { framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), @@ -4710,7 +4727,7 @@ mod tests { let (_, alone) = ink(&at("Atlantic", false)); assert!(cols.first().zip(alone.first()).is_some_and(|(a, b)| a.abs_diff(*b) <= 2), "the longer line keeps its place"); // As wide as the widest line, as tall as its lines; a trailing newline is an empty line. - assert_eq!(text::block_width("ab\ncdef", 10.0, false), text::measure("cdef", 10.0, false)); + assert_eq!(text::block_width("ab\ncdef", 10.0, text::Face::default()), text::measure("cdef", 10.0, false)); // Text drawn on one line (a table cell, a chart's name) is still measured whole, so it is // cut to its column as it is drawn. assert_eq!(text::measure("ab\ncdef", 10.0, false), text::measure("abcdef", 10.0, false)); @@ -5996,6 +6013,7 @@ mod tests { framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), @@ -6034,6 +6052,7 @@ mod tests { framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), diff --git a/crates/render/src/report.rs b/crates/render/src/report.rs index ed3b0b249..febf110ca 100644 --- a/crates/render/src/report.rs +++ b/crates/render/src/report.rs @@ -55,7 +55,7 @@ pub fn width_mm(s: &str, pt: f32, bold: bool) -> f64 { /// [`width_mm`] in the serif a layout draws a `"font": "serif"` text in (the sans where no serif is /// installed): the ink's width, which the line has to hold. pub fn serif_width_mm(s: &str, pt: f32, bold: bool) -> f64 { - let (lo, hi) = text::styled_extent(s, pt * 1.33, text::Face { bold, italic: false, serif: true }, 0.0); + let (lo, hi) = text::styled_extent(s, pt * 1.33, text::Face { bold, italic: false, serif: true, family: None }, 0.0); f64::from((hi - lo).max(0.0)) * 25.4 / 96.0 } diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs index 2b5d26176..13d6e993a 100644 --- a/crates/render/src/stream_tests.rs +++ b/crates/render/src/stream_tests.rs @@ -546,6 +546,7 @@ fn chunked_label_compaction_keeps_late_priorities_stable_ties_and_nan_order() { framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 810667cd4..e61291b74 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -37,6 +37,9 @@ pub struct Face { pub italic: bool, /// The installed serif; the sans where there is none. pub serif: bool, + /// An installed family, by its place in [`crate::families::installed`] (a label's named + /// font); the serif or sans where it can't be drawn. + pub family: Option, } /// The installed serif's faces, parsed once: (regular, bold, italic, bold italic). @@ -81,7 +84,38 @@ fn face_in(face: Face, serif: Option<&'static SerifFaces>) -> Option<(&'static F } fn face_font(face: Face) -> Option<(&'static FontRef<'static>, bool)> { - face_in(face, serif()) + let named = face.family.and_then(|i| crate::families::installed().get(usize::from(i))); + named.and_then(|fam| family_font(fam, face)).or_else(|| face_in(face, serif())) +} + +/// The face of an installed family that draws `face`'s weight and slope, parsed once and kept, +/// and whether it is slanted by hand (the family has no italic face): see +/// [`crate::families::pick`]. `None` when its file can't be read. +fn family_font(fam: &crate::families::Family, face: Face) -> Option<(&'static FontRef<'static>, bool)> { + type Parsed = std::collections::HashMap<(std::path::PathBuf, u32, bool), Option<&'static FontRef<'static>>>; + static PARSED: OnceLock> = OnceLock::new(); + let (installed, slanted) = crate::families::pick(fam, face.bold, face.italic)?; + let heavier = crate::families::heavier(installed, face.bold); + let key = (installed.path.clone(), installed.index, heavier); + let lock = PARSED.get_or_init(Default::default); + if let Some(f) = lock.lock().ok()?.get(&key) { + return f.map(|f| (f, slanted)); + } + // Read and parsed with the lock let go: a file can be 48 MB, and other text is drawn meanwhile. + let read = crate::families::bytes(installed).and_then(|b| FontRef::try_from_slice_and_index(b, installed.index).ok()); + let mut parsed = lock.lock().ok()?; + // Another thread parsed it meanwhile: its face is the one kept. + if let Some(f) = parsed.get(&key) { + return f.map(|f| (f, slanted)); + } + let f = read.map(|mut f| { + if heavier { + f.set_variation(b"wght", crate::families::BOLD_WEIGHT); + } + &*Box::leak(Box::new(f)) + }); + parsed.insert(key, f); + f.map(|f| (f, slanted)) } /// The label font of `face`'s weight, for the characters `f` lacks: `None` when `f` is it. @@ -220,6 +254,23 @@ fn measure_uncached(text: &str, size: f32, bold: bool) -> f32 { shape(text, size * 1.33, f, None).1 } +/// [`measure`] in `face`: a label in a named family is as wide as that family draws it. +pub fn measure_in(text: &str, size: f32, face: Face) -> f32 { + if face.family.is_none() && !face.serif { + return measure(text, size, face.bold); + } + // Labels in a named family are measured as often as those in the label font (every feature, + // every frame), so they share its retention, keyed by the whole face. + static WIDTHS: OnceLock)>> = OnceLock::new(); + let key = (face.bold, face.italic, face.serif, face.family); + WIDTHS.get_or_init(widths::Cache::default).measure(text, size, key, || { + let Some((f, _)) = face_font(face) else { + return text.chars().count() as f32 * size * 0.6; + }; + shape(text, size * 1.33, f, sans_beside(f, face)).1 + }) +} + /// How wide `text` is on one line at `size` with `spacing` points between letters, in points. fn spaced_width(text: &str, size: f32, bold: bool, spacing: f32) -> f32 { let w = measure(text, size, bold); @@ -292,8 +343,8 @@ pub fn wrap(line: &str, size: f32, bold: bool, spacing: f32, max_w: f32) -> Vec< /// A label's width in points: its widest line, as [`draw`] lays its lines out (#577). Text drawn /// on one line (table cells, chart names…) is measured with [`measure`]. -pub fn block_width(text: &str, size: f32, bold: bool) -> f32 { - lines(text).map(|line| measure(line, size, bold)).fold(0.0, f32::max) +pub fn block_width(text: &str, size: f32, face: Face) -> f32 { + lines(text).map(|line| measure_in(line, size, face)).fold(0.0, f32::max) } /// The distance between a label's lines in points, as [`draw`] sets them: 1.25 × the font's @@ -308,22 +359,28 @@ pub fn block_height(text: &str, size: f32) -> f32 { size * 1.25 + line_count(text).saturating_sub(1) as f32 * line_pitch(size) } -/// Whether a label is slanted or letter-spaced (a text graphic's, #535), drawn with -/// [`styled_at`]. +/// Whether a label is slanted or letter-spaced (a text graphic's, #535), or in a named family, +/// drawn with [`styled_at`]. fn styled(l: &PlacedLabel) -> bool { - l.italic || l.spacing != 0.0 + l.italic || l.spacing != 0.0 || l.font.is_some() +} + +/// The face a label is drawn in. +pub fn label_face(l: &PlacedLabel) -> Face { + Face { bold: l.bold, italic: l.italic, serif: false, family: l.font } } /// How wide a line of `l` is laid out, in pixels at `ppp`: its advance, and its letter spacing /// between letters (not past the last), as the map view lays it out (#535), so a text is placed /// alike on screen and in an export. A slant's overhang isn't counted, as it isn't there either. fn line_width(l: &PlacedLabel, line: &str, ppp: f32) -> f32 { - let advance = measure(line, l.size, l.bold) * ppp; + let face = label_face(l); + let advance = measure_in(line, l.size, face) * ppp; if l.spacing == 0.0 { return advance; } - let letters = match font(l.bold) { - Some(f) => shape(line, l.size * 1.33 * ppp, f, None).0.len(), + let letters = match face_font(face) { + Some((f, _)) => shape(line, l.size * 1.33 * ppp, f, sans_beside(f, face)).0.len(), None => line.chars().count(), }; // Letters drawn tighter than their own width (the most negative spacing) don't make it less @@ -521,7 +578,7 @@ fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32, ink: Ink) { let (styled, spacing) = (styled(l), l.spacing * 1.33 * ppp); let put = |pm: &mut Pixmap, &(line, left, lw, y): &(&str, f32, f32, f32), color: Rgba, (dx, dy): (f32, f32)| { if styled { - styled_at(pm, line, left + dx, y + dy, px, color, Face { bold: l.bold, italic: l.italic, serif: false }, spacing); + styled_at(pm, line, left + dx, y + dy, px, color, label_face(l), spacing); } else { draw_text(pm, line, left + lw / 2.0, y, px, color, l.bold, (dx, dy)); } @@ -593,6 +650,7 @@ pub fn scale_bar(pm: &mut Pixmap, view: &View, ground_per_pt: f64) { framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), @@ -694,7 +752,7 @@ mod coverage_tests { use super::{Face, face_in, font}; for bold in [false, true] { for italic in [false, true] { - let (f, slant) = face_in(Face { bold, italic, serif: true }, None).unwrap(); + let (f, slant) = face_in(Face { bold, italic, serif: true, family: None }, None).unwrap(); assert!(std::ptr::eq(f, font(bold).unwrap()) && slant == italic); } } @@ -711,7 +769,7 @@ mod coverage_tests { let faces: &'static SerifFaces = Box::leak(Box::new((parse(epaint_default_fonts::HACK_REGULAR), None, Some(parse(epaint_default_fonts::UBUNTU_LIGHT)), None))); let (regular, italic) = (&faces.0, faces.2.as_ref().unwrap()); - let pick = |bold, it| face_in(Face { bold, italic: it, serif: true }, Some(faces)).unwrap(); + let pick = |bold, it| face_in(Face { bold, italic: it, serif: true, family: None }, Some(faces)).unwrap(); let is = |(f, slant): (&FontRef, bool), g: &FontRef, s: bool| std::ptr::eq(f, g) && slant == s; assert!(is(pick(false, false), regular, false)); assert!(is(pick(false, true), italic, false), "the real italic, not slanted"); @@ -731,6 +789,26 @@ mod coverage_tests { assert!(!leans(Face { serif: true, ..Face::default() }, regular, false, sans), "upright text stays upright"); } + /// A label in an installed family is drawn in its face, parsed once; a family that isn't + /// installed is drawn in the label font. + #[test] + fn a_label_is_drawn_in_its_installed_family() { + use super::{Face, family_font, measure, measure_in, shape}; + use crate::families::{Family, InstalledFace}; + // Hack, a monospaced face, as an installed family: every letter as wide. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Hack-Regular.ttf"); + std::fs::write(&path, epaint_default_fonts::HACK_REGULAR).unwrap(); + let fam = Family { name: "Hack".into(), listed: true, faces: vec![InstalledFace { path, index: 0, weight: 400, italic: false }] }; + let (f, slanted) = family_font(&fam, Face::default()).unwrap(); + assert_eq!(shape("iiii", 20.0, f, None).1, shape("MMMM", 20.0, f, None).1); + assert!(std::ptr::eq(f, family_font(&fam, Face::default()).unwrap().0), "parsed once"); + assert!(!slanted && family_font(&fam, Face { italic: true, ..Face::default() }).unwrap().1, "no italic face: slanted"); + // A family that isn't installed measures and draws as the label font. + let missing = Face { family: Some(u16::MAX), ..Face::default() }; + assert_eq!(measure_in("Winterthur", 12.0, missing), measure("Winterthur", 12.0, false)); + } + /// With a serif installed (Georgia on Windows, Times on macOS, Noto or DejaVu Serif on Linux), /// a text asking for it is drawn in it, and italic in the family's italic face, not slanted /// by hand; the sans is untouched. diff --git a/crates/render/src/text/widths.rs b/crates/render/src/text/widths.rs index 8b92172f8..b787fdf17 100644 --- a/crates/render/src/text/widths.rs +++ b/crates/render/src/text/widths.rs @@ -11,35 +11,41 @@ const SHARD_BYTES: usize = 128 * 1024; const MAX_TEXT_BYTES: usize = 4096; /// The full key accompanies its hash so a collision can only cause a miss, never a wrong width. -struct Entry { +/// `S` is the face measured in: whether it's bold, or a label's whole face. +struct Entry { text: String, size: u32, - bold: bool, + bold: S, width: f32, } /// One shard's entries and owned string capacities; an eviction drops both allocations. -#[derive(Default)] -struct Widths { - entries: HashMap, +struct Widths { + entries: HashMap>, strings: usize, } -impl Widths { +impl Default for Widths { + fn default() -> Self { + Widths { entries: HashMap::new(), strings: 0 } + } +} + +impl Widths { /// Checks the complete measurement key, keeping signed zero and distinct finite sizes apart. - fn get(&self, hash: u64, text: &str, size: u32, bold: bool) -> Option { + fn get(&self, hash: u64, text: &str, size: u32, bold: S) -> Option { self.entries.get(&hash).filter(|e| e.text == text && e.size == size && e.bold == bold).map(|e| e.width) } /// Estimates retained strings and table capacity with slack for buckets, using saturation. /// This is a retention policy, not a strict allocator or process-memory bound. fn retained_bytes(&self) -> usize { - self.strings.saturating_add(self.entries.capacity().saturating_mul(2 * std::mem::size_of::<(u64, Entry)>())) + self.strings.saturating_add(self.entries.capacity().saturating_mul(2 * std::mem::size_of::<(u64, Entry)>())) } /// Replaces a colliding entry and accounts for its old string before checking the budget. /// Over-budget shards release their capacity rather than retaining an empty large table. - fn insert(&mut self, hash: u64, text: &str, size: u32, bold: bool, width: f32) { + fn insert(&mut self, hash: u64, text: &str, size: u32, bold: S, width: f32) { let text = text.to_owned(); self.strings = self.strings.saturating_add(text.capacity()); if let Some(old) = self.entries.insert(hash, Entry { text, size, bold, width }) { @@ -53,16 +59,21 @@ impl Widths { /// Sixteen independent shards retain at most 16 × the per-shard estimate after insertion. /// Misses measure without a lock, so concurrent misses may repeat work but cannot mix results. -#[derive(Default)] -pub(super) struct Cache { - shards: [Mutex; 16], +pub(super) struct Cache { + shards: [Mutex>; 16], +} + +impl Default for Cache { + fn default() -> Self { + Cache { shards: std::array::from_fn(|_| Mutex::default()) } + } } -impl Cache { +impl Cache { /// Returns a matching width or calls `uncached` once, preserving its exact floating-point result. /// Oversized text and non-finite sizes bypass hashing, locking and retention. Poisoned locks /// are recovered: entries are only installed after the caller has measured successfully. - pub(super) fn measure(&self, text: &str, size: f32, bold: bool, uncached: impl FnOnce() -> f32) -> f32 { + pub(super) fn measure(&self, text: &str, size: f32, bold: S, uncached: impl FnOnce() -> f32) -> f32 { if text.len() > MAX_TEXT_BYTES || !size.is_finite() { return uncached(); } diff --git a/crates/render/src/tiles.rs b/crates/render/src/tiles.rs index 66e2d7755..fa1b03047 100644 --- a/crates/render/src/tiles.rs +++ b/crates/render/src/tiles.rs @@ -78,6 +78,7 @@ pub(crate) fn tile_label(l: rustgis_tiles::Label) -> Option<(PlacedLabel, f64)> framed: false, italic: l.italic, spacing, + font: None, line_align, angle: l.angle, collide: l.collide, diff --git a/crates/ui-egui/src/labelfonts.rs b/crates/ui-egui/src/labelfonts.rs new file mode 100644 index 000000000..4e55b9263 --- /dev/null +++ b/crates/ui-egui/src/labelfonts.rs @@ -0,0 +1,81 @@ +//! Label fonts in the map view: a label class's installed family (`LabelClass::font`), loaded into +//! egui when a label first asks for it, as the export draws it. The face is the one the renderer +//! measured the label in (`families::pick`), so a label sits alike on screen and in an export. +//! Until egui has it (fonts set during a frame take effect on the next), the label draws in the +//! interface font; a later reinstall of the interface fonts drops it, and it is loaded again. + +use std::collections::HashMap; +use std::sync::Arc; + +use egui::{FontData, FontFamily}; +use rustgis_engine::render::families; + +/// Fonts asked for, by egui family name: the pass each was set in, or `None` when its file +/// couldn't be read (not asked for again). +#[derive(Clone, Default)] +struct Requests(HashMap>); + +/// Fonts asked for this pass, not yet set. +#[derive(Clone, Default)] +struct Pending(Vec<(String, &'static [u8], u32, bool)>); + +/// The egui family a label in installed family `font` draws in, `bold` and `italic`, and whether +/// egui slants it (the family has no italic face). `None` for the interface font: no family, one +/// not installed, or one not loaded yet (asked for here, so a later frame has it). +pub(crate) fn family(ctx: &egui::Context, font: Option, bold: bool, italic: bool) -> Option<(FontFamily, bool)> { + let fam = families::installed_now()?.get(usize::from(font?))?; + let (face, slanted) = families::pick(fam, bold, italic)?; + let heavier = families::heavier(face, bold); + let key = format!("label-font:{}#{}{}", face.path.display(), face.index, if heavier { ":bold" } else { "" }); + let family = FontFamily::Name(key.as_str().into()); + if ctx.fonts(|f| f.definitions().families.contains_key(&family)) { + return Some((family, slanted)); + } + let id = egui::Id::unique("label-fonts"); + let pass = ctx.cumulative_pass_nr(); + let asked = ctx.data_mut(|d| d.get_temp_mut_or_default::(id).0.get(&key).copied()); + match asked { + // Its file couldn't be read. + Some(None) => return None, + // Set a pass ago: it takes effect on the next. Set before that and gone: the interface + // fonts were installed over it. + Some(Some(p)) if pass <= p + 1 => return None, + _ => {} + } + let bytes = families::bytes(face); + ctx.data_mut(|d| { + d.get_temp_mut_or_default::(id).0.insert(key.clone(), bytes.map(|_| pass)); + if let Some(b) = bytes { + d.get_temp_mut_or_default::(id).0.push((key, b, face.index, heavier)); + } + }); + // A frame to set it, and one more drawn in it. + ctx.request_repaint(); + None +} + +/// Sets the label fonts the last pass asked for, in one go, each with the interface fonts after it +/// for the characters it lacks. Called once a frame, before the labels are drawn and after the +/// interface fonts are installed. +pub(crate) fn install_pending(ctx: &egui::Context) { + let id = egui::Id::unique("label-fonts"); + let pending = ctx.data_mut(|d| std::mem::take(&mut d.get_temp_mut_or_default::(id).0)); + if pending.is_empty() { + return; + } + let mut defs = ctx.fonts(|f| f.definitions().clone()); + let fallback: Vec = defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + for (key, bytes, index, heavier) in pending { + let mut data = FontData::from_static(bytes); + data.index = index; + if heavier { + let weight = egui::epaint::text::VariationCoords::new([(b"wght", families::BOLD_WEIGHT)]); + data = data.tweak(egui::FontTweak { coords: weight, ..Default::default() }); + } + defs.font_data.insert(key.clone(), Arc::new(data)); + let mut list = vec![key.clone()]; + list.extend(fallback.iter().cloned()); + defs.families.insert(FontFamily::Name(key.as_str().into()), list); + } + ctx.set_fonts(defs); +} diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index ea82c1bf6..d82bfbd50 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -6,6 +6,7 @@ use egui::{RichText, Ui, vec2}; use rustgis_engine::map::symbology::{DEFAULT_LABEL_CLASS, LABEL_SIZE, MAX_LABEL_CLASSES}; use rustgis_engine::map::{LabelPlacement, Layer, Rgba}; +use rustgis_engine::render::families; use serde_json::{Value, json}; use crate::RustGisApp; @@ -162,6 +163,8 @@ fn sql(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { fn text_symbol(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { section(ui, "Text Symbol", |ui| { grid(ui, "symbol", |ui| { + font(app, ui, l); + ui.end_row(); let input_label = ui.label(tl!("Size")); // The whole range `labels.style` accepts (#457); the map follows a drag and letting // go runs the command once, so a drag is one undo step. @@ -208,6 +211,58 @@ fn text_symbol(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { }); } +/// The most installed families the font list shows at once (a machine can have thousands); the +/// rest are found by typing part of a name. +const MAX_FONTS_SHOWN: usize = 200; + +/// The installed family the labels are drawn in: the label font, or one picked from the +/// installed families (found by typing part of the name). A family this machine lacks, from a +/// project made on another, is shown as missing and drawn in the label font. +fn font(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { + let input_label = ui.label(tl!("Font")); + // The installed fonts are indexed on a thread of their own; until then there's none to pick. + let Some(installed) = families::installed_now() else { + families::warm_up(); + ui.add_enabled(false, egui::Button::new(tl!("Reading the installed fonts…"))).labelled_by(input_label.id); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); + return; + }; + let cur = l.labels.default.font.as_deref(); + let shown = match cur { + None => tl!("Label font").to_string(), + Some(name) if families::find(name).is_some() => name.to_string(), + Some(name) => crate::i18n::fmt("{name} (not installed)", &[("name", name)]), + }; + let search_id = egui::Id::unique(("label_pane_font_search", l.id)); + egui::ComboBox::from_id_salt("label_pane_font") + .selected_text(shown) + .width(180.0) + .height(320.0) + .show_ui(ui, |ui| { + let mut search = ui.data_mut(|d| d.get_temp::(search_id)).unwrap_or_default(); + if ui.add(egui::TextEdit::singleline(&mut search).hint_text(tl!("Search fonts"))).changed() { + ui.data_mut(|d| d.insert_temp(search_id, search.clone())); + } + if ui.selectable_label(cur.is_none(), tl!("Label font")).clicked() { + run(app, "labels.style", json!({"font": ""})); + } + let search = search.to_lowercase(); + let mut matching = installed.iter().filter(|f| f.listed && (search.is_empty() || f.name.to_lowercase().contains(&search))); + for f in matching.by_ref().take(MAX_FONTS_SHOWN) { + let on = cur.is_some_and(|c| c.eq_ignore_ascii_case(&f.name)); + if ui.selectable_label(on, &f.name).clicked() && !on { + run(app, "labels.style", json!({"font": f.name})); + } + } + let more = matching.count(); + if more > 0 { + ui.weak(crate::i18n::fmt("{count} more: type part of a name", &[("count", &more.to_string())])); + } + }) + .response + .labelled_by(input_label.id); +} + fn position(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { section(ui, "Position", |ui| { let cur = l.labels.default.placement; @@ -424,7 +479,7 @@ mod tests { use egui::accesskit::Role; let mut h = harness(with_cities()); h.run(); - for label in ["Field", "Out Beyond", "In Beyond", "Closest", "Farthest"] { + for label in ["Field", "Font", "Out Beyond", "In Beyond", "Closest", "Farthest"] { assert!(h.query_by_role_and_label(Role::ComboBox, label).is_some(), "missing input: {label}"); } assert!(h.query_by_role_and_label(Role::SpinButton, "Size").is_some()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index becdadbd5..c930bcce1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,7 @@ mod globe_drape; mod graphicformat; pub mod icons; mod keymap; +mod labelfonts; mod labelpane; mod layoutview; #[cfg(not(target_arch = "wasm32"))] @@ -800,6 +801,8 @@ pub struct RustGisApp { fonts_failed: Vec, /// Fonts were installed this frame: `set_fonts` only takes effect on the next one. fonts_pending: bool, + /// Whether the installed font families were indexed by a frame (see `families::warm_up`). + label_fonts_indexed: bool, /// The language the interface is drawn in. It trails the chosen one until a font for its /// script is active, so switching to Chinese never draws a frame of missing-glyph boxes. shown_lang: i18n::Lang, @@ -854,6 +857,8 @@ impl RustGisApp { } pub fn new(session: Session) -> Self { + // The installed fonts a label can be drawn in, indexed while the app starts. + rustgis_engine::render::families::warm_up(); let mut s = session; if s.active_layer.is_none() { s.active_layer = s.map().layers.first().map(|l| l.id); @@ -904,6 +909,7 @@ impl RustGisApp { fonts_requested: Vec::new(), fonts_failed: Vec::new(), fonts_pending: false, + label_fonts_indexed: false, shown_lang: i18n::Lang::EN, seen_project: None, } @@ -3391,6 +3397,24 @@ impl RustGisApp { self.fonts_pending = true; self.map.invalidate(); } + // The label fonts the last frame's labels asked for (after the interface fonts, which + // would replace them). Not in a frame that set the interface fonts: those take effect on + // the next, and setting fonts now, from the definitions in effect, would undo them. + if self.fonts_pending { + ctx.request_repaint(); + } else { + labelfonts::install_pending(ctx); + } + // Labels in a named family draw in the label font until the installed fonts are + // indexed (`families::warm_up`); then the map is drawn again in theirs. + if !self.label_fonts_indexed { + if rustgis_engine::render::families::installed_now().is_some() { + self.label_fonts_indexed = true; + self.map.invalidate(); + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + } // Files from the async picker or browser drops. let arrived: Vec<(String, Vec)> = match &self.services.inbox { Some(inbox) => std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())), diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 4cf746a4a..48c8aa564 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -1171,15 +1171,26 @@ fn tex_rect(app: &RustGisApp, cam: Camera, rect: Rect, globe_view: bool) -> Rect Rect::from_min_max(to_screen(0.0, 0.0), to_screen(tw, th)) } -/// Map text laid out as the map view draws it: its font, and a text graphic's style (#535), its -/// lines aligned in their block, slanted (egui's own slant, as the fonts have no italic face) -/// and letter-spaced. The one layout for drawing a label and for picking or dragging a text -/// graphic, so they agree. -fn label_job(text: &str, size: f32, bold: bool, italic: bool, spacing: f32, align: TextAlign) -> egui::text::LayoutJob { - let font_id = if bold { semibold(size * 1.33) } else { regular(size * 1.33) }; +/// Map text laid out as the map view draws it: its font (a label class's installed `family`, as +/// [`crate::labelfonts::family`] gives it, and whether egui slants it), and a text graphic's +/// style (#535), its lines aligned in their block, slanted (egui's own slant, as the interface +/// fonts have no italic face) and letter-spaced. The one layout for drawing a label and for +/// picking or dragging a text graphic, so they agree. +fn label_job( + text: &str, + size: f32, + bold: bool, + italic: bool, + spacing: f32, + align: TextAlign, + family: Option<(egui::FontFamily, bool)>, +) -> egui::text::LayoutJob { + let (font_id, italics) = match family { + Some((family, slanted)) => (egui::FontId::new(size * 1.33, family), slanted), + None => (if bold { semibold(size * 1.33) } else { regular(size * 1.33) }, italic), + }; // Letter spacing in points, scaled as the size is. - let format = - egui::TextFormat { font_id, color: Color32::WHITE, italics: italic, extra_letter_spacing: spacing * 1.33, ..Default::default() }; + let format = egui::TextFormat { font_id, color: Color32::WHITE, italics, extra_letter_spacing: spacing * 1.33, ..Default::default() }; let mut job = egui::text::LayoutJob::single_section(text.to_owned(), format); job.halign = match align { TextAlign::Left => egui::Align::LEFT, @@ -1216,7 +1227,8 @@ fn draw_label(p: &egui::Painter, l: &PlacedLabel, at: Pos2) { /// Draws a label (not a curved one) at `at`: its halo when `halo`, its text when `text`. fn draw_label_ink(p: &egui::Painter, l: &PlacedLabel, at: Pos2, halo: bool, text: bool) { - let galley = p.layout_job(label_job(&l.text, l.size, l.bold, l.italic, l.spacing, l.line_align)); + let family = crate::labelfonts::family(p.ctx(), l.font, l.bold, l.italic); + let galley = p.layout_job(label_job(&l.text, l.size, l.bold, l.italic, l.spacing, l.line_align, family)); // Text set beside an icon is lined up by its edge, with egui's own measure of its width. let anchor = if l.align < -0.25 { Align2::LEFT_CENTER @@ -1336,6 +1348,7 @@ fn text_shown( graphics::italic(ds, row), graphics::letter_spacing(ds, row), graphics::align(ds, row), + None, ); Some((at, job)) } diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 6a45dbc56..a22a9279b 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -245,6 +245,13 @@ Turn labels on per layer (View › Labeling › Label, or the Style tab), then: - **Label field and text**: the field the labels show (`labels.field={"layer": "Cities", "field": "name"}`), and the text symbol's size (4–72 points), colour, halo, bold and italic (`labels.style={"layer": "Cities", "size": 12, "color": "#5A1E0A", "halo": true}`). +- **Font**: the labels' font family, any installed on the computer (Labeling pane › Text Symbol + › **Font**; type part of a name to find it), or the label font the interface uses. Bold and + italic take the family's own bold and italic faces where it has them. The family is saved by + its name, so a project keeps it on a computer that lacks it, drawing its labels in the label + font there (the pane shows it as not installed), and in the browser, which reads no installed + fonts (`labels.style={"layer": "Rivers", "font": "Liberation Serif"}`; `labels.fonts` lists the + installed families, `{"font": ""}` goes back to the label font). - **SQL**: label only the features that match a SQL clause (the language of definition queries), such as `pop_max > 5000000`. In the Labeling pane, Class › **SQL** › **New expression**: write the clause (the field and operator buttons add to it), **Check** says