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 >>")