Text: run lookup by bisection, bounded path flattening, font folders and files shared

run_at scanned the style runs from the first for every character; the
ordered runs are now bisected (the scan remains for gaps). Path text
flattened at a fixed 0.05 tolerance, so huge coordinates made hundreds of
millions of points; the tolerance grows with the path's size and
non-finite paths measure nothing. Font name records aliasing one string
are decoded once. The font folder walk listed folders reached through
several links again along every path; each real folder is listed once.
Faces of one font collection each read the whole file; they share it.
This commit is contained in:
Dylan M. Taylor
2026-11-03 15:13:54 +00:00
parent 7870b5916a
commit 9e91bdcce9
6 changed files with 93 additions and 5 deletions
+25 -4
View File
@@ -107,14 +107,19 @@ fn extra_dirs(var: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
var.map(|v| std::env::split_paths(v).filter(|p| !p.as_os_str().is_empty()).collect()).unwrap_or_default()
}
fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>, seen: &mut std::collections::HashSet<PathBuf>) {
// Each real folder is listed once: symlinks fanning out to the same folders would otherwise
// be walked again along every path to them (exponentially many within the depth bound).
if !seen.insert(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf())) {
return;
}
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
// Bounded, so a symlink loop ends.
if depth < 8 {
walk(&p, depth + 1, out);
walk(&p, depth + 1, out, seen);
}
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {
out.push(p);
@@ -125,8 +130,9 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
impl FontSource for DirectorySource {
fn faces(&self) -> Vec<FaceInfo> {
let mut files = Vec::new();
let mut seen = std::collections::HashSet::new();
for d in &self.dirs {
walk(d, 0, &mut files);
walk(d, 0, &mut files, &mut seen);
}
files.sort();
files.dedup();
@@ -194,13 +200,28 @@ pub struct VMetrics {
pub underline_thickness: f32,
}
/// A font file's bytes, shared by every face of it that is loaded (the faces of a collection
/// each held their own copy of the whole file).
fn file_bytes(p: &std::path::Path) -> Option<Arc<Vec<u8>>> {
static FILES: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<PathBuf, std::sync::Weak<Vec<u8>>>>> =
std::sync::LazyLock::new(Default::default);
let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(b) = files.get(p).and_then(std::sync::Weak::upgrade) {
return Some(b);
}
let b = Arc::new(std::fs::read(p).ok()?);
files.retain(|_, w| w.strong_count() > 0);
files.insert(p.to_path_buf(), Arc::downgrade(&b));
Some(b)
}
impl Face {
fn bytes(&self) -> Option<&[u8]> {
self.bytes
.get_or_init(|| match &self.info.data {
FaceData::Static(b) => Some(FaceBytes::Static(b)),
FaceData::Shared(b) => Some(FaceBytes::Shared(b.clone())),
FaceData::File(p) => std::fs::read(p).ok().map(|v| FaceBytes::Shared(Arc::new(v))),
FaceData::File(p) => file_bytes(p).map(FaceBytes::Shared),
})
.as_ref()
.map(FaceBytes::get)
+19
View File
@@ -567,3 +567,22 @@ fn unknown_face_id_returns_bundled_default() {
assert!(fallback.font().is_some());
assert!(Arc::ptr_eq(&fallback, &face(0)));
}
#[cfg(unix)]
#[test]
fn a_folder_reached_through_many_links_is_listed_once() {
// Six links back to the folder itself: 6^8 listings within the depth bound before.
let d = std::env::temp_dir().join(format!("effectcraft-font-walk-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("a.ttf"), b"x").unwrap();
for i in 0..6 {
std::os::unix::fs::symlink(&d, d.join(format!("l{i}"))).unwrap();
}
let (mut out, mut seen) = (Vec::new(), std::collections::HashSet::new());
let start = std::time::Instant::now();
walk(&d, 0, &mut out, &mut seen);
assert!(start.elapsed() < std::time::Duration::from_secs(5));
assert_eq!(out.len(), 1);
let _ = std::fs::remove_dir_all(&d);
}
+7
View File
@@ -624,6 +624,13 @@ fn is_hanging_open(c: char) -> bool {
/// The style run index covering global byte `b` (the run before it at a boundary, for empty
/// paragraphs at the end).
fn run_at(runs: &[(Range<usize>, TextStyle)], b: usize) -> usize {
// Runs from a document are ordered and contiguous: the last one starting at or before `b`
// holds it, found by bisection (a scan from the first run per character was quadratic).
// Anything else (a gap, unordered runs) takes the scan as before.
let i = runs.partition_point(|(r, _)| r.start <= b).saturating_sub(1);
if runs.get(i).is_some_and(|(r, _)| r.contains(&b)) {
return i;
}
runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0))
}
+14
View File
@@ -494,3 +494,17 @@ fn a_later_vertical_run_keeps_the_first_line_baseline() {
let mixed = layout_rich_uncached(text, &[(0..4, horiz), (4..text.len(), vert)], &[p]);
assert!((mixed.lines[0].baseline - plain.lines[0].baseline).abs() < 1e-3, "{} vs {}", mixed.lines[0].baseline, plain.lines[0].baseline);
}
#[test]
fn run_lookup_by_bisection_matches_the_scan() {
let scan = |runs: &[(Range<usize>, TextStyle)], b: usize| {
runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0))
};
let runs: Vec<(Range<usize>, TextStyle)> = (0..50).map(|i| (i * 3..i * 3 + 3, st(10.0))).collect();
let gapped: Vec<(Range<usize>, TextStyle)> = (0..50).map(|i| (i * 4..i * 4 + 3, st(10.0))).collect();
for b in 0..220 {
assert_eq!(run_at(&runs, b), scan(&runs, b), "{b}");
assert_eq!(run_at(&gapped, b), scan(&gapped, b), "{b}");
}
assert_eq!(run_at(&[], 5), 0);
}
+23 -1
View File
@@ -19,7 +19,14 @@ impl PathMeasure {
let mut closed = false;
let mut started = false;
let mut done = false;
kurbo::flatten(path.iter(), 0.05, |el| {
// The tolerance grows with the path's size (0.05 for ordinary layer-sized paths): a
// path with huge coordinates would otherwise flatten into hundreds of millions of points.
let bbox = kurbo::Shape::bounding_box(path);
let size = bbox.width().max(bbox.height());
if !size.is_finite() {
done = true;
}
kurbo::flatten(path.iter(), 0.05_f64.max(size * 1e-6), |el| {
if done {
return;
}
@@ -128,6 +135,21 @@ pub fn arc_positions(xs: &[f64], line: [f64; 2], align: u8, length: f64, first_m
mod tests {
use super::*;
#[test]
fn a_huge_curve_flattens_into_a_bounded_number_of_points() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.curve_to((1e15, 0.0), (1e15, 1e15), (0.0, 1e15));
let m = PathMeasure::new(&p, false);
assert!(m.pts.len() < 100_000, "{}", m.pts.len());
assert!(m.length() > 1e15);
// Non-finite coordinates measure nothing.
let mut q = BezPath::new();
q.move_to((0.0, 0.0));
q.curve_to((f64::INFINITY, 0.0), (1.0, 1.0), (2.0, 0.0));
assert_eq!(PathMeasure::new(&q, false).pts.len(), 0);
}
#[test]
fn measures_a_line_and_a_circle() {
let mut p = BezPath::new();
+5
View File
@@ -257,7 +257,12 @@ fn name_string(b: &[u8], id: u16) -> Option<String> {
// Best rank first; a blank record falls through to the next. Decoding is lazy, so aliased
// records are not decoded once a usable one is found.
cands.sort_by_key(|c| c.0);
// Records may alias one string: each (encoding, bytes) is decoded at most once.
let mut tried = std::collections::HashSet::new();
cands.into_iter().find_map(|(_, plat, enc, raw)| {
if !tried.insert((plat, enc, raw.as_ptr(), raw.len())) {
return None;
}
let s = match (plat, enc) {
(3, 1 | 10) | (0, _) => {
let u: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();