diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 5388629cf..56ab8154f 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -107,14 +107,19 @@ fn extra_dirs(var: Option<&std::ffi::OsStr>) -> Vec { 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) { +fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec, seen: &mut std::collections::HashSet) { + // 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) { impl FontSource for DirectorySource { fn faces(&self) -> Vec { 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>> { + static FILES: std::sync::LazyLock>>>> = + 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) diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index d95f07c6a..00dd0d9e8 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -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); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 4c0b5a436..846c0f630 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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, 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)) } diff --git a/crates/text/src/layout/tests.rs b/crates/text/src/layout/tests.rs index 5792c1c16..54246ce38 100644 --- a/crates/text/src/layout/tests.rs +++ b/crates/text/src/layout/tests.rs @@ -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, 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, TextStyle)> = (0..50).map(|i| (i * 3..i * 3 + 3, st(10.0))).collect(); + let gapped: Vec<(Range, 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); +} diff --git a/crates/text/src/path_text.rs b/crates/text/src/path_text.rs index 85cab0719..5689f2c9e 100644 --- a/crates/text/src/path_text.rs +++ b/crates/text/src/path_text.rs @@ -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(); diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs index a5e3907ce..0ca447f5b 100644 --- a/crates/text/src/sfnt.rs +++ b/crates/text/src/sfnt.rs @@ -257,7 +257,12 @@ fn name_string(b: &[u8], id: u16) -> Option { // 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 = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();