From 4020edb8cd4fe3772c0ef95b2d5d3c1ca2fb4ff5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:57:27 -0500 Subject: [PATCH] PDF fonts and fax: bound charstring work, ToUnicode ranges and reference search (#2964) * PDF fonts and fax: bound charstring work, ToUnicode ranges and reference search Type 1 and CFF charstrings bounded subroutine depth but not total work: subroutines that call each other several times multiplied a small font into unbounded instructions and geometry. Each glyph now has a shared budget of 100,000 instructions. The Type 1 Subrs parser looked up /CharStrings again for every entry; it is found once. A ToUnicode bfrange expanded a long destination string for each of up to 65,536 codes; destinations past 256 bytes are skipped and a CMap keeps at most 2^20 mappings. CCITT 2-D decoding scanned the reference row from its start for every code; it bisects. * address review: cap bfchar and array bfrange unicode mappings * address review: seac components share the glyph instruction budget (CFF and Type 1) --- crates/pdf/src/ccitt.rs | 26 ++++++++++++- crates/pdf/src/cff.rs | 84 +++++++++++++++++++++++++++++++++++++---- crates/pdf/src/font.rs | 17 ++++++++- crates/pdf/src/type1.rs | 41 +++++++++++++++++--- 4 files changed, 153 insertions(+), 15 deletions(-) diff --git a/crates/pdf/src/ccitt.rs b/crates/pdf/src/ccitt.rs index fa013161f..ca6a40d21 100644 --- a/crates/pdf/src/ccitt.rs +++ b/crates/pdf/src/ccitt.rs @@ -401,8 +401,10 @@ fn mode(r: &mut Reader) -> Option { /// (index parity: even indices start black runs), and the one after it. fn b1b2(refl: &[usize], a0: i64, white: bool, columns: usize) -> (usize, usize) { let want = if white { 0 } else { 1 }; - let mut i = 0; - while i < refl.len() && (refl[i] as i64 <= a0 || i % 2 != want) { + // The changing elements are in order, so the first past `a0` is found by bisection (a scan + // from the start made every row quadratic in its transitions). + let mut i = refl.partition_point(|&c| c as i64 <= a0); + if i % 2 != want { i += 1; } let b1 = refl.get(i).copied().unwrap_or(columns); @@ -804,4 +806,24 @@ pub(crate) mod tests { let _ = decode(&garbage, &Params { k, columns: 1, rows: 0, ..Params::default() }, 1 << 20); } } + + /// `b1b2` by bisection agrees with the scan it replaced, for every position and colour + /// over a dense reference row. + #[test] + fn b1b2_by_bisection_matches_the_scan() { + let scan = |refl: &[usize], a0: i64, white: bool, columns: usize| { + let want = if white { 0 } else { 1 }; + let mut i = 0; + while i < refl.len() && (refl[i] as i64 <= a0 || i % 2 != want) { + i += 1; + } + (refl.get(i).copied().unwrap_or(columns), refl.get(i + 1).copied().unwrap_or(columns)) + }; + let refl: Vec = (0..64).map(|i| i * 3 / 2).collect(); + for a0 in -1..100 { + for white in [true, false] { + assert_eq!(b1b2(&refl, a0, white, 100), scan(&refl, a0, white, 100), "a0 {a0} white {white}"); + } + } + } } diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs index 61bb7f9eb..cb7214cc2 100644 --- a/crates/pdf/src/cff.rs +++ b/crates/pdf/src/cff.rs @@ -538,16 +538,31 @@ impl Cff { /// A glyph's outline in glyph space (`font_matrix` maps it to text space) and its advance. pub fn outline(&self, gid: u16) -> Option<(BezPath, f64)> { - self.outline_depth(gid, 0) + self.outline_depth(gid, 0, &mut { MAX_GLYPH_OPS }) } - fn outline_depth(&self, gid: u16, depth: usize) -> Option<(BezPath, f64)> { + /// `ops` is the glyph's instruction budget, shared by the base glyph and its seac components. + fn outline_depth(&self, gid: u16, depth: usize, ops: &mut usize) -> Option<(BezPath, f64)> { let r = self.charstrings.get(gid as usize)?.clone(); let fd = self.fdselect.get(gid as usize).copied().unwrap_or(0) as usize; let fd = fd.min(self.lsubrs.len() - 1); - let mut st = - T2 { cff: self, fd, path: BezPath::new(), x: 0.0, y: 0.0, stack: vec![], stems: 0, width: None, open: false, done: false, depth: 0, seac: None }; + let mut st = T2 { + cff: self, + fd, + path: BezPath::new(), + x: 0.0, + y: 0.0, + stack: vec![], + stems: 0, + width: None, + open: false, + done: false, + depth: 0, + ops: *ops, + seac: None, + }; st.run(&self.data[r]); + *ops = st.ops; if st.open { st.path.close_path(); } @@ -563,10 +578,10 @@ impl Cff { && depth < 2 { let find = |code: u8| crate::encoding::Base::Standard.name(code).and_then(|n| self.gid_by_name(&n)); - if let Some((bp, _)) = find(b).and_then(|g| self.outline_depth(g, depth + 1)) { + if let Some((bp, _)) = find(b).and_then(|g| self.outline_depth(g, depth + 1, ops)) { path.extend(bp); } - if let Some((ap, _)) = find(a).and_then(|g| self.outline_depth(g, depth + 1)) { + if let Some((ap, _)) = find(a).and_then(|g| self.outline_depth(g, depth + 1, ops)) { path.extend(kurbo::Affine::translate((adx, ady)) * ap); } } @@ -646,9 +661,16 @@ struct T2<'a> { open: bool, done: bool, depth: usize, + /// Instructions this glyph may still run, across its subroutine calls. + ops: usize, seac: Option<(f64, f64, u8, u8)>, } +/// The most charstring instructions one glyph runs: subroutines that branch (two calls each, +/// ten levels deep) would otherwise multiply a small font into unbounded work and geometry. +/// A real glyph runs a few hundred. +pub(crate) const MAX_GLYPH_OPS: usize = 100_000; + impl T2<'_> { fn move_to(&mut self, dx: f64, dy: f64) { if self.open { @@ -698,6 +720,11 @@ impl T2<'_> { } let mut i = 0; while i < cs.len() && !self.done { + let Some(left) = self.ops.checked_sub(1) else { + self.done = true; + return; + }; + self.ops = left; let b0 = cs[i]; i += 1; match b0 { @@ -958,6 +985,12 @@ impl T2<'_> { /// named by custom strings. #[cfg(test)] pub(crate) fn write_test_cff(glyphs: &[(&str, Vec)]) -> Vec { + write_test_cff_with_gsubrs(glyphs, &[]) +} + +/// [`write_test_cff`] with global subroutines. +#[cfg(test)] +pub(crate) fn write_test_cff_with_gsubrs(glyphs: &[(&str, Vec)], gsubrs: &[Vec]) -> Vec { fn idx(items: &[Vec]) -> Vec { let mut out = (items.len() as u16).to_be_bytes().to_vec(); if items.is_empty() { @@ -983,7 +1016,7 @@ pub(crate) fn write_test_cff(glyphs: &[(&str, Vec)]) -> Vec { let names = idx(&[b"Test".to_vec()]); let strings: Vec> = glyphs.iter().skip(1).map(|(n, _)| n.as_bytes().to_vec()).collect(); let strings_idx = idx(&strings); - let gsubrs = idx(&[]); + let gsubrs = idx(gsubrs); let cs = idx(&glyphs.iter().map(|(_, c)| c.clone()).collect::>()); // charset format 0: SIDs of glyphs 1.. (custom strings from 391). let mut charset = vec![0u8]; @@ -1090,6 +1123,43 @@ mod tests { assert!(p.elements().is_empty()); } + /// A global subroutine that calls itself ten times: 10^10 calls within the depth limit of + /// ten. The glyph stops at its instruction budget instead. + #[test] + fn branching_subroutines_stop_at_the_instruction_budget() { + // `-107 callgsubr` (bias 107) is subroutine 0. + let call = [32u8, 29]; + let mut subr = call.repeat(10); + subr.push(11); + let mut cs = call.to_vec(); + cs.push(14); + let font = write_test_cff_with_gsubrs(&[(".notdef", vec![14]), ("A", cs)], &[subr]); + let cff = Cff::parse(&font).unwrap(); + let (p, _) = cff.outline(cff.gid_by_name("A").unwrap()).unwrap(); + assert!(p.elements().is_empty()); + } + + /// The instruction budget is one per glyph: a seac whose base glyph spends all of it leaves + /// nothing for the accent, instead of each component getting a fresh budget. + #[test] + fn seac_components_share_the_instruction_budget() { + let call = [32u8, 29]; + let mut subr = call.repeat(10); + subr.push(11); + // A square, then the branching subroutine that exhausts the budget. + let mut a = vec![139 + 100, 139 + 100, 139 - 100, 6]; + a.extend(call); + a.push(14); + // `adx ady bchar achar endchar` with both components 'A' (standard code 65). + let seac = vec![139 + 10, 139 + 20, 139 + 65, 139 + 65, 14]; + let font = write_test_cff_with_gsubrs(&[(".notdef", vec![14]), ("A", a), ("Aacute", seac)], &[subr]); + let cff = Cff::parse(&font).unwrap(); + let (alone, _) = cff.outline(cff.gid_by_name("A").unwrap()).unwrap(); + let (both, _) = cff.outline(cff.gid_by_name("Aacute").unwrap()).unwrap(); + assert!(!alone.elements().is_empty()); + assert_eq!(both.elements().len(), alone.elements().len(), "the accent gets no budget after the base spent it"); + } + /// A CFF whose top DICT points its Private DICT at offsets near `usize::MAX`: `size` and `off` /// come from the file, and reading the Private DICT must not overflow `off + size`. #[test] diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs index 64b0a1c3a..8bd91831e 100644 --- a/crates/pdf/src/font.rs +++ b/crates/pdf/src/font.rs @@ -211,6 +211,12 @@ fn utf16(b: &[u8]) -> String { String::from_utf16_lossy(&u) } +/// The most code → text mappings a CMap keeps (more than Unicode has code points), and the +/// longest destination string a `bfrange` is expanded with: a range of 65,536 codes would +/// otherwise multiply a long destination into gigabytes of text. +const MAX_UNICODE_MAPPINGS: usize = 1 << 20; +const MAX_RANGE_DST: usize = 256; + fn parse_cmap(data: &[u8]) -> CMap { let mut m = CMap::default(); let mut lx = Lexer::new(data, 0); @@ -239,6 +245,9 @@ fn parse_cmap(data: &[u8]) -> CMap { } "bfchar" => { for p in a.as_chunks::<2>().0 { + if m.unicode.len() >= MAX_UNICODE_MAPPINGS { + break; + } if let (Obj::Str(src), Obj::Str(dst)) = (&p[0], &p[1]) { m.unicode.insert(be(src), utf16(dst)); } @@ -252,9 +261,12 @@ fn parse_cmap(data: &[u8]) -> CMap { continue; } match &p[2] { - Obj::Str(dst) => { + Obj::Str(dst) if dst.len() <= MAX_RANGE_DST => { let mut d = dst.clone(); for c in lo..=hi { + if m.unicode.len() >= MAX_UNICODE_MAPPINGS { + break; + } m.unicode.insert(c, utf16(&d)); if let Some(last) = d.last_mut() { *last = last.wrapping_add(1); @@ -263,6 +275,9 @@ fn parse_cmap(data: &[u8]) -> CMap { } Obj::Array(list) => { for (k, s) in list.iter().enumerate() { + if m.unicode.len() >= MAX_UNICODE_MAPPINGS { + break; + } if let (Obj::Str(s), Some(c)) = (s, lo.checked_add(k as u32)) { m.unicode.insert(c, utf16(s)); } diff --git a/crates/pdf/src/type1.rs b/crates/pdf/src/type1.rs index 4b3e05756..399905722 100644 --- a/crates/pdf/src/type1.rs +++ b/crates/pdf/src/type1.rs @@ -148,8 +148,9 @@ impl Type1 { }; if let Some(p) = find(&priv_, b"/Subrs", 0) { let mut q = p + 6; + // The section ends at `/CharStrings`, found once rather than for every entry. + let lim = find(&priv_, b"/CharStrings", q).unwrap_or(priv_.len()); while let Some(d) = find(&priv_, b"dup", q) { - let lim = find(&priv_, b"/CharStrings", q).unwrap_or(priv_.len()); if d > lim { break; } @@ -205,10 +206,11 @@ impl Type1 { /// A glyph's outline in glyph space and its advance width. pub fn outline(&self, name: &str) -> Option<(BezPath, f64)> { - self.outline_depth(name, 0) + self.outline_depth(name, 0, &mut { crate::cff::MAX_GLYPH_OPS }) } - fn outline_depth(&self, name: &str, depth: usize) -> Option<(BezPath, f64)> { + /// `ops` is the glyph's instruction budget, shared by the base glyph and its seac components. + fn outline_depth(&self, name: &str, depth: usize, ops: &mut usize) -> Option<(BezPath, f64)> { let cs = self.charstrings.get(name)?; let mut st = T1 { font: self, @@ -223,9 +225,11 @@ impl Type1 { flex: None, done: false, depth: 0, + ops: *ops, seac: None, }; st.run(cs); + *ops = st.ops; if st.open { st.path.close_path(); } @@ -234,10 +238,10 @@ impl Type1 { && depth < 2 { let name_of = |c: u8| crate::encoding::Base::Standard.name(c); - if let Some((bp, _)) = name_of(b).and_then(|n| self.outline_depth(&n, depth + 1)) { + if let Some((bp, _)) = name_of(b).and_then(|n| self.outline_depth(&n, depth + 1, ops)) { path.extend(bp); } - if let Some((ap, _)) = name_of(a).and_then(|n| self.outline_depth(&n, depth + 1)) { + if let Some((ap, _)) = name_of(a).and_then(|n| self.outline_depth(&n, depth + 1, ops)) { // The accent's origin: its own sidebearing is replaced by `asb`. path.extend(kurbo::Affine::translate((adx - asb + st.sbx, ady)) * ap); } @@ -270,6 +274,8 @@ struct T1<'a> { flex: Option>, done: bool, depth: usize, + /// Instructions this glyph may still run, across its subroutine calls. + ops: usize, seac: Option<(f64, f64, f64, u8, u8)>, } @@ -314,6 +320,12 @@ impl T1<'_> { } let mut i = 0; while i < cs.len() && !self.done { + // Subroutines that branch would multiply the work: the budget is shared. + let Some(left) = self.ops.checked_sub(1) else { + self.done = true; + return; + }; + self.ops = left; let b0 = cs[i]; i += 1; let s = |st: &Self, k: usize| st.stack.get(k).copied().unwrap_or(0.0); @@ -533,6 +545,25 @@ mod tests { assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); } + /// A subroutine that calls itself ten times: 10^10 calls within the depth limit of ten. The + /// glyph stops at its instruction budget instead. + #[test] + fn branching_subroutines_stop_at_the_instruction_budget() { + let mut out = b"%!PS-AdobeFont-1.0: Test 001\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\ncurrentfile eexec\n".to_vec(); + // `0 callsubr` is `139 10`; `return` is 11, `endchar` 14 (lenIV -1: charstrings in clear). + let mut subr = [139u8, 10].repeat(10); + subr.push(11); + let mut priv_ = b"dup /Private 8 dict dup begin /lenIV -1 def\n/Subrs 1 array\n".to_vec(); + priv_.extend_from_slice(format!("dup 0 {} RD ", subr.len()).as_bytes()); + priv_.extend_from_slice(&subr); + priv_.extend_from_slice(b" NP\nend\n/CharStrings 1 dict dup begin\n/A 3 RD \x8b\x0a\x0e ND\nend\nend\nmark currentfile closefile\n"); + out.extend(encrypt(&priv_, EEXEC_R, 4)); + out.extend_from_slice(b"\n0000000000000000\ncleartomark\n"); + let t = Type1::parse(&out).unwrap(); + let (p, _) = t.outline("A").unwrap(); + assert!(p.elements().is_empty()); + } + /// A `/Subrs` entry whose `RD` length is `usize::MAX`: the bytes are not in the file, and /// `q + n` must not overflow. #[test]