M2: every match on RenderWarning names MalformedIndexedImage, and an Indexed lookup string given by reference is read (ISO 32000-2 8.6.6.3)

- render_warning_text (crates/automation/src/pages.rs, on main since this
  branch was cut) matched RenderWarning exhaustively and did not build
  with the new variant. It now names it, in the CLI's words, so
  page_render and render_preview report the skip. A workspace search
  finds no other match on RenderWarning outside pdfcraft-render.
- An Indexed lookup is a string or a stream (8.6.6.3). Indexed::new and
  is_malformed_indexed tried it as a stream first; a failed stream read
  of a reference consumed the reference, so a string lookup given by
  reference was never found and the image was skipped. The lookup is now
  read once and dispatched by type, in both places.
- hival is judged before the base: an out-of-range hival rules the space
  out even when the base cannot be read (the table size still needs the
  base, so a usable hival keeps today's handling there).

The size check is unchanged: hival is range-checked to 0..=255 before
(hival + 1) * m is formed with checked_add/checked_mul.

Tests (each seen failing with a fault injected): bounds at DeviceCMYK,
DeviceGray and hival 255 (exact and one short); hivals past u32/u64/any
integer, negative and fractional (with unchecked arithmetic and the range
widened the test panics with "attempt to multiply with overflow"); a
lookup string by reference (fails with the old stream-then-string read);
an unreadable base with hival 300; an undecodable lookup stream; and
page_render reporting the skip (without the new arm the crate does not
build, E0004).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410
2026-11-01 19:20:55 +08:00
co-authored by Claude Opus 5.5
parent b559caaf54
commit b6f93a3aef
5 changed files with 116 additions and 20 deletions
+1
View File
@@ -345,6 +345,7 @@ fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str
match warning {
RenderWarning::ContentTruncated => "content past the page's safety budget was skipped",
RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour",
RenderWarning::MalformedIndexedImage => "an image with a malformed Indexed colour table was not drawn",
}
}
@@ -96,6 +96,33 @@ fn a_clean_page_renders_to_the_png_alone() {
let _ = std::fs::remove_dir_all(dir);
}
/// An image whose Indexed lookup table is too short (ISO 32000-2 §8.6.6.3) is skipped, and
/// `page_render` says so in the words `pdfcraft-cli render` prints.
#[test]
fn page_render_reports_a_skipped_malformed_indexed_image() {
let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-indexed-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let content = "q 200 0 0 200 0 0 cm /Im Do Q";
// hival 7 over DeviceRGB needs 24 bytes; the table holds 6.
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 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 7 <FF0000 00FF00>] /Filter /ASCIIHexDecode /Length 5 >> stream\n0001>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
content.len()
);
std::fs::write(dir.join("indexed.pdf"), pdf).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = open(&mut a, "indexed.pdf");
let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") };
assert_eq!(report["warnings"], json!(["an image with a malformed Indexed colour table was not drawn"]));
let _ = std::fs::remove_dir_all(dir);
}
/// Over MCP the warnings are a text block after the image and the call's `structuredContent`.
#[cfg(feature = "mcp")]
#[test]
+56
View File
@@ -173,3 +173,59 @@ fn a_page_with_several_malformed_images_reports_the_warning_once_and_draws_the_r
let at = (195 * page.width as usize + 5) * 4;
assert_eq!(page.rgba.get(at..at + 3), Some(&[0u8, 0, 255][..]), "content after the skipped images still draws");
}
#[test]
fn a_lookup_string_given_by_reference_is_read() {
// A lookup is a byte string or a stream (ISO 32000-2 §8.6.6.3), and either may be indirect.
// A string by reference used to be missed: trying it as a stream consumed the reference.
let full: &[u8] = b"<FF0000 00FF00>";
assert_drawn("[/Indexed /DeviceRGB 1 6 0 R]", &[full]);
let short: &[u8] = b"<FF0000 00FF>";
assert_skipped("[/Indexed /DeviceRGB 1 6 0 R]", &[short]);
// Something that is neither a string nor a stream is no table.
let number: &[u8] = b"42";
assert_skipped("[/Indexed /DeviceRGB 1 6 0 R]", &[number]);
}
#[test]
fn the_size_check_holds_at_the_bounds_of_hival_and_the_base() {
// Four components (DeviceCMYK): hival 1 needs exactly 8 bytes.
let cmyk = |table: &str| format!("[/Indexed /DeviceCMYK 1 <{table}>]");
let page = render(pdf(&cmyk("FF000000 00FF0000"), DRAW_IMAGE, &[]));
assert!(page.warnings.is_empty(), "exact length: {:?}", page.warnings);
assert_ne!(halves(&page), WHITE, "exact length draws");
assert_skipped(&cmyk("FF000000 00FF00"), &[]);
// One component (DeviceGray): hival 0 needs one byte, and none is not enough.
let page = render(pdf("[/Indexed /DeviceGray 0 <00>]", DRAW_IMAGE, &[]));
assert!(page.warnings.is_empty(), "{:?}", page.warnings);
assert_skipped("[/Indexed /DeviceGray 0 <>]", &[]);
// hival 255 over DeviceRGB needs 768 bytes: 767 is one short.
assert_skipped(&format!("[/Indexed /DeviceRGB 255 <FF0000 00FF00{}>]", "00".repeat(3 * 254 - 1)), &[]);
}
#[test]
fn a_hival_that_would_overflow_a_size_is_ruled_out_without_panicking() {
// Values whose (hival + 1) × components would overflow a 32-bit or 64-bit size, or that do
// not fit an integer at all, never reach the multiplication: they fail the 0..=255 test.
for hival in ["4294967295", "4294967296", "18446744073709551615", "99999999999999999999999", "-2147483649", "256", "255.5"] {
assert_skipped(&format!("[/Indexed /DeviceRGB {hival} <FF0000 00FF00>]"), &[]);
}
// 255.0 written as a real is the integer 255.
assert_drawn(&format!("[/Indexed /DeviceRGB 255.0 <FF0000 00FF00{}>]", "00".repeat(3 * 254)), &[]);
}
#[test]
fn an_unusable_hival_is_judged_even_when_the_base_cannot_be_read() {
// The base is unknown, so the table size cannot be judged, but hival 300 is out of range
// whatever the base.
assert_skipped("[/Indexed /NoSuchSpace 300 <00>]", &[]);
// With a usable hival, an unreadable base keeps its existing handling: no Indexed warning.
let page = render(pdf("[/Indexed /NoSuchSpace 1 <00>]", DRAW_IMAGE, &[]));
assert!(!page.warnings.contains(&RenderWarning::MalformedIndexedImage), "{:?}", page.warnings);
}
#[test]
fn a_lookup_stream_that_cannot_be_decoded_is_no_table() {
let broken: &[u8] = b"<< /Length 6 /Filter /FlateDecode >>\nstream\nnot-zl\nendstream";
assert_skipped("[/Indexed /DeviceRGB 1 6 0 R]", &[broken]);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+31 -19
View File
@@ -999,13 +999,10 @@ impl Indexed {
let hival = iter.next::<u32>()?.min(u8::MAX as u32) as u8;
let values = {
let data = iter
.next::<Stream<'_>>()
.and_then(|s| s.decoded().ok())
.or_else(|| {
iter.next::<object::String<'_>>()
.map(|s| Cow::Owned(s.to_vec()))
})?;
// PdfCraft patch (60): read once and then by type, so a string lookup given by an
// indirect reference is found (a failed stream read had already consumed the
// reference).
let data = iter.next::<Object<'_>>().and_then(indexed_lookup)?;
let num_components = base_color_space.num_components();
@@ -1036,8 +1033,10 @@ impl Indexed {
/// PdfCraft patch (60): whether `object` is an `/Indexed` colour space that ISO 32000-2
/// §8.6.6.3 rules out: `hival` is not an integer in 0..=255, or the lookup table is missing or
/// shorter than m × (hival + 1) bytes for a base space of m components. A longer table is fine
/// (its first bytes are used). An Indexed space whose base cannot be read is not judged here,
/// so its existing handling is unchanged.
/// (its first bytes are used). The lookup may be a string or a stream, given in place or by
/// reference. An Indexed space whose base cannot be read is judged only by its `hival` (the
/// table's size depends on the base), so with a usable `hival` its existing handling is
/// unchanged.
///
/// Upstream treated such a space as unreadable, and an image that named it then fell back to
/// DeviceGray: its indices were drawn as near-black grey levels, even indices whose entries
@@ -1053,19 +1052,21 @@ pub(crate) fn is_malformed_indexed(object: &Object<'_>, cache: &Cache) -> bool {
if !matches!(family.deref(), INDEXED | I) {
return false;
}
let Some(base) = iter
.next::<Object<'_>>()
.and_then(|o| ColorSpace::new(o, cache))
else {
return false;
};
let base = iter.next::<Object<'_>>();
let Some(hival) = iter.next::<object::Number>().and_then(|n| {
let value = n.as_f64();
// A real with a fraction, a negative number or one past 255 is not a usable `hival`.
// A real with a fraction, a negative number or one past 255 is not a usable `hival`
// (NaN and the infinities fail the range test too).
(value.fract() == 0.0 && (0.0..=255.0).contains(&value)).then(|| n.as_i64())
}) else {
// Judged before the base: an unusable `hival` rules the space out whatever the base.
return true;
};
let Some(base) = base.and_then(|o| ColorSpace::new(o, cache)) else {
return false;
};
// `hival` is at most 255 here, so this cannot overflow; it is checked all the same, and a
// size that did not fit would rule the space out rather than wrap.
let Some(needed) = usize::try_from(hival)
.ok()
.and_then(|h| h.checked_add(1))
@@ -1074,12 +1075,23 @@ pub(crate) fn is_malformed_indexed(object: &Object<'_>, cache: &Cache) -> bool {
return true;
};
let lookup_len = iter
.next::<Stream<'_>>()
.and_then(|s| s.decoded().ok().map(|d| d.len()))
.or_else(|| iter.next::<object::String<'_>>().map(|s| s.as_bytes().len()));
.next::<Object<'_>>()
.and_then(indexed_lookup)
.map(|d| d.len());
lookup_len.is_none_or(|len| len < needed)
}
/// PdfCraft patch (60): an Indexed space's lookup table, a byte string or a stream (ISO 32000-2
/// §8.6.6.3), with a stream's filters applied. `None` for any other object or a stream that
/// cannot be decoded.
fn indexed_lookup(lookup: Object<'_>) -> Option<Cow<'_, [u8]>> {
match lookup {
Object::Stream(s) => s.decoded().ok(),
Object::String(s) => Some(Cow::Owned(s.to_vec())),
_ => None,
}
}
impl ToRgb for Indexed {
fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
let mut indexed = vec![0.0; input.len() * self.base.num_components() as usize];