M2: an Indexed image whose lookup table is short or whose hival is out of range is drawn as other readers draw it, with a warning (ISO 32000-2 8.6.6.3)

Patch (60) skipped such an image. A reviewer's side-by-side tests on #4393
(seven cases against main and Chrome's viewer) showed the skip dropped
colours other readers draw, and regressed a real hival that main draws.
Indexed::new now recovers instead:
- a lookup table shorter than m x (hival + 1) bytes, missing, or not a
  string or decodable stream is padded with zero bytes;
- a hival past 255 is taken as 255;
- a real hival is truncated toward zero (as upstream read it).
The space records that it was recovered. ImageXObject::new raises
MalformedIndexedImage from the space it built; a cs/CS colour in such a
space reports ColorFallback, as the upstream DeviceGray fallback did. A
hival below zero or not a number has no reading: the space falls back as
upstream, and the image reports the warning. Index values past hival
stay clamped (8.6.6.3).

The separate is_malformed_indexed check is gone, so no lookup table is
read twice: inline images naming an Indexed resource read its table once
through the resource cache (CodeRabbit, interpret/mod.rs:945). The read
count is exposed as InterpreterCache::indexed_lookup_reads.

Tests: indexed_lookup.rs rewritten as draw tests (17, cases A-G and
bounds); raster::tests::indexed_lookup_cache. On b6f93a3ae logic 14 of
the 17 fail (the three that pass are guards: full table, case C, device
name) and the cache test reads [21, 41] instead of [1, 1].

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-02 02:02:49 +08:00
co-authored by Claude Opus 5.5
parent b6f93a3aef
commit 3275f853c3
13 changed files with 424 additions and 210 deletions
+4 -1
View File
@@ -408,7 +408,10 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
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");
let _ = writeln!(
std::io::stderr().lock(),
"warning: page {page}: an image with a malformed Indexed colour table was drawn, and its colours may be wrong"
);
}
}
}
+1 -1
View File
@@ -345,7 +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",
RenderWarning::MalformedIndexedImage => "an image with a malformed Indexed colour table was drawn, and its colours may be wrong",
}
}
@@ -96,10 +96,10 @@ 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
/// An image whose Indexed lookup table is too short (ISO 32000-2 §8.6.6.3) is drawn padded, and
/// `page_render` says so in the words `pdfcraft-cli render` prints.
#[test]
fn page_render_reports_a_skipped_malformed_indexed_image() {
fn page_render_reports_a_recovered_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();
@@ -119,7 +119,7 @@ fn page_render_reports_a_skipped_malformed_indexed_image() {
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"]));
assert_eq!(report["warnings"], json!(["an image with a malformed Indexed colour table was drawn, and its colours may be wrong"]));
let _ = std::fs::remove_dir_all(dir);
}
+5 -3
View File
@@ -260,9 +260,11 @@ 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.
/// An image's `/Indexed` colour space is malformed (ISO 32000-2 §8.6.6.3) and the image was
/// drawn recovered, so its colours may be wrong: a lookup table shorter than m × (hival + 1)
/// bytes padded with zero bytes, a `hival` past 255 taken as 255, a real one truncated, or
/// (a `hival` below zero or not a number) the image drawn in DeviceGray. Reported once per
/// page however many images it affects.
MalformedIndexedImage,
}
+4
View File
@@ -6275,6 +6275,10 @@ fn decoded_images_serve_every_size_they_are_drawn_at() {
// ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48).
mod icc_profile_cache;
// An Indexed resource named by many inline images reads its lookup once (vendored
// hayro-interpret patch 60).
mod indexed_lookup_cache;
// A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156).
mod symbolic_truetype_cmap;
@@ -0,0 +1,63 @@
//! An `/Indexed` colour space named by a resource is built once, and its lookup table read
//! once, however many inline images name it (vendored hayro-interpret patch 60). An earlier
//! version of the patch checked the table again before every inline image, decoding a lookup
//! stream once per image outside the colour-space cache.
use super::image_cache_pdf;
/// A page that draws `count` 2 × 1 inline images in the resource colour space `/P`, an Indexed
/// space over DeviceRGB with `hival` whose lookup is stream object 5 holding `table`.
fn page(count: usize, hival: u8, table: &[u8]) -> Vec<u8> {
let image = "q 20 0 0 20 0 0 cm BI /W 2 /H 1 /BPC 8 /CS /P /F /AHx ID 0001> EI Q";
let content = vec![image; count].join("\n");
let resources = format!("<< /ColorSpace << /P [/Indexed /DeviceRGB {hival} 5 0 R] >> >>");
let dict = format!("<< /Length {} >>", table.len());
image_cache_pdf(&content, &resources, &[(&dict, table)])
}
fn lookup_reads(pdf: Vec<u8>) -> [usize; 2] {
use hayro::hayro_interpret::font::Glyph;
use hayro::hayro_interpret::hayro_syntax::Pdf;
use hayro::hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
TransformExt, interpret_page,
};
use kurbo::{Affine, BezPath, Rect};
use std::sync::Arc;
/// Draws nothing: only the colour spaces the page reads matter here.
struct Nothing;
impl<'a> Device<'a> for Nothing {
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {}
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = Pdf::new(Arc::new(pdf)).expect("parses");
let page = &pdf.pages()[0];
let cache = InterpreterCache::new();
[(); 2].map(|()| {
let initial = page.initial_transform(true).to_kurbo();
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default());
interpret_page(page, &mut ctx, &mut Nothing);
cache.indexed_lookup_reads()
})
}
/// Twenty inline images naming one resource read its lookup stream once, in the first render
/// and none in a second render through the same cache: for a full table, and for a short one
/// that is padded (whose images an earlier version of the patch checked, and skipped, one by
/// one).
#[test]
fn inline_images_naming_one_indexed_resource_read_its_lookup_once() {
let full = page(20, 1, b"\xFF\x00\x00\x00\xFF\x00");
assert_eq!(lookup_reads(full), [1, 1], "a full table: read once, then cached");
let short = page(20, 7, b"\xFF\x00\x00\x00\xFF\x00");
assert_eq!(lookup_reads(short), [1, 1], "a short table: read once, then cached");
}
+195 -110
View File
@@ -1,14 +1,36 @@
//! 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.
//! An `/Indexed` image whose colour space breaks ISO 32000-2 §8.6.6.3 (a lookup table shorter
//! than m × (hival + 1) bytes, or a `hival` past 255 or with a fraction) is drawn as other
//! readers draw it, and the render reports `RenderWarning::MalformedIndexedImage`:
//! - a short table is padded with zero bytes, so the entries it has keep their colours;
//! - a `hival` past 255 is taken as 255;
//! - a real `hival` is truncated toward zero.
//!
//! A `hival` that is negative or not a number keeps the upstream handling (the image falls back
//! to DeviceGray), and is reported too. Upstream fell back to DeviceGray for every one of these,
//! painting the indices as near-black grey levels, even those whose entries were present.
//!
//! The images are 8 × 1, samples 0 to 7, each column 25 px wide on a 200 × 200 page (the layout
//! of the seven cases A to G a reviewer compared across readers on #4393).
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.
/// The eight entries the tests' tables hold, in order.
const TABLE: &str = "FF0000 00FF00 0000FF FFFF00 FF00FF 00FFFF 808080 000000";
const COLOURS: [[u8; 3]; 8] = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255], [128, 128, 128], [0, 0, 0]];
const BLACK: [u8; 3] = [0, 0, 0];
const WHITE: [u8; 3] = [255, 255, 255];
const DRAW_IMAGE: &str = "q 200 0 0 200 0 0 cm /Im Do Q";
const MALFORMED: [RenderWarning; 1] = [RenderWarning::MalformedIndexedImage];
/// The first `n` hex entries of `TABLE`.
fn entries(n: usize) -> String {
TABLE.split(' ').take(n).collect::<Vec<_>>().join(" ")
}
/// A 200 × 200 page whose `/Im` image is eight samples wide (indices 0 to 7) 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> {
pdf_with_resource("P", colour_space, content, extra)
}
@@ -25,8 +47,8 @@ fn pdf_with_resource(resource: &str, colour_space: &str, content: &str, extra: &
.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"
"<< /Type /XObject /Subtype /Image /Width 8 /Height 1 /BitsPerComponent 8 \
/ColorSpace {colour_space} /Filter /ASCIIHexDecode /Length 17 >>\nstream\n0001020304050607>\nendstream"
)
.into_bytes(),
];
@@ -63,169 +85,232 @@ fn render(bytes: Vec<u8>) -> RenderedPage {
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;
/// The centre pixel of each of the eight columns (indices 0 to 7), RGB.
fn columns(page: &RenderedPage) -> [[u8; 3]; 8] {
std::array::from_fn(|i| {
let at = (100 * page.width as usize + 12 + 25 * i) * 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";
/// The first `n` of `COLOURS`, then black: a table of `n` entries padded with zero bytes.
fn padded(n: usize) -> [[u8; 3]; 8] {
std::array::from_fn(|i| if i < n { COLOURS[i] } else { BLACK })
}
fn assert_skipped(colour_space: &str, extra: &[&[u8]]) {
/// Index `i` drawn as the grey level `i` / 255: the DeviceGray fallback.
fn grey_fallback() -> [[u8; 3]; 8] {
std::array::from_fn(|i| [i as u8; 3])
}
#[track_caller]
fn assert_page(colour_space: &str, extra: &[&[u8]], want: [[u8; 3]; 8], warned: bool) {
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);
assert_eq!(columns(&page), want, "{colour_space}: the columns");
let want_warnings: &[RenderWarning] = if warned { &MALFORMED } else { &[] };
assert_eq!(page.warnings, want_warnings, "{colour_space}: the warnings");
}
/// Case A: a full table draws its eight colours, with no warning.
#[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>]", &[]);
fn a_full_table_draws_its_colours() {
assert_page(&format!("[/Indexed /DeviceRGB 7 <{TABLE}>]"), &[], COLOURS, false);
assert_page(&format!("[/Indexed /DeviceRGB 7.0 <{TABLE}>]"), &[], COLOURS, false);
}
/// Cases B and F: a short table is padded with zero bytes. The entries it has keep their
/// colours, and the rest are black over DeviceRGB.
#[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>]", &[]);
fn a_short_table_is_padded_with_zero_bytes() {
// B: hival 7 needs 24 bytes; 6 are given. Indices 0 and 1 used to draw black.
assert_page(&format!("[/Indexed /DeviceRGB 7 <{}>]", entries(2)), &[], padded(2), true);
// F: one entry short.
assert_page(&format!("[/Indexed /DeviceRGB 7 <{}>]", entries(7)), &[], padded(7), true);
// One byte short: entry 1 is 00FF plus a zero byte, green; indices past hival take entry 1.
let mut want = [COLOURS[1]; 8];
want[0] = COLOURS[0];
assert_page("[/Indexed /DeviceRGB 1 <FF0000 00FF>]", &[], want, true);
}
/// Case C: an index past `hival` takes the nearest entry (ISO 32000-2 §8.6.6.3: "shall be
/// adjusted to the nearest value within that range"); a table longer than needed is fine.
#[test]
fn a_lookup_longer_than_needed_uses_its_first_entries() {
assert_drawn("[/Indexed /DeviceRGB 1 <FF0000 00FF00 0000FF FFFF00>]", &[]);
fn an_index_past_hival_takes_the_last_entry() {
let mut want = COLOURS;
want[4..].fill(COLOURS[3]);
assert_page(&format!("[/Indexed /DeviceRGB 3 <{TABLE}>]"), &[], want, false);
}
/// Case D: a `hival` past 255 is taken as 255, and the table padded to 256 entries.
#[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)), &[]);
fn a_hival_past_255_is_clamped() {
assert_page(&format!("[/Indexed /DeviceRGB 300 <{TABLE}>]"), &[], COLOURS, true);
// With a table long enough for 301 entries, the clamp alone is reported.
let long = format!("<{TABLE}{}>", "00".repeat(3 * 293));
assert_page(&format!("[/Indexed /DeviceRGB 300 {long}]"), &[], COLOURS, true);
// 256, one past.
assert_page(&format!("[/Indexed /DeviceRGB 256 <{TABLE}{}>]", "00".repeat(3 * 249)), &[], COLOURS, true);
// 255, the largest, with a full table draws without a warning.
assert_page(&format!("[/Indexed /DeviceRGB 255 <{TABLE}{}>]", "00".repeat(3 * 248)), &[], COLOURS, false);
}
/// Case E: a real `hival` is truncated toward zero, as hayro read it before patch (60).
#[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]", &[]);
fn a_real_hival_is_truncated() {
assert_page(&format!("[/Indexed /DeviceRGB 7.5 <{TABLE}>]"), &[], COLOURS, true);
// 6.9 is 6, not 7: index 7 takes entry 6.
let mut want = COLOURS;
want[7] = COLOURS[6];
assert_page(&format!("[/Indexed /DeviceRGB 6.9 <{TABLE}>]"), &[], want, true);
// -0.5 is 0 (toward zero; a real above -1 truncates to 0): every index takes entry 0.
assert_page(&format!("[/Indexed /DeviceRGB -0.5 <{TABLE}>]"), &[], [COLOURS[0]; 8], true);
// 255.5 is 255; 255.0 written as a real is the integer 255.
let full = format!("<{TABLE}{}>", "00".repeat(3 * 248));
assert_page(&format!("[/Indexed /DeviceRGB 255.5 {full}]"), &[], COLOURS, true);
assert_page(&format!("[/Indexed /DeviceRGB 255.0 {full}]"), &[], COLOURS, false);
}
/// Case G: inline images, named through the resources or given in place, are padded too.
#[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");
fn inline_images_with_a_short_table_are_padded_too() {
let inline = |cs: &str| format!("q 200 0 0 200 0 0 cm BI /W 8 /H 1 /BPC 8 /CS {cs} /F /AHx ID 0001020304050607> EI Q");
let short = format!("[/Indexed /DeviceRGB 7 <{}>]", entries(2));
// 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]);
let page = render(pdf_with_resource("CS0", &short, &inline("/CS0"), &[]));
assert_eq!(columns(&page), padded(2));
assert_eq!(page.warnings, MALFORMED);
// 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);
let page = render(pdf("/DeviceRGB", &inline(&format!("[/I /RGB 7 <{}>]", entries(2))), &[]));
assert_eq!(columns(&page), padded(2));
assert_eq!(page.warnings, MALFORMED);
// A `hival` with no reading falls back and is reported, named or in place.
let page = render(pdf(&format!("[/Indexed /DeviceRGB -1 <{TABLE}>]"), &inline("/P"), &[]));
assert_eq!((columns(&page), page.warnings), (grey_fallback(), MALFORMED.to_vec()));
let page = render(pdf("/DeviceRGB", &inline(&format!("[/I /RGB -1 <{TABLE}>]")), &[]));
assert_eq!((columns(&page), page.warnings), (grey_fallback(), MALFORMED.to_vec()));
// A well-formed one draws without a warning.
let page = render(pdf(&format!("[/Indexed /DeviceRGB 7 <{TABLE}>]"), &inline("/P"), &[]));
assert_eq!(columns(&page), COLOURS);
assert!(page.warnings.is_empty(), "{:?}", page.warnings);
}
#[test]
fn an_inline_device_space_name_ignores_a_resource_of_the_same_name() {
// ISO 32000-2 §8.6.8 (the CS operator): the device colour space names never refer to resources in the
// ColorSpace subdictionary, so a malformed Indexed resource that shares the name (here the
// inline abbreviation `/RGB`, and `/DeviceRGB`) does not stop the image from drawing.
// ISO 32000-2 §8.6.8 (the CS operator): the device colour space names never refer to
// resources in the ColorSpace subdictionary, so a malformed Indexed resource that shares the
// name (here the inline abbreviation `/RGB`, and `/DeviceRGB`) is not used.
let data: String = TABLE.split(' ').collect();
for (resource, cs) in [("RGB", "/RGB"), ("DeviceRGB", "/DeviceRGB")] {
let content = format!("q 200 0 0 200 0 0 cm BI /W 2 /H 1 /BPC 8 /CS {cs} /F /AHx ID FF000000FF00> EI Q");
let content = format!("q 200 0 0 200 0 0 cm BI /W 8 /H 1 /BPC 8 /CS {cs} /F /AHx ID {data}> EI Q");
let page = render(pdf_with_resource(resource, "[/Indexed /DeviceRGB 7 <FF0000 00FF00>]", &content, &[]));
assert_eq!(halves(&page), RED_GREEN, "{cs}: the device space draws");
assert_eq!(columns(&page), COLOURS, "{cs}: the device space draws");
assert!(page.warnings.is_empty(), "{cs}: {:?}", page.warnings);
}
}
#[test]
fn a_page_with_several_malformed_images_reports_the_warning_once_and_draws_the_rest() {
fn a_page_with_several_malformed_images_reports_the_warning_once() {
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 page = render(pdf(&format!("[/Indexed /DeviceRGB 7 <{}>]", entries(2)), &content, &[]));
assert_eq!(page.warnings, MALFORMED);
assert_eq!(columns(&page), padded(2));
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");
assert_eq!(page.rgba.get(at..at + 3), Some(&[0u8, 0, 255][..]), "content after the 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]);
let full = format!("<{TABLE}>");
assert_page("[/Indexed /DeviceRGB 7 6 0 R]", &[full.as_bytes()], COLOURS, false);
let short = format!("<{}>", entries(2));
assert_page("[/Indexed /DeviceRGB 7 6 0 R]", &[short.as_bytes()], padded(2), true);
// Something that is neither a string nor a stream is no table: every entry is padding.
assert_page("[/Indexed /DeviceRGB 7 6 0 R]", &[b"42"], padded(0), true);
}
#[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 <>]", &[]);
fn padding_holds_at_the_bounds_of_hival_and_the_base() {
// Four components (DeviceCMYK): hival 1 needs 8 bytes. With 7, the missing byte is entry 1's
// black, and zero there is what the full table holds, so both draw the same.
let full = render(pdf("[/Indexed /DeviceCMYK 1 <FF000000 00FF0000>]", DRAW_IMAGE, &[]));
assert!(full.warnings.is_empty(), "{:?}", full.warnings);
let short = render(pdf("[/Indexed /DeviceCMYK 1 <FF000000 00FF00>]", DRAW_IMAGE, &[]));
assert_eq!(short.warnings, MALFORMED);
assert_eq!(columns(&short), columns(&full));
assert_ne!(columns(&full)[0], WHITE, "the image is drawn");
// One component (DeviceGray): hival 0 needs one byte, and an empty table pads it.
assert_page("[/Indexed /DeviceGray 0 <80>]", &[], [[128; 3]; 8], false);
assert_page("[/Indexed /DeviceGray 0 <>]", &[], [BLACK; 8], true);
// hival 255 over DeviceRGB needs 768 bytes: 767 is one short.
assert_skipped(&format!("[/Indexed /DeviceRGB 255 <FF0000 00FF00{}>]", "00".repeat(3 * 254 - 1)), &[]);
assert_page(&format!("[/Indexed /DeviceRGB 255 <{TABLE}{}>]", "00".repeat(3 * 248 - 1)), &[], COLOURS, true);
}
#[test]
fn a_hival_that_would_overflow_a_size_is_ruled_out_without_panicking() {
fn a_hival_that_would_overflow_a_size_is_clamped_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>]"), &[]);
// not fit an integer at all, are taken as 255.
for hival in ["4294967295", "4294967296", "18446744073709551615", "99999999999999999999999"] {
assert_page(&format!("[/Indexed /DeviceRGB {hival} <{}>]", entries(2)), &[], padded(2), true);
}
}
/// A `hival` that is negative or not a number has no reading to recover: the image keeps the
/// upstream handling (it falls back to DeviceGray), and the warning is still reported.
#[test]
fn a_negative_or_non_numeric_hival_falls_back_with_a_warning() {
for hival in ["-1", "-1.5", "-2147483649", "/One", "null"] {
assert_page(&format!("[/Indexed /DeviceRGB {hival} <{TABLE}>]"), &[], grey_fallback(), true);
}
// 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);
fn an_empty_or_missing_lookup_is_all_padding() {
for lookup in ["<>", "()", "", "null"] {
assert_page(&format!("[/Indexed /DeviceRGB 1 {lookup}]"), &[], padded(0), true);
}
}
#[test]
fn a_lookup_stream_that_cannot_be_decoded_is_no_table() {
fn a_lookup_stream_is_measured_after_decoding() {
let short: &[u8] = b"<< /Length 14 /Filter /ASCIIHexDecode >>\nstream\nFF0000 00FF00>\nendstream";
assert_page("[/Indexed /DeviceRGB 7 6 0 R]", &[short], padded(2), true);
let mut want = [COLOURS[1]; 8];
want[0] = COLOURS[0];
assert_page("[/Indexed /DeviceRGB 1 6 0 R]", &[short], want, false);
let empty: &[u8] = b"<< /Length 0 >>\nstream\n\nendstream";
assert_page("[/Indexed /DeviceRGB 1 6 0 R]", &[empty], padded(0), true);
}
#[test]
fn a_lookup_stream_that_cannot_be_decoded_is_all_padding() {
let broken: &[u8] = b"<< /Length 6 /Filter /FlateDecode >>\nstream\nnot-zl\nendstream";
assert_skipped("[/Indexed /DeviceRGB 1 6 0 R]", &[broken]);
assert_page("[/Indexed /DeviceRGB 1 6 0 R]", &[broken], padded(0), true);
}
/// An Indexed space whose base cannot be read keeps the upstream handling (DeviceGray), whatever
/// its `hival`; only a `hival` with no reading is reported.
#[test]
fn an_unreadable_base_keeps_its_fallback() {
for (hival, warned) in [("1", false), ("300", false), ("-1", true)] {
assert_page(&format!("[/Indexed /NoSuchSpace {hival} <{TABLE}>]"), &[], grey_fallback(), warned);
}
}
/// A fill colour set in such a space (`cs`, `sc`) draws through the same recovered table, and
/// the recovery is reported as `ColourFallback`, as the upstream DeviceGray fallback was.
#[test]
fn a_fill_colour_in_a_short_table_is_padded_and_reported() {
let fill = |index: u8| format!("/P cs {index} sc 0 0 200 200 re f");
let short = format!("[/Indexed /DeviceRGB 7 <{}>]", entries(2));
let page = render(pdf(&short, &fill(1), &[]));
assert_eq!(columns(&page)[0], COLOURS[1], "entry 1 is green");
assert_eq!(page.warnings, vec![RenderWarning::ColourFallback]);
let page = render(pdf(&short, &fill(5), &[]));
assert_eq!(columns(&page)[0], BLACK, "entry 5 is padding");
let page = render(pdf(&format!("[/Indexed /DeviceRGB 7 <{TABLE}>]"), &fill(1), &[]));
assert_eq!(columns(&page)[0], COLOURS[1]);
assert!(page.warnings.is_empty(), "{:?}", page.warnings);
}
+2 -1
View File
File diff suppressed because one or more lines are too long
+19 -3
View File
@@ -9,9 +9,10 @@ use std::sync::{Arc, Mutex};
type CacheMap = HashMap<u128, Option<Box<dyn Any + Send + Sync>>>;
/// The objects read from a document, by key. PdfCraft patch (48): the second field counts the
/// ICC profiles read through it ([`Cache::icc_profile_reads`]).
/// ICC profiles read through it ([`Cache::icc_profile_reads`]). PdfCraft patch (60): the third
/// counts the `/Indexed` lookup tables read ([`Cache::indexed_lookup_reads`]).
#[derive(Clone)]
pub(crate) struct Cache(Arc<Mutex<CacheMap>>, Arc<AtomicUsize>);
pub(crate) struct Cache(Arc<Mutex<CacheMap>>, Arc<AtomicUsize>, Arc<AtomicUsize>);
impl Default for Cache {
fn default() -> Self {
Self::new()
@@ -20,7 +21,11 @@ impl Default for Cache {
impl Cache {
pub(crate) fn new() -> Self {
Self(Arc::new(Mutex::new(HashMap::new())), Arc::default())
Self(
Arc::new(Mutex::new(HashMap::new())),
Arc::default(),
Arc::default(),
)
}
/// PdfCraft patch (48): count one ICC profile read, the colour space of an `/ICCBased`
@@ -34,6 +39,17 @@ impl Cache {
self.1.load(Ordering::Relaxed)
}
/// PdfCraft patch (60): count one `/Indexed` lookup table read (a stream decoded or a
/// string copied).
pub(crate) fn count_indexed_lookup_read(&self) {
self.2.fetch_add(1, Ordering::Relaxed);
}
/// PdfCraft patch (60): how many `/Indexed` lookup tables were read through this cache.
pub(crate) fn indexed_lookup_reads(&self) -> usize {
self.2.load(Ordering::Relaxed)
}
pub(crate) fn get_or_insert_with<T: Clone + Send + Sync + 'static>(
&self,
id: u128,
+80 -68
View File
@@ -336,6 +336,12 @@ impl ColorSpace {
matches!(self.0.as_ref(), ColorSpaceType::Indexed(_))
}
/// PdfCraft patch (60): whether this is an indexed colour space that broke ISO 32000-2
/// §8.6.6.3 and was recovered (see `Indexed::new`).
pub(crate) fn is_recovered_indexed(&self) -> bool {
matches!(self.0.as_ref(), ColorSpaceType::Indexed(i) if i.recovered)
}
/// Get the default decode array for the color space.
pub(crate) fn default_decode_arr(&self, n: f32) -> SmallVec<[(f32, f32); 4]> {
match self.0.as_ref() {
@@ -985,106 +991,112 @@ impl ToRgb for Lab {
#[derive(Debug, Clone)]
pub(crate) struct Indexed {
/// `hival + 1` entries of the base's component count each.
values: Vec<Vec<f32>>,
hival: u8,
base: Box<ColorSpace>,
/// PdfCraft patch (60): the space broke ISO 32000-2 §8.6.6.3 and was recovered (see
/// `Indexed::new`).
recovered: bool,
}
impl Indexed {
/// PdfCraft patch (60): ISO 32000-2 §8.6.6.3 says `hival` "shall be an integer" no greater
/// than 255 and the lookup table "shall be m × (hival + 1) bytes long", but not how a reader
/// recovers when a file breaks this. Other readers draw such a space, and so does this:
/// - a real `hival` is truncated toward zero (upstream read it so too);
/// - a `hival` past 255 is taken as 255;
/// - a lookup table that is short, missing, or not a string or decodable stream is padded
/// with zero bytes to m × (hival + 1), so the entries it has keep their colours.
///
/// Any of these sets `recovered`, which the image path reports. A `hival` below zero (once
/// truncated), or not a number, has no reading: the space cannot be read, as upstream, and
/// `has_unusable_hival` lets the image path report it.
///
/// Upstream treated a short table or a `hival` past `u32` as unreadable, and an image that
/// named the space fell back to DeviceGray: its indices were drawn as near-black grey
/// levels, even indices whose entries were present.
fn new(array: &Array<'_>, cache: &Cache) -> Option<Self> {
let mut iter = array.flex_iter();
// Skip name
let _ = iter.next::<Name<'_>>()?;
let base_color_space = ColorSpace::new(iter.next::<Object<'_>>()?, cache)?;
let hival = iter.next::<u32>()?.min(u8::MAX as u32) as u8;
let (hival, hival_recovered) = indexed_hival(iter.next::<object::Number>()?)?;
let values = {
// 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 = usize::from(base_color_space.num_components());
// At most 256 entries of at most 255 components: 65 280 values whatever the input.
let entries = usize::from(hival) + 1;
let num_components = base_color_space.num_components();
let mut byte_iter = data.iter().copied();
let mut vals = vec![];
for _ in 0..=hival {
let mut temp = vec![];
for _ in 0..num_components {
temp.push(byte_iter.next()? as f32 / 255.0);
}
vals.push(temp);
}
vals
};
// 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(|o| indexed_lookup(o, cache));
let data: &[u8] = data.as_deref().unwrap_or_default();
let mut bytes = data.iter().copied();
let values: Vec<Vec<f32>> = (0..entries)
.map(|_| {
(0..num_components)
.map(|_| f32::from(bytes.next().unwrap_or(0)) / 255.0)
.collect()
})
.collect();
// `entries × num_components` cannot overflow (see above); a product that somehow did
// not fit counts as short, and the table is padded all the same.
let short = entries
.checked_mul(num_components)
.is_none_or(|needed| data.len() < needed);
Some(Self {
values,
hival,
base: Box::new(base_color_space),
recovered: hival_recovered || short,
})
}
}
/// 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). 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
/// 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 {
/// PdfCraft patch (60): an Indexed space's `hival` (ISO 32000-2 §8.6.6.3) as drawn, and whether
/// it had to be recovered: a real is truncated toward zero and a value past 255 is taken as 255.
/// `None` for a value below zero once truncated, or one that is not finite.
fn indexed_hival(number: object::Number) -> Option<(u8, bool)> {
let value = number.as_f64();
let whole = value.trunc();
// NaN fails this test too.
if !(whole >= 0.0 && whole.is_finite()) {
return None;
}
let clamped = whole.min(f64::from(u8::MAX));
// `clamped` is a whole number in 0..=255, so the cast is exact.
Some((clamped as u8, clamped != value))
}
/// PdfCraft patch (60): whether `object` is an `/Indexed` colour space array whose `hival` has no
/// reading (below zero, or not a number), which `Indexed::new` cannot recover. Reads the array's
/// first and third entries only; the lookup table is not touched.
pub(crate) fn has_unusable_hival(object: &Object<'_>) -> 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) {
if !iter
.next::<Name<'_>>()
.is_some_and(|family| matches!(family.deref(), INDEXED | I))
{
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`
// (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))
.and_then(|entries| entries.checked_mul(usize::from(base.num_components())))
else {
return true;
};
let lookup_len = iter
.next::<Object<'_>>()
.and_then(indexed_lookup)
.map(|d| d.len());
lookup_len.is_none_or(|len| len < needed)
let _base = iter.next::<Object<'_>>();
iter.next::<object::Number>()
.and_then(indexed_hival)
.is_none()
}
/// 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]>> {
/// cannot be decoded. Each call is counted on `cache` (`Cache::indexed_lookup_reads`).
fn indexed_lookup<'a>(lookup: Object<'a>, cache: &Cache) -> Option<Cow<'a, [u8]>> {
cache.count_indexed_lookup_read();
match lookup {
Object::Stream(s) => s.decoded().ok(),
Object::String(s) => Some(Cow::Owned(s.to_vec())),
+7
View File
@@ -153,6 +153,13 @@ impl<'a> InterpreterCache<'a> {
pub fn icc_profile_reads(&self) -> usize {
self.object_cache.icc_profile_reads()
}
/// PdfCraft patch (60): how many `/Indexed` lookup tables were read through this cache, for
/// diagnostics and tests. A colour space named by a resource is built, and its table read,
/// once however many images or colours use it.
pub fn indexed_lookup_reads(&self) -> usize {
self.object_cache.indexed_lookup_reads()
}
}
/// A per-page interpretation context that borrows shared data from an [`InterpreterCache`].
+23 -10
View File
@@ -161,9 +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.
/// PdfCraft patch (60): an image's `/Indexed` colour space is malformed (ISO 32000-2
/// §8.6.6.3) and the image was drawn recovered: a lookup table shorter than its entries need
/// padded with zero bytes, a `hival` past 255 taken as 255, or a real one truncated. A `hival`
/// below zero or not a number cannot be recovered, and the image falls back to DeviceGray.
MalformedIndexedImage,
}
@@ -660,6 +661,11 @@ fn interpret_window<'a>(
crate::color::note_color_fallback();
ColorSpace::device_gray()
});
// PdfCraft patch (60): so is an Indexed space drawn recovered (a short lookup
// table padded, a `hival` clamped or truncated; see `Indexed::new`).
if cs.is_recovered_indexed() {
crate::color::note_color_fallback();
}
if !cs.is_pattern() {
context.get_mut().graphics_state.stroke_pattern = None;
@@ -676,6 +682,11 @@ fn interpret_window<'a>(
crate::color::note_color_fallback();
ColorSpace::device_gray()
});
// PdfCraft patch (60): so is an Indexed space drawn recovered (a short lookup
// table padded, a `hival` clamped or truncated; see `Indexed::new`).
if cs.is_recovered_indexed() {
crate::color::note_color_fallback();
}
if !cs.is_pattern() {
context.get_mut().graphics_state.non_stroke_pattern = None;
@@ -931,21 +942,23 @@ 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. A device space name (or its inline abbreviation) never
// refers to a resource (ISO 32000-2 §8.6.8), and `ImageXObject::new` takes it
// first, so a resource of that name is not consulted.
// PdfCraft patch (60): an inline image naming a resource colour space that is an
// Indexed space whose `hival` has no reading is reported, as `ImageXObject::new`
// reports one given in place; the image falls back as upstream. Only the array's
// name and `hival` are read here, never its lookup table: a recovered space is
// reported by `ImageXObject::new` from the space it builds through the resource
// cache. A device space name (or its inline abbreviation) never refers to a
// resource (ISO 32000-2 §8.6.8), and `ImageXObject::new` takes it first, so a
// resource of that name is not consulted.
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))
.filter(|name| ColorSpace::new_from_name(name).is_none())
.and_then(|name| resources.get_color_space(&name))
.is_some_and(|cs| crate::color::is_malformed_indexed(&cs, &cache))
.is_some_and(|cs| crate::color::has_unusable_hival(&cs))
{
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
+18 -10
View File
@@ -508,6 +508,7 @@ impl<'a> ImageXObject<'a> {
is_mask |= is_stencil_mask;
let mut default_space = None;
let mut malformed_indexed = false;
let image_cs = if is_mask {
// Masks are always single-channel.
Some(ColorSpace::device_gray())
@@ -524,17 +525,12 @@ 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 {
// PdfCraft patch (60): an Indexed space given in place whose `hival` has no
// reading cannot be built and falls back below, as upstream; it is reported.
malformed_indexed = cs_obj
.as_ref()
.is_some_and(crate::color::has_unusable_hival);
cs_obj
.clone()
.and_then(|c| ColorSpace::new(c, cache))
@@ -546,6 +542,14 @@ impl<'a> ImageXObject<'a> {
})
}
};
// PdfCraft patch (60): an Indexed space that ISO 32000-2 §8.6.6.3 rules out (a short
// lookup table, a `hival` past 255 or with a fraction) is drawn recovered (see
// `Indexed::new`), and reported. The check asks the space that was built, so it reads
// no lookup table of its own (an inline image naming a resource builds it once, through
// the resource cache).
malformed_indexed |= image_cs
.as_ref()
.is_some_and(ColorSpace::is_recovered_indexed);
let interpolate = dict
.get::<bool>(I)
@@ -559,6 +563,10 @@ impl<'a> ImageXObject<'a> {
return None;
}
if malformed_indexed {
warning_sink(InterpreterWarning::MalformedIndexedImage);
}
Some(Self {
width,
cache: cache.clone(),