mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:53:07 +00:00
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:
co-authored by
Claude Opus 5.5
parent
b559caaf54
commit
b6f93a3aef
@@ -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]
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+31
-19
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user