diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f2033a64de..1f2a6c6a9a 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -14337,6 +14337,38 @@ fn a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through() { assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}"); } +/// Only a read the allowance refuses leaves a font of the face unread for want of it. With the +/// allowance spent to the byte as above, another font of the face, whose ToUnicode is an empty +/// stream behind a filter nothing decodes (unread with any allowance), still has its program read, +/// which the allowance refuses: the edit is refused, rather than going through without that font. +#[test] +fn a_font_of_the_face_unread_on_its_own_once_the_allowance_is_spent_still_refuses_the_edit() { + let mut program = legacy_thai_program(); + program.resize((16 << 20) - 192, 0); + let mut unicode = LEGACY_THAI_UNICODE.to_vec(); + unicode.resize(256, b' '); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, &unicode); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + // Not a subset, so its metrics read no program, only the ToUnicode. + let mut other = legacy_thai_font(&mut doc, "THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + other.set(b"ToUnicode".to_vec(), Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, Vec::new()))))); + let other = doc.add(Object::Dict(other)); + doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |page| { + let Some(Object::Dict(mut resources)) = page.get(b"Resources").cloned() else { panic!("fixture") }; + let Some(Object::Dict(mut fonts)) = resources.get(b"Font").cloned() else { panic!("fixture") }; + fonts.set(b"F2".to_vec(), Object::Ref(other)); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let error = text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).unwrap_err().to_string(); + assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}"); +} + /// A THSarabunPSK-named subset that has OpenType Thai mark positioning is a modern face: it is /// shaped with its own layout like any source font, not by the legacy shaper (which would ask /// for Windows PUA alternates such a face doesn't need). diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index f68761e02e..00ace3bf44 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -70,7 +70,9 @@ pub struct Metrics { /// A stream these metrics come from (a CMap, the program, `/CIDToGIDMap`, a Type 3 glyph /// description) was left unread because the decode allowance [`Metrics::from_dict_within`] /// was given was spent: what it would have decided (code lengths, CIDs, widths, meanings, a - /// Type 3 font's height) is a guess. + /// Type 3 font's height) is a guess. It is set exactly when the allowance counted a refusal + /// during the read ([`DecodeBudget::refused`]), which callers that share it may refuse on; a + /// stream that fails on its own data doesn't set it, with or without anything left. pub past_allowance: bool, } @@ -420,12 +422,16 @@ impl<'a> Reads<'a> { /// stage past `max` is unread. When what is left is more than what the stages before that one /// made and `max`, it is charged those two and isn't refused; otherwise, with more left than /// the stages' total (behind one filter, `max`), it is charged that total and isn't refused; - /// with no more left than that total, it spends all of it and counts as refused. + /// with no more left than that total, it spends all of it and counts as refused. Only what + /// the allowance counts as refused ([`DecodeBudget::refused`]) is: a stream that fails on its + /// own data fails the same with more left, even when it spends the last of it or nothing was + /// left, and callers that refuse on that count must agree with [`Metrics::past_allowance`]. fn decode(&mut self, stream: &Stream, max: usize) -> Option> { + let refused = self.budget.refused(); match self.budget.decode_stages_within(self.doc, stream, max) { Ok(data) => Some(data), Err(_) => { - self.refused |= self.budget.remaining() == 0; + self.refused |= self.budget.refused() > refused; None } } @@ -491,10 +497,13 @@ fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Option break; } let before = reads.budget.remaining(); + let refused = reads.budget.refused(); let read = reads.budget.decode_with_damage(doc, s, MAX_TYPE3_GLYPH_BYTES.min(left)).map(|(data, _)| data); left = left.saturating_sub(before.saturating_sub(reads.budget.remaining())); let Ok(data) = read else { - if reads.budget.remaining() == 0 { + // As in `Reads::decode`, only a refusal the allowance counts: a description that fails + // on its own data, spending the last of it, leaves the rest to be refused above. + if reads.budget.refused() > refused { // The rest go unread too, for want of the reading's allowance. reads.refused = true; complete = false; diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 975b71289e..7a925c0b78 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -309,17 +309,27 @@ fn a_fonts_streams_are_paid_for_from_its_allowance() { } /// With nothing left, an empty stream still reads, as the allowance reads one, and is no reason to -/// call the font read past it; a stream with data is left unread, and is. +/// call the font read past it; a stream with data is left unread, and is. An empty stream behind a +/// filter nothing decodes is unread with any allowance, so it isn't either: the font is read past +/// its allowance exactly when the allowance counts a refusal, which is what callers refuse on. #[test] fn an_empty_stream_reads_with_nothing_left() { - let past = |data: &[u8]| { + let past = |filter: Option<&str>, data: &[u8]| { let mut doc = Document::new_empty(); - let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec())))); + let mut dict = Dict::new(); + if let Some(filter) = filter { + dict.set(b"Filter".to_vec(), Object::name(filter)); + } + let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, data.to_vec())))); let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("ToUnicode", unicode)]); - Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(0, 0)).past_allowance + let mut budget = DecodeBudget::new(0, 0); + let past = Metrics::from_dict_within(&doc, &f, &mut budget).past_allowance; + assert_eq!(past, budget.refused() > 0, "{filter:?}"); + past }; - assert!(!past(b"")); - assert!(past(b"1 beginbfchar <41> <0058> endbfchar")); + assert!(!past(None, b"")); + assert!(!past(Some("NoSuchFilter"), b"")); + assert!(past(None, b"1 beginbfchar <41> <0058> endbfchar")); } /// A stream behind one filter past its own limit, with more left than its limit, is left unread as @@ -969,6 +979,41 @@ fn type3_glyph_boxes_are_paid_for_from_the_allowance() { assert!(m.past_allowance, "a byte short, the second description is left unread"); } +/// A glyph description that fails on its own once the allowance is spent to the byte, an empty one +/// behind a filter nothing decodes, fails the same with more left: it doesn't make the metrics read +/// past the allowance, and the descriptions after it are read, or refused by the allowance. +#[test] +fn a_type3_description_unread_on_its_own_is_not_past_the_allowance() { + let mut doc = Document::new_empty(); + let procs = ["1 0 0 -20 1 300 d1", "", "1 0 0 0 1 400 d1"]; + let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + let unread = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, Vec::new())))); + let Some(Object::Dict(mut charprocs)) = f.get(b"CharProcs").cloned() else { panic!("CharProcs") }; + charprocs.set(b"g1".to_vec(), unread); + f.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); + with_differences(&mut f, &["g0", "g1", "g2"].map(String::from)); + let first = procs[0].len(); + let whole = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(usize::MAX, 0)); + assert!(!whole.past_allowance); + assert_eq!((whole.ascent, whole.descent), (400.0, -20.0)); + // The first description spends it to the byte; the third, which has data, is refused. + let mut budget = DecodeBudget::new(first, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert_eq!(budget.refused(), 1); + assert!(m.past_allowance); + // Without the third, nothing is refused, and the metrics aren't past the allowance. + let Some(Object::Dict(mut charprocs)) = f.get(b"CharProcs").cloned() else { panic!("CharProcs") }; + charprocs.remove(b"g2"); + f.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); + let mut budget = DecodeBudget::new(first, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert_eq!((budget.refused(), budget.remaining()), (0, 0)); + assert!(!m.past_allowance); + assert_eq!((m.ascent, m.descent), (300.0, -20.0)); +} + /// A Type 3 font measured from its glyphs' `d1` boxes keeps them over a descriptor's `/Ascent` /// and `/Descent`, which are optional for Type 3 (ISO 32000-2 Table 120). #[test]