mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 16:36:13 -06:00
M2: an Indexed image whose lookup table is too short is skipped with a warning, not drawn black (ISO 32000-2 8.6.6.3)
An image in [/Indexed /DeviceRGB 7 <FF0000 00FF00>] was drawn entirely black, indices 0 and 1 included. The interpreter rejected the colour space when the table ran out, and the image fell back to DeviceGray, so its index values were painted as near-black grey levels. ISO 32000-2 8.6.6.3 says hival "shall be an integer", no greater than 255, and the table "shall be m x (hival + 1) bytes long". An image whose Indexed space breaks this (hival not an integer in 0..=255, or a lookup missing or shorter than needed) is now not drawn, and the render reports RenderWarning::MalformedIndexedImage once per page; pdfcraft-cli render prints it. A longer table is still fine. Inline images, including one naming such a space as a resource, are skipped the same way. Fill and stroke colours in such a space go through a different path and are unchanged. Vendored hayro-interpret patch (60), recorded in vendor/README.md. Tests: crates/render/tests/it/indexed_lookup.rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3517d05b47
commit
7734556137
@@ -407,6 +407,9 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
|
||||
let _ =
|
||||
writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
|
||||
}
|
||||
pdfcraft_render::RenderWarning::MalformedIndexedImage => {
|
||||
let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: an image with a malformed Indexed colour table was not drawn");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -230,6 +230,10 @@ pub enum RenderWarning {
|
||||
/// A colour could not be computed (a colour space that cannot be read, or a Separation or
|
||||
/// DeviceN tint transform that cannot be evaluated) and was painted in a fallback colour.
|
||||
ColourFallback,
|
||||
/// An image was not drawn because its `/Indexed` colour space is malformed (ISO 32000-2
|
||||
/// §8.6.6.3): `hival` not an integer in 0..=255, or a lookup table shorter than
|
||||
/// m × (hival + 1) bytes. Reported once per page however many images it affects.
|
||||
MalformedIndexedImage,
|
||||
}
|
||||
|
||||
/// Counters for work performed by a renderer or render pool.
|
||||
@@ -412,10 +416,12 @@ fn warning_settings(settings: &InterpreterSettings, warnings: Arc<Mutex<Vec<Rend
|
||||
InterpreterSettings {
|
||||
warning_sink: Arc::new(move |warning| {
|
||||
// Nested forms, Type 3 glyphs and patterns are interpreted again and each reports the
|
||||
// exhausted budget, or the colours that fell back: once per page is enough.
|
||||
// exhausted budget, or the colours that fell back, and several images can share one
|
||||
// malformed colour space: each warning is recorded once per page.
|
||||
let warning = match warning {
|
||||
InterpreterWarning::ContentTruncated => RenderWarning::ContentTruncated,
|
||||
InterpreterWarning::ColorFallback => RenderWarning::ColourFallback,
|
||||
InterpreterWarning::MalformedIndexedImage => RenderWarning::MalformedIndexedImage,
|
||||
InterpreterWarning::UnsupportedFont | InterpreterWarning::ImageDecodeFailure => return,
|
||||
};
|
||||
let mut warnings = lock(&target);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! An `/Indexed` image whose colour space ISO 32000-2 §8.6.6.3 rules out (a lookup table
|
||||
//! shorter than m × (hival + 1) bytes, or a `hival` that is not an integer in 0..=255) is not
|
||||
//! drawn, and the render reports `RenderWarning::MalformedIndexedImage`. It used to fall back to
|
||||
//! DeviceGray and paint its indices as near-black, even indices whose entries were present.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderWarning, RenderedPage, RequestKind};
|
||||
|
||||
/// A 200 × 200 page whose `/Im` image is two samples wide (indices 0 and 1) and covers the page,
|
||||
/// with `/P` naming `colour_space` as a resource. `extra` objects are numbered from 6.
|
||||
fn pdf(colour_space: &str, content: &str, extra: &[&[u8]]) -> Vec<u8> {
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R \
|
||||
/Resources << /XObject << /Im 5 0 R >> /ColorSpace << /P {colour_space} >> >> >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(),
|
||||
format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 \
|
||||
/ColorSpace {colour_space} /Filter /ASCIIHexDecode /Length 5 >>\nstream\n0001>\nendstream"
|
||||
)
|
||||
.into_bytes(),
|
||||
];
|
||||
objs.extend(extra.iter().map(|o| o.to_vec()));
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
let size = objs.len() + 1;
|
||||
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
|
||||
for off in offsets {
|
||||
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn render(bytes: Vec<u8>) -> RenderedPage {
|
||||
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(), "{:?}", page.error);
|
||||
assert_eq!((page.width, page.height), (200, 200));
|
||||
page
|
||||
}
|
||||
|
||||
/// The left (index 0) and right (index 1) halves' centre pixels, RGB.
|
||||
fn halves(page: &RenderedPage) -> [[u8; 3]; 2] {
|
||||
[50usize, 150].map(|x| {
|
||||
let at = (100 * page.width as usize + x) * 4;
|
||||
let px = page.rgba.get(at..at + 3).expect("pixel inside the page");
|
||||
[px[0], px[1], px[2]]
|
||||
})
|
||||
}
|
||||
|
||||
const WHITE: [[u8; 3]; 2] = [[255, 255, 255], [255, 255, 255]];
|
||||
const RED_GREEN: [[u8; 3]; 2] = [[255, 0, 0], [0, 255, 0]];
|
||||
const DRAW_IMAGE: &str = "q 200 0 0 200 0 0 cm /Im Do Q";
|
||||
|
||||
fn assert_skipped(colour_space: &str, extra: &[&[u8]]) {
|
||||
let page = render(pdf(colour_space, DRAW_IMAGE, extra));
|
||||
assert_eq!(halves(&page), WHITE, "{colour_space}: the image must not be drawn");
|
||||
assert_eq!(page.warnings, vec![RenderWarning::MalformedIndexedImage], "{colour_space}: the skip is reported");
|
||||
}
|
||||
|
||||
fn assert_drawn(colour_space: &str, extra: &[&[u8]]) {
|
||||
let page = render(pdf(colour_space, DRAW_IMAGE, extra));
|
||||
assert_eq!(halves(&page), RED_GREEN, "{colour_space}: palette entries 0 and 1");
|
||||
assert!(page.warnings.is_empty(), "{colour_space}: {:?}", page.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lookup_shorter_than_hival_needs_skips_the_image_with_a_warning() {
|
||||
// hival 7 over DeviceRGB needs 24 bytes; 6 are given. Indices 0 and 1 used to draw black.
|
||||
assert_skipped("[/Indexed /DeviceRGB 7 <FF0000 00FF00>]", &[]);
|
||||
// One byte short of two entries.
|
||||
assert_skipped("[/Indexed /DeviceRGB 1 <FF0000 00FF>]", &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_lookup_draws_its_colours() {
|
||||
assert_drawn("[/Indexed /DeviceRGB 1 <FF0000 00FF00>]", &[]);
|
||||
assert_drawn("[/Indexed /DeviceRGB 7 <FF0000 00FF00 000000 000000 000000 000000 000000 000000>]", &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lookup_longer_than_needed_uses_its_first_entries() {
|
||||
assert_drawn("[/Indexed /DeviceRGB 1 <FF0000 00FF00 0000FF FFFF00>]", &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hival_outside_0_to_255_or_not_an_integer_skips_the_image() {
|
||||
let long = format!("<FF0000 00FF00{}>", "00".repeat(3 * 300));
|
||||
assert_skipped(&format!("[/Indexed /DeviceRGB 300 {long}]"), &[]);
|
||||
assert_skipped("[/Indexed /DeviceRGB -1 <FF0000 00FF00>]", &[]);
|
||||
assert_skipped("[/Indexed /DeviceRGB 1.5 <FF0000 00FF00 0000FF>]", &[]);
|
||||
assert_skipped("[/Indexed /DeviceRGB /One <FF0000 00FF00>]", &[]);
|
||||
// 255, the largest hival, with a full table still draws.
|
||||
assert_drawn(&format!("[/Indexed /DeviceRGB 255 <FF0000 00FF00{}>]", "00".repeat(3 * 254)), &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_or_missing_lookup_skips_the_image_without_panicking() {
|
||||
assert_skipped("[/Indexed /DeviceRGB 1 <>]", &[]);
|
||||
assert_skipped("[/Indexed /DeviceRGB 1 ()]", &[]);
|
||||
assert_skipped("[/Indexed /DeviceRGB 1]", &[]);
|
||||
assert_skipped("[/Indexed /DeviceRGB 0 null]", &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lookup_stream_is_measured_after_decoding() {
|
||||
let short: &[u8] = b"<< /Length 14 /Filter /ASCIIHexDecode >>\nstream\nFF0000 00FF00>\nendstream";
|
||||
assert_skipped("[/Indexed /DeviceRGB 7 6 0 R]", &[short]);
|
||||
assert_drawn("[/Indexed /DeviceRGB 1 6 0 R]", &[short]);
|
||||
let empty: &[u8] = b"<< /Length 0 >>\nstream\n\nendstream";
|
||||
assert_skipped("[/Indexed /DeviceRGB 1 6 0 R]", &[empty]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_images_with_a_short_lookup_are_skipped_too() {
|
||||
let inline = |cs: &str| format!("q 200 0 0 200 0 0 cm BI /W 2 /H 1 /BPC 8 /CS {cs} /F /AHx ID 0001> EI Q");
|
||||
// Named through the page's resources.
|
||||
let page = render(pdf("[/Indexed /DeviceRGB 7 <FF0000 00FF00>]", &inline("/P"), &[]));
|
||||
assert_eq!(halves(&page), WHITE);
|
||||
assert_eq!(page.warnings, vec![RenderWarning::MalformedIndexedImage]);
|
||||
// Given in place, with the inline abbreviations.
|
||||
let page = render(pdf("/DeviceRGB", &inline("[/I /RGB 7 <FF0000 00FF00>]"), &[]));
|
||||
assert_eq!(halves(&page), WHITE);
|
||||
assert_eq!(page.warnings, vec![RenderWarning::MalformedIndexedImage]);
|
||||
// And a well-formed one still draws.
|
||||
let page = render(pdf("[/Indexed /DeviceRGB 1 <FF0000 00FF00>]", &inline("/P"), &[]));
|
||||
assert_eq!(halves(&page), RED_GREEN);
|
||||
assert!(page.warnings.is_empty(), "{:?}", page.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_with_several_malformed_images_reports_the_warning_once_and_draws_the_rest() {
|
||||
let content = format!("{DRAW_IMAGE} {DRAW_IMAGE} 0 0 1 rg 0 0 10 10 re f");
|
||||
let page = render(pdf("[/Indexed /DeviceRGB 7 <FF0000 00FF00>]", &content, &[]));
|
||||
assert_eq!(page.warnings, vec![RenderWarning::MalformedIndexedImage]);
|
||||
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");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod cid_fallback_query;
|
||||
mod indexed_lookup;
|
||||
mod inline_image_ei;
|
||||
mod jbig2_invariants;
|
||||
mod knockout_backdrop;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+47
@@ -1033,6 +1033,53 @@ 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.
|
||||
///
|
||||
/// 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
|
||||
/// were present. The image path uses this to skip the image with a warning instead.
|
||||
pub(crate) fn is_malformed_indexed(object: &Object<'_>, cache: &Cache) -> bool {
|
||||
let Object::Array(array) = object else {
|
||||
return false;
|
||||
};
|
||||
let mut iter = array.flex_iter();
|
||||
let Some(family) = iter.next::<Name<'_>>() else {
|
||||
return false;
|
||||
};
|
||||
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 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`.
|
||||
(value.fract() == 0.0 && (0.0..=255.0).contains(&value)).then(|| n.as_i64())
|
||||
}) else {
|
||||
return true;
|
||||
};
|
||||
let Some(needed) = usize::try_from(hival)
|
||||
.ok()
|
||||
.and_then(|h| h.checked_add(1))
|
||||
.and_then(|entries| entries.checked_mul(usize::from(base.num_components())))
|
||||
else {
|
||||
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()));
|
||||
lookup_len.is_none_or(|len| len < needed)
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
+17
@@ -161,6 +161,10 @@ pub enum InterpreterWarning {
|
||||
/// read, or a Separation or DeviceN tint transform that cannot be evaluated) and was painted
|
||||
/// in a fallback colour. Reported at most once per content stream.
|
||||
ColorFallback,
|
||||
/// PdfCraft patch (60): an image was not drawn because its `/Indexed` colour space is
|
||||
/// malformed (ISO 32000-2 §8.6.6.3): `hival` not an integer in 0..=255, or a lookup table
|
||||
/// shorter than its entries need.
|
||||
MalformedIndexedImage,
|
||||
}
|
||||
|
||||
/// interpret the contents of the page and render them into the device.
|
||||
@@ -927,6 +931,19 @@ fn interpret_window<'a>(
|
||||
let warning_sink = context.settings.warning_sink.clone();
|
||||
let transfer_function = context.get().graphics_state.transfer_function.clone();
|
||||
let cache = context.interpreter_cache.object_cache.clone();
|
||||
// PdfCraft patch (60): an inline image naming a resource colour space that is a
|
||||
// malformed Indexed space is skipped with a warning, as `ImageXObject::new` does
|
||||
// for one given in place.
|
||||
let inline_dict = i.0.dict();
|
||||
if inline_dict
|
||||
.get::<hayro_syntax::object::Name<'_>>(hayro_syntax::object::dict::keys::CS)
|
||||
.or_else(|| inline_dict.get::<hayro_syntax::object::Name<'_>>(hayro_syntax::object::dict::keys::COLORSPACE))
|
||||
.and_then(|name| resources.get_color_space(&name))
|
||||
.is_some_and(|cs| crate::color::is_malformed_indexed(&cs, &cache))
|
||||
{
|
||||
warning_sink(InterpreterWarning::MalformedIndexedImage);
|
||||
continue;
|
||||
}
|
||||
// PdfCraft patch: inline images are not cached (see `DecodedImageKey`).
|
||||
// PdfCraft patch (38): an inline image in a device space, named directly or by a
|
||||
// resource, draws in its default.
|
||||
|
||||
+10
@@ -524,6 +524,16 @@ impl<'a> ImageXObject<'a> {
|
||||
if let Some((space, key)) = default {
|
||||
default_space = Some(key);
|
||||
Some(space)
|
||||
} else if cs_obj
|
||||
.as_ref()
|
||||
.is_some_and(|c| crate::color::is_malformed_indexed(c, cache))
|
||||
{
|
||||
// PdfCraft patch (60): an Indexed space that ISO 32000-2 §8.6.6.3 rules out
|
||||
// (a short lookup table, an unusable `hival`) leaves no colour for some or all
|
||||
// samples. The image is not drawn, and the caller is told, rather than falling
|
||||
// back to DeviceGray and painting its indices as near-black.
|
||||
warning_sink(InterpreterWarning::MalformedIndexedImage);
|
||||
return None;
|
||||
} else {
|
||||
cs_obj
|
||||
.clone()
|
||||
|
||||
Reference in New Issue
Block a user