From 9a017e99317500f752323013631190375e118f60 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 10 Oct 2026 11:41:23 +0200 Subject: [PATCH] fix(pdf): keep the soft mask of a JPEG image A JPEG passes through to the browser undecoded, so its /SMask or stencil /Mask was dropped, and the transparent pixels showed the colour the JPEG holds there, often black. Keep the alpha apart as an RGBA png and mask the image with it in the SVG, so the browser composites what a JPEG cannot hold. Refs #1188 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01S5yx1nDeVcNX3BxqK1WpJz --- CHANGELOG.md | 2 + src/odr/internal/html/pdf_file.cpp | 55 ++++++++++++++----- src/odr/internal/pdf/pdf_document_element.hpp | 2 + src/odr/internal/pdf/pdf_document_parser.cpp | 3 +- src/odr/internal/pdf/pdf_image.cpp | 19 +++++-- src/odr/internal/pdf/pdf_image.hpp | 6 +- src/odr/internal/pdf/pdf_page_element.hpp | 2 + src/odr/internal/pdf/pdf_page_extractor.cpp | 1 + test/src/internal/pdf/pdf_file.cpp | 35 ++++++++++++ 9 files changed, 103 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ee2d3526..02278f6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ The release run heads these entries with the version and opens a fresh it. - pdf text in Adobe small capitals and old-style figures (`Asmall`, `oneoldstyle`) is found by a search and copies as letters and digits. +- A pdf JPEG image with a soft mask shows its transparency instead of the + colour the JPEG holds there, which is often black. ## v7.5.0 - 2026-10-06 diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index a99111d28..451817e3b 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -631,28 +631,30 @@ class ImageRegistry { return it->second; } } + std::string result = url(image.data, image.mime); + if (image.source != nullptr) { + m_url_by_source.emplace(image.source, result); + } + return result; + } + /// Keyed on the bytes: a stencil, an inline image and an alpha mask carry no + /// object to key on, so identical bytes reach us as separate elements. + std::string url(const std::string &data, const std::string &mime) { const std::string name = "image" + - crypto::util::hex_encode(crypto::util::sha256(image.data)) - .substr(0, 16) + - extension(image.mime); - // a stencil and an inline image carry no object to key on, so identical - // bytes reach us as separate elements + crypto::util::hex_encode(crypto::util::sha256(data)).substr(0, 16) + + extension(mime); const auto [it, fresh] = m_url_by_name.try_emplace(name); if (fresh) { odr::HtmlResource resource = HtmlResource::create( - HtmlResourceType::image, image.mime, name, name, - File(std::make_shared(image.data)), false, false, true); + HtmlResourceType::image, mime, name, name, + File(std::make_shared(data)), false, false, true); HtmlResourceLocation location = m_config->resource_locator(resource, *m_config); - it->second = location.value_or(file_to_url(image.data, image.mime)); + it->second = location.value_or(file_to_url(data, mime)); m_resources->emplace_back(std::move(resource), std::move(location)); } - - if (image.source != nullptr) { - m_url_by_source.emplace(image.source, it->second); - } return it->second; } @@ -673,9 +675,11 @@ class ImageRegistry { /// Emits an image in the flipped unit square (ISO 32000-1 8.10.5), or empty. /// Apply clip_id to a wrapper so clipping stays in page coordinates. +/// @p mask_id names the `` holding a JPEG's alpha; empty for none. std::string svg_image_fragment(const pdf::ImageElement &image, const util::math::Transform2D &to_box, const std::string &clip_id, + const std::string &mask_id, ImageRegistry &images) { if (image.data.empty()) { return {}; @@ -693,6 +697,9 @@ std::string svg_image_fragment(const pdf::ImageElement &image, if (image.alpha < 1) { f << " opacity=\"" << round2(image.alpha) << '"'; } + if (!mask_id.empty()) { + f << " mask=\"url(#" << mask_id << ")\""; + } if (const std::string blend = blend_mode_to_css(image.blend_mode); !blend.empty()) { f << " style=\"mix-blend-mode:" << blend << '"'; @@ -936,7 +943,7 @@ class PatternRegistry : public DefsRegistry { } tile << svg_path_fragment(painted, util::math::Transform2D(), "", ""); } else if (const auto *image = std::get_if(&element)) { - tile << svg_image_fragment(*image, util::math::Transform2D(), "", + tile << svg_image_fragment(*image, util::math::Transform2D(), "", "", images); } } @@ -1027,6 +1034,19 @@ class MaskRegistry : public DefsRegistry { m_defs << std::move(body).str() << ""; return id; } + + /// The alpha of a JPEG image as a `` in the image's own unit square. + std::string register_image_mask(const std::string &url) { + const auto [id, inserted] = intern("I;" + url, "m"); + if (inserted) { + m_defs + << ")" + << R"("; + } + return id; + } }; /// Wrap an SVG fragment in a `` carrying an opacity, a soft mask and/or a @@ -1106,8 +1126,13 @@ std::string render_graphic_fragment( } if (const auto *image = std::get_if(&element)) { const std::string clip_id = clips.register_clip(image->clip, to_box); - return wrap_mask(svg_image_fragment(*image, to_box, clip_id, images), - image->soft_mask); + const std::string mask_id = image->alpha_mask.empty() + ? "" + : masks.register_image_mask(images.url( + image->alpha_mask, "image/png")); + return wrap_mask( + svg_image_fragment(*image, to_box, clip_id, mask_id, images), + image->soft_mask); } if (const auto *group = std::get_if(&element)) { if (group->children == nullptr) { diff --git a/src/odr/internal/pdf/pdf_document_element.hpp b/src/odr/internal/pdf/pdf_document_element.hpp index 24742dbb6..b50e7d53b 100644 --- a/src/odr/internal/pdf/pdf_document_element.hpp +++ b/src/odr/internal/pdf/pdf_document_element.hpp @@ -174,6 +174,8 @@ struct XObject final : Element { /// for an undecodable image and for a stencil, so `Do` skips it. std::string image_data; std::string image_mime; + /// The alpha of a JPEG `image_data` as an RGBA png; empty for none. + std::string image_alpha; /// `/ImageMask true` (8.9.6.2): a 1-bpc stencil painted in the current fill /// colour, which is known only at `Do` time — so the bitmap is carried raw diff --git a/src/odr/internal/pdf/pdf_document_parser.cpp b/src/odr/internal/pdf/pdf_document_parser.cpp index 65e0238ff..388e7c9fc 100644 --- a/src/odr/internal/pdf/pdf_document_parser.cpp +++ b/src/odr/internal/pdf/pdf_document_parser.cpp @@ -863,7 +863,7 @@ void parse_stencil_mask(DocumentParser &parser, const Dictionary &dictionary, /// Builds image bytes: JPEG passthrough or PNG/RGBA (ISO 32000-1 8.9). /// Resolves named colours through resources; unsupported images return no -/// bytes. JPEG transparency is ignored. +/// bytes. A JPEG keeps its alpha apart and drops a colour key. void parse_image_data(DocumentParser &parser, const Dictionary &dictionary, const IndirectObject &object, XObject &x_object, const Resources *resources) { @@ -924,6 +924,7 @@ void parse_image_data(DocumentParser &parser, const Dictionary &dictionary, encoded.has_value()) { x_object.image_data = std::move(encoded->data); x_object.image_mime = std::move(encoded->mime); + x_object.image_alpha = std::move(encoded->alpha_png); } } diff --git a/src/odr/internal/pdf/pdf_image.cpp b/src/odr/internal/pdf/pdf_image.cpp index f5377d691..2fba40e32 100644 --- a/src/odr/internal/pdf/pdf_image.cpp +++ b/src/odr/internal/pdf/pdf_image.cpp @@ -126,7 +126,7 @@ encode_jpx(const std::string &data, const ColorSpaceDef *color_space, if (png.empty()) { return std::nullopt; } - return EncodedImage{png, "image/png"}; + return EncodedImage{png, "image/png", {}}; } } // namespace @@ -386,10 +386,19 @@ std::optional pdf::encode_image( if (terminal == "DCTDecode") { // A JPEG the browser decodes itself: hand back its bytes undecoded. DecodeResult result = decode(filter, decode_parms, std::move(raw)); - if (result.stopped_at_filter == "DCTDecode") { - return EncodedImage{std::move(result.data), "image/jpeg"}; + if (result.stopped_at_filter != "DCTDecode") { + return std::nullopt; } - return std::nullopt; + EncodedImage image{std::move(result.data), "image/jpeg", {}}; + if (width > 0 && height > 0 && + alpha.size() == static_cast(width) * height) { + std::string rgba(alpha.size() * 4, '\0'); + for (std::size_t i = 0; i < alpha.size(); ++i) { + rgba[i * 4 + 3] = static_cast(alpha[i]); + } + image.alpha_png = png::write(rgba, width, height, 4); + } + return image; } if (terminal == "JPXDecode") { DecodeResult result = decode(filter, decode_parms, std::move(raw)); @@ -413,7 +422,7 @@ std::optional pdf::encode_image( if (png.empty()) { return std::nullopt; } - return EncodedImage{std::move(png), "image/png"}; + return EncodedImage{std::move(png), "image/png", {}}; } } // namespace odr::internal diff --git a/src/odr/internal/pdf/pdf_image.hpp b/src/odr/internal/pdf/pdf_image.hpp index 5d84d9f95..8fd10a624 100644 --- a/src/odr/internal/pdf/pdf_image.hpp +++ b/src/odr/internal/pdf/pdf_image.hpp @@ -27,10 +27,14 @@ bool valid_image_bit_depth(std::int32_t bits); struct EncodedImage { std::string data; std::string mime; + /// The alpha of a JPEG, which cannot hold one, as an RGBA png for the + /// browser to mask with. Empty where there is none. + std::string alpha_png; }; /// Convert encoded samples to JPEG or PNG; nullopt if unsupported or invalid. -/// JPEG passes through without masks; JPX ignores Decode (Table 89). +/// JPEG passes through, its alpha apart and its colour key dropped; 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, diff --git a/src/odr/internal/pdf/pdf_page_element.hpp b/src/odr/internal/pdf/pdf_page_element.hpp index ea66e9b05..eaf61158b 100644 --- a/src/odr/internal/pdf/pdf_page_element.hpp +++ b/src/odr/internal/pdf/pdf_page_element.hpp @@ -108,6 +108,8 @@ struct ImageElement { std::vector clip; std::string data; std::string mime; // e.g. "image/jpeg" + /// An RGBA png whose alpha masks a JPEG `data`; empty for none. + std::string alpha_mask; /// Null for an inline (`BI`) image and for a stencil. const XObject *source{nullptr}; /// `/ExtGState` `ca` (the nonstroking alpha applies to images) and `/BM`. diff --git a/src/odr/internal/pdf/pdf_page_extractor.cpp b/src/odr/internal/pdf/pdf_page_extractor.cpp index 43a260a0b..f20b81c70 100644 --- a/src/odr/internal/pdf/pdf_page_extractor.cpp +++ b/src/odr/internal/pdf/pdf_page_extractor.cpp @@ -700,6 +700,7 @@ void invoke_x_object(const std::string &name, const Resources &resources, // PNG-encoded raster). Codecs we cannot hand off carry none, so skip. image.data = x_object->image_data; image.mime = x_object->image_mime; + image.alpha_mask = x_object->image_alpha; // memoized per document, so its address identifies a repeat image.source = x_object; } else { diff --git a/test/src/internal/pdf/pdf_file.cpp b/test/src/internal/pdf/pdf_file.cpp index 6344cbda6..24de7b626 100644 --- a/test/src/internal/pdf/pdf_file.cpp +++ b/test/src/internal/pdf/pdf_file.cpp @@ -592,6 +592,41 @@ TEST(PdfFile, uniform_run_at_the_floor_keeps_its_own_font_size) { EXPECT_FALSE(contains(html, "transform:scale(")); } +// A JPEG cannot hold an alpha, and the browser decodes it, so its `/SMask` +// travels apart as a png the image is masked with. Without it the transparent +// pixels show the colour the JPEG holds there, often black. +TEST(PdfFile, a_jpeg_keeps_its_soft_mask) { + const std::string jpeg("\xff\xd8\xff\xd9", 4); + PdfFileBuilder builder; + builder.object("<< /Type /Catalog /Pages 2 0 R >>") + .object("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + "/Resources << /XObject << /Im1 5 0 R /Im2 7 0 R >> >> " + "/Contents 4 0 R >>") + .stream_object("", "q 50 0 0 50 10 10 cm /Im1 Do Q " + "q 50 0 0 50 100 10 cm /Im2 Do Q") + .stream_object("/Type /XObject /Subtype /Image /Width 2 /Height 2 " + "/ColorSpace /DeviceRGB /BitsPerComponent 8 " + "/Filter /DCTDecode /SMask 6 0 R", + jpeg) + .stream_object("/Type /XObject /Subtype /Image /Width 2 /Height 2 " + "/ColorSpace /DeviceGray /BitsPerComponent 8", + std::string("\x00\xff\xff\x00", 4)) + .stream_object("/Type /XObject /Subtype /Image /Width 2 /Height 2 " + "/ColorSpace /DeviceRGB /BitsPerComponent 8 " + "/Filter /DCTDecode", + jpeg); + const std::string bytes = builder.trailer("/Root 1 0 R").build_classic(); + + for (const PdfTextMode mode : + {PdfTextMode::dual_layer, PdfTextMode::single_layer}) { + const std::string html = render_html(bytes, mode); + EXPECT_EQ(count(html, R"(mask-type="alpha" maskContentUnits)"), 1); + EXPECT_EQ(count(html, R"( mask="url(#)"), 1); + EXPECT_EQ(count(html, "data:image/jpeg"), 2); + } +} + TEST(PdfFile, each_page_prints_on_a_sheet_of_its_own_size) { PdfFileBuilder builder; builder.object("<< /Type /Catalog /Pages 2 0 R >>")