M2: indices past a short Indexed table draw black in every base space, and an image with no lookup table is not drawn (ISO 32000-2 8.6.6.3)

Seven readers were compared on synthetic files. Most draw the entries a
short table holds and black past them, and none draws an image whose
lookup is missing.
- Indexed::new keeps how many entries the table holds in full. Indices
  at or past that are set to black after conversion, so DeviceCMYK,
  ICCBased, Separation, DeviceN and Lab come out black too (zero bytes
  gave white, or blue in Lab). Fill and stroke colours follow the same
  rule. An incomplete last entry counts as missing.
- A lookup that is missing, null, a number, or a stream that does not
  decode (or decodes to nothing although it holds data) is no table.
  ColorSpace::new_allowing_tableless builds such a space marked
  tableless (for the resource cache and ImageXObject::new only), and
  the image is skipped with MalformedIndexedImage. A cs/CS colour in it
  keeps the upstream DeviceGray fallback with ColorFallback. Every other
  caller still gets None, as upstream.
- hival clamping and truncation are unchanged, and so is case C.
- The warning now reads "an image with a malformed Indexed colour table
  may be drawn wrongly or not at all".
- vendor/README.md: the patch (60) row is one line again. 3275f853c
  split it.

Tests: indexed_lookup.rs now has 25 tests, including one per base space
(DeviceGray, DeviceCMYK, Lab, Separation, DeviceN, and ICCBased N=4
drawn through its alternate). Against 6179cf795, 13 fail, including
every base space except DeviceGray, where zero is black. Against
b6f93a3ae, 19 fail.

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 07:32:46 +08:00
co-authored by Claude Opus 5.5
parent 6179cf795d
commit c709dd99ec
10 changed files with 265 additions and 73 deletions
+1 -1
View File
@@ -410,7 +410,7 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
pdfcraft_render::RenderWarning::MalformedIndexedImage => {
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"
"warning: page {page}: an image with a malformed Indexed colour table may be drawn wrongly or not at all"
);
}
}
+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 drawn, and its colours may be wrong",
RenderWarning::MalformedIndexedImage => "an image with a malformed Indexed colour table may be drawn wrongly or not at all",
}
}
@@ -96,7 +96,7 @@ 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 drawn padded, and
/// An image whose Indexed lookup table is too short (ISO 32000-2 §8.6.6.3) is drawn recovered, and
/// `page_render` says so in the words `pdfcraft-cli render` prints.
#[test]
fn page_render_reports_a_recovered_malformed_indexed_image() {
@@ -119,7 +119,7 @@ fn page_render_reports_a_recovered_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 drawn, and its colours may be wrong"]));
assert_eq!(report["warnings"], json!(["an image with a malformed Indexed colour table may be drawn wrongly or not at all"]));
let _ = std::fs::remove_dir_all(dir);
}
+6 -5
View File
@@ -260,11 +260,12 @@ 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'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.
/// An image's `/Indexed` colour space is malformed (ISO 32000-2 §8.6.6.3), so the image may be
/// drawn wrongly or not at all. It is drawn recovered when it can be: indices past a lookup
/// table shorter than m × (hival + 1) bytes in black, a `hival` past 255 taken as 255, a real
/// one truncated, or (a `hival` below zero or not a number) the image in DeviceGray. With no
/// lookup table at all (missing, not a string or a stream, or a stream that does not decode)
/// it is not drawn. Reported once per page however many images it affects.
MalformedIndexedImage,
}
+141 -30
View File
@@ -1,13 +1,16 @@
//! 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
//! than m × (hival + 1) bytes, or a `hival` past 255 or with a fraction) is drawn as most
//! 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 short table keeps the entries it holds in full, and an index past them is drawn black,
//! whatever the base space;
//! - 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.
//! An image whose lookup is missing, `null`, neither a string nor a stream, or a stream that
//! does not decode is not drawn, and is reported. 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).
@@ -94,7 +97,7 @@ fn columns(page: &RenderedPage) -> [[u8; 3]; 8] {
})
}
/// The first `n` of `COLOURS`, then black: a table of `n` entries padded with zero bytes.
/// The first `n` of `COLOURS`, then black: a table of `n` entries.
fn padded(n: usize) -> [[u8; 3]; 8] {
std::array::from_fn(|i| if i < n { COLOURS[i] } else { BLACK })
}
@@ -119,20 +122,87 @@ fn a_full_table_draws_its_colours() {
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.
/// Cases B and F: a short table keeps the entries it holds, and an index past them is black.
#[test]
fn a_short_table_is_padded_with_zero_bytes() {
fn a_short_table_draws_its_entries_and_black_past_them() {
// 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];
// One byte short: entry 1 is incomplete, so it is not held; indices past hival take it.
let mut want = [BLACK; 8];
want[0] = COLOURS[0];
assert_page("[/Indexed /DeviceRGB 1 <FF0000 00FF>]", &[], want, true);
}
/// The base space for each of the short-table tests below, with its two-entry table, and the
/// stream objects it needs (numbered from 6).
fn short_table_in(base: &str, table: &str) -> String {
format!("[/Indexed {base} 7 <{table}>]")
}
/// A short table over `base`: the two entries it holds draw as they do with a full table (the
/// rest of which is white, never drawn black by a reader), and every index past them is black.
/// Zero bytes would be white in DeviceCMYK, Separation and DeviceN, and blue in Lab.
#[track_caller]
fn assert_black_past_the_table(base: &str, two: &str, white: &str, extra: &[&[u8]]) {
let full = render(pdf(&short_table_in(base, &format!("{two} {}", white.repeat(6))), DRAW_IMAGE, extra));
assert!(full.warnings.is_empty(), "{base}: {:?}", full.warnings);
let full = columns(&full);
assert!(full[..2].iter().all(|c| *c != BLACK && *c != WHITE), "{base}: the two entries have colour: {full:?}");
let page = render(pdf(&short_table_in(base, two), DRAW_IMAGE, extra));
assert_eq!(page.warnings, MALFORMED, "{base}");
let mut want = [BLACK; 8];
want[..2].copy_from_slice(&full[..2]);
assert_eq!(columns(&page), want, "{base}: entries 2 to 7 are black");
}
/// DeviceGray: zero is black here, so a zero-padded table drew the same (a guard).
#[test]
fn indices_past_a_short_table_are_black_over_device_gray() {
assert_black_past_the_table("/DeviceGray", "40 80", "FF ", &[]);
}
/// DeviceCMYK: a zero entry would be white (no ink).
#[test]
fn indices_past_a_short_table_are_black_over_device_cmyk() {
assert_black_past_the_table("/DeviceCMYK", "FF000000 00FF0000", "00000000 ", &[]);
}
/// Lab: a zero entry would be L* 0 with a* and b* at the bottom of their ranges, blue.
#[test]
fn indices_past_a_short_table_are_black_over_lab() {
assert_black_past_the_table("[/Lab << /WhitePoint [0.9505 1 1.089] /Range [-128 127 -128 127] >>]", "80FF80 8080FF", "FF8080 ", &[]);
}
/// Separation (a spot orange: tint 1 is orange in DeviceCMYK): a zero entry would be tint 0,
/// white.
#[test]
fn indices_past_a_short_table_are_black_over_separation() {
assert_black_past_the_table(
"[/Separation /Orange /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.5 1 0] /N 1 >>]",
"FF 80",
"00 ",
&[],
);
}
/// DeviceN (cyan and magenta, mapped onto DeviceCMYK by a Type 4 function): a zero entry would
/// be white.
#[test]
fn indices_past_a_short_table_are_black_over_device_n() {
let tint: &[u8] = b"<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 9 >>\nstream\n{ 0 0 }\nendstream";
assert_black_past_the_table("[/DeviceN [/Cyan /Magenta] /DeviceCMYK 6 0 R]", "FF00 00FF", "0000 ", &[tint]);
}
/// ICCBased with four components and no usable profile, drawn in its /Alternate DeviceCMYK: a
/// zero entry would be white.
#[test]
fn indices_past_a_short_table_are_black_over_icc_based_with_four_components() {
let profile: &[u8] = b"<< /N 4 /Alternate /DeviceCMYK /Length 4 >>\nstream\nJUNK\nendstream";
assert_black_past_the_table("[/ICCBased 6 0 R]", "FF000000 00FF0000", "00000000 ", &[profile]);
}
/// 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]
@@ -171,9 +241,10 @@ fn a_real_hival_is_truncated() {
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.
/// Case G: inline images, named through the resources or given in place, are recovered too, and
/// skipped when the space has no table.
#[test]
fn inline_images_with_a_short_table_are_padded_too() {
fn inline_images_with_a_short_table_are_recovered_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.
@@ -189,6 +260,11 @@ fn inline_images_with_a_short_table_are_padded_too() {
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()));
// No table: not drawn, named or in place.
let page = render(pdf("[/Indexed /DeviceRGB 7 null]", &inline("/P"), &[]));
assert_eq!((columns(&page), page.warnings), ([WHITE; 8], MALFORMED.to_vec()));
let page = render(pdf("/DeviceRGB", &inline("[/I /RGB 7]"), &[]));
assert_eq!((columns(&page), page.warnings), ([WHITE; 8], 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);
@@ -227,21 +303,23 @@ fn a_lookup_string_given_by_reference_is_read() {
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);
// Something that is neither a string nor a stream is no table: the image is not drawn.
assert_page("[/Indexed /DeviceRGB 7 6 0 R]", &[b"42"], [WHITE; 8], true);
}
#[test]
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.
fn the_table_size_holds_at_the_bounds_of_hival_and_the_base() {
// Four components (DeviceCMYK): hival 1 needs 8 bytes. With 7, entry 1 is incomplete, so it
// is not held: index 1, and the indices past hival that take it, are black.
let full = render(pdf("[/Indexed /DeviceCMYK 1 <FF000000 00FF0000>]", DRAW_IMAGE, &[]));
assert!(full.warnings.is_empty(), "{:?}", full.warnings);
assert_ne!(columns(&full)[0], WHITE, "the image is drawn");
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.
let mut want = [BLACK; 8];
want[0] = columns(&full)[0];
assert_eq!(columns(&short), want);
// One component (DeviceGray): hival 0 needs one byte; an empty table holds no entry.
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.
@@ -266,13 +344,23 @@ fn a_negative_or_non_numeric_hival_falls_back_with_a_warning() {
}
}
/// An empty string is a table that holds no entry: every index is black.
#[test]
fn an_empty_or_missing_lookup_is_all_padding() {
for lookup in ["<>", "()", "", "null"] {
fn an_empty_table_draws_black() {
for lookup in ["<>", "()"] {
assert_page(&format!("[/Indexed /DeviceRGB 1 {lookup}]"), &[], padded(0), true);
}
}
/// A lookup that is missing, `null` or a number is no table at all: the image is not drawn (the
/// page stays white), and the warning is reported.
#[test]
fn a_missing_lookup_skips_the_image() {
for lookup in ["", "null", "42"] {
assert_page(&format!("[/Indexed /DeviceRGB 7 {lookup}]"), &[], [WHITE; 8], true);
}
}
#[test]
fn a_lookup_stream_is_measured_after_decoding() {
let short: &[u8] = b"<< /Length 14 /Filter /ASCIIHexDecode >>\nstream\nFF0000 00FF00>\nendstream";
@@ -280,14 +368,20 @@ fn a_lookup_stream_is_measured_after_decoding() {
let mut want = [COLOURS[1]; 8];
want[0] = COLOURS[0];
assert_page("[/Indexed /DeviceRGB 1 6 0 R]", &[short], want, false);
// An empty stream decodes to an empty table, which holds no entry.
let empty: &[u8] = b"<< /Length 0 >>\nstream\n\nendstream";
assert_page("[/Indexed /DeviceRGB 1 6 0 R]", &[empty], padded(0), true);
}
/// A stream that does not decode is no table: the image is not drawn, and is reported.
#[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_page("[/Indexed /DeviceRGB 1 6 0 R]", &[broken], padded(0), true);
fn a_lookup_stream_that_cannot_be_decoded_skips_the_image() {
for broken in [
&b"<< /Length 12 /Filter /FlateDecode >>\nstream\nnot deflated\nendstream"[..],
b"<< /Length 6 /Filter /FlateDecode >>\nstream\nnot-zl\nendstream",
] {
assert_page("[/Indexed /DeviceRGB 7 6 0 R]", &[broken], [WHITE; 8], true);
}
}
/// An Indexed space whose base cannot be read keeps the upstream handling (DeviceGray), whatever
@@ -299,17 +393,22 @@ fn an_unreadable_base_keeps_its_fallback() {
}
}
/// A fill or stroke colour set in such a space (`cs`, `sc`, `CS`, `SC`) draws through the same recovered table, and
/// the recovery is reported as `ColourFallback`, as the upstream DeviceGray fallback was.
/// A fill or stroke colour set in such a space (`cs`, `sc`, `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_or_stroke_colour_in_a_short_table_is_padded_and_reported() {
fn a_fill_or_stroke_colour_in_a_short_table_is_recovered_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");
assert_eq!(columns(&page)[0], BLACK, "entry 5 is past the table");
// Over DeviceCMYK too, where a zero entry would be white.
let cmyk = "[/Indexed /DeviceCMYK 7 <FF000000 00FF0000>]";
let page = render(pdf(cmyk, &fill(5), &[]));
assert_eq!(columns(&page)[0], BLACK, "DeviceCMYK entry 5 is past the table");
// A stroke colour (`CS`, `SC`) the same way.
let page = render(pdf(&short, "/P CS 1 SC 20 w 0 100 m 200 100 l S", &[]));
assert_eq!(columns(&page)[0], COLOURS[1], "the stroke is green");
@@ -318,3 +417,15 @@ fn a_fill_or_stroke_colour_in_a_short_table_is_padded_and_reported() {
assert_eq!(columns(&page)[0], COLOURS[1]);
assert!(page.warnings.is_empty(), "{:?}", page.warnings);
}
/// A fill colour in an Indexed space with no lookup table keeps the upstream fallback: DeviceGray,
/// reported as `ColourFallback` (tint 1 is white, over a red fill).
#[test]
fn a_fill_colour_in_a_space_with_no_table_keeps_the_fallback() {
for lookup in ["", "null"] {
let content = "1 0 0 rg 0 0 200 200 re f /P cs 1 sc 0 0 200 200 re f";
let page = render(pdf(&format!("[/Indexed /DeviceRGB 7 {lookup}]"), content, &[]));
assert_eq!(columns(&page)[0], WHITE, "{lookup:?}: DeviceGray 1");
assert_eq!(page.warnings, vec![RenderWarning::ColourFallback], "{lookup:?}");
}
}
+1 -2
View File
File diff suppressed because one or more lines are too long
+83 -23
View File
@@ -246,7 +246,7 @@ impl ColorSpaceType {
return Some(Self::Lab(Lab::new(&lab_dict)?));
}
INDEXED | I => {
return Some(Self::Indexed(Indexed::new(&color_array, cache)?));
return Some(Self::Indexed(Indexed::new(&color_array, cache, false)?));
}
SEPARATION => {
return Some(Self::Separation(Separation::new(&color_array, cache)?));
@@ -294,6 +294,22 @@ impl ColorSpace {
Some(Self(Arc::new(ColorSpaceType::new(object, cache)?)))
}
/// PdfCraft patch (60): as `new`, except that an `/Indexed` space with no lookup table is
/// built (marked tableless) rather than unreadable, for the image and colour operators to
/// handle it (see `Indexed::new`). Every other space is read as by `new`.
pub(crate) fn new_allowing_tableless(object: Object<'_>, cache: &Cache) -> Option<Self> {
if let Object::Array(array) = &object
&& array
.flex_iter()
.next::<Name<'_>>()
.is_some_and(|family| matches!(family.deref(), INDEXED | I))
{
return Indexed::new(array, cache, true)
.map(|indexed| Self(Arc::new(ColorSpaceType::Indexed(indexed))));
}
Self::new(object, cache)
}
/// Create a new color space from the name.
pub(crate) fn new_from_name(name: &Name<'_>) -> Option<Self> {
ColorSpaceType::new_from_name(name).map(|c| Self(Arc::new(c)))
@@ -339,7 +355,13 @@ impl ColorSpace {
/// 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)
matches!(self.0.as_ref(), ColorSpaceType::Indexed(i) if i.recovered && !i.tableless)
}
/// PdfCraft patch (60): whether this is an indexed colour space with no lookup table (see
/// `Indexed::new`), which only `ColorSpace::new_allowing_tableless` builds.
pub(crate) fn is_tableless_indexed(&self) -> bool {
matches!(self.0.as_ref(), ColorSpaceType::Indexed(i) if i.tableless)
}
/// Get the default decode array for the color space.
@@ -991,40 +1013,48 @@ impl ToRgb for Lab {
#[derive(Debug, Clone)]
pub(crate) struct Indexed {
/// `hival + 1` entries of the base's component count each.
/// `hival + 1` entries of the base's component count each; entries from `present` on are
/// zeros and are drawn black (see `convert_f32`).
values: Vec<Vec<f32>>,
hival: u8,
base: Box<ColorSpace>,
/// PdfCraft patch (60): how many entries the lookup table holds in full (at most
/// `hival + 1`). An index at or past it is drawn black.
present: usize,
/// PdfCraft patch (60): the space broke ISO 32000-2 §8.6.6.3 and was recovered (see
/// `Indexed::new`).
recovered: bool,
/// PdfCraft patch (60): the space has no lookup table at all (see `Indexed::new`).
tableless: 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:
/// recovers when a file breaks this. Most 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.
/// - a lookup table shorter than m × (hival + 1) bytes keeps the entries it holds in full,
/// and an index past them is drawn black, after conversion, whatever the base space (zero
/// bytes would be white in DeviceCMYK, Separation and DeviceN, and blue in Lab).
///
/// Any of these sets `recovered`, which the image path reports. A `hival` below zero (once
/// Any of these sets `recovered`, which the drawing paths report. 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> {
/// A lookup that is missing, or neither a string nor a stream that decodes, is no table at
/// all. Upstream could not read such a space either. With `allow_tableless` it is built,
/// marked `tableless`, so that the image path can skip the image with a warning and the
/// colour operators can fall back as upstream; without it, it is `None` as upstream.
fn new(array: &Array<'_>, cache: &Cache, allow_tableless: bool) -> 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, hival_recovered) = indexed_hival(iter.next::<object::Number>()?)?;
// At most 255 components (a `u8`), and at most 256 entries.
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;
// PdfCraft patch (60): read once and then by type, so a string lookup given by an
@@ -1033,26 +1063,38 @@ impl Indexed {
let data = iter
.next::<Object<'_>>()
.and_then(|o| indexed_lookup(o, cache));
let tableless = data.is_none();
if tableless && !allow_tableless {
return None;
}
let data: &[u8] = data.as_deref().unwrap_or_default();
let present = data
.len()
.checked_div(num_components)
.unwrap_or(0)
.min(entries);
let mut bytes = data.iter().copied();
let values: Vec<Vec<f32>> = (0..entries)
.map(|_| {
.map(|entry| {
(0..num_components)
.map(|_| f32::from(bytes.next().unwrap_or(0)) / 255.0)
.map(|_| {
if entry < present {
f32::from(bytes.next().unwrap_or(0)) / 255.0
} else {
0.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,
present,
recovered: hival_recovered || present < entries,
tableless,
})
}
}
@@ -1094,11 +1136,16 @@ pub(crate) fn has_unusable_hival(object: &Object<'_>) -> bool {
/// 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. Each call is counted on `cache` (`Cache::indexed_lookup_reads`).
/// cannot be decoded. A stream that decodes to nothing although it holds data counts as one that
/// cannot be decoded: the Flate decoder recovers what it can from a broken stream, and here
/// recovered nothing. 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::Stream(s) => s
.decoded()
.ok()
.filter(|d| !d.is_empty() || s.raw_data().is_empty()),
Object::String(s) => Some(Cow::Owned(s.to_vec())),
_ => None,
}
@@ -1117,7 +1164,20 @@ impl ToRgb for Indexed {
output.copy_from_slice(&self.values[idx]);
}
self.base.convert_f32(&indexed, output, true)
self.base.convert_f32(&indexed, output, true)?;
// PdfCraft patch (60): an index past the entries the table holds is drawn black, in
// whatever space the base is (see `Indexed::new`).
if self.present <= usize::from(self.hival) {
for (input, rgb) in input.iter().copied().zip(output.chunks_exact_mut(3)) {
let idx = (input.clamp(0.0, self.hival as f32) + 0.5) as usize;
if idx >= self.present {
rgb.fill(0);
}
}
}
Some(())
}
}
+6 -1
View File
@@ -451,6 +451,8 @@ impl<'a> Context<'a> {
self.get_mut().ctm *= transform;
}
/// PdfCraft patch (60): an `/Indexed` resource with no lookup table is built, marked
/// tableless, for the image and colour operators to handle (see `Indexed::new`).
pub(crate) fn get_color_space(
&mut self,
resources: &Resources<'_>,
@@ -460,7 +462,10 @@ impl<'a> Context<'a> {
self.interpreter_cache
.object_cache
.get_or_insert_with(cs_object.cache_key(), || {
ColorSpace::new(cs_object.clone(), &self.interpreter_cache.object_cache)
ColorSpace::new_allowing_tableless(
cs_object.clone(),
&self.interpreter_cache.object_cache,
)
})
}
+13 -7
View File
@@ -162,9 +162,9 @@ pub enum InterpreterWarning {
/// in a fallback colour. Reported at most once per content stream.
ColorFallback,
/// 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.
/// §8.6.6.3). The image was drawn recovered (indices past a short lookup table in black, a
/// `hival` past 255 taken as 255, a real one truncated), or, with a `hival` below zero or not a
/// number, in DeviceGray; with no lookup table at all it was not drawn.
MalformedIndexedImage,
}
@@ -656,13 +656,16 @@ fn interpret_window<'a>(
// PdfCraft patch (38): see `named_color_space`.
// PdfCraft patch (55): a space that cannot be read is drawn in DeviceGray, as
// before, and the fallback is reported.
// PdfCraft patch (60): an Indexed space with no lookup table counts as one that
// cannot be read, as upstream.
let cs = named_color_space(c.0, resources, &defaults, context)
.filter(|cs| !cs.is_tableless_indexed())
.unwrap_or_else(|| {
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`).
// PdfCraft patch (60): so is an Indexed space drawn recovered (indices past a
// short table drawn black, a `hival` clamped or truncated; see `Indexed::new`).
if cs.is_recovered_indexed() {
crate::color::note_color_fallback();
}
@@ -677,13 +680,16 @@ fn interpret_window<'a>(
// PdfCraft patch (38): see `named_color_space`.
// PdfCraft patch (55): a space that cannot be read is drawn in DeviceGray, as
// before, and the fallback is reported.
// PdfCraft patch (60): an Indexed space with no lookup table counts as one that
// cannot be read, as upstream.
let cs = named_color_space(c.0, resources, &defaults, context)
.filter(|cs| !cs.is_tableless_indexed())
.unwrap_or_else(|| {
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`).
// PdfCraft patch (60): so is an Indexed space drawn recovered (indices past a
// short table drawn black, a `hival` clamped or truncated; see `Indexed::new`).
if cs.is_recovered_indexed() {
crate::color::note_color_fallback();
}
+11 -1
View File
@@ -533,7 +533,7 @@ impl<'a> ImageXObject<'a> {
.is_some_and(crate::color::has_unusable_hival);
cs_obj
.clone()
.and_then(|c| ColorSpace::new(c, cache))
.and_then(|c| ColorSpace::new_allowing_tableless(c, cache))
// Inline images can also refer to color spaces by name.
.or_else(|| {
cs_obj
@@ -550,6 +550,16 @@ impl<'a> ImageXObject<'a> {
malformed_indexed |= image_cs
.as_ref()
.is_some_and(ColorSpace::is_recovered_indexed);
// PdfCraft patch (60): an Indexed space with no lookup table at all (missing, or not a
// string or a stream that decodes) leaves nothing to draw: the image is skipped, and
// reported. Upstream drew it in DeviceGray.
if image_cs
.as_ref()
.is_some_and(ColorSpace::is_tableless_indexed)
{
warning_sink(InterpreterWarning::MalformedIndexedImage);
return None;
}
let interpolate = dict
.get::<bool>(I)