From 0209bc9675bf86f307fab916a1d0e9d7fea39e72 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 6 Oct 2026 22:06:10 +0200 Subject: [PATCH 1/2] feat(pdf): decode packed TIFF predictors and validate LZW limits --- CHANGELOG.md | 4 ++ src/odr/internal/pdf/pdf_filter.cpp | 39 ++++++++++++++---- test/src/internal/pdf/pdf_filter.cpp | 61 ++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba7df6d9..30646ac68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- PDF streams support TIFF predictor samples packed at 1, 2, or 4 bits per + component. LZW decoding rejects invalid width parameters and dictionary + growth beyond the format's limit. + - HTML document views honor their configured output filename. Audio and video resources are served at custom locator paths without replacing the player or stylesheet, and named in-memory WebM files retain their playable MIME type. diff --git a/src/odr/internal/pdf/pdf_filter.cpp b/src/odr/internal/pdf/pdf_filter.cpp index 868dca244..ec1ed9cbe 100644 --- a/src/odr/internal/pdf/pdf_filter.cpp +++ b/src/odr/internal/pdf/pdf_filter.cpp @@ -146,14 +146,10 @@ PredictorLayout predictor_layout(const Integer colors, const Integer bits, static_cast((pixel_bits + 7) / 8)}; } -std::string apply_tiff_predictor(std::string data, +std::string apply_tiff_predictor(std::string data, const Integer colors, + const Integer columns, const Integer bits_per_component, const PredictorLayout layout) { - if (bits_per_component != 8 && bits_per_component != 16) { - throw std::runtime_error("unsupported TIFF predictor bits per component: " + - std::to_string(bits_per_component)); - } - const std::size_t component_bytes = bits_per_component / 8; const auto [row_bytes, pixel_bytes] = layout; if (data.size() % row_bytes != 0) { @@ -161,6 +157,28 @@ std::string apply_tiff_predictor(std::string data, } for (std::size_t row = 0; row < data.size(); row += row_bytes) { + if (bits_per_component < 8) { + // ISO 32000-1 7.4.4.4: TIFF predicts components, not packed bytes. + const auto bits = static_cast(bits_per_component); + const std::uint32_t mask = (1u << bits) - 1; + const auto samples = static_cast(colors) * columns; + const auto sample = [&](const std::uint64_t index) { + const std::uint64_t bit = index * bits; + return (static_cast(data[row + bit / 8]) >> + (8 - bits - bit % 8)) & + mask; + }; + for (auto i = static_cast(colors); i < samples; ++i) { + const std::uint32_t value = (sample(i) + sample(i - colors)) & mask; + const std::uint64_t bit = i * bits; + const auto shift = static_cast(8 - bits - bit % 8); + auto &byte = data[row + bit / 8]; + byte = static_cast( + (static_cast(byte) & ~(mask << shift)) | + (value << shift)); + } + continue; + } for (std::size_t i = pixel_bytes; i < row_bytes; i += component_bytes) { if (component_bytes == 1) { data[row + i] = static_cast( @@ -417,6 +435,9 @@ std::string pdf::ascii85_decode(const std::string &input) { std::string pdf::lzw_decode(const std::string &input, const Integer early_change) { + if (early_change != 0 && early_change != 1) { + throw std::runtime_error("LZWDecode: invalid EarlyChange"); + } std::string result; std::vector table; // codes 258 and up @@ -467,6 +488,10 @@ std::string pdf::lzw_decode(const std::string &input, } if (!previous.empty()) { + // ISO 32000-1 7.4.4.2: entry 4095 is the last; a full table must clear. + if (table.size() == 4096 - 258) { + throw std::runtime_error("LZWDecode: missing clear-table code"); + } table.push_back(previous + entry[0]); const std::uint64_t next_code = 258 + table.size(); if (next_code + early_change == 512) { @@ -519,7 +544,7 @@ std::string pdf::apply_predictor(std::string data, const Integer predictor, } if (predictor == 2) { return apply_tiff_predictor( - std::move(data), bits_per_component, + std::move(data), colors, columns, bits_per_component, predictor_layout(colors, bits_per_component, columns)); } if (predictor >= 10 && predictor <= 15) { diff --git a/test/src/internal/pdf/pdf_filter.cpp b/test/src/internal/pdf/pdf_filter.cpp index 87bebec77..0cce08a96 100644 --- a/test/src/internal/pdf/pdf_filter.cpp +++ b/test/src/internal/pdf/pdf_filter.cpp @@ -53,6 +53,53 @@ TEST(PdfFilter, lzw_decode) { EXPECT_EQ(lzw_decode(encoded), "-----A---B"); } +TEST(PdfFilter, lzw_full_dictionary_and_reset) { + for (const Integer early_change : {Integer{0}, Integer{1}}) { + SCOPED_TRACE(early_change); + std::string encoded; + std::size_t bit = 0; + const auto append = [&](const std::uint32_t code, + const std::uint32_t width) { + for (std::uint32_t i = width; i > 0; --i, ++bit) { + if (bit % 8 == 0) { + encoded.push_back('\0'); + } + encoded.back() = + static_cast(static_cast(encoded.back()) | + (((code >> (i - 1)) & 1u) << (7 - bit % 8))); + } + }; + append(256, 9); + // Literal codes fill entries 258 through 4095, crossing each width + // boundary. + const auto first = static_cast(255 - early_change); + for (std::size_t i = 0; i < 3839; ++i) { + const std::uint32_t width = i < first ? 9 + : i < first + 512 ? 10 + : i < first + 1536 ? 11 + : 12; + append('A', width); + } + const std::string full = encoded; + const std::size_t full_bit = bit; + append('A', 12); + append(257, 12); + EXPECT_THROW(lzw_decode(encoded, early_change), std::runtime_error); + + encoded = full; + bit = full_bit; + append(256, 12); + append('B', 9); + append(257, 9); + EXPECT_EQ(lzw_decode(encoded, early_change), std::string(3839, 'A') + 'B'); + } +} + +TEST(PdfFilter, lzw_rejects_invalid_early_change) { + EXPECT_THROW(lzw_decode("", -1), std::runtime_error); + EXPECT_THROW(lzw_decode("", 2), std::runtime_error); +} + TEST(PdfFilter, run_length_decode) { // copy 3 literal bytes, then repeat 'z' 257-254 = 3 times EXPECT_EQ(run_length_decode("\x02" @@ -129,6 +176,20 @@ TEST(PdfFilter, tiff_predictor) { std::string("\x01\x02\x03\x02\x04\x06", 6)); } +TEST(PdfFilter, tiff_predictor_packed_components) { + // Each row starts fresh; unused low bits stay untouched. + EXPECT_EQ(apply_predictor(std::string("\xEB\xEB", 2), 2, 1, 1, 5), + std::string("\xB3\xB3", 2)); + // RGB components straddle bytes: (1,2,3), (3,1,2), (0,3,1). + EXPECT_EQ(apply_predictor(std::string("\x6E\xF6\xFF", 3), 2, 3, 2, 3), + std::string("\x6F\x63\x7F", 3)); + // Four-bit addition wraps independently of adjacent components. + EXPECT_EQ(apply_predictor(std::string("\xF2\x1A", 2), 2, 1, 4, 3), + std::string("\xF1\x2A", 2)); + EXPECT_EQ(apply_predictor("", 2, 1, 2, 3), ""); + EXPECT_THROW(apply_predictor("x", 2, 3, 4, 2), std::runtime_error); +} + TEST(PdfFilter, decode_no_filter) { const DecodeResult result = decode(Object(), Object(), "hello"); EXPECT_EQ(result.data, "hello"); From 6b368ee8b774deb7d14fb435b21c9f35363f796f Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 6 Oct 2026 22:36:40 +0200 Subject: [PATCH 2/2] fix(pdf): keep decoding LZW over a full table instead of failing A stream without a clear code after entry 4095 threw, so its image or content was lost. The table still stops growing there, which bounds memory, and the remaining codes decode at 12 bits over the full table, as pdf.js reads them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MxyTMutqSUJRGfxA8CyzMc --- src/odr/internal/pdf/pdf_filter.cpp | 8 +++----- test/src/internal/pdf/pdf_filter.cpp | 3 ++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/odr/internal/pdf/pdf_filter.cpp b/src/odr/internal/pdf/pdf_filter.cpp index ec1ed9cbe..5eea260e0 100644 --- a/src/odr/internal/pdf/pdf_filter.cpp +++ b/src/odr/internal/pdf/pdf_filter.cpp @@ -487,11 +487,9 @@ std::string pdf::lzw_decode(const std::string &input, } } - if (!previous.empty()) { - // ISO 32000-1 7.4.4.2: entry 4095 is the last; a full table must clear. - if (table.size() == 4096 - 258) { - throw std::runtime_error("LZWDecode: missing clear-table code"); - } + // ISO 32000-1 7.4.4.2 ends the table at entry 4095 and asks for a clear + // code then; without one, the codes go on at 12 bits over the full table + if (!previous.empty() && table.size() < 4096 - 258) { table.push_back(previous + entry[0]); const std::uint64_t next_code = 258 + table.size(); if (next_code + early_change == 512) { diff --git a/test/src/internal/pdf/pdf_filter.cpp b/test/src/internal/pdf/pdf_filter.cpp index 0cce08a96..379df003f 100644 --- a/test/src/internal/pdf/pdf_filter.cpp +++ b/test/src/internal/pdf/pdf_filter.cpp @@ -82,9 +82,10 @@ TEST(PdfFilter, lzw_full_dictionary_and_reset) { } const std::string full = encoded; const std::size_t full_bit = bit; + // Without a clear code, decoding goes on over the full table. append('A', 12); append(257, 12); - EXPECT_THROW(lzw_decode(encoded, early_change), std::runtime_error); + EXPECT_EQ(lzw_decode(encoded, early_change), std::string(3840, 'A')); encoded = full; bit = full_bit;