M8: values given through a chain of references are read as their object

/F, /Width, /Height, /Filter and its entries, /ColorSpace and an ICC
profile were resolved one reference deep, while /DecodeParms and
/JBIG2Globals follow a chain (followed). So /F through a reference to a
reference to null counted as data in another file and refused the run,
and a /ColorSpace chained to /DeviceRGB wasn't known to have three
components, so a JBIG2 image naming it was stored as one bit, where main
refused it. All now go through followed, which a ring ends.

image::components serves clear too, so an image whose colour space is
given that way is now cleared, where it was removed whole; clear's
/Decode for a stencil mask, read one reference deep, follows the chain
as well, so a chained [1 0] isn't missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz Martens
2026-11-02 11:57:43 +01:00
co-authored by Claude Opus 5.5
parent 1b39d9a00c
commit f0ff1f7fe6
3 changed files with 49 additions and 12 deletions
+4 -4
View File
@@ -21,7 +21,7 @@ pub(crate) fn kept(doc: &Document) -> HashSet<u32> {
}
/// `o`, or what references lead to from it. A ring of them ends as a reference, which is no dictionary.
fn followed(doc: &Document, o: &Object) -> Arc<Object> {
pub(crate) fn followed(doc: &Document, o: &Object) -> Arc<Object> {
let mut at = doc.resolve(o);
for _ in 0..MAX_HOPS {
let next = match &*at {
@@ -60,10 +60,10 @@ fn named(doc: &Document, s: &Stream) -> Vec<u32> {
found
}
/// `v`, or what it refers to, inside an array too.
/// `v`, or what references lead to from it, inside an array too.
fn direct(doc: &Document, v: &Object) -> Object {
match &*doc.resolve(v) {
Object::Array(a) => Object::Array(a.iter().map(|e| (*doc.resolve(e)).clone()).collect()),
match &*followed(doc, v) {
Object::Array(a) => Object::Array(a.iter().map(|e| (*followed(doc, e)).clone()).collect()),
other => other.clone(),
}
}
+9 -8
View File
@@ -13,8 +13,9 @@ use pdfcraft_cos::{Dict, Document, Object, Stream};
/// Most pixels an image may have to be cleared in place (larger ones are removed).
pub(crate) const MAX_PIXELS: u64 = 64 << 20;
/// The components of image `s`'s colour space, through any references (`None` when unknown).
pub(crate) fn components(doc: &Document, s: &Stream) -> Option<usize> {
let cs = doc.resolve(s.dict.get(b"ColorSpace")?);
let cs = crate::globals::followed(doc, s.dict.get(b"ColorSpace")?);
let cs = &*cs;
let name = match cs {
Object::Name(n) => n.as_slice(),
@@ -27,7 +28,7 @@ pub(crate) fn components(doc: &Document, s: &Stream) -> Option<usize> {
b"DeviceCMYK" | b"CMYK" => 4,
b"ICCBased" => {
// /N lives in the ICC profile stream, and shall be 1, 3 or 4 (ISO 32000-2 §8.6.5.5).
match &*doc.resolve(cs.as_array()?.get(1)?) {
match &*crate::globals::followed(doc, cs.as_array()?.get(1)?) {
Object::Stream(icc) => usize::try_from(icc.dict.int(b"N")?).ok().filter(|n| matches!(n, 1 | 3 | 4))?,
_ => return None,
}
@@ -119,9 +120,9 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
data.truncate(bytes);
// Image masks: a sample of 0 paints unless /Decode is [1 0]; cleared samples must not paint.
let clear_bit = if mask {
// The array, and its first number, given by reference too.
let decode = s.dict.get(b"Decode").map(|d| doc.resolve(d));
let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| doc.resolve(v));
// The array, and its first number, given by reference too, through any references.
let decode = s.dict.get(b"Decode").map(|d| crate::globals::followed(doc, d));
let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| crate::globals::followed(doc, v));
let inverted = first.as_deref().and_then(Object::as_f64) == Some(1.0);
!inverted
} else {
@@ -175,10 +176,10 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
}
/// Whether `s`'s data is in another file, which a reader may draw instead of what is here: a `/F`
/// that isn't null (a null entry is as if absent, and a reference to no object is null, ISO
/// 32000-2 §7.3.7 and §7.3.10).
/// that isn't null, through any references (a null entry is as if absent, and a reference to no
/// object is null, ISO 32000-2 §7.3.7 and §7.3.10). A ring of references counts as a file.
pub(crate) fn data_elsewhere(doc: &Document, s: &Stream) -> bool {
s.dict.get(b"F").is_some_and(|f| !matches!(&*doc.resolve(f), Object::Null))
s.dict.get(b"F").is_some_and(|f| !matches!(&*crate::globals::followed(doc, f), Object::Null))
}
/// The cleared samples `data` of a `w` × `h` image as a stream with `dict`: Flate, or, for one bit
+36
View File
@@ -620,6 +620,25 @@ fn a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed() {
vec![b"1".to_vec()],
[0x00, 0xFF, 0xFF, 0xFF],
),
// Through a reference to a reference: the array, its first number, and a colour space.
(
format!("{image} /ImageMask true /Decode 8 0 R /Filter /CCITTFaxDecode /DecodeParms {parms}"),
g4.clone(),
vec![b"9 0 R".to_vec(), b"[1 0]".to_vec()],
[0x00, 0xFF, 0xFF, 0xFF],
),
(
format!("{image} /ImageMask true /Decode [8 0 R 0] /Filter /CCITTFaxDecode /DecodeParms {parms}"),
g4.clone(),
vec![b"9 0 R".to_vec(), b"1".to_vec()],
[0x00, 0xFF, 0xFF, 0xFF],
),
(
format!("{image} /ColorSpace 8 0 R /Filter /CCITTFaxDecode /DecodeParms {parms}"),
g4.clone(),
vec![b"9 0 R".to_vec(), b"/DeviceGray".to_vec()],
[0x00, 0xFF, 0xFF, 0xFF],
),
] {
let mut objs = vec![stream(&dict, data)];
objs.extend(extra);
@@ -1544,6 +1563,17 @@ fn what_stays_is_stored_without_the_dictionary_however_it_names_it() {
(format!("{image} /F null"), scan.page.clone(), vec![]),
(format!("{image} /F 10 0 R"), scan.page.clone(), vec![b"null".to_vec()]),
(format!("{image} /F 99 0 R"), scan.page.clone(), vec![]),
// Through a reference to a reference: /F to null, the size, the filter, the colour space.
(format!("{image} /F 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"null".to_vec()]),
(image.replace("/Width 400", "/Width 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"400".to_vec()]),
(image.replace("/Height 40", "/Height 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"40".to_vec()]),
(image.replace("/Filter /JBIG2Decode", "/Filter 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"/JBIG2Decode".to_vec()]),
(
shared_image("/DecodeParms [null << /JBIG2Globals 9 0 R >>]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode 10 0 R]"),
hex.clone(),
vec![b"11 0 R".to_vec(), b"/JBIG2Decode".to_vec()],
),
(image.replace("/ColorSpace /DeviceGray", "/ColorSpace 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"/DeviceGray".to_vec()]),
] {
let mut doc = SharedPages { other: other.clone(), other_data: data, extra, ..SharedPages::new(&scan) }.build(&scan);
mark(&mut doc, 0, &[CLEARS], "");
@@ -1641,6 +1671,12 @@ fn a_shared_jbig2_dictionary_that_cant_leave_refuses_the_redaction() {
for (other, data, extra) in [
(image.replace("/Height 40", "/Height 41"), scan.page.clone(), vec![]),
(image.replace("/DeviceGray", "/DeviceRGB"), scan.page.clone(), vec![]),
(image.replace("/ColorSpace /DeviceGray", "/ColorSpace 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"/DeviceRGB".to_vec()]),
(
format!("{image} /ColorSpace [/ICCBased 10 0 R]").replace("/ColorSpace /DeviceGray ", ""),
scan.page.clone(),
vec![b"11 0 R".to_vec(), stream("/N 3", b"")],
),
(shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"), scan.page.clone(), vec![b"9 0 R".to_vec()]),
(elsewhere.to_string(), vec![0], vec![]),
(not_jbig2, pdfcraft_filters::encode_flate(&scan.bits), vec![]),