From 506502386e009e0721a14a9149852326e91ece49 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 10 Nov 2026 13:59:52 +0100 Subject: [PATCH 1/2] M8: the Type 3 cleanup charges the page only when there is a Type 3 font CodeRabbit found this on #6805, which landed #6341 on main. `prune_type3_from_resources` reads what the content draws through its resources (`resource_ops`) whenever the resources list any font, and only then looks for a Type 3 font, the only kind it prunes. Since #6341 that read is charged to what the content walk left of the page's allowance. So a page with only TrueType fonts and a large form without resources of its own could be refused, by Apply Redactions or Remove Hidden Information, for a read that prunes nothing: a 100 MiB form crossing the mark is read by the walk, the page's check and this read, 300 MiB against the page's 256. Without a Type 3 font, the read is now charged to an allowance of a page of its own; with one, to the page's, as before. The read is still made with any font listed, as CodeRabbit's suggested early return would not: it refuses bytes the parser can't read in the streams and forms it reads, and an early return would drop that refusal on every page without a Type 3 font. The walk reads past such bytes, so where nothing else reads the same content, text a stray `)` hides in a `TJ` array under the mark would stay while Apply Redactions and Remove Hidden Information reported success (found in review). The refusal covers only what the cleanups read, so such bytes still go by elsewhere, here as on main. The read and the pruning tell a Type 3 font the same way (`is_type3`). Tests: - `the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance` now also redacts the 100 MiB page with no Type 3 font, which goes on; it and the two tests like it (a rewritten form's cleanups, Remove Hidden Information) list an unused Type 3 font where their font cleanup runs, so that it still charges the page's allowance (`list_type3`). - `text_beside_bytes_the_parser_cannot_read_refuses_the_redaction`: four such `TJ` arrays in a page's second stream, one in a form without resources, and hidden text beside one, on pages listing only a TrueType font, are refused by Apply Redactions and Remove Hidden Information. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/tests.rs | 96 ++++++++++++++++++++++++++++++++++---- crates/redact/src/type3.rs | 35 +++++++++----- 2 files changed, 109 insertions(+), 22 deletions(-) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 46ac131450..465294dc9a 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -3571,14 +3571,43 @@ fn what_content_draws_through_its_resources_is_read_within_its_allowance() { } } +/// An unused Type 3 font, listed as /T3 beside /F1 in the `/Font` of `holder`'s resources (a page's +/// or a form's), so that the font cleanup, which prunes only Type 3 fonts, charges its read of what +/// they draw to the page's allowance (without one it has an allowance of its own). +fn list_type3(doc: &mut Document, holder: ObjRef) { + let font = Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type3")), + (b"FontBBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1000.into(), 1000.into()])), + (b"FontMatrix".to_vec(), Object::Array(vec![Object::Real(0.001), 0.into(), 0.into(), Object::Real(0.001), 0.into(), 0.into()])), + (b"CharProcs".to_vec(), Object::Dict(Dict::new())), + ])); + let font = Object::Ref(doc.add(font)); + let list = |d: &mut Dict| { + let Some(Object::Dict(res)) = d.get_mut(b"Resources") else { panic!("{holder:?} has resources") }; + let Some(Object::Dict(fonts)) = res.get_mut(b"Font") else { panic!("{holder:?} lists fonts") }; + fonts.set(b"T3".to_vec(), font); + }; + match &*doc.get(holder) { + Object::Stream(s) => { + let mut s = s.clone(); + list(&mut s.dict); + doc.set(holder, Object::Stream(s)); + } + _ => doc.update_dict(holder, list).unwrap(), + } +} + /// The cleanups after the content walk spend what the walk left of the page's allowance, one /// allowance between them. A form without resources of its own that crosses the mark is read by -/// the walk, then again by the check of what the page still draws and by the font cleanup: at -/// 100 MiB, three reads are more than the page's 256 MiB, and the redaction is refused; at -/// 50 MiB it goes on. +/// the walk, then again by the check of what the page still draws and by the font cleanup, which +/// charges the page's allowance when the page lists a Type 3 font: at 100 MiB, three reads are more +/// than the page's 256 MiB, and the redaction is refused; at 50 MiB it goes on. With no Type 3 font +/// the font cleanup's read has an allowance of its own, so the page's pays for two reads of +/// 100 MiB, and the redaction goes on. #[test] fn the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance() { - for (mib, refused) in [(100, true), (50, false)] { + for (mib, type3, refused) in [(100, true, true), (50, true, false), (100, false, false)] { let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]); let mut doc = one_page( b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", @@ -3588,16 +3617,61 @@ fn the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"o"), ], ); + if type3 { + list_type3(&mut doc, ObjRef::new(3, 0)); + } mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); - assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB"); + assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB, Type 3: {type3}"); } } +/// Text under the mark in a content stream that also holds bytes the parser can't read, inside a +/// `TJ` array, is read past by the walk and would stay. The font cleanup's read of what the content +/// draws refuses such bytes, and makes that read with any font listed, a Type 3 font or not (with +/// none, on an allowance of its own): Apply Redactions refuses the page, whether the stream is the +/// page's or a form's without resources of its own, and so does Remove Hidden Information. +#[test] +fn text_beside_bytes_the_parser_cannot_read_refuses_the_redaction() { + use crate::sanitize::{Hidden, remove_hidden}; + let page_of = |second: &[u8]| { + pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 6 0 R >> >> >>".to_vec(), + stream("", b"BT /F1 10 Tf 20 230 Td (SECRET) Tj ET"), + stream("", second), + FONT.replace("95 0 R", "7 0 R").into_bytes(), + widths(), + ]) + }; + for tail in [ + &b"BT /F1 10 Tf 20 260 Td [(TAIL) ) (SECRET)] TJ ET"[..], + b"BT /F1 10 Tf 20 260 Td [(TAIL) } (SECRET)] TJ ET", + b"BT /F1 10 Tf 20 260 Td [(TAILSECRET) )] TJ ET", + b"BT /F1 10 Tf 20 260 Td [) (TAILSECRET)] TJ ET", + ] { + let mut doc = page_of(tail); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert!(apply(&mut doc, None).is_err(), "{}", String::from_utf8_lossy(tail)); + } + let mut doc = one_page( + b"BT /F1 10 Tf 20 230 Td (SECRET) Tj ET /FmBare Do", + "/XObject << /FmBare 7 0 R >>", + vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 20 260 Td [(TAIL) ) (SECRET)] TJ ET")], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert!(apply(&mut doc, None).is_err(), "a form's text"); + let mut doc = page_of(b"BT /F1 10 Tf 3 Tr 20 260 Td [(TAIL) ) (HIDDEN)] TJ ET"); + doc.set(ObjRef::new(4, 0), Object::Stream(Stream::from_raw(Dict::new(), b"BT /F1 10 Tf 3 Tr 20 230 Td (HIDDEN) Tj ET".to_vec()))); + assert!(remove_hidden(&mut doc, &[Hidden::HiddenText]).is_err(), "hidden text"); +} + /// The cleanups of a form the redaction rewrites spend the page's allowance too. The rewritten /// form has resources of its own, so the page's cleanups don't follow it, and draws through them a /// form without any that crosses the mark: the walk reads that form, then the check of what the -/// rewritten form still draws and its font cleanup read it again. At 100 MiB, three reads are more -/// than the page's 256 MiB, and the redaction is refused; at 50 MiB it goes on. +/// rewritten form still draws and its font cleanup (it lists a Type 3 font) read it again. At +/// 100 MiB, three reads are more than the page's 256 MiB, and the redaction is refused; at 50 MiB +/// it goes on. #[test] fn the_cleanups_of_a_rewritten_form_spend_the_pages_allowance() { for (mib, refused) in [(100, true), (50, false)] { @@ -3613,6 +3687,7 @@ fn the_cleanups_of_a_rewritten_form_spend_the_pages_allowance() { stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), ], ); + list_type3(&mut doc, ObjRef::new(7, 0)); mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB"); } @@ -3620,9 +3695,9 @@ fn the_cleanups_of_a_rewritten_form_spend_the_pages_allowance() { /// Removing hidden text spends what its content walk left of the page's allowance too. The page /// draws a form holding hidden text, which is rewritten, and a form without resources of its own: -/// the walk reads the second, then the check of what the page still draws and the font cleanup -/// read it again. At 100 MiB, three reads are more than the page's 256 MiB, and the removal is -/// refused; at 50 MiB it goes on. +/// the walk reads the second, then the check of what the page still draws and the font cleanup (the +/// page lists a Type 3 font) read it again. At 100 MiB, three reads are more than the page's +/// 256 MiB, and the removal is refused; at 50 MiB it goes on. #[test] fn removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance() { use crate::sanitize::{Hidden, remove_hidden}; @@ -3636,6 +3711,7 @@ fn removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), ], ); + list_type3(&mut doc, ObjRef::new(3, 0)); assert_eq!(remove_hidden(&mut doc, &[Hidden::HiddenText]).is_err(), refused, "{mib} MiB"); } } diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 4c3f2211e8..759f66000b 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -539,6 +539,14 @@ fn gs_font_object(doc: &Document, state_obj: &Object) -> Option { font_entry.into_iter().next() } +/// Whether font `f` (a reference, or a font dictionary) is a Type 3 font, the only kind the font +/// cleanup prunes: its `/Subtype` is read directly, as the cleanup's other readers read it, so one +/// given by reference isn't recognised by any of them. Which allowance the cleanup's read is charged +/// to, and what it prunes, both go by this. +fn is_type3(doc: &Document, f: &Object) -> bool { + doc.resolve(f).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Type3")) +} + /// Prune Type 3 fonts from a rewritten page or form's `/Resources` dictionary if the rewritten /// content no longer draws any text using that font name. `page` is the page's dictionary when /// `res` are a page's resources. What it reads is charged to `budget`, what is left of the page's @@ -556,12 +564,18 @@ pub(crate) fn prune_type3_from_resources( } // The operators of forms that draw through these resources count too: dropping a font a child - // form still shows would leave its text without one. - let mut ops = crate::interp::resource_ops(doc, budget, res, content)?; + // form still shows would leave its text without one. Without a Type 3 font nothing is pruned, + // but the read is still made: it refuses bytes the parser can't read in what the content draws, + // which for a text redaction is the only check that does (the walk reads past them, so text + // they hide would stay under the mark). It is then charged to an allowance of a page of its own, + // not to what the walk left of the page's, since it decides nothing about the fonts. + let type3 = fonts.iter().any(|(_, f)| is_type3(doc, f)); + let mut own = pdfcraft_cos::DecodeBudget::page(); + let mut ops = crate::interp::resource_ops(doc, if type3 { &mut *budget } else { &mut own }, res, content)?; // So do those of a page's annotation appearances, and of soft mask groups, without resources of // their own. They are read only when there is a Type 3 font to keep, so other files are not // refused over them. - if fonts.iter().any(|(_, f)| doc.resolve(f).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Type3"))) { + if type3 { if let Some(page) = page { // One total for all of them, as for the content: each appearance under the cap could // still add up to far more than it. Their decoded bytes, and their forms', are charged to @@ -605,15 +619,12 @@ pub(crate) fn prune_type3_from_resources( let mut changed = false; let font_names: Vec> = fonts.iter().map(|(n, _)| n.to_vec()).collect(); for name in font_names { - if let Some(font_obj) = fonts.get(&name) { - let resolved = doc.resolve(font_obj); - if let Some(d) = resolved.as_dict() - && d.name(b"Subtype") == Some(b"Type3") - && !drawn_font_names.contains(&name) - { - fonts.remove(&name); - changed = true; - } + if let Some(font_obj) = fonts.get(&name) + && is_type3(doc, font_obj) + && !drawn_font_names.contains(&name) + { + fonts.remove(&name); + changed = true; } } From ac1eba10927457983b992a768845ca872a822cbc Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 10 Nov 2026 13:59:52 +0100 Subject: [PATCH 2/2] M8: the form and hidden-text callers are tested without a Type 3 font too Review of the allowance change. Only Apply's page caller had a row with no Type 3 font listed. `the_cleanups_of_a_rewritten_form_spend_the_pages_allowance` and `removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance` now have one too: at 100 MiB the redaction and the removal go on. Doc comments that said every cleanup of a page spends the page's allowance (`prune_type3_from_resources`, `resource_ops`, `prune`) now say the font cleanup has one of its own when no Type 3 font is listed. The doc of `text_beside_bytes_the_parser_cannot_read_refuses_the_redaction` says the cleanups run only when the walk rewrites some of the page, and read what it rewrote as rewritten. The comment in `prune_type3_from_resources` no longer calls its read the only check for text: `prune_form_resources` (for a rewritten form), `prune` (when an image or form is replaced) and `prune_properties` make the same read when they run, and `unlink_type3` reads every page when the document still lists a Type 3 font. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/interp/prune.rs | 8 +++--- crates/redact/src/tests.rs | 44 ++++++++++++++++++++----------- crates/redact/src/type3.rs | 11 +++++--- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 39089601ec..43cc886826 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -9,8 +9,9 @@ use super::{ /// by a form without /Resources of its own, which draws through `xo` too. Such forms are read /// within `budget`, what is left of the page's allowance ([`resource_ops`]). Fails, as the cleanup /// of the fonts and properties these forms draw with does, when that can't be told: such a form -/// doesn't decode (an image codec or a /Filter it can't run, say) or runs past `budget`, the walk -/// passes 2,000,000 operators, or the parser can't read all of such a form or of `content`. +/// doesn't decode (an image codec or a /Filter it can't run, say) or runs past `budget` (for the +/// fonts' cleanup, an allowance of its own when no Type 3 font is listed), the walk passes +/// 2,000,000 operators, or the parser can't read all of such a form or of `content`. /// Corrupt data in such a form is read, tolerantly, up to the damage, which may be nothing. pub(crate) fn prune(doc: &Document, budget: &mut DecodeBudget, xo: &mut Dict, replaced: &[Vec], content: &[Vec]) -> Result<(), CosError> { if replaced.is_empty() { @@ -473,7 +474,8 @@ pub(super) fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Op /// The operators of `content`, and of every form without /Resources of its own that it draws /// through `res`, nested ones too: what draws with these resources. Each such form is read once, /// with its `/DecodeParms` given by reference, its decoded bytes charged to `budget`, so the -/// cleanups of one page that walk the same forms spend one allowance between them. An error +/// cleanups of one page that walk the same forms spend one allowance between them (all but the +/// fonts' cleanup when no Type 3 font is listed, which has one of its own). An error /// when that can't all be read: the parser can't read all of `content` or of such a form, the walk /// passes 2,000,000 operators, or such a form doesn't decode or runs past `budget`. Operators are /// read one at a time, so the walk stops at one past the limit rather than holding them all. diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 465294dc9a..99e808139b 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -3625,11 +3625,16 @@ fn the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance } } -/// Text under the mark in a content stream that also holds bytes the parser can't read, inside a -/// `TJ` array, is read past by the walk and would stay. The font cleanup's read of what the content -/// draws refuses such bytes, and makes that read with any font listed, a Type 3 font or not (with -/// none, on an allowance of its own): Apply Redactions refuses the page, whether the stream is the -/// page's or a form's without resources of its own, and so does Remove Hidden Information. +/// Text under the mark in a content stream that holds bytes the parser can't read, inside a `TJ` +/// array, is read past by the walk and would stay. The font cleanup's read of what the content +/// draws refuses such bytes in the streams and forms the walk leaves as they were, and makes that +/// read with any font listed, a Type 3 font or not (with none, on an allowance of its own). Here +/// the walk rewrites the text in the page's first stream and leaves the broken `TJ` as it was: +/// Apply Redactions refuses the page, whether that `TJ` is in the page's second stream or in a +/// form without resources of its own, and so does Remove Hidden Information. The cleanups run only +/// when the walk rewrites some of the page, and read what it rewrote as rewritten, so such bytes in +/// a stream it rewrites still go by, and so do those on a page it rewrites nothing of, unless the +/// document still lists a Type 3 font (`unlink_type3` then reads every page). #[test] fn text_beside_bytes_the_parser_cannot_read_refuses_the_redaction() { use crate::sanitize::{Hidden, remove_hidden}; @@ -3669,12 +3674,13 @@ fn text_beside_bytes_the_parser_cannot_read_refuses_the_redaction() { /// The cleanups of a form the redaction rewrites spend the page's allowance too. The rewritten /// form has resources of its own, so the page's cleanups don't follow it, and draws through them a /// form without any that crosses the mark: the walk reads that form, then the check of what the -/// rewritten form still draws and its font cleanup (it lists a Type 3 font) read it again. At +/// rewritten form still draws and its font cleanup (when it lists a Type 3 font) read it again. At /// 100 MiB, three reads are more than the page's 256 MiB, and the redaction is refused; at 50 MiB -/// it goes on. +/// it goes on. With no Type 3 font the font cleanup's read has an allowance of its own, so the +/// page's pays for two reads of 100 MiB, and the redaction goes on. #[test] fn the_cleanups_of_a_rewritten_form_spend_the_pages_allowance() { - for (mib, refused) in [(100, true), (50, false)] { + for (mib, type3, refused) in [(100, true, true), (50, true, false), (100, false, false)] { let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]); let mut doc = one_page( b"/FmText Do", @@ -3687,21 +3693,25 @@ fn the_cleanups_of_a_rewritten_form_spend_the_pages_allowance() { stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), ], ); - list_type3(&mut doc, ObjRef::new(7, 0)); + if type3 { + list_type3(&mut doc, ObjRef::new(7, 0)); + } mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); - assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB"); + assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB, Type 3: {type3}"); } } /// Removing hidden text spends what its content walk left of the page's allowance too. The page /// draws a form holding hidden text, which is rewritten, and a form without resources of its own: -/// the walk reads the second, then the check of what the page still draws and the font cleanup (the -/// page lists a Type 3 font) read it again. At 100 MiB, three reads are more than the page's -/// 256 MiB, and the removal is refused; at 50 MiB it goes on. +/// the walk reads the second, then the check of what the page still draws and the font cleanup +/// (when the page lists a Type 3 font) read it again. At 100 MiB, three reads are more than the +/// page's 256 MiB, and the removal is refused; at 50 MiB it goes on. With no Type 3 font the font +/// cleanup's read has an allowance of its own, so the page's pays for two reads of 100 MiB, and the +/// removal goes on. #[test] fn removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance() { use crate::sanitize::{Hidden, remove_hidden}; - for (mib, refused) in [(100, true), (50, false)] { + for (mib, type3, refused) in [(100, true, true), (50, true, false), (100, false, false)] { let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]); let mut doc = one_page( b"/FmHidden Do /FmBare Do", @@ -3711,8 +3721,10 @@ fn removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), ], ); - list_type3(&mut doc, ObjRef::new(3, 0)); - assert_eq!(remove_hidden(&mut doc, &[Hidden::HiddenText]).is_err(), refused, "{mib} MiB"); + if type3 { + list_type3(&mut doc, ObjRef::new(3, 0)); + } + assert_eq!(remove_hidden(&mut doc, &[Hidden::HiddenText]).is_err(), refused, "{mib} MiB, Type 3: {type3}"); } } diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 759f66000b..8f8579a7f4 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -550,7 +550,8 @@ fn is_type3(doc: &Document, f: &Object) -> bool { /// Prune Type 3 fonts from a rewritten page or form's `/Resources` dictionary if the rewritten /// content no longer draws any text using that font name. `page` is the page's dictionary when /// `res` are a page's resources. What it reads is charged to `budget`, what is left of the page's -/// allowance. +/// allowance, when the resources list a Type 3 font, and otherwise to an allowance of a page of its +/// own (below). pub(crate) fn prune_type3_from_resources( doc: &Document, budget: &mut pdfcraft_cos::DecodeBudget, @@ -566,9 +567,11 @@ pub(crate) fn prune_type3_from_resources( // The operators of forms that draw through these resources count too: dropping a font a child // form still shows would leave its text without one. Without a Type 3 font nothing is pruned, // but the read is still made: it refuses bytes the parser can't read in what the content draws, - // which for a text redaction is the only check that does (the walk reads past them, so text - // they hide would stay under the mark). It is then charged to an allowance of a page of its own, - // not to what the walk left of the page's, since it decides nothing about the fonts. + // and on a page with nothing replaced and no properties listed no other check does, unless the + // document still lists a Type 3 font (`unlink_type3` then reads every page). The walk reads + // past them, so text they hide would stay under the mark. The read is then charged to an + // allowance of a page of its own, not to what the walk left of the page's, since it decides + // nothing about the fonts. let type3 = fonts.iter().any(|(_, f)| is_type3(doc, f)); let mut own = pdfcraft_cos::DecodeBudget::page(); let mut ops = crate::interp::resource_ops(doc, if type3 { &mut *budget } else { &mut own }, res, content)?;