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