M10: export reads a /Decode array's numbers through references

Since the renderer now draws an image with /Decode [7 0 R 0], export disagreed with it. Its check of
which way round a stencil mask or a CMYK JPEG is compared the two numbers as given, and a reference
reads as none, so the file came out a negative of what the page draws.

Tests for the function arrays the previous commit's tuple fix also covers: a function's /Domain,
/Range, /Encode and /Decode are read a pair at a time 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 Martens
2026-10-31 10:01:27 +01:00
co-authored by Claude Opus 5.5
parent fa7acd3156
commit ae94b54505
4 changed files with 79 additions and 3 deletions
+2 -1
View File
@@ -236,11 +236,12 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
let key = if mask { None } else { colour_key(doc, s) };
// Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images.
let endpoint = |value: Option<&Object>| value.map(|v| doc.resolve(v)).and_then(|v| v.as_f64());
let inverted = (jpeg || mask)
&& s.dict
.get(b"Decode")
.map(|d| doc.resolve(d))
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
.and_then(|d| d.as_array().map(|a| endpoint(a.first()) > endpoint(a.get(1))))
.unwrap_or(false);
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
+28
View File
@@ -2209,3 +2209,31 @@ fn jbig2_globals_are_decoded_within_a_limit_of_their_own() {
assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}");
}
}
/// `/Decode` says which way round a stencil mask is, and its numbers can be given by reference. Read
/// as absent, they gave the wrong answer, so the file came out a negative of what the page draws.
#[test]
fn a_stencil_masks_decode_array_can_hold_references() {
use pdfcraft_cos::Document;
let export = |decode: &str| {
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im 4 0 R >> >> >> endobj\n\
4 0 obj << /Subtype /Image /ImageMask true /Width 8 /Height 1 /BitsPerComponent 1 /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\nF0>\nendstream endobj\n\
5 0 obj 1 endobj\n6 0 obj 0 endobj\ntrailer << /Root 1 0 R >>\n%%EOF"
);
let doc = Document::open(std::sync::Arc::new(pdf.into_bytes())).unwrap();
let mut export = crate::extract::extract_images(&doc, &[0], 1);
assert_eq!(export.images.len(), 1, "{decode}: {:?}", export.skipped);
export.images.remove(0).data
};
let (inverted, identity) = (export("[1 0]"), export("[0 1]"));
assert_ne!(inverted, identity, "the two ways round differ");
for decode in ["[5 0 R 0]", "[1 6 0 R]", "[5 0 R 6 0 R]"] {
assert_eq!(export(decode), inverted, "{decode}");
}
for decode in ["[0 5 0 R]", "[6 0 R 1]", "[6 0 R 5 0 R]"] {
assert_eq!(export(decode), identity, "{decode}");
}
}
+47
View File
@@ -904,6 +904,53 @@ fn a_decode_array_with_references_in_it_is_read() {
}
}
/// A function's `/Encode`, `/Decode`, `/Domain` and `/Range` are read a pair at a time too, so they
/// lost a reference at the start of a pair the same way, and the array with it. Each form is compared
/// with the direct one, which differs from the identity so the array is shown to count.
#[test]
fn a_functions_arrays_can_start_with_a_reference() {
use super::*;
let render = |function: String| -> [u8; 4] {
let content = "/C cs 0.25 sc 0 0 200 200 re f";
let bytes = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /C [/Separation /X /DeviceRGB 9 0 R] >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj 1 endobj\n6 0 obj 0 endobj\n\
7 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 0 0] /N 1 >> endobj\n\
8 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0 1 0] /C1 [0 0 1] /N 1 >> endobj\n\
9 0 obj {function} endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
content.len()
)
.into_bytes();
let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
page: 0,
kind: RequestKind::Pixels,
tile: None,
scale: 1.0,
tag: 0,
preview: false,
});
assert!(page.error.is_none(), "{function}: {:?}", page.error);
let start = (100 * page.width as usize + 100) * 4;
let mut pixel = [0; 4];
pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
pixel
};
let sampled = |encode: &str| {
format!(
"<< /FunctionType 0 /Size [2] /BitsPerSample 8 /Domain [0 1] /Range [0 1 0 1 0 1] /Encode {encode} /Filter /ASCIIHexDecode /Length 13 >> stream\n000000FF8040>\nendstream"
)
};
let stitched = |encode: &str| format!("<< /FunctionType 3 /Domain [0 1] /Functions [7 0 R 8 0 R] /Bounds [.5] /Encode {encode} >>");
assert_ne!(render(sampled("[1 0]")), render(sampled("[0 1]")), "/Encode counts");
assert_eq!(render(sampled("[5 0 R 0]")), render(sampled("[1 0]")), "a sampled function");
assert_ne!(render(stitched("[1 0 0 1]")), render(stitched("[0 1 0 1]")), "/Encode counts");
assert_eq!(render(stitched("[5 0 R 0 0 1]")), render(stitched("[1 0 0 1]")), "a stitching function");
}
/// The lookup table of an `/Indexed` space is a string or a stream, and any part of the space can
/// be indirect (ISO 32000-2 §8.6.6.3, §7.3.10). A table in an indirect string was lost: reading it
/// as a stream used up the reference, so the space couldn't be read and drew in grey.
+2 -2
View File
File diff suppressed because one or more lines are too long