text: bound layout and variable font metadata (#2193)

* text: bound layout and variable font metadata

* text: address review (intern once, f64 prefix sums, bounded item search, name fallback, consistent variation fallback)
This commit is contained in:
Dylan M. Taylor
2026-10-23 08:55:07 -04:00
committed by GitHub
parent 4b48482e8f
commit ca8043cbba
3 changed files with 176 additions and 48 deletions
+78 -21
View File
@@ -597,7 +597,7 @@ fn run_at(runs: &[(Range<usize>, TextStyle)], b: usize) -> usize {
}
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option<f32>) -> Vec<ParaLine> {
fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primaries: &[Resolved], variation_ids: &[u32], para: &ParagraphStyle, width: Option<f32>) -> Vec<ParaLine> {
let rtl_para = para.rtl == Some(true);
let margins_for = |first: bool| -> (f32, f32) {
let fi = if first { para.indent_first } else { 0.0 };
@@ -702,18 +702,26 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
char_adv[g.cluster.min(text.len())] += g.adv + g.pre;
}
}
let range_w = |r: Range<usize>| -> f32 { char_adv[r].iter().sum() };
let trailing_ws = |r: &Range<usize>| -> (usize, f32) {
let mut end = r.end;
let mut w = 0.0;
for (b, c) in text[r.clone()].char_indices().rev() {
if !c.is_whitespace() {
break;
}
end = r.start + b;
w += char_adv[r.start + b];
// 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.
let mut prefix_adv = vec![0.0f64; text.len() + 1];
for i in 0..text.len() {
prefix_adv[i + 1] = prefix_adv[i] + f64::from(char_adv[i]);
}
let mut previous_non_ws = vec![0usize; text.len() + 1];
let mut non_ws_end = 0usize;
for (b, c) in text.char_indices() {
previous_non_ws[b] = non_ws_end;
if !c.is_whitespace() {
non_ws_end = b + c.len_utf8();
}
(end, w)
previous_non_ws[b + c.len_utf8()] = non_ws_end;
}
let range_w = |r: Range<usize>| -> f32 { (prefix_adv[r.end] - prefix_adv[r.start]) as f32 };
let trailing_ws = |r: &Range<usize>| -> (usize, f32) {
let end = previous_non_ws[r.end].max(r.start).min(r.end);
(end, (prefix_adv[r.end] - prefix_adv[end]) as f32)
};
// Hanging punctuation widths (left, right) of a line's content.
let hang_of = |r: &Range<usize>, ce: usize| -> (f32, f32) {
@@ -824,7 +832,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
let mut underline: Option<(f32, f32)> = None;
for run in vruns {
let rtl = levels[run.start].is_rtl();
let mut idx: Vec<usize> = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect();
// Items are ordered and disjoint: binary-search both ends of the overlapping slice.
let run_limit = items.partition_point(|item| item.range.start < run.end);
let run_first = items[..run_limit].partition_point(|item| item.range.end <= run.start);
let mut idx: Vec<usize> = (run_first..run_limit).collect();
if rtl {
idx.reverse();
}
@@ -845,7 +856,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
h_scale: st.h_scale,
v_scale: st.v_scale,
run: g.run,
variations: crate::variable::intern(&st.variations),
variations: variation_ids[g.run],
});
pen += g.adv;
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {
@@ -862,18 +873,25 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
}
}
}
// caret stops for each char boundary in the line
// Caret stops for each char boundary in the line.
let mut carets = Vec::new();
let mut cluster_starts: Vec<usize> = extents.keys().copied().collect();
cluster_starts.sort_unstable();
let char_bytes: Vec<usize> = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect();
let mut char_index = 0usize;
for (ci, &cs) in cluster_starts.iter().enumerate() {
let (x0, x1, rtl) = extents[&cs];
let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end);
let members: Vec<usize> = char_bytes.iter().copied().filter(|b| *b >= cs && *b < next).collect();
let n = members.len().max(1) as f32;
for (mi, b) in members.iter().enumerate() {
let f = mi as f32 / n;
while char_index < char_bytes.len() && char_bytes[char_index] < cs {
char_index += 1;
}
let begin = char_index;
while char_index < char_bytes.len() && char_bytes[char_index] < next {
char_index += 1;
}
let count = (char_index - begin).max(1) as f32;
for (mi, b) in char_bytes[begin..char_index].iter().enumerate() {
let f = mi as f32 / count;
let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f };
carets.push((base + b, x));
}
@@ -900,7 +918,9 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
carets.dedup_by_key(|c| c.0);
// Line metrics: the tallest face and the largest leading on the line.
let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32);
let mut seen: Vec<usize> = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect();
let char_start = chars.partition_point(|c| c.0 < r.start);
let char_end = chars.partition_point(|c| c.0 < r.end);
let mut seen: Vec<usize> = chars[char_start..char_end].iter().map(|c| c.6).collect();
seen.sort_unstable();
seen.dedup();
if seen.is_empty() {
@@ -945,10 +965,17 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
}
/// Maximum input size processed by one layout request. Above this, return an empty layout
/// retaining the source length, so untrusted text cannot fan out into unbounded shaped data.
const MAX_LAYOUT_BYTES: usize = 1 << 20;
/// Lay out `text` with character style runs (byte ranges covering the text, in order; at least
/// one) and paragraph settings (one per CR/LF/CRLF-separated paragraph; the last repeats). The wrap
/// width is the first paragraph's.
pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Layout {
if text.len() > MAX_LAYOUT_BYTES || runs.len() > MAX_LAYOUT_BYTES || paras.len() > MAX_LAYOUT_BYTES {
return Layout { text_len: text.len(), ..Default::default() };
}
let fallback = [(0..text.len(), TextStyle::default())];
let runs = if runs.is_empty() { &fallback[..] } else { runs };
let default_para = ParagraphStyle::default();
@@ -975,11 +1002,29 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
} else {
runs
};
// A location that cannot be interned would outline at the default; shape there too.
let cleared: Vec<(Range<usize>, TextStyle)>;
let runs = if runs.iter().any(|(_, s)| !s.variations.is_empty() && crate::variable::intern(&s.variations) == 0) {
cleared = runs
.iter()
.map(|(r, s)| {
let mut s = s.clone();
if crate::variable::intern(&s.variations) == 0 {
s.variations.clear();
}
(r.clone(), s)
})
.collect();
&cleared[..]
} else {
runs
};
let variation_ids: Vec<u32> = runs.iter().map(|(_, s)| crate::variable::intern(&s.variations)).collect();
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, para_at(pi), width).into_iter().map(|l| (pi, l)));
plines.extend(paragraph(p, span.bytes.start, runs, &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() {
@@ -1060,6 +1105,9 @@ pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layou
/// [`layout_rich_uncached`], cached by text, runs and paragraph settings.
pub fn layout_rich(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Arc<Layout> {
if text.len() > MAX_LAYOUT_BYTES || runs.len() > MAX_LAYOUT_BYTES || paras.len() > MAX_LAYOUT_BYTES {
return Arc::new(layout_rich_uncached(text, runs, paras));
}
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
runs.hash(&mut h);
@@ -1415,6 +1463,15 @@ mod tests {
assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right");
}
#[test]
fn oversized_layout_requests_are_bounded_without_caching() {
let text = "a".repeat(MAX_LAYOUT_BYTES + 1);
let layout = layout(&text, &TextStyle::default(), &ParagraphStyle::default());
assert_eq!(layout.text_len, text.len());
assert!(layout.lines.is_empty() && layout.glyphs.is_empty());
assert!(layout_rich_uncached("ok", &[], &[]).lines.len() == 1);
}
#[test]
fn rtl_paragraph_carets_run_right_to_left() {
let text = "\u{5d0}\u{5d1}\u{5d2}";
+62 -19
View File
@@ -201,13 +201,21 @@ fn native_name(b: &[u8], id: u16) -> Option<String> {
let count = be16(b, 2)? as usize;
let storage = be16(b, 4)? as usize;
for i in 0..count {
let r = 6 + i * 12;
let (plat, enc, lang, nid, len, off) =
(be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize);
let r = 6usize.checked_add(i.checked_mul(12)?)?;
let (plat, enc, lang, nid, len, off) = (
be16(b, r)?,
be16(b, r.checked_add(2)?)?,
be16(b, r.checked_add(4)?)?,
be16(b, r.checked_add(6)?)?,
be16(b, r.checked_add(8)?)? as usize,
be16(b, r.checked_add(10)?)? as usize,
);
if nid != id || plat != 3 || !(enc == 1 || enc == 10) || lang == 0x409 {
continue;
}
let raw = b.get(storage + off..storage + off + len)?;
let start = storage.checked_add(off)?;
let end = start.checked_add(len)?;
let raw = b.get(start..end)?;
let u: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
let s = String::from_utf16_lossy(&u);
if !s.trim().is_empty() {
@@ -221,32 +229,45 @@ fn native_name(b: &[u8], id: u16) -> Option<String> {
fn name_string(b: &[u8], id: u16) -> Option<String> {
let count = be16(b, 2)? as usize;
let storage = be16(b, 4)? as usize;
let mut best: Option<(u8, String)> = None;
let mut cands: Vec<(u8, u16, u16, &[u8])> = Vec::new();
for i in 0..count {
let r = 6 + i * 12;
let r = 6usize.checked_add(i.checked_mul(12)?)?;
let (plat, enc, lang, nid, len, off) =
(be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize);
if nid != id {
continue;
}
let raw = b.get(storage + off..storage + off + len)?;
let (rank, s) = match plat {
let start = storage.checked_add(off)?;
let end = start.checked_add(len)?;
let raw = b.get(start..end)?;
let rank = match plat {
3 if enc == 1 || enc == 10 => {
let u: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(if lang == 0x409 { 0 } else { 1 }, String::from_utf16_lossy(&u))
if lang == 0x409 {
0
} else {
1
}
}
0 => {
let u: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(2, String::from_utf16_lossy(&u))
}
1 if enc == 0 => (3, raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect()),
0 => 2,
1 if enc == 0 => 3,
_ => continue,
};
if best.as_ref().is_none_or(|(r0, _)| rank < *r0) {
best = Some((rank, s));
}
cands.push((rank, plat, enc, raw));
}
best.map(|(_, s)| s).filter(|s| !s.trim().is_empty())
// 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);
cands.into_iter().find_map(|(_, plat, enc, raw)| {
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();
String::from_utf16_lossy(&u)
}
(1, 0) => raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect(),
_ => return None,
};
(!s.trim().is_empty()).then_some(s)
})
}
/// Synthetic font files for tests: just `name` and `fvar`, enough for the scan and the resolver.
@@ -367,6 +388,28 @@ mod tests {
/// The scan marks a face with an `fvar` table that declares axes as variable, reading only
/// the table directory and the fvar header; a static font, an fvar without axes and a
/// truncated fvar are not.
#[test]
fn name_table_offsets_are_checked_and_duplicate_records_do_not_repeat_decode() {
let valid = names_font("Name Bounds", None);
let mut invalid = valid.clone();
// name record storage offset near u16::MAX must be rejected rather than overflowing
// when combined with its length (the name table starts at byte 28 in this fixture).
invalid[28 + 6 + 10] = 0xFF;
invalid[28 + 6 + 11] = 0xFF;
assert!(read_faces_bytes(&invalid).is_empty());
// Repeated aliases for one id retain the same selected name and do not change the face.
let mut duplicated = valid;
let count = u16::from_be_bytes([duplicated[28 + 2], duplicated[28 + 3]]);
let first = duplicated[28 + 6..28 + 18].to_vec();
for i in 1..count as usize {
duplicated[28 + 6 + i * 12..28 + 18 + i * 12].copy_from_slice(&first);
}
let faces = read_faces_bytes(&duplicated);
assert_eq!(faces.len(), 1);
assert_eq!((faces[0].family.as_str(), faces[0].style.as_str()), ("Name Bounds", "Regular"));
}
#[test]
fn detects_variable_faces_from_fvar() {
let v = read_faces_bytes(&names_font("Var Test", Some(Some(1))));
+36 -8
View File
@@ -4,6 +4,19 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
/// Keep process-wide variation storage bounded; id 0 is the safe default location when full.
const MAX_INTERNED_LOCATIONS: usize = 8192;
const MAX_AXES_PER_LOCATION: usize = 32;
const MAX_AXIS_TAG_BYTES: usize = 16;
type CoordKey = Vec<(String, u32)>;
#[derive(Default)]
struct Table {
values: Vec<Arc<Vec<(String, f32)>>>,
by_coords: HashMap<CoordKey, u32>,
}
use kurbo::BezPath;
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::DrawSettings;
@@ -14,15 +27,27 @@ use crate::{CharGlyph, Pen};
/// Intern a set of axis values (0 for none), so `Copy` glyphs can carry them.
pub fn intern(coords: &[(String, f32)]) -> u32 {
if coords.is_empty() {
if coords.is_empty() || coords.len() > MAX_AXES_PER_LOCATION || coords.iter().any(|(tag, v)| tag.len() > MAX_AXIS_TAG_BYTES || !v.is_finite()) {
return 0;
}
// Axis order is not significant. Canonicalize it once so equivalent locations share one key.
let mut key: CoordKey = coords.iter().map(|(tag, v)| (tag.clone(), if *v == 0.0 { 0 } else { v.to_bits() })).collect();
key.sort_unstable_by(|a, b| a.0.cmp(&b.0));
if key.windows(2).any(|w| w[0].0 == w[1].0) {
return 0;
}
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
if let Some(i) = t.iter().position(|c| c.as_slice() == coords) {
return i as u32 + 1;
if let Some(id) = t.by_coords.get(&key) {
return *id;
}
t.push(Arc::new(coords.to_vec()));
t.len() as u32
if t.values.len() >= MAX_INTERNED_LOCATIONS {
return 0;
}
let Some(id) = u32::try_from(t.values.len()).ok().and_then(|n| n.checked_add(1)) else { return 0 };
let value = Arc::new(key.iter().map(|(tag, bits)| (tag.clone(), f32::from_bits(*bits))).collect());
t.values.push(value);
t.by_coords.insert(key, id);
id
}
/// The axis values of an interned id (empty for 0).
@@ -30,11 +55,11 @@ pub fn coords(id: u32) -> Arc<Vec<(String, f32)>> {
if id == 0 {
return Arc::new(Vec::new());
}
table().lock().unwrap_or_else(|e| e.into_inner()).get(id as usize - 1).cloned().unwrap_or_default()
table().lock().unwrap_or_else(|e| e.into_inner()).values.get(id as usize - 1).cloned().unwrap_or_default()
}
fn table() -> &'static Mutex<Vec<Arc<Vec<(String, f32)>>>> {
static T: OnceLock<Mutex<Vec<Arc<Vec<(String, f32)>>>>> = OnceLock::new();
fn table() -> &'static Mutex<Table> {
static T: OnceLock<Mutex<Table>> = OnceLock::new();
T.get_or_init(Default::default)
}
@@ -197,6 +222,9 @@ mod tests {
let id = intern(&[("wght".into(), 700.0)]);
assert_eq!(intern(&[("wght".into(), 700.0)]), id);
assert_eq!(coords(id).as_slice(), &[("wght".to_string(), 700.0)]);
assert_eq!(intern(&[("wdth".into(), 20.0), ("wght".into(), 700.0)]), intern(&[("wght".into(), 700.0), ("wdth".into(), 20.0)]));
assert_eq!(intern(&[("wght".into(), f32::NAN)]), 0);
assert_eq!(intern(&vec![("wght".into(), 1.0); MAX_AXES_PER_LOCATION + 1]), 0);
let Some((face, axes)) = find_variable_face() else {
eprintln!("no variable font installed: skipped");
return;