diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d199bf0ff..c0eea0bd7 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -107,17 +107,29 @@ 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) { - 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); +/// Font files under `roots`, each real folder listed once. Breadth first from every root, so a +/// folder reached by several routes is listed at its shallowest and keeps the most depth below it. +fn walk(roots: &[PathBuf], out: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + let mut queue: std::collections::VecDeque<(PathBuf, u32)> = roots.iter().map(|r| (r.clone(), 0)).collect(); + while let Some((dir, depth)) = queue.pop_front() { + // 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.clone())) { + continue; + } + let Ok(rd) = std::fs::read_dir(&dir) else { continue }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + // Bounded, so a symlink loop ends. + if depth < 8 { + queue.push_back((p, depth + 1)); + } + } 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); } - } 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,9 +137,7 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { impl FontSource for DirectorySource { fn faces(&self) -> Vec { let mut files = Vec::new(); - for d in &self.dirs { - walk(d, 0, &mut files); - } + walk(&self.dirs, &mut files); files.sort(); files.dedup(); let mut out = Vec::new(); @@ -158,17 +168,6 @@ fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo { } } -/// A font file's bytes and its metadata from the same open file (so the metadata describes the -/// bytes, not a later version of the file). -fn read_with_metadata(path: &std::path::Path) -> std::io::Result<(Vec, std::fs::Metadata)> { - use std::io::Read; - let mut f = std::fs::File::open(path)?; - let meta = f.metadata()?; - let mut bytes = Vec::new(); - f.read_to_end(&mut bytes)?; - Ok((bytes, meta)) -} - /// A loaded (or loadable) face. pub struct Face { pub id: FaceId, @@ -207,6 +206,36 @@ 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), with the metadata of the open file they were read +/// from (so it describes the bytes, not a later version of the file). +fn file_bytes(p: &std::path::Path) -> std::io::Result<(Arc>, std::fs::Metadata)> { + use std::io::Read; + type Key = (PathBuf, u64, Option); + type Entry = (std::sync::Weak>, std::fs::Metadata); + static FILES: std::sync::LazyLock>> = std::sync::LazyLock::new(Default::default); + let lock = || FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let hit = |files: &std::collections::HashMap, key: &Key| files.get(key).and_then(|(w, m)| Some((w.upgrade()?, m.clone()))); + let mut f = std::fs::File::open(p)?; + // Keyed by size and modification time too: a file replaced at the same path is read afresh. + let m = f.metadata()?; + let key: Key = (p.to_path_buf(), m.len(), m.modified().ok()); + if let Some(found) = hit(&lock(), &key) { + return Ok(found); + } + // Read outside the lock: a slow file must not hold up every other face's first load. + let mut bytes = Vec::new(); + f.read_to_end(&mut bytes)?; + let b = Arc::new(bytes); + let mut files = lock(); + if let Some(found) = hit(&files, &key) { + return Ok(found); + } + files.retain(|_, (w, _)| w.strong_count() > 0); + files.insert(key, (Arc::downgrade(&b), m.clone())); + Ok((b, m)) +} + impl Face { /// The font file bytes, loaded once on first use, or `None` when they cannot be read. fn bytes(&self) -> Option<&[u8]> { @@ -215,9 +244,9 @@ impl Face { FaceData::Static(b) => Some(FaceBytes::Static(b)), FaceData::Shared(b) => Some(FaceBytes::Shared(b.clone())), FaceData::File(p) => { - let (bytes, meta) = read_with_metadata(p).ok()?; + let (bytes, meta) = file_bytes(p).ok()?; let _ = self.file_meta.set(meta); - Some(FaceBytes::Shared(Arc::new(bytes))) + Some(FaceBytes::Shared(bytes)) } }) .as_ref() diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index d95f07c6a..2e04e5f0e 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -567,3 +567,72 @@ fn unknown_face_id_returns_bundled_default() { assert!(fallback.font().is_some()); assert!(Arc::ptr_eq(&fallback, &face(0))); } + +/// A scratch folder this test created itself (never one that already exists), removed on drop. +struct Scratch(std::path::PathBuf); +impl Scratch { + fn new(tag: &str) -> Self { + let d = (0u32..) + .map(|n| std::env::temp_dir().join(format!("effectcraft-{tag}-{}-{n}", std::process::id()))) + .find_map(|d| match std::fs::create_dir(&d) { + Ok(()) => Some(d), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => None, + Err(e) => panic!("create scratch dir {}: {e}", d.display()), + }) + .unwrap(); + Scratch(d) + } +} +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.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 scratch = Scratch::new("font-walk"); + let d = &scratch.0; + 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 = Vec::new(); + let start = std::time::Instant::now(); + walk(std::slice::from_ref(d), &mut out); + assert!(start.elapsed() < std::time::Duration::from_secs(5)); + assert_eq!(out.len(), 1); +} + +#[test] +fn a_folder_inside_another_root_keeps_its_own_depth() { + // root/a/b/c/d/e/f/g/h is also a root: the walk of `root` reaches it at depth 8 and could not + // go below it, so it must be listed from the shallower visit. + let scratch = Scratch::new("font-depth"); + let root = &scratch.0; + let deep = root.join("a/b/c/d/e/f/g/h"); + std::fs::create_dir_all(deep.join("sub")).unwrap(); + std::fs::write(deep.join("sub/x.ttf"), b"x").unwrap(); + let mut out = Vec::new(); + walk(&[root.clone(), deep.clone()], &mut out); + assert_eq!(out, vec![deep.join("sub/x.ttf")]); +} + +#[test] +fn a_font_file_replaced_at_the_same_path_is_read_again() { + let scratch = Scratch::new("font-bytes"); + let p = scratch.0.join("a.ttf"); + std::fs::write(&p, b"old").unwrap(); + let (old, old_meta) = file_bytes(&p).unwrap(); + // Faces of one file share its bytes, and the metadata is that of the file the bytes came from. + let (again, _) = file_bytes(&p).unwrap(); + assert!(Arc::ptr_eq(&old, &again)); + assert_eq!(old_meta.len(), 3); + std::fs::write(&p, b"new, longer").unwrap(); + let (new, new_meta) = file_bytes(&p).unwrap(); + assert_eq!(new.as_slice(), b"new, longer"); + assert_eq!(new_meta.len(), 11); + assert_eq!(old.as_slice(), b"old"); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 25cdb27f5..acf292889 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -628,9 +628,31 @@ fn is_hanging_open(c: char) -> bool { matches!(c, '"' | '\'' | '\u{2018}' | '\u{201c}' | '(' | '\u{ab}') } +/// Whether no run is reversed and each starts at or after the end of the one before it (the runs +/// of a document): the precondition of [`run_at`]'s bisection. +fn runs_ordered(runs: &[(Range, TextStyle)]) -> bool { + runs.iter().all(|(r, _)| r.start <= r.end) && runs.windows(2).all(|w| w[0].0.end <= w[1].0.start) +} + +/// Style runs with whether they are [`runs_ordered`]. +#[derive(Clone, Copy)] +struct Runs<'a> { + list: &'a [(Range, TextStyle)], + ordered: 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 { +/// paragraphs at the end). `ordered` is [`runs_ordered`] of `runs`. +fn run_at(runs: &[(Range, TextStyle)], ordered: bool, b: usize) -> usize { + // Ordered, non-overlapping runs: the last one starting at or before `b` is the only one that + // can hold it, found by bisection (a scan from the first run per character was quadratic). + // Anything else (a gap, overlapping or unordered runs) takes the scan, whose first match wins. + if ordered { + 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)) } @@ -706,7 +728,7 @@ impl<'a> Para<'a> { fn new( text: &'a str, base: usize, - runs: &'a [(Range, TextStyle)], + runs: Runs<'a>, primaries: &'a [Resolved], variation_ids: &'a [u32], para: &'a ParagraphStyle, @@ -716,7 +738,7 @@ impl<'a> Para<'a> { let bidi = BidiInfo::new(text, default_level); let base_rtl = bidi.paragraphs[0].level.is_rtl(); let chars = char_forms(text, base, runs, primaries, &bidi); - let items = build_items(text, &chars, runs); + let items = build_items(text, &chars, runs.list); // Prefix widths and the preceding non-whitespace boundary make candidate line widths O(1). // Every-line composition evaluates O(n²) candidate breaks; summing each candidate's bytes // or rescanning its trailing whitespace would otherwise add another factor of text length. @@ -739,7 +761,7 @@ impl<'a> Para<'a> { } previous_non_ws[b + c.len_utf8()] = non_ws_end; } - Para { text, base, runs, primaries, variation_ids, para, width, bidi, base_rtl, chars, items, char_adv, prefix_adv, previous_non_ws } + Para { text, base, runs: runs.list, primaries, variation_ids, para, width, bidi, base_rtl, chars, items, char_adv, prefix_adv, previous_non_ws } } fn range_w(&self, r: Range) -> f32 { @@ -1021,11 +1043,11 @@ impl<'a> Para<'a> { } /// Each char with its shaped form, face, direction and run. -fn char_forms(text: &str, base: usize, runs: &[(Range, TextStyle)], primaries: &[Resolved], bidi: &BidiInfo) -> Vec { +fn char_forms(text: &str, base: usize, runs: Runs, primaries: &[Resolved], bidi: &BidiInfo) -> Vec { text.char_indices() .map(|(b, c)| { - let si = run_at(runs, base + b); - let style = &runs[si].1; + let si = run_at(runs.list, runs.ordered, base + b); + let style = &runs.list[si].1; let primary = primaries[si].face; let face_of = |ch: char| if ch.is_whitespace() { primary } else { fonts::fallback_for(ch, primary) }; let all_small = style.opentype.all_small_caps && style.caps != Caps::All; @@ -1079,17 +1101,9 @@ fn build_items(text: &str, chars: &[CharInfo], runs: &[(Range, TextStyle) items } -fn paragraph( - text: &str, - base: usize, - runs: &[(Range, TextStyle)], - primaries: &[Resolved], - variation_ids: &[u32], - para: &ParagraphStyle, - width: Option, -) -> Vec { +fn paragraph(text: &str, base: usize, runs: Runs, primaries: &[Resolved], variation_ids: &[u32], para: &ParagraphStyle, width: Option) -> Vec { if text.is_empty() { - let si = run_at(runs, base); + let (runs, si) = (runs.list, run_at(runs.list, runs.ordered, base)); let pm = style_metrics(primaries, runs, si); // No text to resolve a direction from: the explicit one. let rtl = para.rtl == Some(true); @@ -1167,18 +1181,20 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para }); let runs = cleared.as_deref().unwrap_or(runs); let variation_ids: Vec = runs.iter().map(|(_, s)| crate::variable::intern(&s.variations)).collect(); + let ordered = runs_ordered(runs); let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() }; let mut plines: Vec<(usize, ParaLine)> = Vec::new(); for (pi, span) in paragraph_ranges(text).enumerate() { let p = text.get(span.bytes.clone()).unwrap_or_default(); - plines.extend(paragraph(p, span.bytes.start, runs, &primaries, &variation_ids, para_at(pi), width).into_iter().map(|l| (pi, l))); + plines + .extend(paragraph(p, span.bytes.start, Runs { list: runs, ordered }, &primaries, &variation_ids, para_at(pi), width).into_iter().map(|l| (pi, l))); } let mut baseline = 0.0f32; for (i, (pi, pl)) in plines.into_iter().enumerate() { let para = para_at(pi); if i == 0 { // Vertical type keeps its first column at the font ascent (not measured in AE). - let vertical = runs.get(run_at(runs, pl.range.start)).is_some_and(|r| r.1.vertical) + let vertical = runs.get(run_at(runs, ordered, pl.range.start)).is_some_and(|r| r.1.vertical) || runs.iter().any(|r| r.1.vertical && r.0.start < pl.range.end && r.0.end > pl.range.start); baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 }; } else { @@ -1198,7 +1214,7 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para })); let x = shift + pl.left_trim; if let Some((u0, u1)) = pl.underline { - let si = run_at(runs, pl.range.start); + let si = run_at(runs, ordered, pl.range.start); let pm = fonts::face(primaries[si].face).metrics(runs[si].1.size); lay.underlines.push([u0 + shift, baseline + pm.underline_pos, u1 + shift, baseline + pm.underline_pos + pm.underline_thickness]); } diff --git a/crates/text/src/layout/tests.rs b/crates/text/src/layout/tests.rs index 099680148..c467cb5f9 100644 --- a/crates/text/src/layout/tests.rs +++ b/crates/text/src/layout/tests.rs @@ -505,3 +505,29 @@ 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(); + // Overlapping runs: the first run holding a byte wins, as in the scan. + let overlapping: Vec<(Range, TextStyle)> = vec![(0..10, st(10.0)), (5..15, st(20.0)), (12..20, st(30.0))]; + assert!(runs_ordered(&runs) && runs_ordered(&gapped) && !runs_ordered(&overlapping)); + for b in 0..220 { + assert_eq!(run_at(&runs, true, b), scan(&runs, b), "{b}"); + assert_eq!(run_at(&gapped, true, b), scan(&gapped, b), "{b}"); + assert_eq!(run_at(&overlapping, false, b), scan(&overlapping, b), "{b}"); + } + assert_eq!(run_at(&overlapping, false, 6), 0); + // A reversed range (20..0) among others is not ordered either, and takes the scan. + #[allow(clippy::reversed_empty_ranges)] + let reversed: Vec<(Range, TextStyle)> = vec![(0..10, st(10.0)), (20..0, st(20.0)), (5..15, st(30.0)), (15..20, st(40.0))]; + assert!(!runs_ordered(&reversed)); + for b in 0..30 { + assert_eq!(run_at(&reversed, runs_ordered(&reversed), b), scan(&reversed, b), "{b}"); + } + assert_eq!(run_at(&[], true, 5), 0); +} diff --git a/crates/text/src/path_text.rs b/crates/text/src/path_text.rs index 85cab0719..7455456ad 100644 --- a/crates/text/src/path_text.rs +++ b/crates/text/src/path_text.rs @@ -19,7 +19,26 @@ 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. + // Only the first subpath is measured, so only it sets the tolerance and gets flattened. + let mut first = BezPath::new(); + for el in path.iter() { + if matches!(el, PathEl::MoveTo(_)) && !first.elements().is_empty() { + break; + } + first.push(el); + if matches!(el, PathEl::ClosePath) { + break; + } + } + // A non-finite coordinate (an infinite control point too) measures nothing. + if !first.is_finite() { + return PathMeasure::default(); + } + let bbox = kurbo::Shape::bounding_box(&first); + let size = bbox.width().max(bbox.height()); + kurbo::flatten(first.iter(), 0.05_f64.max(size * 1e-6), |el| { if done { return; } @@ -128,6 +147,38 @@ 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); + // Even with finite endpoints, an infinite control point is refused before flattening. + let mut r = BezPath::new(); + r.move_to((0.0, 0.0)); + r.curve_to((f64::INFINITY, f64::INFINITY), (f64::INFINITY, 0.0), (1.0, 0.0)); + assert_eq!(PathMeasure::new(&r, false).pts.len(), 0); + } + + #[test] + fn a_distant_second_subpath_does_not_coarsen_the_first() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.curve_to((0.0, 100.0), (100.0, 100.0), (100.0, 0.0)); + p.move_to((1e9, 1e9)); + p.line_to((1e9 + 1.0, 1e9)); + let m = PathMeasure::new(&p, false); + assert!(m.pts.len() > 10, "{}", m.pts.len()); + assert!(m.length() > 150.0, "{}", m.length()); + } + #[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();