M2: an ICC profile's fallback isn't kept with the profile

The depth limit didn't hold. An ICC profile read was kept whole, with the alternate it fell back
to, and a later read took it without counting its levels. A page selecting the last of a chain
first built each space on the one before, and no read was deep: 20,000 levels, and the stack
overflowed painting with it. In the other direction, a profile met at the limit kept the fallback
it got there, and a read nearer the top got that instead of its alternate.

Both come from keeping a result that depends on the depth. What's kept now is what the profile
itself reads as, one level whatever it is, and the alternate is read where it's met.

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 19:53:17 +01:00
co-authored by Claude Opus 5.5
parent b71914b1de
commit 4c815786c6
3 changed files with 98 additions and 28 deletions
+70
View File
@@ -977,6 +977,76 @@ fn colour_spaces_are_read_to_the_limit_of_their_nesting() {
assert!(!green(32), "one past it");
}
/// A page with the colour space resources `spaces` and the other objects `objects`, drawn with
/// `content`. Objects 5 and 6 are tint transforms: the tint itself, and paper to green.
fn page_with_colour_spaces(spaces: &str, objects: &str, content: &str) -> super::RenderedPage {
use super::*;
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 << {spaces} >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\
6 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [1 1 1] /C1 [0 1 0] >> endobj\n\
{objects}\n\
trailer << /Root 1 0 R >>\n%%EOF",
content.len()
)
.into_bytes();
PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
page: 0,
kind: RequestKind::Pixels,
tile: None,
scale: 1.0,
tag: 0,
preview: false,
})
}
/// What an ICC profile reads as is kept. If what it falls back to were kept with it, a page
/// selecting the last of a chain first would build each space on the one before, and no read
/// would be deep: 20,000 levels past the limit, for whatever walks the space to paint with it.
#[test]
fn a_chain_built_on_spaces_already_read_ends() {
const LINKS: u32 = 20_000;
let selected: String = (0..LINKS).rev().map(|i| format!("/C{i} cs\n")).collect();
let content = format!("1 0 0 rg 0 0 100 100 re f\n{selected}0 sc 100 100 100 100 re f");
let spaces: String = (0..LINKS).map(|i| format!("/C{i} [/ICCBased {} 0 R] ", 10 + i)).collect();
let around: [&dyn Fn(&str) -> String; 4] = [
&|next| format!("[/Pattern {next}]"),
&|next| format!("[/Indexed {next} 1 <0011>]"),
&|next| format!("[/Separation /A {next} 5 0 R]"),
&|next| format!("[/DeviceN [/A] {next} 5 0 R]"),
];
for around in around {
let mut objects: String = (0..LINKS)
.map(|i| {
let alternate = around(&format!("[/ICCBased {} 0 R]", 11 + i));
format!("{} 0 obj << /N 1 /Alternate {alternate} /Length 1 >> stream\nx\nendstream endobj\n", 10 + i)
})
.collect();
objects.push_str(&format!("{} 0 obj << /N 1 /Length 1 >> stream\nx\nendstream endobj\n", 10 + LINKS));
let page = page_with_colour_spaces(&spaces, &objects, &content);
assert!(page.error.is_none(), "{}: {:?}", around("next"), page.error);
let start = ((150 * page.width as usize) + 50) * 4;
assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{}: what comes before it drew", around("next"));
}
}
/// A profile met at the limit can't read its alternate there, which says nothing of a read nearer
/// the top: the space kept from the first read isn't what the second gets.
#[test]
fn a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top() {
let chain: String = (0..31).map(|i| format!("{} 0 obj [/Separation /A {} 0 R 5 0 R] endobj\n", 10 + i, 11 + i)).collect();
let objects = format!(
"{chain}41 0 obj [/ICCBased 7 0 R] endobj\n\
7 0 obj << /N 1 /Alternate [/Separation /A /DeviceRGB 6 0 R] /Length 1 >> stream\nx\nendstream endobj\n"
);
let page = page_with_colour_spaces("/Deep 10 0 R /Near 41 0 R", &objects, "/Deep cs /Near cs 1 scn 0 0 200 200 re f");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(page.rgba.get(..4), Some(&[0, 255, 0, 255][..]));
}
/// An image's `/Decode` pairs can have a reference at the start of a pair. Its read took that for
/// the whole pair, found no pair there, and ended the array, so the image didn't draw at all.
#[test]
+1 -1
View File
File diff suppressed because one or more lines are too long
+27 -27
View File
@@ -198,6 +198,9 @@ impl ColorSpaceType {
// PdfCraft patch (48): cached by what the profile is read from (see
// `IccProfileKey`), not by the stream dictionary's bytes alone.
// PdfCraft patch (74): what's kept is what the profile itself reads as. What
// it falls back to depends on how deep it was met, and kept with the profile
// a page could build a chain past the limit on spaces already read.
let read = || {
let data = icc_stream.raw_data();
let content = IccProfileKey::Content {
@@ -206,40 +209,37 @@ impl ColorSpaceType {
};
cache.get_or_insert_with(hash128(&content), || {
cache.count_icc_profile_read();
if let Some(decoded) = icc_stream.decoded().ok().as_ref() {
ICCProfile::new(decoded, num_components)
.map(|icc| {
// TODO: For SVG and PNG we can assume that the output color space is
// sRGB. If we ever implement PDF-to-PDF, we probably want to
// let the user pass the native color type and don't make this optimization
// if it's not sRGB.
if icc.is_srgb() {
Self::DeviceRgb
} else {
Self::ICCBased(icc)
}
})
.or_else(|| {
dict.get::<Object<'_>>(ALTERNATE)
.and_then(|o| Self::new(o, cache, depth + 1))
})
.or_else(|| match dict.get::<u8>(N) {
Some(1) => Some(Self::DeviceGray),
Some(3) => Some(Self::DeviceRgb),
Some(4) => Some(Self::DeviceCmyk),
_ => None,
})
} else {
None
}
let decoded = icc_stream.decoded().ok()?;
Some(ICCProfile::new(&decoded, num_components).map(|icc| {
// TODO: For SVG and PNG we can assume that the output color space is
// sRGB. If we ever implement PDF-to-PDF, we probably want to
// let the user pass the native color type and don't make this optimization
// if it's not sRGB.
if icc.is_srgb() {
Self::DeviceRgb
} else {
Self::ICCBased(icc)
}
}))
})
};
return match icc_stream_object(&color_array, &icc_stream) {
let profile = match icc_stream_object(&color_array, &icc_stream) {
Some(object) => {
cache.get_or_insert_with(hash128(&IccProfileKey::Object(object)), read)
}
None => read(),
};
return profile?
.or_else(|| {
dict.get::<Object<'_>>(ALTERNATE)
.and_then(|o| Self::new(o, cache, depth + 1))
})
.or_else(|| match dict.get::<u8>(N) {
Some(1) => Some(Self::DeviceGray),
Some(3) => Some(Self::DeviceRgb),
Some(4) => Some(Self::DeviceCmyk),
_ => None,
});
}
CALCMYK => return Some(Self::DeviceCmyk),
CALGRAY => {