From 71ca5a9861a6668c61914d09855db55786d23c08 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 5 Oct 2026 09:24:06 +0200 Subject: [PATCH] fix(pdf): honor image sample depths and JPX decode rules --- CHANGELOG.md | 3 +++ src/odr/internal/pdf/pdf_image.cpp | 18 ++++++++++----- src/odr/internal/pdf/pdf_image.hpp | 35 +++++++---------------------- test/src/internal/pdf/pdf_image.cpp | 12 +++++++++- test/src/internal/pdf/pdf_jpx.cpp | 15 +++++++++++++ 5 files changed, 49 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1ba100d3..ca37d1cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- JPEG 2000 images ignore the PDF Decode array as required. Raster decoding + rejects invalid sample depths, including non-1-bit stencil masks. + - Conan packages include the license text. Android profiles identify NDK 28.1 as Clang 19 so dependency package IDs match the compiler. diff --git a/src/odr/internal/pdf/pdf_image.cpp b/src/odr/internal/pdf/pdf_image.cpp index e1ec34389..215ad8924 100644 --- a/src/odr/internal/pdf/pdf_image.cpp +++ b/src/odr/internal/pdf/pdf_image.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,11 @@ namespace odr::internal::pdf { namespace { +bool valid_bit_depth(const std::int32_t bits) { + return bits > 0 && bits <= 16 && + std::has_single_bit(static_cast(bits)); +} + /// Reads fixed-width big-endian sample values out of a byte buffer, MSB first. /// Constructed at a row offset; rows are byte-aligned (8.9.5.2). Reads past the /// end yield zero (lenient for a truncated stream). @@ -83,7 +89,6 @@ void unpremultiply(std::string &samples, const std::int32_t components, /// (ISO 32000-1 8.9.5.1: `/ColorSpace` is optional for `JPXDecode`). std::optional encode_jpx(const std::string &data, const ColorSpaceDef *color_space, - const std::span decode_array, const std::span alpha, const std::span color_key, const std::int32_t smask_in_data) { @@ -120,7 +125,7 @@ encode_jpx(const std::string &data, const ColorSpaceDef *color_space, : device; const std::string png = encode_image_png( - image->samples, image->width, image->height, 8, space, decode_array, + image->samples, image->width, image->height, 8, space, {}, alpha.empty() ? std::span{image->alpha} : alpha, color_key); if (png.empty()) { @@ -143,8 +148,8 @@ std::string pdf::encode_image_png(const std::string &samples, const std::span alpha, const std::span color_key) { const std::int32_t components = color_space.components; - if (width <= 0 || height <= 0 || components <= 0 || bits_per_component <= 0 || - bits_per_component > 16) { + if (width <= 0 || height <= 0 || components <= 0 || + !valid_bit_depth(bits_per_component)) { return {}; } @@ -262,7 +267,8 @@ std::vector pdf::decode_mask_alpha( const std::span decode, const bool stencil, const std::int32_t base_width, const std::int32_t base_height) { if (width <= 0 || height <= 0 || base_width <= 0 || base_height <= 0 || - bits_per_component <= 0 || bits_per_component > 16) { + !valid_bit_depth(bits_per_component) || + (stencil && bits_per_component != 1)) { return {}; } const std::uint32_t max_sample = @@ -379,7 +385,7 @@ std::optional pdf::encode_image( if (result.stopped_at_filter != "JPXDecode") { return std::nullopt; } - return encode_jpx(result.data, color_space, decode_array, alpha, color_key, + return encode_jpx(result.data, color_space, alpha, color_key, smask_in_data); } // A fully decodable raster: decode, assemble samples and PNG-encode. diff --git a/src/odr/internal/pdf/pdf_image.hpp b/src/odr/internal/pdf/pdf_image.hpp index 8c40577a2..d2ee32f39 100644 --- a/src/odr/internal/pdf/pdf_image.hpp +++ b/src/odr/internal/pdf/pdf_image.hpp @@ -21,16 +21,8 @@ struct EncodedImage { std::string mime; }; -/// Turn an image's raw (still filter-encoded) bytes into browser-ready ones: a -/// `DCTDecode` JPEG passes through, any other decodable raster is re-encoded as -/// PNG through `color_space` (required only on that path). `alpha` and -/// `color_key` (see `encode_image_png`) make the raster RGBA and are ignored by -/// the JPEG pass-through. A `JPXDecode` raster comes through `decode_jpx`, its -/// own opacity channel taken only as `smask_in_data` says (Table 89: 0 ignores -/// it, 2 says the colour is premultiplied by it). A `JBIG2Decode` raster goes -/// through `decode_jbig2`, `options` carrying the globals it may need, and a -/// `CCITTFaxDecode` one through `decode_ccitt`. `nullopt` for a codec past its -/// decoder's reach or an inconsistent raster. +/// Convert encoded samples to JPEG or PNG; nullopt if unsupported or invalid. +/// JPEG passes through without masks; JPX ignores Decode (Table 89). std::optional encode_image(std::string raw, const Object &filter, const Object &decode_parms, std::int32_t width, std::int32_t height, @@ -40,14 +32,8 @@ encode_image(std::string raw, const Object &filter, const Object &decode_parms, std::span color_key = {}, std::int32_t smask_in_data = 0, const DecodeOptions &options = {}); -/// Assemble decoded image samples (ISO 32000-1 8.9.5: MSB-first, rows padded -/// to a byte boundary, `bits_per_component` of 1/2/4/8/16) into an 8-bit PNG, -/// converting through `color_space`, or a 1-bit palette PNG for a 1-bpc gray -/// image without alpha. `decode` is the `/Decode` array remapping the sample -/// range. `alpha` (one byte per pixel, row-major) and `color_key` -/// ([min0 max0 …] in raw sample units) each make the output RGBA, transparent -/// where coverage is 0 / every component is inside the ranges (8.9.6). Returns -/// "" for an inconsistent configuration, so the caller skips the image. +/// Decode 1/2/4/8/16-bit samples into PNG; empty for invalid parameters. +/// Alpha is row-major coverage; color_key is [min0 max0 …] in raw sample units. std::string encode_image_png(const std::string &samples, std::int32_t width, std::int32_t height, std::int32_t bits_per_component, @@ -56,21 +42,16 @@ std::string encode_image_png(const std::string &samples, std::int32_t width, std::span alpha = {}, std::span color_key = {}); -/// Resolve a `/SMask` or stencil `/Mask` sub-image into a coverage plane sized -/// to the *base* image, nearest-neighbour resampled — the two resolutions need -/// not match (ISO 32000-1 8.9.5.4 / 11.6.5.2). A soft mask's grey value is the -/// alpha; a stencil (`stencil`, 1 bpc) masks out where a sample decodes to 1. -/// Empty for an inconsistent mask. +/// Decode and resample a soft or 1-bit stencil mask to the base image size. +/// Stencil values of 1 mask out pixels; empty for invalid parameters. std::vector decode_mask_alpha(const std::string &samples, std::int32_t width, std::int32_t height, std::int32_t bits_per_component, std::span decode, bool stencil, std::int32_t base_width, std::int32_t base_height); -/// Paint a 1-bpc stencil mask (ISO 32000-1 8.9.6.2) into an RGBA PNG: a sample -/// decoding to 0 paints `color` (sRGB in [0, 1]) opaquely, a 1 is transparent, -/// and a `/Decode` of `[1 0]` swaps that. `color` is the fill colour at draw -/// time, so only the page extractor can call this. "" when inconsistent. +/// Paint a 1-bit stencil in the fill color: decoded 0 paints, 1 is transparent. +/// Decode [1 0] inverts coverage; empty for invalid dimensions (8.9.6.2). std::string encode_stencil_png(const std::string &samples, std::int32_t width, std::int32_t height, const std::array &color, diff --git a/test/src/internal/pdf/pdf_image.cpp b/test/src/internal/pdf/pdf_image.cpp index bc32bac84..206594dea 100644 --- a/test/src/internal/pdf/pdf_image.cpp +++ b/test/src/internal/pdf/pdf_image.cpp @@ -133,7 +133,17 @@ TEST(PdfImage, encode_honours_decode_array) { TEST(PdfImage, encode_rejects_bad_parameters) { EXPECT_TRUE(encode_image_png("", 0, 1, 8, device_rgb(), {}).empty()); - EXPECT_TRUE(encode_image_png("", 1, 1, 0, device_rgb(), {}).empty()); + for (const std::int32_t bits : {-1, 0, 3, 7, 12, 15, 17}) { + SCOPED_TRACE(bits); + EXPECT_TRUE(encode_image_png("", 1, 1, bits, device_rgb(), {}).empty()); + EXPECT_TRUE(decode_mask_alpha("", 1, 1, bits, {}, false, 1, 1).empty()); + } + EXPECT_TRUE(decode_mask_alpha("", 1, 1, 8, {}, true, 1, 1).empty()); + for (const std::int32_t bits : {2, 16}) { + const auto png = decode_png( + encode_image_png(bytes({255, 255}), 1, 1, bits, device_gray(), {})); + EXPECT_EQ(png.pixel(0, 0), bytes({255, 255, 255})); + } ColorSpaceDef zero; zero.components = 0; EXPECT_TRUE(encode_image_png("", 1, 1, 8, zero, {}).empty()); diff --git a/test/src/internal/pdf/pdf_jpx.cpp b/test/src/internal/pdf/pdf_jpx.cpp index 3dcd6b98b..5c3744e62 100644 --- a/test/src/internal/pdf/pdf_jpx.cpp +++ b/test/src/internal/pdf/pdf_jpx.cpp @@ -1,5 +1,9 @@ #include +#include +#include +#include + #include #include @@ -71,5 +75,16 @@ TEST(PdfJpx, scales_samples_to_eight_bits) { EXPECT_EQ(static_cast(image->samples[0]), 0); EXPECT_EQ(static_cast(image->samples[1]), middle); EXPECT_EQ(static_cast(image->samples[2]), 255); + + // JPX ignores the PDF bit depth and Decode array (Table 89). + const std::array inverted{1, 0}; + const auto encoded = encode_image( + odr::internal::crypto::util::hex_decode(hex), Object(Name{"JPXDecode"}), + Object{}, 3, 1, 0, nullptr, inverted); + ASSERT_TRUE(encoded.has_value()); + const auto png = odr::test::png::decode_png(encoded->data); + for (std::size_t x = 0; x < 3; ++x) { + EXPECT_EQ(png.pixel(x, 0), std::string(3, image->samples[x])); + } } }