diff --git a/CHANGELOG.md b/CHANGELOG.md index c8816c32c..577841291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,8 @@ The release run heads these entries with the version and opens a fresh - A pdf page that is off screen is no longer laid out or painted. On a drawing- heavy file this halves the time to open and takes a zoom step from ~340ms to ~50ms. +- A pdf image placed on several pages is one image, and the pdf view honors + `HtmlConfig::embed_images`, which it used to ignore. ## v6.10.1 - 2026-08-21 diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index 2cb8acd66..167f55b68 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -3,10 +3,13 @@ #include #include #include +#include #include #include +#include #include +#include #include #include #include @@ -616,6 +619,61 @@ std::string svg_path_fragment(const pdf::PathElement &path, return std::move(f).str(); } +/// One resource per distinct image: every placement of it gets the same url. +/// The name is a digest of the bytes, so the views of one document agree on it +/// and a host may render them into one directory. +class ImageRegistry { +public: + ImageRegistry(const HtmlConfig &config, HtmlResources &resources) + : m_config{&config}, m_resources{&resources} {} + + std::string url(const pdf::ImageElement &image) { + if (image.source != nullptr) { + if (const auto it = m_url_by_source.find(image.source); + it != std::end(m_url_by_source)) { + return it->second; + } + } + + 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 + 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); + HtmlResourceLocation location = + m_config->resource_locator(resource, *m_config); + it->second = location.value_or(file_to_url(image.data, image.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; + } + +private: + static std::string extension(const std::string &mime) { + const FileType type = file_type_by_mimetype(mime); + if (type == FileType::unknown) { + return {}; + } + return "." + std::string(file_extension_by_file_type(type)); + } + + const HtmlConfig *m_config{nullptr}; + HtmlResources *m_resources{nullptr}; + std::unordered_map m_url_by_source; + std::unordered_map m_url_by_name; +}; + /// An image XObject as an SVG `` fragment in the page viewBox, or "" /// when it carries no pass-through bytes. The image fills the unit square in /// user space (ISO 32000-1 8.10.5), flipped vertically because its first row is @@ -624,7 +682,8 @@ std::string svg_path_fragment(const pdf::PathElement &path, /// would resolve in the image's post-transform unit-square space instead. std::string svg_image_fragment(const pdf::ImageElement &image, const util::math::Transform2D &to_box, - const std::string &clip_id) { + const std::string &clip_id, + ImageRegistry &images) { if (image.data.empty()) { return {}; } @@ -645,7 +704,7 @@ std::string svg_image_fragment(const pdf::ImageElement &image, !blend.empty()) { f << " style=\"mix-blend-mode:" << blend << '"'; } - f << " href=\"" << file_to_url(image.data, image.mime) << "\"/>"; + f << " href=\"" << escape_attribute(images.url(image)) << "\"/>"; if (!clip_id.empty()) { f << ""; } @@ -812,7 +871,7 @@ class PatternRegistry : public DefsRegistry { std::string register_pattern(const pdf::Pattern &pattern, const util::math::Transform2D &m, const pdf::GraphicsState::Color &fill_color, - const Logger &logger) { + ImageRegistry &images, const Logger &logger) { if (pattern.resources == nullptr || pattern.content.empty() || pattern.x_step == 0 || pattern.y_step == 0) { return {}; @@ -842,7 +901,8 @@ 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); } } @@ -877,13 +937,11 @@ class MaskRegistry; /// element or one that paints nothing. A `GroupElement`'s children are wrapped /// in a single `` so the group composites as a unit before its /// opacity/blend/mask apply. -std::string render_graphic_fragment(const pdf::PageElement &element, - const util::math::Transform2D &to_box, - double width, double height, - ClipRegistry &clips, - GradientRegistry &gradients, - PatternRegistry &patterns, - MaskRegistry &masks, const Logger &logger); +std::string render_graphic_fragment( + const pdf::PageElement &element, const util::math::Transform2D &to_box, + double width, double height, ClipRegistry &clips, + GradientRegistry &gradients, PatternRegistry &patterns, MaskRegistry &masks, + ImageRegistry &images, const Logger &logger); /// A page's soft masks (`/SMask`, ISO 32000-1 11.6.5.2) as `` defs /// (`m_`). The extractor has already rendered each mask's transparency @@ -897,13 +955,15 @@ class MaskRegistry : public DefsRegistry { const util::math::Transform2D &to_box, const double width, const double height, ClipRegistry &clips, GradientRegistry &gradients, - PatternRegistry &patterns, const Logger &logger) { + PatternRegistry &patterns, ImageRegistry &images, + const Logger &logger) { // A fresh `SoftMask` is built for every `gs`, but many are identical (one // drop-shadow across a run of glyphs); dedupe on the rendered body. std::ostringstream body; for (const pdf::PageElement &element : mask.group) { body << render_graphic_fragment(element, to_box, width, height, clips, - gradients, patterns, *this, logger); + gradients, patterns, *this, images, + logger); } std::string signature = mask.type == pdf::SoftMask::Type::alpha ? "A" : "L"; if (mask.backdrop.has_value()) { @@ -944,14 +1004,14 @@ std::string wrap_effects(std::string fragment, const double alpha, const double width, const double height, ClipRegistry &clips, GradientRegistry &gradients, PatternRegistry &patterns, MaskRegistry &masks, - const Logger &logger) { + ImageRegistry &images, const Logger &logger) { if (fragment.empty()) { return fragment; } std::string mask_id; if (soft_mask != nullptr) { mask_id = masks.register_mask(*soft_mask, to_box, width, height, clips, - gradients, patterns, logger); + gradients, patterns, images, logger); } const std::string blend = blend_mode_to_css(blend_mode); if (alpha >= 1 && mask_id.empty() && blend.empty()) { @@ -972,19 +1032,17 @@ std::string wrap_effects(std::string fragment, const double alpha, return std::move(g).str(); } -std::string render_graphic_fragment(const pdf::PageElement &element, - const util::math::Transform2D &to_box, - const double width, const double height, - ClipRegistry &clips, - GradientRegistry &gradients, - PatternRegistry &patterns, - MaskRegistry &masks, const Logger &logger) { +std::string render_graphic_fragment( + const pdf::PageElement &element, const util::math::Transform2D &to_box, + const double width, const double height, ClipRegistry &clips, + GradientRegistry &gradients, PatternRegistry &patterns, MaskRegistry &masks, + ImageRegistry &images, const Logger &logger) { const auto wrap_mask = [&](std::string fragment, const std::shared_ptr &soft_mask) { return wrap_effects(std::move(fragment), 1.0, soft_mask, "", to_box, width, height, clips, gradients, patterns, masks, - logger); + images, logger); }; if (const auto *path = std::get_if(&element)) { const std::string clip_id = clips.register_clip(path->clip, to_box); @@ -995,7 +1053,7 @@ std::string render_graphic_fragment(const pdf::PageElement &element, } else if (path->fill_pattern != nullptr) { fill_url_id = patterns.register_pattern(*path->fill_pattern, path->pattern_transform * to_box, - path->fill_color, logger); + path->fill_color, images, logger); } return wrap_mask(svg_path_fragment(*path, to_box, clip_id, fill_url_id), path->soft_mask); @@ -1013,7 +1071,7 @@ std::string render_graphic_fragment(const pdf::PageElement &element, } 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), + return wrap_mask(svg_image_fragment(*image, to_box, clip_id, images), image->soft_mask); } if (const auto *group = std::get_if(&element)) { @@ -1022,12 +1080,13 @@ std::string render_graphic_fragment(const pdf::PageElement &element, } std::string inner; for (const pdf::PageElement &child : group->children->elements) { - inner += render_graphic_fragment(child, to_box, width, height, clips, - gradients, patterns, masks, logger); + inner += + render_graphic_fragment(child, to_box, width, height, clips, + gradients, patterns, masks, images, logger); } return wrap_effects(std::move(inner), group->alpha, group->soft_mask, group->blend_mode, to_box, width, height, clips, - gradients, patterns, masks, logger); + gradients, patterns, masks, images, logger); } return {}; } @@ -1223,16 +1282,30 @@ class HtmlServiceImpl final : public HtmlService { // serialized. std::lock_guard lock(m_mutex); if (path == config().document_output_file_name) { - return write_document(out); + return remember_linked(write_document(out)); } for (std::size_t i = 0; i < m_pages.size(); ++i) { if (path == m_views[i + 1].path()) { - return write_page(i, out); + return remember_linked(write_page(i, out)); } } throw FileNotFound("Unknown path: " + path); } + /// Keeps a render's linked resources so `exists`/`mimetype`/`write` can serve + /// what the markup points at. A view has to be rendered before its images can + /// be asked for, so collecting them here is enough; pre-rendering every view + /// to fill the list up front is not worth what it costs on a pdf. + HtmlResources remember_linked(HtmlResources resources) const { + for (const auto &entry : resources) { + if (entry.second.has_value() && + resource_at(m_resources, *entry.second) == nullptr) { + m_resources.push_back(entry); + } + } + return resources; + } + /// Whether the 0-based page index falls inside the rendered page range. [[nodiscard]] bool page_rendered(const std::size_t index) const { return index >= m_first_page && index < m_first_page + m_pages.size(); @@ -1333,6 +1406,7 @@ class HtmlServiceImpl final : public HtmlService { const std::size_t first_page_number, const PageHref &page_href) const { HtmlResources resources; + ImageRegistry images(config(), resources); const WritingState state(out, config(), resources); pdf::DocumentParser &parser = *m_parser; @@ -1442,7 +1516,7 @@ class HtmlServiceImpl final : public HtmlService { page_elements(*page, stream, m_logger)) { if (handle_graphic_element( element, to_box, width, height, clips, gradients, patterns, - masks, m_logger, [&] { vis_close_line(); }, + masks, images, m_logger, [&] { vis_close_line(); }, [&](std::string frag) { page_out.vis_items.push_back(PathOut{std::move(frag)}); })) { @@ -1886,6 +1960,7 @@ class HtmlServiceImpl final : public HtmlService { HtmlWriter &out, const std::span pages, const std::size_t first_page_number, const PageHref &page_href) const { HtmlResources resources; + ImageRegistry images(config(), resources); const WritingState state(out, config(), resources); pdf::DocumentParser &parser = *m_parser; @@ -2033,7 +2108,7 @@ class HtmlServiceImpl final : public HtmlService { page_elements(page, page_streams[pi], m_logger)) { if (handle_graphic_element( element, to_box, width, height, clips, gradients, patterns, - masks, m_logger, [&] { close_line(); }, + masks, images, m_logger, [&] { close_line(); }, [&](std::string frag) { page_out.items.push_back(SinglePathOut{std::move(frag)}); })) { @@ -2835,20 +2910,19 @@ class HtmlServiceImpl final : public HtmlService { /// Handles the non-text elements common to both modes, calling `close_line` /// and `push_svg` for a non-empty fragment. Returns false for a text element. template - static bool - handle_graphic_element(const pdf::PageElement &element, - const util::math::Transform2D &to_box, double width, - double height, ClipRegistry &clips, - GradientRegistry &gradients, PatternRegistry &patterns, - MaskRegistry &masks, const Logger &logger, - CloseLine &&close_line, PushSvg &&push_svg) { + static bool handle_graphic_element( + const pdf::PageElement &element, const util::math::Transform2D &to_box, + double width, double height, ClipRegistry &clips, + GradientRegistry &gradients, PatternRegistry &patterns, + MaskRegistry &masks, ImageRegistry &images, const Logger &logger, + CloseLine &&close_line, PushSvg &&push_svg) { // Text is handled by the caller; every other element kind is a graphic. if (std::holds_alternative(element)) { return false; } std::string frag = render_graphic_fragment(element, to_box, width, height, clips, - gradients, patterns, masks, logger); + gradients, patterns, masks, images, logger); if (!frag.empty()) { close_line(); push_svg(std::move(frag)); @@ -2860,7 +2934,7 @@ class HtmlServiceImpl final : public HtmlService { PdfFile m_pdf_file; /// The search css and js every view links; empty of locations when the config /// embeds them. - HtmlResources m_resources; + mutable HtmlResources m_resources; // Lazily initialized by `warmup()` (all guarded by `m_mutex`): one parse // shared by the combined-document and per-page renders. diff --git a/src/odr/internal/pdf/pdf_page_element.hpp b/src/odr/internal/pdf/pdf_page_element.hpp index 33c0071f2..ea66e9b05 100644 --- a/src/odr/internal/pdf/pdf_page_element.hpp +++ b/src/odr/internal/pdf/pdf_page_element.hpp @@ -14,6 +14,7 @@ namespace odr::internal::pdf { struct Font; struct Shading; +struct XObject; struct Pattern; struct SoftMask; @@ -107,6 +108,8 @@ struct ImageElement { std::vector clip; std::string data; std::string mime; // e.g. "image/jpeg" + /// Null for an inline (`BI`) image and for a stencil. + const XObject *source{nullptr}; /// `/ExtGState` `ca` (the nonstroking alpha applies to images) and `/BM`. double alpha{1}; std::string blend_mode; diff --git a/src/odr/internal/pdf/pdf_page_extractor.cpp b/src/odr/internal/pdf/pdf_page_extractor.cpp index 9cda42196..37eea0615 100644 --- a/src/odr/internal/pdf/pdf_page_extractor.cpp +++ b/src/odr/internal/pdf/pdf_page_extractor.cpp @@ -658,6 +658,8 @@ 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; + // memoized per document, so its address identifies a repeat + image.source = x_object; } else { return; } diff --git a/test/src/internal/pdf/pdf_file.cpp b/test/src/internal/pdf/pdf_file.cpp index 39f681b60..33639fea2 100644 --- a/test/src/internal/pdf/pdf_file.cpp +++ b/test/src/internal/pdf/pdf_file.cpp @@ -9,9 +9,12 @@ #include +#include +#include #include #include #include +#include #include @@ -109,6 +112,47 @@ std::string text_mini_pdf(const std::string &content) { return builder.trailer("/Root 1 0 R").build_classic(); } +std::string shared_image_mini_pdf() { + PdfFileBuilder builder; + builder.object("<< /Type /Catalog /Pages 2 0 R >>"); // 1 + builder.object("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"); // 2 + const std::string page = "<< /Type /Page /Parent 2 0 R " + "/MediaBox [0 0 200 200] " + "/Resources << /XObject << /Im0 5 0 R >> >> " + "/Contents 6 0 R >>"; + builder.object(page); // 3 + builder.object(page); // 4 + // pass through undecoded, so the payload only has to be distinguishable + builder.stream_object("/Type /XObject /Subtype /Image /Width 1 /Height 1 " + "/ColorSpace /DeviceRGB /BitsPerComponent 8 " + "/Filter /DCTDecode", + "\xff\xd8\xff\xd9-not-really-a-jpeg"); // 5 + builder.stream_object("", "q 100 0 0 100 10 10 cm /Im0 Do Q"); // 6 + builder.trailer("/Root 1 0 R"); + return builder.build_classic(); +} + +std::string two_image_mini_pdf() { + PdfFileBuilder builder; + builder.object("<< /Type /Catalog /Pages 2 0 R >>"); // 1 + builder.object("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"); // 2 + const auto page = [](const std::string &image, const std::string &content) { + return "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + "/Resources << /XObject << /Im0 " + + image + " >> >> /Contents " + content + " >>"; + }; + builder.object(page("5 0 R", "7 0 R")); // 3 + builder.object(page("6 0 R", "7 0 R")); // 4 + const std::string image = "/Type /XObject /Subtype /Image /Width 1 /Height 1 " + "/ColorSpace /DeviceRGB /BitsPerComponent 8 " + "/Filter /DCTDecode"; + builder.stream_object(image, "\xff\xd8\xff\xd9-first"); // 5 + builder.stream_object(image, "\xff\xd8\xff\xd9-second"); // 6 + builder.stream_object("", "q 100 0 0 100 10 10 cm /Im0 Do Q"); // 7 + builder.trailer("/Root 1 0 R"); + return builder.build_classic(); +} + } // namespace // `/Info` document-information strings and the page count surface through @@ -316,3 +360,107 @@ TEST(PdfFile, page_range_begin_keeps_global_numbering) { EXPECT_THROW(render_path(service, "page0.html"), FileNotFound); } + +TEST(PdfFile, one_image_on_two_pages_is_one_resource) { + for (const PdfTextMode mode : + {PdfTextMode::dual_layer, PdfTextMode::single_layer}) { + HtmlConfig config; + config.pdf_text_mode = mode; + config.embed_images = false; + + const HtmlService service = make_service(shared_image_mini_pdf(), config); + std::ostringstream out; + const HtmlResources resources = service.write_html("document.html", out); + + std::vector images; + for (const auto &[resource, location] : resources) { + if (resource.type() == HtmlResourceType::image) { + images.push_back(resource.path()); + EXPECT_TRUE(location.has_value()) << "mode " << static_cast(mode); + } + } + ASSERT_EQ(images.size(), 1) << "mode " << static_cast(mode); + + const std::string html = std::move(out).str(); + EXPECT_EQ(count(html, images.front()), 2) + << "mode " << static_cast(mode); + EXPECT_EQ(count(html, "data:image"), 0) + << "mode " << static_cast(mode); + } +} + +TEST(PdfFile, embedded_images_stay_inline) { + HtmlConfig config; + config.embed_images = true; + + const HtmlService service = make_service(shared_image_mini_pdf(), config); + std::ostringstream out; + const HtmlResources resources = service.write_html("document.html", out); + + std::size_t images = 0; + for (const auto &[resource, location] : resources) { + if (resource.type() == HtmlResourceType::image) { + ++images; + EXPECT_FALSE(location.has_value()); + } + } + ASSERT_EQ(images, 1); + + const std::string html = std::move(out).str(); + EXPECT_EQ(count(html, "data:image/jpeg;base64,"), 2); +} + +// A linked image is served back by the service, which is how the http server +// hands it to a browser. +TEST(PdfFile, linked_image_is_served_by_the_service) { + HtmlConfig config; + config.embed_images = false; + + const HtmlService service = make_service(shared_image_mini_pdf(), config); + std::ostringstream out; + const HtmlResources resources = service.write_html("document.html", out); + + std::string location; + for (const auto &[resource, resource_location] : resources) { + if (resource.type() == HtmlResourceType::image) { + ASSERT_TRUE(resource_location.has_value()); + location = *resource_location; + } + } + ASSERT_FALSE(location.empty()); + + EXPECT_TRUE(service.exists(location)); + EXPECT_EQ(service.mimetype(location), "image/jpeg"); + EXPECT_EQ(render_path(service, location), + "\xff\xd8\xff\xd9-not-really-a-jpeg"); +} + +// Every view names an image the same, so a host rendering them into one +// directory does not have one view's image overwrite another's. +TEST(PdfFile, views_agree_on_an_image_name) { + HtmlConfig config; + config.embed_images = false; + + const HtmlService service = make_service(two_image_mini_pdf(), config); + + // path -> the bytes served under it, across every view + std::map bytes_by_path; + for (const std::string &view : + {"document.html", "page0.html", "page1.html"}) { + std::ostringstream out; + for (const auto &[resource, location] : service.write_html(view, out)) { + if (resource.type() != HtmlResourceType::image) { + continue; + } + ASSERT_TRUE(location.has_value()) << view; + std::ostringstream bytes; + resource.write_resource(bytes); + const auto [it, fresh] = + bytes_by_path.try_emplace(*location, bytes.str()); + EXPECT_EQ(it->second, bytes.str()) + << "path " << *location << " means two images, seen in " << view; + } + } + + EXPECT_EQ(bytes_by_path.size(), 2); +}