mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
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:
+78
-21
@@ -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
@@ -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))));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user