diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 2e863d1a..8f692888 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -597,7 +597,7 @@ fn run_at(runs: &[(Range, 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, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option) -> Vec { +fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primaries: &[Resolved], variation_ids: &[u32], para: &ParagraphStyle, width: Option) -> Vec { 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, TextStyle)], primar char_adv[g.cluster.min(text.len())] += g.adv + g.pre; } } - let range_w = |r: Range| -> f32 { char_adv[r].iter().sum() }; - let trailing_ws = |r: &Range| -> (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| -> f32 { (prefix_adv[r.end] - prefix_adv[r.start]) as f32 }; + let trailing_ws = |r: &Range| -> (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, ce: usize| -> (f32, f32) { @@ -824,7 +832,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let mut underline: Option<(f32, f32)> = None; for run in vruns { let rtl = levels[run.start].is_rtl(); - let mut idx: Vec = (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 = (run_first..run_limit).collect(); if rtl { idx.reverse(); } @@ -845,7 +856,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, 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, 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 = extents.keys().copied().collect(); cluster_starts.sort_unstable(); let char_bytes: Vec = 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 = 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, 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 = 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 = 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, 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, TextStyle)], para } else { runs }; + // A location that cannot be interned would outline at the default; shape there too. + let cleared: Vec<(Range, 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 = 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, TextStyle)], paras: &[ParagraphStyle]) -> Arc { + 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}"; diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs index 753bbdd2..a5e3907c 100644 --- a/crates/text/src/sfnt.rs +++ b/crates/text/src/sfnt.rs @@ -201,13 +201,21 @@ fn native_name(b: &[u8], id: u16) -> Option { 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 = 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 { fn name_string(b: &[u8], id: u16) -> Option { 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 = 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 = 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 = 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)))); diff --git a/crates/text/src/variable.rs b/crates/text/src/variable.rs index fc9d7ab4..bf4fde39 100644 --- a/crates/text/src/variable.rs +++ b/crates/text/src/variable.rs @@ -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>>, + by_coords: HashMap, +} + 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> { 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>>> { - static T: OnceLock>>>> = OnceLock::new(); +fn table() -> &'static Mutex { + static T: OnceLock> = 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;