fix(fonts): look up CID and width ranges by binary search

Code-to-CID and /W range lookups scanned every record in declaration
order, once per glyph and once per explicit ToUnicode entry while a font
was read, so a few thousand records multiplied by as many codes took
billions of steps. Both tables are indexed once into stretches whose
first-declared covering record wins, the precedence the scan had, and the
/W ranges stop at the CMap entry limit.
This commit is contained in:
aiongg
2026-10-22 14:15:50 -04:00
committed by Dylan M. Taylor
parent 1cc392e5b0
commit 75c4275810
+141 -16
View File
@@ -33,9 +33,9 @@ pub struct Metrics {
widths: Vec<f64>,
/// Composite fonts: CID → width, and CID ranges with one width.
cid_widths: HashMap<u32, f64>,
cid_ranges: Vec<(u32, u32, f64)>,
cid_ranges: Spans<f64>,
/// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`).
cid_map: Vec<(u32, u32, u32)>,
cid_map: Spans<u32>,
default: f64,
/// Composite fonts with an explicit `/DW`: `/W` may leave out the glyphs that have that
/// width (Chrome and Skia do), so a CID missing from `/W` is not a missing glyph.
@@ -598,8 +598,8 @@ impl Metrics {
first: 0,
widths: Vec::new(),
cid_widths: HashMap::new(),
cid_ranges: Vec::new(),
cid_map: Vec::new(),
cid_ranges: Spans::default(),
cid_map: Spans::default(),
default: 500.0,
explicit_default: false,
std14: Some(crate::Std14::Helvetica),
@@ -685,7 +685,7 @@ impl Metrics {
if !spaces.is_empty() {
m.codes = Codes::Ranges(spaces);
}
m.cid_map = cids;
m.cid_map = Spans::new(cids);
}
let desc = font
.get(b"DescendantFonts")
@@ -698,6 +698,7 @@ impl Metrics {
m.explicit_default = true;
}
if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) {
let mut ranges = Vec::new();
let mut i = 0;
while i < w.len() {
let Some(c0) = doc.resolve(&w[i]).as_f64().map(|v| v as u32) else { break };
@@ -714,13 +715,16 @@ impl Metrics {
}
Some(c1) => {
if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) {
m.cid_ranges.push((c0, c1 as u32, v));
if ranges.len() < MAX_CMAP_ENTRIES {
ranges.push((c0, c1 as u32, v));
}
}
i += 3;
}
None => break,
}
}
m.cid_ranges = Spans::new(ranges);
}
descriptor = dict(doc, desc.get(b"FontDescriptor"));
} else {
@@ -791,14 +795,14 @@ impl Metrics {
if self.cid_map.is_empty() {
return code;
}
self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo))
self.cid_map.find(code).map_or(0, |(lo, _, c)| c.saturating_add(code.saturating_sub(*lo)))
}
/// The advance of `code` in text space per unit of font size.
pub fn width(&self, code: u32) -> f64 {
if self.composite {
let cid = self.cid(code);
let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.iter().find(|(a, b, _)| cid >= *a && cid <= *b).map(|r| r.2));
let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.find(cid).map(|r| r.2));
return w.unwrap_or(self.default) * self.scale;
}
if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) {
@@ -858,7 +862,7 @@ impl Metrics {
// A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for
// those, only the subset's program says whether the glyph is there.
return self.cid_widths.contains_key(&cid)
|| self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b)
|| self.cid_ranges.find(cid).is_some()
|| !self.subset
|| self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1)
// No readable TrueType program to ask (e.g. CFF or missing): an explicit /DW
@@ -958,16 +962,69 @@ fn add_range(out: &mut Unicode, budget: &mut usize, seq: &mut u32, lo: u32, hi:
true
}
#[cfg(test)]
thread_local! {
/// Code span records read by CID and width lookups, for the tests that keep a lookup logarithmic.
static SPAN_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Counts one span record read (tests only).
#[inline]
fn count_span_reads() {
#[cfg(test)]
SPAN_READS.with(|c| c.set(c.get().saturating_add(1)));
}
/// Code spans `(lo, hi, value)` in declaration order, where the first-declared span covering a code
/// wins, indexed so that a lookup is one binary search.
#[derive(Clone, Debug, Default, PartialEq)]
struct Spans<T> {
spans: Vec<(u32, u32, T)>,
/// The span covering each stretch of codes; see [`stretches`].
cover: Vec<(u64, Option<usize>)>,
}
impl<T> Spans<T> {
fn new(spans: Vec<(u32, u32, T)>) -> Self {
let bounds: Vec<(u32, u32)> = spans.iter().map(|s| (s.0, s.1)).collect();
Spans { cover: stretches(&bounds, false), spans }
}
fn is_empty(&self) -> bool {
self.spans.is_empty()
}
/// The first-declared span covering `code`.
fn find(&self, code: u32) -> Option<&(u32, u32, T)> {
let step = self.cover.partition_point(|&(start, _)| {
count_span_reads();
start <= u64::from(code)
});
let &(_, record) = self.cover.get(step.checked_sub(1)?)?;
self.spans.get(record?)
}
}
/// For each stretch of codes, from its first code on, the latest-declared range covering it (`None`
/// where no range does), sorted by code; a lookup is one binary search over it.
fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option<usize>)> {
let spans: Vec<(u32, u32)> = ranges.iter().map(|r| (r.lo, r.hi)).collect();
stretches(&spans, true)
}
/// For each stretch of codes, from its first code on, the span `(lo, hi)` covering it (`None` where
/// none does), sorted by code: the latest-declared one when `later_wins`, else the first-declared.
/// At most `u32::MAX` spans are indexed.
fn stretches(spans: &[(u32, u32)], later_wins: bool) -> Vec<(u64, Option<usize>)> {
// Heap keys, largest for the winning record; the mapping is its own inverse.
let key = |record: u32| if later_wins { record } else { !record };
// Openings (first code, record) and closings (one past the last code), each in code order. The cover
// can only change at one of their codes, so the sweep visits those codes in order.
let mut opens: Vec<(u32, u32)> = ranges.iter().enumerate().map(|(i, r)| (r.lo, i as u32)).collect();
let mut opens: Vec<(u32, u32)> = spans.iter().zip(0..u32::MAX).map(|(&(lo, _), i)| (lo, i)).collect();
opens.sort_unstable();
let mut closes: Vec<u64> = ranges.iter().map(|r| u64::from(r.hi) + 1).collect();
let mut closes: Vec<u64> = spans.iter().take(opens.len()).map(|&(_, hi)| u64::from(hi) + 1).collect();
closes.sort_unstable();
// Records that have opened and not yet closed; the top is the latest declared one.
// Keys of the records that have opened and not yet closed; the top is the winning one.
let mut open: BinaryHeap<u32> = BinaryHeap::new();
let mut cover: Vec<(u64, Option<usize>)> = Vec::new();
let (mut next_open, mut next_close) = (0, 0);
@@ -981,16 +1038,16 @@ fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option<usize>)> {
while let Some(&(lo, record)) = opens.get(next_open)
&& u64::from(lo) <= p
{
open.push(record);
open.push(key(record));
next_open += 1;
}
while closes.get(next_close).is_some_and(|&end| end <= p) {
next_close += 1;
}
while open.peek().is_some_and(|&top| ranges.get(top as usize).is_none_or(|r| u64::from(r.hi) < p)) {
while open.peek().is_some_and(|&top| spans.get(key(top) as usize).is_none_or(|&(_, hi)| u64::from(hi) < p)) {
open.pop();
}
let winner = open.peek().map(|&top| top as usize);
let winner = open.peek().map(|&top| key(top) as usize);
if cover.last().map(|&(_, w)| w) != Some(winner) {
cover.push((p, winner));
}
@@ -1266,6 +1323,74 @@ mod tests {
use super::*;
/// Span records read while `f` runs.
fn span_reads(f: impl FnOnce()) -> usize {
SPAN_READS.with(|c| c.set(0));
f();
SPAN_READS.with(|c| c.get())
}
/// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`.
fn composite(doc: &mut Document, encoding: &str, w: Vec<Object>, to_unicode: &str) -> Dict {
let mut desc = Dict::new();
desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
desc.set(b"W".to_vec(), Object::Array(w));
let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes()))));
let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes()))));
font(
doc,
vec![
("Subtype", Object::name("Type0")),
("Encoding", enc),
("DescendantFonts", Object::Array(vec![Object::Dict(desc)])),
("ToUnicode", tu),
],
)
}
/// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all.
#[test]
fn widths_of_a_code_no_range_covers_take_few_steps() {
let mut doc = Document::new_empty();
let w: Vec<Object> = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect();
let f = composite(&mut doc, "", w, "");
let m = Metrics::from_dict(&doc, &f);
assert_eq!(m.width(4), 0.5);
let reads = span_reads(|| {
for _ in 0..1000 {
assert_eq!(m.width(3), 1.0);
}
});
assert!(reads <= 1000 * 64, "{reads} span reads");
}
/// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it.
#[test]
fn overlapping_spans_resolve_to_the_first_declared() {
let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]);
let found = |code| spans.find(code).map(|s| s.2);
let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))];
for (code, value) in want {
assert_eq!(found(code), value, "code {code}");
}
}
/// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map.
#[test]
fn many_unicode_entries_look_up_their_cids_in_few_steps() {
let n = 2000;
let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect();
let encoding = format!("1 begincodespacerange <0000> <FFFF> endcodespacerange {n} begincidchar {cidchars} endcidchar");
let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect();
let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar");
let mut doc = Document::new_empty();
let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode);
let reads = span_reads(|| {
Metrics::from_dict(&doc, &f);
});
assert!(reads <= n * 64, "{reads} span reads");
}
/// Every glyph of a sequential range builds its text from the destination, so a long one is cut
/// to the CMap limit once, when it is read.
#[test]
@@ -1736,7 +1861,7 @@ mod tests {
let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <zz> <0043>] endbfrange");
assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C")));
let mut m = Metrics::fallback();
(_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> <FFFF> 4294967295 endcidrange");
m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> <FFFF> 4294967295 endcidrange").1);
assert_eq!(m.cid(5), u32::MAX);
let mut doc = Document::new_empty();
let mut enc = Dict::new();