Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 40 additions & 15 deletions src/odr/internal/html/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<MemoryFile>(image.data)), false, false, true);
HtmlResourceType::image, mime, name, name,
File(std::make_shared<MemoryFile>(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;
}

Expand All @@ -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 `<mask>` 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 {};
Expand All @@ -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 << '"';
Expand Down Expand Up @@ -936,7 +943,7 @@ class PatternRegistry : public DefsRegistry {
}
tile << svg_path_fragment(painted, util::math::Transform2D(), "", "");
} else if (const auto *image = std::get_if<pdf::ImageElement>(&element)) {
tile << svg_image_fragment(*image, util::math::Transform2D(), "",
tile << svg_image_fragment(*image, util::math::Transform2D(), "", "",
images);
}
}
Expand Down Expand Up @@ -1027,6 +1034,19 @@ class MaskRegistry : public DefsRegistry {
m_defs << std::move(body).str() << "</mask>";
return id;
}

/// The alpha of a JPEG image as a `<mask>` 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
<< "<mask id=\"" << id
<< R"(" mask-type="alpha" maskContentUnits="userSpaceOnUse">)"
<< R"(<image width="1" height="1" preserveAspectRatio="none" href=")"
<< xml::escape_attribute(url) << "\"/></mask>";
}
return id;
}
};

/// Wrap an SVG fragment in a `<g>` carrying an opacity, a soft mask and/or a
Expand Down Expand Up @@ -1106,8 +1126,13 @@ std::string render_graphic_fragment(
}
if (const auto *image = std::get_if<pdf::ImageElement>(&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<pdf::GroupElement>(&element)) {
if (group->children == nullptr) {
Expand Down
2 changes: 2 additions & 0 deletions src/odr/internal/pdf/pdf_document_element.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/odr/internal/pdf/pdf_document_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}

Expand Down
19 changes: 14 additions & 5 deletions src/odr/internal/pdf/pdf_image.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -386,10 +386,19 @@ std::optional<pdf::EncodedImage> 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<std::size_t>(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<char>(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));
Expand All @@ -413,7 +422,7 @@ std::optional<pdf::EncodedImage> 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
6 changes: 5 additions & 1 deletion src/odr/internal/pdf/pdf_image.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<EncodedImage>
encode_image(std::string raw, const Object &filter, const Object &decode_parms,
std::int32_t width, std::int32_t height,
Expand Down
2 changes: 2 additions & 0 deletions src/odr/internal/pdf/pdf_page_element.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ struct ImageElement {
std::vector<ClipPath> 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`.
Expand Down
1 change: 1 addition & 0 deletions src/odr/internal/pdf/pdf_page_extractor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions test/src/internal/pdf/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 >>")
Expand Down
Loading