M8: an image stored again past the allowance has a test of its own again

Since b1f221611 the dictionary is read within the allowance too, so the test
that gave unshare no allowance was refused reading the dictionary, before the
image was decoded: turning the image's refusal into "can't be read" left every
test green (Claims review of 5bef9b0e7). The test now also gives exactly what
reading the dictionary costs, so the image is what is refused.

The same review: the README lists the other images that can't be stored
again (more than one component, no size) and names ImageBudget::decode, not
Document::decoded_image_within, which #5569 made private;
SharedJbig2Budget's doc and remove_hidden's comment name the dictionaries read
to know copies too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-02 19:10:17 +01:00
1 parent 5bef9b0e71
commit 4826ca6ebc
4 files changed
+19 -11

No files matched your search

+4 -3
View File
@@ -19,7 +19,7 @@ clear_marks(&mut doc, None)?; // remove marks without app
inside;
- **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared
(8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as
the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its
the last filter, decoded through `ImageBudget::decode`, a JBIG2 image with its
`/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy
is Flate, or for a one-bit image of one component (a scan as a rule) CCITT Group 4, or JBIG2
when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to
@@ -27,8 +27,9 @@ clear_marks(&mut doc, None)?; // remove marks without app
those of marks under the area included, so whatever left the document, by any route, takes its
globals with it on the full rewrite: anything else the save would keep that names them, or a
copy of them (the same bytes once decoded), is stored again without them, as a copy is (Flate, Group 4 or JBIG2,
whichever is smallest; `Report::images_restored`). When one can't be (it isn't JBIG2, it's past
the size redaction clears, its data is in another file, or it can't be decoded), or something else refers to them, `apply`
whichever is smallest; `Report::images_restored`). When one can't be (it isn't JBIG2, its colour
space is known to have more than one component, it has no size or one past what redaction
clears, its data is in another file, or it can't be decoded), or something else refers to them, `apply`
refuses and changes nothing (`RedactError::SharedJbig2Globals`). `remove_hidden` takes them out
the same way. Another dictionary holding the same shapes in other bytes isn't found, nor is a copy
nothing names, and an image that hasn't left keeps its globals with its pixels: one another page
+3 -2
View File
@@ -84,8 +84,9 @@ pub enum RedactError {
/// Finding what shares JBIG2 symbol data with what is being removed would read more of the file
/// than one run allows (`globals::RUN_BUDGET`): its images and parameters refer to the same
/// objects in more ways than are checked, or hold more than is decoded. Removing hidden
/// information says so too when storing those images again would decode more than its image
/// allowance, which no choice of pages makes smaller.
/// information says so too when the dictionaries it reads to know copies, or the images it
/// stores again, would decode more than its image allowance, which no choice of pages makes
/// smaller.
#[error(
"finding and storing again what shares JBIG2 symbol data with something being removed would read more of this file than one run allows, so it can't be shown that the data leaves with it; this was refused"
)]
+2 -1
View File
@@ -689,7 +689,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
if done.is_empty() {
return Err(RedactError::NothingToApply);
}
// Images stored again without a dictionary are decoded within an allowance of their own.
// Images stored again without a dictionary, and the dictionaries read to know copies, are
// decoded within an allowance of their own.
unshare_unmarked(doc, &kept_before, &mut pdfcraft_cos::ImageBudget::new(doc))?;
doc.require_full_save();
Ok(done)
+10 -5
View File
@@ -1329,11 +1329,10 @@ fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() {
let mut doc = build(&image.replace(&format!("/Height {h}"), &format!("/Height {}", h + 1)));
mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], "");
assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1))));
// Storing the second page's image again decodes it, within what is left of the run's
// allowance: with nothing left the redaction is refused as one past the allowance, not
// reported as an image that can't be read.
// The first page's image leaves (its page no longer lists it), and what is left of the
// allowance is all that decodes the second's.
// Reading the dictionary to know copies of it, and storing the second page's image again,
// decode within what is left of the run's allowance: past it the redaction is refused as one
// past the allowance, not reported as an image that can't be read. The first page's image
// leaves (its page no longer lists it).
let mut doc = build(&image);
let before = crate::globals::kept(&doc);
let first = pdfcraft_model::pages(&doc)[0].obj;
@@ -1343,6 +1342,12 @@ fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() {
.unwrap();
let refused = crate::globals::unshare(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(0));
assert!(matches!(refused, Err(RedactError::ImagesPastAllowance)), "{refused:?}");
// Enough to read the dictionary, and nothing left for the image.
let mut reading = pdfcraft_cos::ImageBudget::with_work(u64::MAX);
crate::globals::content(&mut crate::globals::Run::new(crate::globals::RUN_BUDGET), &doc, 9, &mut reading).unwrap();
let read = u64::MAX - reading.work_left();
let refused = crate::globals::unshare(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(read));
assert!(matches!(refused, Err(RedactError::ImagesPastAllowance)), "the image: {refused:?}");
// Removing hidden information chose no pages, so fewer of them is no way out: it is refused as
// finding what shares the dictionary is.
let refused = crate::sanitize::unshare_unmarked(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(0));