Text: run lookup by bisection, bounded path flattening, font folders and files shared (#2991)

* 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.

* address review: font walk breadth-first across roots, font file cache keyed by size and mtime and read outside the lock, first-subpath path measure with finite check, scratch dir guard

* address review: font files shared through file_bytes with the open file's metadata; run lookup by bisection only for ordered runs

* Pass the runs' ordered flag with them to stay under the argument limit

* address review: reversed run ranges take the scan
This commit is contained in:
Dylan M. Taylor
2026-11-11 14:40:29 -05:00
committed by GitHub
parent 428a861356
commit 6c1391d4de
6 changed files with 244 additions and 48 deletions
+55 -26
View File
@@ -107,17 +107,29 @@ 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>) {
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<PathBuf>) {
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<PathBuf>) {
impl FontSource for DirectorySource {
fn faces(&self) -> Vec<FaceInfo> {
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<u8>, 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<Vec<u8>>, std::fs::Metadata)> {
use std::io::Read;
type Key = (PathBuf, u64, Option<std::time::SystemTime>);
type Entry = (std::sync::Weak<Vec<u8>>, std::fs::Metadata);
static FILES: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<Key, Entry>>> = 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, Entry>, 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()
+69
View File
@@ -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");
}
+37 -21
View File
@@ -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<usize>, 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<usize>, 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<usize>, TextStyle)], b: usize) -> usize {
/// paragraphs at the end). `ordered` is [`runs_ordered`] of `runs`.
fn run_at(runs: &[(Range<usize>, 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<usize>, 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<usize>) -> 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<usize>, TextStyle)], primaries: &[Resolved], bidi: &BidiInfo) -> Vec<CharInfo> {
fn char_forms(text: &str, base: usize, runs: Runs, primaries: &[Resolved], bidi: &BidiInfo) -> Vec<CharInfo> {
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<usize>, TextStyle)
items
}
fn paragraph(
text: &str,
base: usize,
runs: &[(Range<usize>, TextStyle)],
primaries: &[Resolved],
variation_ids: &[u32],
para: &ParagraphStyle,
width: Option<f32>,
) -> Vec<ParaLine> {
fn paragraph(text: &str, base: usize, runs: Runs, primaries: &[Resolved], variation_ids: &[u32], para: &ParagraphStyle, width: Option<f32>) -> Vec<ParaLine> {
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<usize>, TextStyle)], para
});
let runs = cleared.as_deref().unwrap_or(runs);
let variation_ids: Vec<u32> = 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<usize>, 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]);
}
+26
View File
@@ -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<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();
// Overlapping runs: the first run holding a byte wins, as in the scan.
let overlapping: Vec<(Range<usize>, 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<usize>, 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);
}
+52 -1
View File
@@ -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();
+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();