diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index a2f28702ab..b51bb0a657 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -316,9 +316,16 @@ impl<'a> Reads<'a> { /// `stream` decoded within `max` and the allowance; `None` when it can't be, noting when the /// allowance is why. A stream past `max` is unread as before allowances, not refused. + /// + /// `max` bounds what the stream decodes to. Its filter stages are paid for together, up to + /// `max` for each, the most a decode without an allowance lets them make: a map of 65,536 CIDs + /// behind ASCIIHex and Flate makes all of `MAX_CID_TO_GID` in its last stage, with the hex + /// stage's output on top. fn decode(&mut self, stream: &Stream, max: usize) -> Option> { - match self.budget.decode_within(stream, max) { - Ok(data) => Some(data), + let stages = stream.filters().len().max(1); + match self.budget.decode_within(stream, max.saturating_mul(stages)) { + Ok(data) if data.len() <= max => Some(data), + Ok(_) => None, Err(_) => { self.refused |= self.budget.remaining() == 0; None diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 798c619018..dbcf367bd4 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -297,6 +297,43 @@ fn a_cmap_past_its_own_limit_is_not_past_the_allowance() { assert_eq!(usize::MAX / 2 - budget.remaining(), sizes[..3].iter().sum::() + MAX_CMAP_BYTES); } +/// A `/CIDToGIDMap` as long as a map can be reads the same behind no filter, one or two: each +/// stage may make as much as the map's limit, so the hex stage's output on top of it doesn't +/// leave it unread. A map past the limit is unread behind any of them. +#[test] +fn a_full_cid_map_reads_behind_any_number_of_filters() { + let read = |entries: usize, filters: usize| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..entries).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect(); + let flate = Stream::flate(Dict::new(), &map); + let stream = match filters { + 0 => Stream::from_raw(Dict::new(), map), + 1 => flate, + _ => { + let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, hex.into_bytes()) + } + }; + let map = Object::Ref(doc.add(Object::Stream(stream))); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + Metrics::from_dict(&doc, &f).outlined_cids + }; + let full = read(1 << 16, 1); + assert!(!full.is_empty()); + assert_eq!(read(1 << 16, 0), full); + assert_eq!(read(1 << 16, 2), full); + for filters in 0..=2 { + assert!(read((1 << 16) + 1, filters).is_empty(), "{filters} filters"); + } +} + /// Fonts that each name one large ToUnicode share the allowance they are read within: it pays for /// as many readings as it holds, and the rest are left unread, where each reading used to decode /// it afresh.