From d15673fb646567ccd07da67a8954f4716af77ffa Mon Sep 17 00:00:00 2001 From: Jeff Witt <1848307+wittjeff@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:32:18 -0400 Subject: [PATCH 1/4] fix(render): composite ExtGState soft masks (/SMask) An object painted under an ExtGState whose /SMask names a transparency group was painted fully opaque: the key was parsed to a three-value enum and the renderer had nothing to composite. InDesign builds drop shadows and feathered photo edges from this mask, so those effects came out as opaque boxes over the photos. The mask is now composited (ISO 32000-1, 11.6.5): - page_grph.h keeps the /SMask dictionary as a soft_mask_spec: the /G group, /S, /BC as device RGB and /TR sampled through pdf_function. - grph.h snapshots the CTM at the gs, which is the mask's coordinate system; /SMask /None clears it; the mask survives q/Q and reaches forms. - stream.h sub-decodes the group right after the gs, the way Do decodes a form, into containers of its own and once per entry and CTM. Every text, shape, bitmap and shading instruction painted under the mask shares the decoded soft_mask_instruction, like clip_state. - blend2d_renderer.h paints the group once per page over its backdrop, reads it back as an A8 plane through /TR (backdrop outside /BBox) and caches it per mask. A masked instruction re-enters its paint routine with page_context() redirected to a layer; the layer is multiplied by the plane and blitted with the instruction's blend mode. render_config.render_soft_masks switches the compositing off; the time spent is reported under render_page.soft_mask. A mask set inside a mask group, knockout and isolated attributes, and canvases above 8192 px per side stay unhandled and are reported. Fixes #384 Co-Authored-By: Claude Fable 5.1 Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com> --- app/pybind_parse.cpp | 2 + docling_parse/pdf_parser.py | 2 + src/parse.h | 10 +- src/parse/enums.h | 2 +- src/parse/page_items/render_instructions.h | 59 ++- src/parse/page_items/soft_mask_instruction.h | 69 +++ src/parse/pdf_decoders/stream.h | 181 ++++++++ src/parse/pdf_resources/page_grph.h | 180 +++++++- src/parse/pdf_states/bitmap.h | 1 + src/parse/pdf_states/grph.h | 57 ++- src/parse/pdf_states/shape.h | 1 + src/parse/pdf_states/text.h | 1 + src/parse/utils/pdf_timings.h | 6 + src/render/blend2d_renderer.h | 378 +++++++++++++++ src/render/config.h | 7 + tests/test_unit_soft_masks.py | 457 +++++++++++++++++++ 16 files changed, 1388 insertions(+), 25 deletions(-) create mode 100644 src/parse/page_items/soft_mask_instruction.h create mode 100644 tests/test_unit_soft_masks.py diff --git a/app/pybind_parse.cpp b/app/pybind_parse.cpp index d622da707..aa6371da8 100644 --- a/app/pybind_parse.cpp +++ b/app/pybind_parse.cpp @@ -1270,6 +1270,7 @@ PYBIND11_MODULE(pdf_parsers, m) { render_shapes (bool): Paint vector shape instructions [default=true]. render_shadings (bool): Paint shading instructions [default=true]. render_non_rect_clip_masks (bool): Apply non-rectangular clip masks [default=true]. + render_soft_masks (bool): Composite paint through ExtGState soft masks (/SMask) [default=true]. min_stroke_width (float): Minimum stroke width in device pixels for hairlines and sub-pixel vector strokes [default=1.0]. draw_text_bbox (bool): Draw bounding quad for each text cell [default=false]. draw_text_basepoint (bool): Draw the text baseline origin as a small red dot [default=false]. @@ -1289,6 +1290,7 @@ PYBIND11_MODULE(pdf_parsers, m) { .def_readwrite("render_shapes", &pdflib::render_config::render_shapes) .def_readwrite("render_shadings", &pdflib::render_config::render_shadings) .def_readwrite("render_non_rect_clip_masks", &pdflib::render_config::render_non_rect_clip_masks) + .def_readwrite("render_soft_masks", &pdflib::render_config::render_soft_masks) .def_readwrite("min_stroke_width", &pdflib::render_config::min_stroke_width) .def_readwrite("draw_text_bbox", &pdflib::render_config::draw_text_bbox) .def_readwrite("draw_text_basepoint", &pdflib::render_config::draw_text_basepoint) diff --git a/docling_parse/pdf_parser.py b/docling_parse/pdf_parser.py index d6c1857ec..f397b6f6d 100644 --- a/docling_parse/pdf_parser.py +++ b/docling_parse/pdf_parser.py @@ -1549,6 +1549,8 @@ def _copy_render_config(src: RenderConfig) -> RenderConfig: dst.render_shadings = src.render_shadings if hasattr(src, "render_non_rect_clip_masks"): dst.render_non_rect_clip_masks = src.render_non_rect_clip_masks + if hasattr(src, "render_soft_masks"): + dst.render_soft_masks = src.render_soft_masks dst.min_stroke_width = src.min_stroke_width dst.draw_text_bbox = src.draw_text_bbox dst.resolve_fonts = src.resolve_fonts diff --git a/src/parse.h b/src/parse.h index ee3bb9632..0e05205f6 100644 --- a/src/parse.h +++ b/src/parse.h @@ -76,6 +76,7 @@ #include #include #include +#include // the outline resolves its destinations against the page geometry, so it comes // after the page items rather than next to the other qpdf extractors @@ -98,13 +99,14 @@ #include #include +// pdf_function carries the colour ramp of a shading, the tint transform of a +// /Separation or /DeviceN colour space and the transfer function of a soft +// mask, so it comes before the resources that hold those +#include + #include #include -// pdf_function carries both the colour ramp of a shading and the tint -// transform of a /Separation or /DeviceN colour space, so it comes first -#include - #include #include diff --git a/src/parse/enums.h b/src/parse/enums.h index 8d5319841..2a62e1b14 100644 --- a/src/parse/enums.h +++ b/src/parse/enums.h @@ -332,7 +332,7 @@ namespace pdflib enum soft_mask_state { SOFT_MASK_ABSENT, // key not in the dictionary: inherit the current mask SOFT_MASK_NONE, // /None: clear the current soft mask - SOFT_MASK_PRESENT, // a mask dictionary (not applied) + SOFT_MASK_PRESENT, // a mask dictionary (see soft_mask_spec) }; std::string to_string(blend_mode_name name) diff --git a/src/parse/page_items/render_instructions.h b/src/parse/page_items/render_instructions.h index d067ec62d..ace338b3e 100644 --- a/src/parse/page_items/render_instructions.h +++ b/src/parse/page_items/render_instructions.h @@ -151,6 +151,11 @@ namespace pdflib SHADING_RENDER_INSTRUCTION, // paint a shading (`sh`) over the clip region }; + // An ExtGState soft mask decoded for the renderer (soft_mask_instruction.h). + // It holds render instructions of its own, so it is defined after the + // container below; the paint instructions only ever hold it by pointer. + class soft_mask_instruction; + // One sample of a shading's colour ramp: `offset` runs from 0.0 at the // start of the shading axis to 1.0 at its end (the shading's /Domain is // already folded in), `rgb` is the colour there in 0-255 device RGB. @@ -327,6 +332,14 @@ namespace pdflib void set_blend_mode(blend_mode_name mode) { blend_mode_ = mode; } blend_mode_name get_blend_mode() const { return blend_mode_; } + // ExtGState /SMask in force when this was painted (11.6.5), decoded to + // page-space instructions; null when none. Every instruction painted + // under the same mask shares one object, so the renderer rasterises the + // mask once. + void set_soft_mask(std::shared_ptr mask) { soft_mask_ = std::move(mask); } + const std::shared_ptr& get_soft_mask() const { return soft_mask_; } + bool has_soft_mask() const { return soft_mask_ != nullptr; } + void set_rendering_mode(int mode) { rendering_mode_ = mode; } int get_rendering_mode() const { return rendering_mode_; } bool is_invisible() const { return rendering_mode_ == 3 or rendering_mode_ == 7; } @@ -401,6 +414,7 @@ namespace pdflib double fill_alpha_ = 1.0; clip_state_instruction clip_state_; blend_mode_name blend_mode_ = BLEND_MODE_NORMAL; + std::shared_ptr soft_mask_; int rendering_mode_ = 0; }; @@ -573,6 +587,14 @@ namespace pdflib void set_blend_mode(blend_mode_name mode) { blend_mode_ = mode; } blend_mode_name get_blend_mode() const { return blend_mode_; } + // ExtGState /SMask in force when this was painted (11.6.5), decoded to + // page-space instructions; null when none. Every instruction painted + // under the same mask shares one object, so the renderer rasterises the + // mask once. + void set_soft_mask(std::shared_ptr mask) { soft_mask_ = std::move(mask); } + const std::shared_ptr& get_soft_mask() const { return soft_mask_; } + bool has_soft_mask() const { return soft_mask_ != nullptr; } + // The /Filter chain of the stream these samples came out of, in the order // the PDF lists it (e.g. {"/FlateDecode", "/JPXDecode"}); empty for an // uncompressed image and for a rasterised Type3 glyph, which has no stream @@ -588,6 +610,7 @@ namespace pdflib private: blend_mode_name blend_mode_ = BLEND_MODE_NORMAL; + std::shared_ptr soft_mask_; std::vector filters_; const std::string xobject_key; @@ -775,6 +798,8 @@ namespace pdflib stroke_alpha, fill_alpha, clip_state.translated(dx, dy)); copy.blend_mode_ = blend_mode_; + // the mask is anchored in page space, so a moved copy keeps it as is + copy.soft_mask_ = soft_mask_; return copy; } @@ -784,9 +809,18 @@ namespace pdflib void set_blend_mode(blend_mode_name mode) { blend_mode_ = mode; } blend_mode_name get_blend_mode() const { return blend_mode_; } + // ExtGState /SMask in force when this was painted (11.6.5), decoded to + // page-space instructions; null when none. Every instruction painted + // under the same mask shares one object, so the renderer rasterises the + // mask once. + void set_soft_mask(std::shared_ptr mask) { soft_mask_ = std::move(mask); } + const std::shared_ptr& get_soft_mask() const { return soft_mask_; } + bool has_soft_mask() const { return soft_mask_ != nullptr; } + private: blend_mode_name blend_mode_ = BLEND_MODE_NORMAL; + std::shared_ptr soft_mask_; const std::vector subpaths; @@ -875,6 +909,8 @@ namespace pdflib extend_start, extend_end, fill_alpha, clip_state.translated(dx, dy)); copy.blend_mode_ = blend_mode_; + // the mask is anchored in page space, so a moved copy keeps it as is + copy.soft_mask_ = soft_mask_; return copy; } @@ -884,9 +920,18 @@ namespace pdflib void set_blend_mode(blend_mode_name mode) { blend_mode_ = mode; } blend_mode_name get_blend_mode() const { return blend_mode_; } + // ExtGState /SMask in force when this was painted (11.6.5), decoded to + // page-space instructions; null when none. Every instruction painted + // under the same mask shares one object, so the renderer rasterises the + // mask once. + void set_soft_mask(std::shared_ptr mask) { soft_mask_ = std::move(mask); } + const std::shared_ptr& get_soft_mask() const { return soft_mask_; } + bool has_soft_mask() const { return soft_mask_ != nullptr; } + private: blend_mode_name blend_mode_ = BLEND_MODE_NORMAL; + std::shared_ptr soft_mask_; const std::string shading_key; const shading_geometry geometry; @@ -941,9 +986,11 @@ namespace pdflib return shading_instructions; } - // render method + // render method. `set_size` sizes the renderer's canvas from the size + // instruction first; a soft-mask group is painted onto a canvas the + // renderer already holds, so it passes false. template - void iterate_over_instructions(renderer_type& renderer); + void iterate_over_instructions(renderer_type& renderer, bool set_size = true); private: @@ -1005,9 +1052,13 @@ namespace pdflib } template - void pdf_render_instructions::iterate_over_instructions(renderer_type& renderer) + void pdf_render_instructions::iterate_over_instructions(renderer_type& renderer, + bool set_size) { - renderer.set_size(size_instr); + if(set_size) + { + renderer.set_size(size_instr); + } for(const auto& instr : instructions) { diff --git a/src/parse/page_items/soft_mask_instruction.h b/src/parse/page_items/soft_mask_instruction.h new file mode 100644 index 000000000..653021148 --- /dev/null +++ b/src/parse/page_items/soft_mask_instruction.h @@ -0,0 +1,69 @@ +//-*-C++-*- + +#ifndef PDF_SOFT_MASK_INSTRUCTION_H +#define PDF_SOFT_MASK_INSTRUCTION_H + +namespace pdflib +{ + // An ExtGState soft mask (ISO 32000-1, 11.6.5), decoded for the renderer. + // + // The mask is a transparency group whose luminosity (or alpha) becomes the + // coverage of everything painted while the mask is in force. The group's + // content has been sub-decoded into `group`: ordinary render instructions + // in page space, placed by the CTM in force at the `gs` that installed the + // mask -- which is what fixes the mask's coordinate system (11.6.5.1), not + // the CTM at the paint -- composed with the group's /Matrix. + // + // One instance is shared by every instruction painted under the same mask, + // so a renderer rasterises the group once and reuses the coverage. + class soft_mask_instruction + { + public: + soft_mask_instruction(std::string key, + bool luminosity, + std::array backdrop_rgb, + std::vector transfer, + std::array bbox_quad): + key_(std::move(key)), + luminosity_(luminosity), + backdrop_rgb_(backdrop_rgb), + transfer_(std::move(transfer)), + bbox_quad_(bbox_quad) + {} + + // The ExtGState resource name the mask came from, for diagnostics. + const std::string& get_key() const { return key_; } + + // /S /Luminosity reads the coverage off the group's colour, /S /Alpha + // off its alpha. + bool is_luminosity() const { return luminosity_; } + + // /BC, the colour the group is composited against, in device RGB. It is + // also what shows outside the group's /BBox. Black when absent. + const std::array& get_backdrop_rgb() const { return backdrop_rgb_; } + + // /TR sampled at 256 points, mapping luminosity (or alpha) to coverage; + // empty for the identity. + const std::vector& get_transfer() const { return transfer_; } + + // The group's /BBox as four page-space corners (x0, y0, ..., x3, y3). + const std::array& get_bbox_quad() const { return bbox_quad_; } + + // The group's content, as instructions in page space. + pdf_render_instructions& get_group() { return group_; } + const pdf_render_instructions& get_group() const { return group_; } + + private: + + std::string key_; + bool luminosity_; + std::array backdrop_rgb_; + std::vector transfer_; + std::array bbox_quad_; + + pdf_render_instructions group_; + }; + +} + +#endif diff --git a/src/parse/pdf_decoders/stream.h b/src/parse/pdf_decoders/stream.h index 12c8f38e6..d4ce2481d 100644 --- a/src/parse/pdf_decoders/stream.h +++ b/src/parse/pdf_decoders/stream.h @@ -101,6 +101,14 @@ namespace pdflib void do_postscript(const std::string& xobj_name, const xobject_subtype_name& xobj_subtype); + // Soft masks (11.6.5). A `gs` that installs one leaves its group + // undecoded on the graphics state; this decodes the group -- once per + // ExtGState entry and CTM -- into page-space instructions the renderer + // composites, and hands the result to the state. + void resolve_soft_mask(); + std::shared_ptr + decode_soft_mask(const soft_mask_spec& spec, const std::array& ctm); + // `sh`: resolve the named /Shading resource and emit the paint // instruction that covers the current clip region. void do_shading(const std::string& sh_name); @@ -189,6 +197,25 @@ namespace pdflib int inline_image_count = 0; inline_image_entry inline_image; + // Decoded soft masks, shared by every decoder of one page (forms, + // patterns, appearance streams) so a mask installed at page level and + // painted under inside a form is decoded once. Keyed by the parsed + // entry's address and the CTM at the `gs`; the entry is held alongside + // so its address cannot be reused while the key is live. A failed decode + // is kept, as null, so it is not retried. + struct soft_mask_cache_entry + { + std::shared_ptr spec; + std::shared_ptr mask; + }; + std::shared_ptr> + soft_mask_cache_ = + std::make_shared>(); + + // Greater than zero while decoding a soft mask's own group. A mask set + // inside a mask is not composited; it is reported instead. + int soft_mask_depth_ = 0; + // BDC/EMC pairs must balance within a single content stream (PDF 32000-1, // 14.6), so this stack is per-decoder; cells created by nested form // XObjects still land in the shared page_cells and are covered by the @@ -1024,6 +1051,7 @@ namespace pdflib grph_state.get_fill_alpha(), clip_state); instr.set_blend_mode(grph_state.get_blend_mode()); + instr.set_soft_mask(grph_state.get_soft_mask_instruction()); return instr; } } @@ -1344,6 +1372,7 @@ namespace pdflib current_graphic_state().get_fill_alpha(), std::move(clip_state)); shinstr.set_blend_mode(current_graphic_state().get_blend_mode()); + shinstr.set_soft_mask(current_graphic_state().get_soft_mask_instruction()); instructions.add_shading_instruction(std::move(shinstr)); this->Q(); @@ -1540,6 +1569,9 @@ namespace pdflib instructions, timings); + cell_stream.soft_mask_cache_ = soft_mask_cache_; + cell_stream.soft_mask_depth_ = soft_mask_depth_; + bool updated_stack = cell_stream.update_stack(stack, stack_count); cell_stream.current_graphic_state().materialize_pattern_fill_color(); @@ -1641,6 +1673,7 @@ namespace pdflib std::move(clip_state)); shinstr.set_blend_mode(current_graphic_state().get_blend_mode()); + shinstr.set_soft_mask(current_graphic_state().get_soft_mask_instruction()); instructions.add_shading_instruction(std::move(shinstr)); } @@ -1764,6 +1797,9 @@ namespace pdflib timings); + new_stream.soft_mask_cache_ = soft_mask_cache_; + new_stream.soft_mask_depth_ = soft_mask_depth_; + bool updated_stack = new_stream.update_stack(stack, stack_count); // copy the stack @@ -1949,6 +1985,150 @@ namespace pdflib this->Q(); } + void pdf_decoder::resolve_soft_mask() + { + if(not current_graphic_state().has_pending_soft_mask()) { return; } + + // Copies: decoding pushes states of its own and may reallocate the stack. + const std::shared_ptr spec = + current_graphic_state().get_soft_mask_spec(); + const std::array ctm = current_graphic_state().get_soft_mask_ctm(); + + if(soft_mask_depth_ > 0) + { + LOG_S(WARNING) << "ExtGState " << spec->key + << ": soft mask set inside a soft-mask group is not applied"; + return; + } + + std::string cache_key = std::to_string(reinterpret_cast(spec.get())); + for(double v : ctm) + { + cache_key += ':' + std::to_string(v); + } + + std::shared_ptr mask; + + auto found = soft_mask_cache_->find(cache_key); + if(found != soft_mask_cache_->end()) + { + mask = found->second.mask; + } + else + { + mask = decode_soft_mask(*spec, ctm); + (*soft_mask_cache_)[cache_key] = soft_mask_cache_entry{spec, mask}; + } + + if(mask) + { + current_graphic_state().set_soft_mask_instruction(mask); + } + } + + // The group is decoded the way `Do` decodes a form, with two differences: + // it lands in containers of its own, since what it paints is coverage and + // not page content (none of its cells, shapes or images may reach the + // page), and it starts from the CTM at the `gs` rather than the current one. + std::shared_ptr + pdf_decoder::decode_soft_mask(const soft_mask_spec& spec, + const std::array& ctm) + { + LOG_S(INFO) << "decoding the soft mask of ExtGState " << spec.key; + + try + { + pdf_resource form; + form.set("SMask:" + spec.key, spec.group); + + // the group's own resources, with the current ones as fallback + auto fonts_ = std::make_shared>(page_fonts); + auto grphs_ = std::make_shared>(page_grphs); + auto colorspaces_ = std::make_shared>(page_colorspaces); + auto shadings_ = std::make_shared>(page_shadings); + auto patterns_ = std::make_shared>(page_patterns); + auto xobjects_ = std::make_shared>(page_xobjects); + + if(form.has_fonts()) { QPDFObjectHandle r = form.get_fonts(); fonts_->set(r, timings); } + if(form.has_grphs()) { QPDFObjectHandle r = form.get_grphs(); grphs_->set(r, timings); } + if(form.has_colorspaces()) { QPDFObjectHandle r = form.get_colorspaces(); colorspaces_->set(r); } + if(form.has_shadings()) { QPDFObjectHandle r = form.get_shadings(); shadings_->set(r); } + if(form.has_patterns()) { QPDFObjectHandle r = form.get_patterns(); patterns_->set(r); } + if(form.has_xobjects()) { QPDFObjectHandle r = form.get_xobjects(); xobjects_->set(r, timings); } + + // Mask space is the group's /Matrix composed with the CTM at the `gs`, + // in the order `cm` composes them (form first, then the CTM). + const std::array fm = form.get_matrix(); + const std::array matrix = { + fm[0] * ctm[0] + fm[1] * ctm[3], + fm[0] * ctm[1] + fm[1] * ctm[4], + fm[2] * ctm[0] + fm[3] * ctm[3], + fm[2] * ctm[1] + fm[3] * ctm[4], + fm[4] * ctm[0] + fm[5] * ctm[3] + ctm[6], + fm[4] * ctm[1] + fm[5] * ctm[4] + ctm[7]}; + + // the /BBox corners in page space: outside them the backdrop shows + const std::array bbox = form.get_bbox(); + const double xs[4] = {bbox[0], bbox[2], bbox[2], bbox[0]}; + const double ys[4] = {bbox[1], bbox[1], bbox[3], bbox[3]}; + + std::array quad{}; + for(int c = 0; c < 4; c++) + { + quad[2 * c] = matrix[0] * xs[c] + matrix[2] * ys[c] + matrix[4]; + quad[2 * c + 1] = matrix[1] * xs[c] + matrix[3] * ys[c] + matrix[5]; + } + + auto mask = std::make_shared(spec.key, + spec.luminosity, + spec.backdrop_rgb, + spec.transfer, + quad); + + page_item mask_dimension; + page_item mask_cells; + page_item mask_shapes; + page_item mask_images; + + pdf_decoder mask_decoder(config, + mask_dimension, + mask_cells, + mask_shapes, + mask_images, + fonts_, + grphs_, + colorspaces_, + shadings_, + patterns_, + xobjects_, + mask->get_group(), + timings); + mask_decoder.soft_mask_cache_ = soft_mask_cache_; + mask_decoder.soft_mask_depth_ = soft_mask_depth_ + 1; + + QPDFObjectHandle group = spec.group; + mask_decoder.decode(group); + mask_decoder.set_base_matrix(matrix); + + std::vector parameters; + mask_decoder.interprete(parameters); + + for(const auto& item : mask_decoder.get_unknown_operators()) + { + unknown_operators.insert(item); + } + + return mask; + } + catch(const std::exception& exc) + { + LOG_S(WARNING) << "ExtGState " << spec.key + << ": soft mask group could not be decoded (" << exc.what() + << "), paint stays unmasked"; + return nullptr; + } + } + void pdf_decoder::do_postscript(const std::string& xobj_name, const xobject_subtype_name& xobj_subtype) { @@ -2211,6 +2391,7 @@ namespace pdflib { LOG_S(INFO) << "executing " << to_string(name); current_graphic_state().gs(parameters); + resolve_soft_mask(); } break; diff --git a/src/parse/pdf_resources/page_grph.h b/src/parse/pdf_resources/page_grph.h index d1a2ad66b..05e8282da 100644 --- a/src/parse/pdf_resources/page_grph.h +++ b/src/parse/pdf_resources/page_grph.h @@ -6,6 +6,23 @@ namespace pdflib { + // An ExtGState /SMask entry as parsed (ISO 32000-1, 11.6.5.1, Table 144): + // the transparency group that paints the mask, which of its planes the mask + // is read from, the backdrop the group is composited against and the + // transfer function that maps the result to coverage. + // + // The group's content stream is not decoded here. That needs the resources + // and the CTM in force at the `gs`, so the stream decoder does it, once + // per entry and CTM, into a soft_mask_instruction. + struct soft_mask_spec + { + std::string key; // ExtGState resource name + QPDFObjectHandle group; // /G, a form XObject stream + bool luminosity = true; // /S /Luminosity, else /Alpha + std::array backdrop_rgb = {0, 0, 0}; // /BC in device RGB, black when absent + std::vector transfer; // /TR sampled at 256 points, empty = identity + }; + // A single entry of a page's (or form's) /ExtGState resource dictionary, // ie the parameter set a `gs` operator installs into the graphics state. // @@ -50,12 +67,16 @@ namespace pdflib const std::vector& get_dash_array() const { return dash_array_; } double get_dash_phase() const { return dash_phase_; } - // Transparency parameters: parsed and reported, not composited. + // Transparency parameters. /BM and /SMask are composited by the + // renderer; /AIS is recorded only. bool has_blend_mode() const { return has_blend_mode_; } bool has_alpha_is_shape() const { return has_alpha_is_shape_; } blend_mode_name get_blend_mode() const { return blend_mode_; } soft_mask_state get_soft_mask() const { return soft_mask_; } + + // The parsed /SMask when get_soft_mask() is SOFT_MASK_PRESENT, else null. + const std::shared_ptr& get_soft_mask_spec() const { return soft_mask_spec_; } bool get_alpha_is_shape() const { return alpha_is_shape_; } // /Font: [font_ref size]. The font is an indirect reference to a font @@ -78,6 +99,11 @@ namespace pdflib void set_stroke_parameters(QPDFObjectHandle& qpdf_grph); void set_transparency_parameters(QPDFObjectHandle& qpdf_grph); + // Reads an /SMask dictionary. Null when it cannot be read, in which case + // the reason has been logged. + static std::shared_ptr parse_soft_mask(const std::string& key, + QPDFObjectHandle smask); + private: std::string key; @@ -108,6 +134,7 @@ namespace pdflib blend_mode_name blend_mode_ = BLEND_MODE_NORMAL; soft_mask_state soft_mask_ = SOFT_MASK_ABSENT; + std::shared_ptr soft_mask_spec_; bool has_alpha_is_shape_ = false; bool alpha_is_shape_ = false; @@ -325,9 +352,139 @@ namespace pdflib } } - // /BM, /SMask, /AIS, /Font and /RI are recorded but not applied: blending - // and soft masks need compositing support in the renderer, and /Font needs - // the text state, which `gs` does not reach. + std::shared_ptr + pdf_resource::parse_soft_mask(const std::string& key, + QPDFObjectHandle smask) + { + if(not smask.isDictionary()) + { + LOG_S(WARNING) << "ExtGState " << key + << ": /SMask is neither /None nor a dictionary, paint stays unmasked"; + return nullptr; + } + + if(not smask.hasKey("/G") or not smask.getKey("/G").isStream()) + { + LOG_S(WARNING) << "ExtGState " << key + << ": soft mask has no /G group stream, paint stays unmasked"; + return nullptr; + } + + auto spec = std::make_shared(); + spec->key = key; + spec->group = smask.getKey("/G"); + + if(smask.hasKey("/S") and smask.getKey("/S").isName()) + { + const std::string type = smask.getKey("/S").getName(); + if(type=="/Alpha") + { + spec->luminosity = false; + } + else if(type!="/Luminosity") + { + LOG_S(WARNING) << "ExtGState " << key << ": unknown soft mask type " + << type << ", reading it as /Luminosity"; + } + } + + // /BC is given in the group's colour space. The component count says + // which device space to read it as, the way SC/sc operands are read. + if(smask.hasKey("/BC") and smask.getKey("/BC").isArray()) + { + QPDFObjectHandle BC = smask.getKey("/BC"); + + std::vector comps; + for(int i=0; i(std::lround(255.0 * v)); }; + + if(comps.size()==1) + { + spec->backdrop_rgb = {to_byte(comps[0]), to_byte(comps[0]), to_byte(comps[0])}; + } + else if(comps.size()==3) + { + spec->backdrop_rgb = {to_byte(comps[0]), to_byte(comps[1]), to_byte(comps[2])}; + } + else if(comps.size()==4) + { + const double k = comps[3]; + spec->backdrop_rgb = {to_byte((1.0 - comps[0]) * (1.0 - k)), + to_byte((1.0 - comps[1]) * (1.0 - k)), + to_byte((1.0 - comps[2]) * (1.0 - k))}; + } + else + { + LOG_S(WARNING) << "ExtGState " << key << ": soft mask /BC has " + << comps.size() << " components, using black"; + } + } + + // /TR maps the group's luminosity (or alpha) to coverage. It is sampled + // once here so the renderer only indexes a table. + if(smask.hasKey("/TR")) + { + QPDFObjectHandle TR = smask.getKey("/TR"); + + if(TR.isName()) + { + if(TR.getName()!="/Identity") + { + LOG_S(WARNING) << "ExtGState " << key << ": soft mask /TR " + << TR.getName() << " is not a function, using the identity"; + } + } + else + { + pdf_function function; + if(function.set(TR) and function.get_num_inputs()==1) + { + std::vector table(256, 0); + std::vector out; + bool ok = true; + for(int i=0; i<256 and ok; i++) + { + ok = function.evaluate(i / 255.0, out) and not out.empty(); + if(ok) + { + const double v = std::min(1.0, std::max(0.0, out[0])); + table[i] = static_cast(std::lround(255.0 * v)); + } + } + + if(ok) + { + spec->transfer = std::move(table); + } + else + { + LOG_S(WARNING) << "ExtGState " << key + << ": soft mask /TR could not be evaluated, using the identity"; + } + } + else + { + LOG_S(WARNING) << "ExtGState " << key << ": soft mask /TR unreadable (" + << function.get_reason() << "), using the identity"; + } + } + } + + return spec; + } + + // /BM and /SMask are kept for the renderer to composite. /AIS, /Font and + // /RI are recorded but not applied: /Font needs the text state, which `gs` + // does not reach. // // The deviations are reported here rather than in `gs`, so that each // affected ExtGState is named exactly once per document instead of once per @@ -373,13 +530,16 @@ namespace pdflib { QPDFObjectHandle SMask = qpdf_grph.getKey("/SMask"); - soft_mask_ = (SMask.isName() and SMask.getName()=="/None")? - SOFT_MASK_NONE : SOFT_MASK_PRESENT; - - if(soft_mask_==SOFT_MASK_PRESENT) + if(SMask.isName() and SMask.getName()=="/None") { - LOG_S(WARNING) << "ExtGState " << key - << ": soft mask is not applied, paint stays unmasked"; + soft_mask_ = SOFT_MASK_NONE; + } + else + { + // A mask that cannot be read clears the current one rather than + // keeping a stale mask on paint it was never meant for. + soft_mask_spec_ = parse_soft_mask(key, SMask); + soft_mask_ = soft_mask_spec_ ? SOFT_MASK_PRESENT : SOFT_MASK_NONE; } } diff --git a/src/parse/pdf_states/bitmap.h b/src/parse/pdf_states/bitmap.h index ff6233e2b..e3ba4c988 100644 --- a/src/parse/pdf_states/bitmap.h +++ b/src/parse/pdf_states/bitmap.h @@ -1639,6 +1639,7 @@ namespace pdflib source, std::move(clip_state)); binstr.set_blend_mode(grph_state.get_blend_mode()); + binstr.set_soft_mask(grph_state.get_soft_mask_instruction()); binstr.set_filters(image.filters); instructions.add_bitmap_instruction(std::move(binstr)); diff --git a/src/parse/pdf_states/grph.h b/src/parse/pdf_states/grph.h index b11e837da..9626d9480 100644 --- a/src/parse/pdf_states/grph.h +++ b/src/parse/pdf_states/grph.h @@ -201,9 +201,29 @@ namespace pdflib return (blend_mode == BLEND_MODE_NORMAL) ? group_blend_mode : blend_mode; } - // /SMask is carried on the state (it is part of it and survives q/Q) but - // not composited yet. + // /SMask is part of the graphics state (it survives q/Q). A `gs` that + // installs one leaves the parsed entry and the CTM of that moment here; + // the stream decoder then decodes the group -- it has the resources and + // the state does not -- and hands back the instruction every paint under + // the mask carries. soft_mask_state get_soft_mask() const { return soft_mask; } + const std::shared_ptr& get_soft_mask_spec() const { return soft_mask_spec_; } + const std::array& get_soft_mask_ctm() const { return soft_mask_ctm_; } + + // True between the `gs` that installed a mask and the decode of its group. + bool has_pending_soft_mask() const + { + return soft_mask_spec_ != nullptr and soft_mask_instr_ == nullptr; + } + + void set_soft_mask_instruction(std::shared_ptr instr) + { + soft_mask_instr_ = std::move(instr); + } + const std::shared_ptr& get_soft_mask_instruction() const + { + return soft_mask_instr_; + } // Enters a transparency group XObject (11.6.6). The group is supposed to // be composited as a unit: its contents paint into their own buffer with a @@ -284,6 +304,12 @@ namespace pdflib blend_mode_name blend_mode; soft_mask_state soft_mask; + // the mask installed by the last `gs` that set one, the CTM at that `gs` + // (the mask's coordinate system, 11.6.5.1) and the decoded group + std::shared_ptr soft_mask_spec_; + std::array soft_mask_ctm_; + std::shared_ptr soft_mask_instr_; + // accumulated parameters of the enclosing transparency groups double group_alpha; blend_mode_name group_blend_mode; @@ -326,6 +352,12 @@ namespace pdflib blend_mode(BLEND_MODE_NORMAL), soft_mask(SOFT_MASK_NONE), + soft_mask_spec_(nullptr), + soft_mask_ctm_({1.0, 0.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 1.0}), + soft_mask_instr_(nullptr), + group_alpha(1.0), group_blend_mode(BLEND_MODE_NORMAL) {} @@ -371,6 +403,9 @@ namespace pdflib this->blend_mode = other.blend_mode; this->soft_mask = other.soft_mask; + this->soft_mask_spec_ = other.soft_mask_spec_; + this->soft_mask_ctm_ = other.soft_mask_ctm_; + this->soft_mask_instr_ = other.soft_mask_instr_; this->group_alpha = other.group_alpha; this->group_blend_mode = other.group_blend_mode; @@ -666,13 +701,23 @@ namespace pdflib dash_phase = grph.get_dash_phase(); } - // recorded for downstream reporting; neither is composited yet, the - // deviation is reported once per ExtGState in pdf_resource if(grph.has_blend_mode()) { blend_mode = grph.get_blend_mode(); } - if(grph.get_soft_mask()!=SOFT_MASK_ABSENT) + if(grph.get_soft_mask()==SOFT_MASK_NONE) + { + soft_mask = SOFT_MASK_NONE; + soft_mask_spec_ = nullptr; + soft_mask_instr_ = nullptr; + } + else if(grph.get_soft_mask()==SOFT_MASK_PRESENT) { - soft_mask = grph.get_soft_mask(); + // The mask's coordinate system is the CTM at this `gs`, not the one + // at the paint (11.6.5.1), so it is captured now. The group itself is + // decoded by the stream decoder right after this returns. + soft_mask = SOFT_MASK_PRESENT; + soft_mask_spec_ = grph.get_soft_mask_spec(); + soft_mask_ctm_ = trafo_matrix; + soft_mask_instr_ = nullptr; } LOG_S(INFO) << "gs " << key diff --git a/src/parse/pdf_states/shape.h b/src/parse/pdf_states/shape.h index 50db662d3..8948a4d1b 100644 --- a/src/parse/pdf_states/shape.h +++ b/src/parse/pdf_states/shape.h @@ -788,6 +788,7 @@ namespace pdflib grph_state.get_fill_alpha(), get_clip_state()); shpinstr.set_blend_mode(grph_state.get_blend_mode()); + shpinstr.set_soft_mask(grph_state.get_soft_mask_instruction()); instructions.add_shape_instruction(std::move(shpinstr)); } diff --git a/src/parse/pdf_states/text.h b/src/parse/pdf_states/text.h index 6848d35d1..855e8760d 100644 --- a/src/parse/pdf_states/text.h +++ b/src/parse/pdf_states/text.h @@ -1023,6 +1023,7 @@ namespace pdflib tinstr.set_clip_state(shape_state.get_clip_state()); tinstr.set_rendering_mode(rendering_mode); tinstr.set_blend_mode(grph_state.get_blend_mode()); + tinstr.set_soft_mask(grph_state.get_soft_mask_instruction()); tinstr.set_is_type3(font.is_type3()); // A Type3 glyph is a content-stream procedure, not a font glyph, so diff --git a/src/parse/utils/pdf_timings.h b/src/parse/utils/pdf_timings.h index ee0717138..117869b66 100644 --- a/src/parse/utils/pdf_timings.h +++ b/src/parse/utils/pdf_timings.h @@ -134,6 +134,7 @@ namespace pdflib static const std::string KEY_RENDER_BITMAP; static const std::string KEY_RENDER_SHAPE; static const std::string KEY_RENDER_SHADING; + static const std::string KEY_RENDER_SOFT_MASK; static const std::string KEY_RENDER_WIDGET; static const std::string KEY_RENDER_GET_CANVAS; @@ -507,6 +508,9 @@ namespace pdflib const std::string pdf_timings::KEY_RENDER_BITMAP = "render_page.bitmap"; const std::string pdf_timings::KEY_RENDER_SHAPE = "render_page.shape"; const std::string pdf_timings::KEY_RENDER_SHADING = "render_page.shading"; + // painting through ExtGState soft masks: the mask raster plus the masked + // paint itself, which the per-kind keys above do not see + const std::string pdf_timings::KEY_RENDER_SOFT_MASK = "render_page.soft_mask"; const std::string pdf_timings::KEY_RENDER_WIDGET = "render_page.widget"; const std::string pdf_timings::KEY_RENDER_GET_CANVAS = "render_page.get_canvas"; @@ -549,6 +553,7 @@ namespace pdflib KEY_RENDER_BITMAP, KEY_RENDER_SHAPE, KEY_RENDER_SHADING, + KEY_RENDER_SOFT_MASK, KEY_RENDER_WIDGET, KEY_RENDER_GET_CANVAS, KEY_DECODE_PAGE, @@ -679,6 +684,7 @@ namespace pdflib {KEY_RENDER_BITMAP, KEY_RENDER_PAGE}, {KEY_RENDER_SHAPE, KEY_RENDER_PAGE}, {KEY_RENDER_SHADING, KEY_RENDER_PAGE}, + {KEY_RENDER_SOFT_MASK, KEY_RENDER_PAGE}, {KEY_RENDER_WIDGET, KEY_RENDER_PAGE}, {KEY_RENDER_GET_CANVAS, KEY_RENDER_PAGE}, {KEY_RENDER_TEXT_FONT_RESOLVE, KEY_RENDER_TEXT}, diff --git a/src/render/blend2d_renderer.h b/src/render/blend2d_renderer.h index 8e3422121..429a9f081 100644 --- a/src/render/blend2d_renderer.h +++ b/src/render/blend2d_renderer.h @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -187,6 +188,33 @@ namespace pdflib mutable BLImage image_; // internal canvas (PRGB32 format) mutable BLContext context_; mutable bool context_active_ = false; + + // The context paint goes through: the page context, or an offscreen + // layer while a soft mask is being rasterised or applied. Routed through + // page_context(), so the paint routines know nothing of either. + mutable BLContext* context_override_ = nullptr; + + struct context_override_scope + { + context_override_scope(BLContext*& slot, BLContext* ctx): + slot_(slot), saved_(slot) + { + slot_ = ctx; + } + ~context_override_scope() { slot_ = saved_; } + + context_override_scope(const context_override_scope&) = delete; + context_override_scope& operator=(const context_override_scope&) = delete; + + BLContext*& slot_; + BLContext* saved_; + }; + + // Canvas-sized A8 coverage of every soft mask met on this page, keyed by + // the mask. One mask covers many objects (a shadow per photo, a feather + // per figure) and the group render behind the plane is the costly part. + // An empty image records a build that failed, so it is not retried. + std::unordered_map soft_mask_planes_; std::array shape_; // {height, width, 4}, display orientation int canvas_width_ = 0; // internal canvas width (unrotated page space) int canvas_height_ = 0; // internal canvas height (unrotated page space) @@ -811,6 +839,76 @@ namespace pdflib return true; } + // Same, with the mask read through a window at (ox, oy): the layer is a + // cut-out of the canvas and the mask covers the whole canvas. + static bool multiply_prgb32_by_a8_window(BLImage& layer, + const BLImage& mask, + int ox, int oy) + { + BLImageData d, s; + if(layer.make_mutable(&d) != BL_SUCCESS or mask.get_data(&s) != BL_SUCCESS) + { + return false; + } + if(d.format != BL_FORMAT_PRGB32 or s.format != BL_FORMAT_A8) { return false; } + if(d.size.w <= 0 or d.size.h <= 0) { return false; } + if(ox < 0 or oy < 0 or + ox + d.size.w > s.size.w or oy + d.size.h > s.size.h) + { + return false; + } + if(d.stride < static_cast(d.size.w) * 4 or + s.stride < s.size.w) + { + return false; + } + + for(int y = 0; y < d.size.h; y++) + { + uint8_t* drow = static_cast(d.pixel_data) + y * d.stride; + const uint8_t* srow = + static_cast(s.pixel_data) + (y + oy) * s.stride + ox; + for(int x = 0; x < d.size.w; x++) + { + const uint32_t m = srow[x]; + if(m == 255) { continue; } + uint8_t* px = drow + 4 * x; + for(int c = 0; c < 4; c++) + { + px[c] = static_cast((px[c] * m + 127) / 255); + } + } + } + return true; + } + + // Soft masks (ISO 32000-1, 11.6.5). An instruction painted under one is + // painted into a layer, the layer is multiplied by the mask's coverage + // and the result composited -- the same route a non-rectangular clip + // takes, with the coverage coming from the mask group's luminosity. + template + bool soft_mask_applies(const instruction_type& instr) const + { + // context_override_ is set while painting into a layer: the masked + // instruction re-enters its paint routine there and must not mask twice + return instr.has_soft_mask() + and config_.render_soft_masks + and context_override_ == nullptr + and has_canvas(); + } + + // The mask's coverage over the whole canvas, built on first use and + // cached per mask for the page. Null when it could not be built. + const BLImage* soft_mask_plane(soft_mask_instruction& mask); + + // Runs `paint` (which draws through page_context()) into a layer over + // `bbox`, knocks the layer back with the mask and blits it with `mode`. + template + void paint_through_soft_mask(soft_mask_instruction& mask, + const BLRect& bbox, + blend_mode_name mode, + paint_type&& paint); + static bool fill_a8(BLImage& image, uint8_t value) { BLImageData d; @@ -1738,6 +1836,11 @@ namespace pdflib inline BLContext& renderer::page_context() { + if (context_override_ != nullptr) + { + return *context_override_; + } + if (context_active_) { return context_; @@ -2735,6 +2838,24 @@ namespace pdflib inline void renderer::render_text(text_instruction& instr) { + // Type3 and invisible text paint no glyphs, so there is nothing to mask. + if (soft_mask_applies(instr) and not instr.is_type3() and not instr.is_invisible()) + { + const text_geometry geom = make_text_geometry(instr); + if (geom.quad_h >= 0.5) + { + // glyphs overhang their cell, so the layer takes a margin of the size + BLRect bbox = axis_aligned_rect(geom.bbox); + const double margin = geom.size + 2.0; + bbox = BLRect(bbox.x - margin, bbox.y - margin, + bbox.w + 2.0 * margin, bbox.h + 2.0 * margin); + + paint_through_soft_mask(*instr.get_soft_mask(), bbox, instr.get_blend_mode(), + [&]() { render_text(instr); }); + return; + } + } + utils::scoped_timer phase_timer(timings_, pdf_timings::KEY_RENDER_TEXT); // LOG_S(INFO) << __FUNCTION__; @@ -3385,6 +3506,22 @@ namespace pdflib inline void renderer::render_bitmap(bitmap_instruction& instr) { + if (soft_mask_applies(instr)) + { + const bitmap_quad quad = { + canvas_x(instr.get_r_x0()), canvas_y(instr.get_r_y0()), + canvas_x(instr.get_r_x1()), canvas_y(instr.get_r_y1()), + canvas_x(instr.get_r_x2()), canvas_y(instr.get_r_y2()), + canvas_x(instr.get_r_x3()), canvas_y(instr.get_r_y3()) + }; + BLRect bbox = axis_aligned_rect(quad); + bbox = BLRect(bbox.x - 1.0, bbox.y - 1.0, bbox.w + 2.0, bbox.h + 2.0); + + paint_through_soft_mask(*instr.get_soft_mask(), bbox, instr.get_blend_mode(), + [&]() { render_bitmap(instr); }); + return; + } + utils::scoped_timer phase_timer(timings_, pdf_timings::KEY_RENDER_BITMAP); LOG_S(INFO) << __FUNCTION__ << " for xobject_key=" << instr.get_key(); @@ -3818,8 +3955,237 @@ namespace pdflib // implement dashing. // --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // soft_mask_plane / paint_through_soft_mask + // + // A soft mask is a transparency group whose luminosity (or alpha) is the + // coverage of everything painted under it (11.6.5). The group arrives as + // page-space instructions; it is painted once onto a page-sized canvas over + // its /BC backdrop, read back as an A8 plane through /TR, and the plane is + // then applied to each masked instruction the way a non-rectangular clip is. + // --------------------------------------------------------------------------- + + inline const BLImage* renderer::soft_mask_plane(soft_mask_instruction& mask) + { + auto found = soft_mask_planes_.find(&mask); + if (found != soft_mask_planes_.end()) + { + return found->second.is_empty() ? nullptr : &found->second; + } + + BLImage& plane = soft_mask_planes_[&mask]; + + const int w = canvas_width_; + const int h = canvas_height_; + if (w <= 0 or h <= 0 or w > 8192 or h > 8192) + { + LOG_S(WARNING) << "soft_mask_plane: canvas " << w << "x" << h + << " is too large for a mask plane; ExtGState " + << mask.get_key() << " paints unmasked"; + return nullptr; + } + + // 1. The group over its backdrop, on a canvas of the page's size: its + // instructions are in page space, like the page's own. + BLImage group; + if (group.create(w, h, BL_FORMAT_PRGB32) != BL_SUCCESS) + { + LOG_S(WARNING) << "soft_mask_plane: could not allocate the group canvas;" + << " ExtGState " << mask.get_key() << " paints unmasked"; + return nullptr; + } + + { + BLContext gctx(group); + gctx.set_comp_op(BL_COMP_OP_SRC_COPY); + if (mask.is_luminosity()) + { + gctx.fill_all(make_rgba32(mask.get_backdrop_rgb(), 1.0)); + } + else + { + gctx.fill_all(BLRgba32(0x00000000u)); + } + gctx.set_comp_op(BL_COMP_OP_SRC_OVER); + + { + context_override_scope scope(context_override_, &gctx); + mask.get_group().iterate_over_instructions(*this, false); + } + gctx.end(); + } + + // 2. Coverage of the group's /BBox: beyond it the backdrop shows. + BLImage inside; + if (inside.create(w, h, BL_FORMAT_A8) != BL_SUCCESS) + { + LOG_S(WARNING) << "soft_mask_plane: could not allocate the bbox plane;" + << " ExtGState " << mask.get_key() << " paints unmasked"; + return nullptr; + } + + { + const std::array& q = mask.get_bbox_quad(); + BLPath quad; + quad.move_to(canvas_x(q[0]), canvas_y(q[1])); + for (int c = 1; c < 4; c++) + { + quad.line_to(canvas_x(q[2 * c]), canvas_y(q[2 * c + 1])); + } + quad.close(); + + if (not rasterize_path_to_a8(inside, quad, BL_FILL_RULE_NON_ZERO)) + { + LOG_S(WARNING) << "soft_mask_plane: could not rasterise the bbox;" + << " ExtGState " << mask.get_key() << " paints unmasked"; + return nullptr; + } + } + + // 3. Luminosity (or alpha), through /TR, into coverage. + if (plane.create(w, h, BL_FORMAT_A8) != BL_SUCCESS) + { + LOG_S(WARNING) << "soft_mask_plane: could not allocate the mask plane;" + << " ExtGState " << mask.get_key() << " paints unmasked"; + plane.reset(); + return nullptr; + } + + BLImageData g, in, p; + if (group.get_data(&g) != BL_SUCCESS or + inside.get_data(&in) != BL_SUCCESS or + plane.make_mutable(&p) != BL_SUCCESS) + { + plane.reset(); + return nullptr; + } + + const bool luminosity = mask.is_luminosity(); + const std::vector& transfer = mask.get_transfer(); + + // 0.3 R + 0.59 G + 0.11 B (11.6.5.2) in 8.8 fixed point + auto luminance = [](uint32_t r, uint32_t g_, uint32_t b) -> int + { + return static_cast((77 * r + 151 * g_ + 28 * b + 128) >> 8); + }; + auto through_transfer = [&](int v) -> int + { + return transfer.empty() ? v : transfer[static_cast(v)]; + }; + + const std::array& bc = mask.get_backdrop_rgb(); + const int outside = luminosity + ? through_transfer(luminance(bc[0], bc[1], bc[2])) + : through_transfer(0); + + for (int y = 0; y < h; y++) + { + const uint8_t* grow = static_cast(g.pixel_data) + y * g.stride; + const uint8_t* irow = static_cast(in.pixel_data) + y * in.stride; + uint8_t* prow = static_cast(p.pixel_data) + y * p.stride; + + for (int x = 0; x < w; x++) + { + uint32_t px = 0; + std::memcpy(&px, grow + 4 * x, sizeof(px)); + + // PRGB32 is 0xAARRGGBB; the group canvas is opaque for a + // luminosity mask, so its colour is not premultiplied away. + const int value = luminosity + ? luminance((px >> 16) & 0xFFu, (px >> 8) & 0xFFu, px & 0xFFu) + : static_cast(px >> 24); + + const int covered = irow[x]; + prow[x] = static_cast( + (through_transfer(value) * covered + outside * (255 - covered) + 127) / 255); + } + } + + LOG_S(INFO) << "soft_mask_plane: built " << w << "x" << h + << " plane for ExtGState " << mask.get_key() + << " (" << (luminosity ? "luminosity" : "alpha") << ")"; + return &plane; + } + + template + inline void renderer::paint_through_soft_mask(soft_mask_instruction& mask, + const BLRect& bbox, + blend_mode_name mode, + paint_type&& paint) + { + utils::scoped_timer phase_timer(timings_, pdf_timings::KEY_RENDER_SOFT_MASK); + + const BLImage* plane = soft_mask_plane(mask); + if (plane == nullptr) + { + // reported by soft_mask_plane; unmasked paint is what the page got + // before masks were composited at all + paint(); + return; + } + + const BLRectI area = canvas_clamped_rect(bbox); + if (area.w <= 0 or area.h <= 0) { return; } + + BLImage layer; + if (layer.create(area.w, area.h, BL_FORMAT_PRGB32) != BL_SUCCESS) + { + LOG_S(WARNING) << "paint_through_soft_mask: could not allocate a " + << area.w << "x" << area.h << " layer, painting unmasked"; + paint(); + return; + } + + { + BLContext lctx(layer); + lctx.set_comp_op(BL_COMP_OP_SRC_COPY); + lctx.fill_all(BLRgba32(0x00000000u)); + lctx.set_comp_op(BL_COMP_OP_SRC_OVER); + + // the layer is a window onto the canvas, so the paint routine draws in + // canvas coordinates as it always does + lctx.translate(-area.x, -area.y); + { + context_override_scope scope(context_override_, &lctx); + paint(); + } + lctx.end(); + } + + if (not multiply_prgb32_by_a8_window(layer, *plane, area.x, area.y)) + { + LOG_S(WARNING) << "paint_through_soft_mask: could not apply the mask of ExtGState " + << mask.get_key(); + } + + // The blend mode belongs to this composite. The paint routine also set + // it on the layer, where it had nothing to blend with and painted as + // Normal, which is what a layer needs. + BLContext& ctx = page_context(); + const blend_mode_scope blend(ctx, mode); + ctx.blit_image(BLPointI(area.x, area.y), layer); + } + inline void renderer::render_shape(shape_instruction& instr) { + if (soft_mask_applies(instr)) + { + BLRect bbox; + const BLPath path = make_shape_path(instr, bbox); + if (path.is_empty()) { return; } + + // a stroke reaches beyond the path; the layer must hold all of it + const double reach = + instr.get_line_width() * 0.5 * (scale_x_ + scale_y_) + * std::max(1.0, instr.get_miter_limit()) + 2.0; + bbox = BLRect(bbox.x - reach, bbox.y - reach, + bbox.w + 2.0 * reach, bbox.h + 2.0 * reach); + + paint_through_soft_mask(*instr.get_soft_mask(), bbox, instr.get_blend_mode(), + [&]() { render_shape(instr); }); + return; + } + utils::scoped_timer phase_timer(timings_, pdf_timings::KEY_RENDER_SHAPE); if (not has_canvas()) { return; } @@ -4008,6 +4374,18 @@ namespace pdflib inline void renderer::render_shading(shading_instruction& instr) { + if (soft_mask_applies(instr)) + { + // a shading covers its clip region, which can be the whole page + const BLRect bbox(0.0, 0.0, + static_cast(canvas_width_), + static_cast(canvas_height_)); + + paint_through_soft_mask(*instr.get_soft_mask(), bbox, instr.get_blend_mode(), + [&]() { render_shading(instr); }); + return; + } + utils::scoped_timer phase_timer(timings_, pdf_timings::KEY_RENDER_SHADING); if (not has_canvas()) { return; } diff --git a/src/render/config.h b/src/render/config.h index 36b20b36e..31b35941e 100644 --- a/src/render/config.h +++ b/src/render/config.h @@ -38,6 +38,13 @@ namespace pdflib // simple rectangular clips are applied; non-rectangular clips are ignored. bool render_non_rect_clip_masks = true; + // Composite paint through ExtGState soft masks (/SMask, ISO 32000-1 + // 11.6.5): the mask group is rasterised once per page and its luminosity + // (or alpha) scales everything painted under it. When false, such paint + // lands unmasked, which is what a drop shadow or feathered photo edge + // looks like as an opaque box. + bool render_soft_masks = true; + // Minimum stroke width in device pixels. PDF hairlines (`0 w`) and // sub-pixel strokes are promoted to this width so they remain visible. float min_stroke_width = 1.0f; diff --git a/tests/test_unit_soft_masks.py b/tests/test_unit_soft_masks.py new file mode 100644 index 000000000..e855100f4 --- /dev/null +++ b/tests/test_unit_soft_masks.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python +"""ExtGState soft masks (ISO 32000-1, 11.6.5). + +An ExtGState `/SMask` names a transparency group whose luminosity (or alpha) +becomes the coverage of everything painted while the mask is in force. The +mask's coordinate system is the CTM at the `gs` that installed it, its `/BBox` +bounds it (outside, the `/BC` backdrop shows), and `/TR` remaps the result. + +InDesign builds drop shadows and feathered photo edges from exactly this, so a +reader that ignores the mask paints the effect as an opaque box over the +photo. These tests assert on rendered pixels, since compositing has no other +output. +""" + +from io import BytesIO +from typing import List, Tuple + +import pytest +from PIL import Image as PILImage + +from docling_parse.pdf_parser import ( + DecodeConfig, + DoclingThreadedPdfParser, + RenderConfig, + ThreadedPdfParserConfig, +) + +PAGE_WIDTH = 200 +PAGE_HEIGHT = 100 + +# Blend2D composites in 8-bit: 0.5 of 255 lands on 127 or 128 depending on +# the rounding of each step, and a gradient sample is a pixel wide. +COLOR_TOLERANCE = 3 + +RED = (255, 0, 0) +BLUE = (0, 0, 255) +HALF_BLUE_ON_RED = (128, 0, 128) + +# a red page with a blue box in the middle, painted under /G1 +BOX = f"0 0 1 rg 50 25 {PAGE_WIDTH // 2} {PAGE_HEIGHT // 2} re f" +MASKED_BOX = f"1 0 0 rg 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f /G1 gs {BOX}" + +FULL_BBOX = f"[0 0 {PAGE_WIDTH} {PAGE_HEIGHT}]" + + +def _stream(entries: str, data: str) -> str: + return f"<< {entries} /Length {len(data)} >>\nstream\n{data}\nendstream" + + +def _group(content: str, bbox: str = FULL_BBOX, extra: str = "") -> str: + """A transparency-group form XObject, the /G of a soft mask.""" + return _stream( + "/Type /XObject /Subtype /Form " + f"/BBox {bbox} /Group << /S /Transparency /CS /DeviceGray >> {extra}", + content, + ) + + +def _build_pdf(content: str, resources: str, objects: List[str]) -> bytes: + """One page. Objects 1-4 are catalog, pages, page and content; `objects` + follow from 5 on, which is how `resources` refers to them.""" + all_objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {PAGE_WIDTH} {PAGE_HEIGHT}] " + f"/Resources << {resources} >> /Contents 4 0 R >>", + _stream("", content), + *objects, + ] + + out = b"%PDF-1.7\n" + offsets = [] + for index, obj in enumerate(all_objects, start=1): + offsets.append(len(out)) + out += f"{index} 0 obj\n{obj}\nendobj\n".encode("latin-1") + + startxref = len(out) + out += f"xref\n0 {len(all_objects) + 1}\n".encode("latin-1") + out += b"0000000000 65535 f \n" + for offset in offsets: + out += f"{offset:010d} 00000 n \n".encode("latin-1") + out += ( + f"trailer\n<< /Size {len(all_objects) + 1} /Root 1 0 R >>\n" + f"startxref\n{startxref}\n%%EOF" + ).encode("latin-1") + return out + + +def _render(pdf: bytes, render_soft_masks: bool = True) -> PILImage.Image: + render_config = RenderConfig() + render_config.scale = 1.0 + render_config.render_soft_masks = render_soft_masks + + parser = DoclingThreadedPdfParser( + parser_config=ThreadedPdfParserConfig( + loglevel="fatal", + threads=1, + max_concurrent_results=1, + render_config=render_config, + ), + decode_config=DecodeConfig(), + ) + key = parser.load(BytesIO(pdf)) + try: + results = list(parser.iterate_results()) + assert len(results) == 1 + return results[0].get_image().convert("RGB") + finally: + parser.unload(key) + + +def _pixel(image: PILImage.Image, x: int, y: int) -> Tuple[int, int, int]: + """The pixel at PDF user-space coordinates (x, y), y up.""" + pixel = image.getpixel((x, PAGE_HEIGHT - 1 - y)) + assert isinstance(pixel, tuple) and len(pixel) >= 3, ( + f"expected an RGB pixel, got {pixel!r}" + ) + red, green, blue = pixel[:3] + return (red, green, blue) + + +def _assert_color( + actual: Tuple[int, int, int], + expected: Tuple[int, int, int], + where: str, + tolerance: int = COLOR_TOLERANCE, +) -> None: + deltas = [abs(a - e) for a, e in zip(actual, expected)] + assert max(deltas) <= tolerance, ( + f"{where}: expected ~{expected}, got {actual} (max delta {max(deltas)})" + ) + + +def _smask(group_ref: str, extra: str = "") -> str: + return f"<< /SMask << /S /Luminosity /G {group_ref} {extra} >> >>" + + +# --------------------------------------------------------------------------- +# luminosity is coverage +# --------------------------------------------------------------------------- + + +def test_luminosity_mask_halves_the_paint(): + """The page of docling-parse issue #384: a 50% grey mask lets half of the + blue through, as pdfium and MuPDF draw it.""" + pdf = _build_pdf( + MASKED_BOX, + "/ExtGState << /G1 5 0 R >>", + [_smask("6 0 R"), _group(f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f")], + ) + image = _render(pdf) + _assert_color(_pixel(image, 100, 50), HALF_BLUE_ON_RED, "inside the box") + _assert_color(_pixel(image, 10, 10), RED, "outside the box") + + +def test_mask_is_read_where_it_is_painted(): + """A mask that is black on the left and white on the right hides the left + half of the box and leaves the right half opaque.""" + pdf = _build_pdf( + MASKED_BOX, + "/ExtGState << /G1 5 0 R >>", + [ + _smask("6 0 R"), + _group( + f"0 g 0 0 {PAGE_WIDTH // 2} {PAGE_HEIGHT} re f " + f"1 g {PAGE_WIDTH // 2} 0 {PAGE_WIDTH // 2} {PAGE_HEIGHT} re f" + ), + ], + ) + image = _render(pdf) + _assert_color(_pixel(image, 75, 50), RED, "under the black half") + _assert_color(_pixel(image, 125, 50), BLUE, "under the white half") + + +def test_gradient_mask_feathers_the_edge(): + """An axial shading in the group fades the box in from left to right.""" + shading = ( + "<< /Sh0 << /ShadingType 2 /ColorSpace /DeviceGray /Coords [50 0 150 0] " + "/Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> " + "/Extend [true true] >> >>" + ) + pdf = _build_pdf( + MASKED_BOX, + "/ExtGState << /G1 5 0 R >>", + [ + _smask("6 0 R"), + _group("/Sh0 sh", extra=f"/Resources << /Shading {shading} >>"), + ], + ) + image = _render(pdf) + + blues = [_pixel(image, x, 50)[2] for x in (60, 100, 140)] + assert blues[0] < blues[1] < blues[2], f"blue should rise left to right: {blues}" + assert blues[2] - blues[0] > 150, f"the fade should span most of the range: {blues}" + _assert_color(_pixel(image, 100, 50), HALF_BLUE_ON_RED, "mid-fade", tolerance=8) + + +def test_strokes_are_masked_too(): + pdf = _build_pdf( + f"1 0 0 rg 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f /G1 gs " + "0 0 1 RG 20 w 50 50 m 150 50 l S", + "/ExtGState << /G1 5 0 R >>", + [_smask("6 0 R"), _group(f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f")], + ) + image = _render(pdf) + _assert_color(_pixel(image, 100, 50), HALF_BLUE_ON_RED, "on the stroke") + _assert_color(_pixel(image, 100, 80), RED, "beside the stroke") + + +def test_images_are_masked_too(): + """A photo under a mask is the InDesign case: a 1x1 blue image scaled to + the box.""" + pdf = _build_pdf( + f"1 0 0 rg 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f /G1 gs " + "q 100 0 0 50 50 25 cm /Im1 Do Q", + "/ExtGState << /G1 5 0 R >> /XObject << /Im1 7 0 R >>", + [ + _smask("6 0 R"), + _group(f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f"), + _stream( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 " + "/ColorSpace /DeviceRGB /BitsPerComponent 8", + "\x00\x00\xff", + ), + ], + ) + image = _render(pdf) + _assert_color(_pixel(image, 100, 50), HALF_BLUE_ON_RED, "inside the image") + + +def _circle_path(cx: float, cy: float, r: float) -> str: + """Four-bezier circle, as a PDF path without a painting operator.""" + k = 0.5523 * r + return ( + f"{cx + r} {cy} m " + f"{cx + r} {cy + k} {cx + k} {cy + r} {cx} {cy + r} c " + f"{cx - k} {cy + r} {cx - r} {cy + k} {cx - r} {cy} c " + f"{cx - r} {cy - k} {cx - k} {cy - r} {cx} {cy - r} c " + f"{cx + k} {cy - r} {cx + r} {cy - k} {cx + r} {cy} c h" + ) + + +def test_mask_combines_with_a_curved_clip(): + """Inside a circular clip the mask applies; outside it nothing paints. + The curved clip takes the renderer's coverage-mask route, so this is the + mask layer inside the soft-mask layer.""" + pdf = _build_pdf( + f"1 0 0 rg 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f " + f"{_circle_path(100, 50, 20)} W n /G1 gs {BOX}", + "/ExtGState << /G1 5 0 R >>", + [_smask("6 0 R"), _group(f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f")], + ) + image = _render(pdf) + _assert_color(_pixel(image, 100, 50), HALF_BLUE_ON_RED, "inside the clip") + _assert_color(_pixel(image, 60, 35), RED, "in the box but outside the clip") + + +# --------------------------------------------------------------------------- +# /BBox, /BC, /TR and /S +# --------------------------------------------------------------------------- + + +def test_outside_the_bbox_the_backdrop_shows(): + """The group covers the left half only. Beyond its /BBox the mask is the + backdrop, black by default, so the right half of the box vanishes.""" + pdf = _build_pdf( + MASKED_BOX, + "/ExtGState << /G1 5 0 R >>", + [ + _smask("6 0 R"), + _group( + f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f", + bbox=f"[0 0 {PAGE_WIDTH // 2} {PAGE_HEIGHT}]", + ), + ], + ) + image = _render(pdf) + _assert_color(_pixel(image, 75, 50), HALF_BLUE_ON_RED, "inside the bbox") + _assert_color(_pixel(image, 125, 50), RED, "outside the bbox") + + +def test_bc_sets_the_backdrop(): + """With /BC white, beyond the /BBox the mask is fully opaque.""" + pdf = _build_pdf( + MASKED_BOX, + "/ExtGState << /G1 5 0 R >>", + [ + _smask("6 0 R", extra="/BC [1]"), + _group( + f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f", + bbox=f"[0 0 {PAGE_WIDTH // 2} {PAGE_HEIGHT}]", + ), + ], + ) + image = _render(pdf) + _assert_color(_pixel(image, 75, 50), HALF_BLUE_ON_RED, "inside the bbox") + _assert_color(_pixel(image, 125, 50), BLUE, "outside the bbox") + + +def test_tr_remaps_the_luminosity(): + """An inverting /TR turns a 25% grey group into 75% coverage.""" + pdf = _build_pdf( + MASKED_BOX, + "/ExtGState << /G1 5 0 R >>", + [ + _smask( + "6 0 R", + extra="/TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>", + ), + _group(f"0.25 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f"), + ], + ) + image = _render(pdf) + _assert_color(_pixel(image, 100, 50), (64, 0, 191), "inside the box", tolerance=4) + + +def test_alpha_mask_reads_the_group_alpha(): + """/S /Alpha takes the group's alpha: a fill at /ca 0.5 is 50% coverage.""" + pdf = _build_pdf( + MASKED_BOX, + "/ExtGState << /G1 5 0 R >>", + [ + "<< /SMask << /S /Alpha /G 6 0 R >> >>", + _group( + f"/GA gs 0 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f", + extra="/Resources << /ExtGState << /GA << /ca 0.5 >> >> >>", + ), + ], + ) + image = _render(pdf) + _assert_color(_pixel(image, 100, 50), HALF_BLUE_ON_RED, "inside the box") + + +# --------------------------------------------------------------------------- +# the graphics state +# --------------------------------------------------------------------------- + + +def test_smask_none_clears_the_mask(): + pdf = _build_pdf( + f"1 0 0 rg 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f /G1 gs /G2 gs {BOX}", + "/ExtGState << /G1 5 0 R /G2 7 0 R >>", + [ + _smask("6 0 R"), + _group(f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f"), + "<< /SMask /None >>", + ], + ) + image = _render(pdf) + _assert_color(_pixel(image, 100, 50), BLUE, "after /SMask /None") + + +def test_mask_is_part_of_the_saved_state(): + objects = [ + _smask("6 0 R"), + _group(f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f"), + ] + resources = "/ExtGState << /G1 5 0 R >>" + red_page = f"1 0 0 rg 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f " + + # installed inside q/Q: gone after the Q + image = _render(_build_pdf(red_page + f"q /G1 gs Q {BOX}", resources, objects)) + _assert_color(_pixel(image, 100, 50), BLUE, "after Q restored the state") + + # installed before a q/Q pair: still there after the Q + image = _render( + _build_pdf(red_page + f"/G1 gs q 1 0 0 1 0 0 cm Q {BOX}", resources, objects) + ) + _assert_color( + _pixel(image, 100, 50), HALF_BLUE_ON_RED, "mask survives a nested q/Q" + ) + + +def test_mask_space_is_the_ctm_at_gs(): + """The mask is black left of x=100 and white right of it in its own + space. Installed under a CTM shifted right by 50, its edge sits at page + x=150, so the whole box is under black even though the box is painted + after the shift is undone. Reading the mask at paint time would put the + edge at x=100 and show the right half.""" + pdf = _build_pdf( + f"1 0 0 rg 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f " + f"1 0 0 1 50 0 cm /G1 gs 1 0 0 1 -50 0 cm {BOX}", + "/ExtGState << /G1 5 0 R >>", + [ + _smask("6 0 R"), + _group( + f"0 g 0 0 {PAGE_WIDTH // 2} {PAGE_HEIGHT} re f " + f"1 g {PAGE_WIDTH // 2} 0 {PAGE_WIDTH // 2} {PAGE_HEIGHT} re f" + ), + ], + ) + image = _render(pdf) + _assert_color(_pixel(image, 75, 50), RED, "left of the box") + _assert_color(_pixel(image, 125, 50), RED, "right of the box, still under black") + + +def test_mask_applies_inside_a_form_xobject(): + """A mask installed on the page masks what a form paints.""" + pdf = _build_pdf( + f"1 0 0 rg 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f /G1 gs /Fm1 Do", + "/ExtGState << /G1 5 0 R >> /XObject << /Fm1 7 0 R >>", + [ + _smask("6 0 R"), + _group(f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f"), + _stream(f"/Type /XObject /Subtype /Form /BBox {FULL_BBOX}", BOX), + ], + ) + image = _render(pdf) + _assert_color(_pixel(image, 100, 50), HALF_BLUE_ON_RED, "inside the form's box") + + +def test_mask_inside_the_mask_group_is_not_applied(): + """The group's own ExtGState sets a mask that would hide everything; it + is reported and skipped, and the outer mask still applies.""" + pdf = _build_pdf( + MASKED_BOX, + "/ExtGState << /G1 5 0 R >>", + [ + _smask("6 0 R"), + _group( + f"/GN gs 0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f", + extra="/Resources << /ExtGState << /GN 7 0 R >> >>", + ), + _smask("8 0 R"), + _group(f"0 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f"), + ], + ) + image = _render(pdf) + _assert_color(_pixel(image, 100, 50), HALF_BLUE_ON_RED, "outer mask only") + + +# --------------------------------------------------------------------------- +# the switch +# --------------------------------------------------------------------------- + + +def test_render_soft_masks_off_paints_unmasked(): + pdf = _build_pdf( + MASKED_BOX, + "/ExtGState << /G1 5 0 R >>", + [_smask("6 0 R"), _group(f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f")], + ) + image = _render(pdf, render_soft_masks=False) + _assert_color(_pixel(image, 100, 50), BLUE, "masks switched off") + + +@pytest.mark.parametrize( + "smask, description", + [ + ("<< /SMask << /S /Luminosity >> >>", "no /G"), + ("<< /SMask 42 >>", "not a dictionary"), + ], +) +def test_unreadable_mask_paints_unmasked(smask: str, description: str): + pdf = _build_pdf(MASKED_BOX, "/ExtGState << /G1 5 0 R >>", [smask]) + image = _render(pdf) + _assert_color(_pixel(image, 100, 50), BLUE, description) From ba1ab9ff72894c367ee6d51ae28e790a5d8da25b Mon Sep 17 00:00:00 2001 From: Jeff Witt <1848307+wittjeff@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:46:52 -0400 Subject: [PATCH 2/4] fix(render): bound the soft-mask caches per page A page can install a new soft mask with every `gs` for next to nothing, and each decoded mask cost an instruction list at parse time and a canvas-sized A8 plane at render time, so a hostile page could hold gigabytes. - The renderer keeps at most 256 MB of mask planes per page; past that the cache is dropped and planes are rebuilt as needed. - The decoder decodes at most 512 distinct masks per page; the rest of the page paints unmasked, reported once. - The CTM in the decode cache key is quantised to 1e-3, so float noise between two `gs` of the same mask does not make two decodes. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com> --- src/parse/pdf_decoders/stream.h | 36 ++++++++++++++++++++++++++------- src/render/blend2d_renderer.h | 27 +++++++++++++++++++++++-- tests/test_unit_soft_masks.py | 25 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/parse/pdf_decoders/stream.h b/src/parse/pdf_decoders/stream.h index d4ce2481d..689f607c0 100644 --- a/src/parse/pdf_decoders/stream.h +++ b/src/parse/pdf_decoders/stream.h @@ -203,14 +203,23 @@ namespace pdflib // entry's address and the CTM at the `gs`; the entry is held alongside // so its address cannot be reused while the key is live. A failed decode // is kept, as null, so it is not retried. + // + // A page can install a new mask with every `gs` for next to nothing, so + // the number of decodes is capped: past max_soft_mask_decodes the rest of + // the page paints unmasked, reported once. struct soft_mask_cache_entry { std::shared_ptr spec; std::shared_ptr mask; }; - std::shared_ptr> - soft_mask_cache_ = - std::make_shared>(); + struct soft_mask_page_cache + { + std::unordered_map decoded; + bool budget_reported = false; + }; + static constexpr std::size_t max_soft_mask_decodes = 512; + std::shared_ptr soft_mask_cache_ = + std::make_shared(); // Greater than zero while decoding a soft mask's own group. A mask set // inside a mask is not composited; it is reported instead. @@ -2001,23 +2010,36 @@ namespace pdflib return; } + // the CTM is quantised so that float noise between two `gs` of the same + // mask does not make two decodes std::string cache_key = std::to_string(reinterpret_cast(spec.get())); for(double v : ctm) { - cache_key += ':' + std::to_string(v); + cache_key += ':' + std::to_string(std::round(v * 1000.0) / 1000.0); } std::shared_ptr mask; - auto found = soft_mask_cache_->find(cache_key); - if(found != soft_mask_cache_->end()) + auto& decoded = soft_mask_cache_->decoded; + auto found = decoded.find(cache_key); + if(found != decoded.end()) { mask = found->second.mask; } + else if(decoded.size() >= max_soft_mask_decodes) + { + if(not soft_mask_cache_->budget_reported) + { + LOG_S(WARNING) << "ExtGState " << spec->key << ": " << decoded.size() + << " soft masks decoded on this page already, the rest paint unmasked"; + soft_mask_cache_->budget_reported = true; + } + return; + } else { mask = decode_soft_mask(*spec, ctm); - (*soft_mask_cache_)[cache_key] = soft_mask_cache_entry{spec, mask}; + decoded[cache_key] = soft_mask_cache_entry{spec, mask}; } if(mask) diff --git a/src/render/blend2d_renderer.h b/src/render/blend2d_renderer.h index 429a9f081..78f4b4835 100644 --- a/src/render/blend2d_renderer.h +++ b/src/render/blend2d_renderer.h @@ -215,6 +215,14 @@ namespace pdflib // per figure) and the group render behind the plane is the costly part. // An empty image records a build that failed, so it is not retried. std::unordered_map soft_mask_planes_; + + // Budget for those planes. Each is a canvas-sized A8 image, and a page can + // install a new mask with every `gs` for next to nothing, so without a + // bound a hostile page holds gigabytes. Past the budget the cache is + // dropped and planes are rebuilt as they are needed, which bounds memory + // at the cost of time. + static constexpr std::size_t max_soft_mask_plane_bytes = std::size_t(256) << 20; + std::size_t soft_mask_plane_bytes_ = 0; std::array shape_; // {height, width, 4}, display orientation int canvas_width_ = 0; // internal canvas width (unrotated page space) int canvas_height_ = 0; // internal canvas height (unrotated page space) @@ -3973,8 +3981,6 @@ namespace pdflib return found->second.is_empty() ? nullptr : &found->second; } - BLImage& plane = soft_mask_planes_[&mask]; - const int w = canvas_width_; const int h = canvas_height_; if (w <= 0 or h <= 0 or w > 8192 or h > 8192) @@ -3982,9 +3988,24 @@ namespace pdflib LOG_S(WARNING) << "soft_mask_plane: canvas " << w << "x" << h << " is too large for a mask plane; ExtGState " << mask.get_key() << " paints unmasked"; + soft_mask_planes_[&mask]; return nullptr; } + const std::size_t plane_bytes = static_cast(w) * static_cast(h); + if (soft_mask_plane_bytes_ + plane_bytes > max_soft_mask_plane_bytes) + { + // Nothing holds a pointer into the cache across instructions, so the + // planes can go; the ones still in use are rebuilt on their next paint. + LOG_S(WARNING) << "soft_mask_plane: " << soft_mask_planes_.size() + << " cached mask planes reach the " << (max_soft_mask_plane_bytes >> 20) + << " MB budget, dropping them"; + soft_mask_planes_.clear(); + soft_mask_plane_bytes_ = 0; + } + + BLImage& plane = soft_mask_planes_[&mask]; + // 1. The group over its backdrop, on a canvas of the page's size: its // instructions are in page space, like the page's own. BLImage group; @@ -4101,6 +4122,8 @@ namespace pdflib } } + soft_mask_plane_bytes_ += plane_bytes; + LOG_S(INFO) << "soft_mask_plane: built " << w << "x" << h << " plane for ExtGState " << mask.get_key() << " (" << (luminosity ? "luminosity" : "alpha") << ")"; diff --git a/tests/test_unit_soft_masks.py b/tests/test_unit_soft_masks.py index e855100f4..2e250e4c2 100644 --- a/tests/test_unit_soft_masks.py +++ b/tests/test_unit_soft_masks.py @@ -455,3 +455,28 @@ def test_unreadable_mask_paints_unmasked(smask: str, description: str): pdf = _build_pdf(MASKED_BOX, "/ExtGState << /G1 5 0 R >>", [smask]) image = _render(pdf) _assert_color(_pixel(image, 100, 50), BLUE, description) + + +def test_decodes_per_page_are_capped(): + """A page can install a new mask with every `gs` for next to nothing. + Past 512 distinct masks the rest of the page paints unmasked: the box + under the first mask is half-blended, the one under the 600th is opaque.""" + count = 600 + objects: List[str] = [] + resources = [] + for index in range(1, count + 1): + smask_number = 5 + 2 * (index - 1) + objects.append(_smask(f"{smask_number + 1} 0 R")) + objects.append(_group(f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f")) + resources.append(f"/G{index} {smask_number} 0 R") + + content = ( + f"1 0 0 rg 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f " + f"/G1 gs 0 0 1 rg 0 0 {PAGE_WIDTH // 2} {PAGE_HEIGHT} re f " + + " ".join(f"/G{index} gs" for index in range(2, count)) + + f" /G{count} gs 0 0 1 rg {PAGE_WIDTH // 2} 0 {PAGE_WIDTH // 2} {PAGE_HEIGHT} re f" + ) + pdf = _build_pdf(content, f"/ExtGState << {' '.join(resources)} >>", objects) + image = _render(pdf) + _assert_color(_pixel(image, 50, 50), HALF_BLUE_ON_RED, "under the first mask") + _assert_color(_pixel(image, 150, 50), BLUE, "past the per-page cap") From f4fcedfcaa4c4686f02756e351cc46c0147366b3 Mon Sep 17 00:00:00 2001 From: Jeff Witt <1848307+wittjeff@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:57:01 -0400 Subject: [PATCH 3/4] fix(render): keep the outer soft mask inside a masked transparency group A transparency group painted under a soft mask is masked as a whole, and a mask that the group's content installs acts inside the group (ISO 32000-1, 11.6.6). The group's alpha and blend mode were already pushed down onto its content; the soft mask was not. A `gs` inside the group replaced the outer mask, so only the inner mask took effect, and `/SMask /None` inside the group removed the outer mask. The graphics state now keeps the mask in force at the `Do` as the group mask. Paints inside the group carry the intersection of the group mask and their own mask, and the renderer builds its plane as the product of the two planes. Intersections are cached per pair, like decoded masks. Reported by dlaszlo on #384: a 50% mask inside a group painted under a 50% mask now leaves a quarter of the paint, (191, 0, 64), as pdfium does. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com> --- src/parse/page_items/soft_mask_instruction.h | 24 +++++++ src/parse/pdf_decoders/stream.h | 24 ++++++- src/parse/pdf_states/grph.h | 46 ++++++++++--- src/render/blend2d_renderer.h | 72 ++++++++++++++++++++ tests/test_unit_soft_masks.py | 32 +++++++++ 5 files changed, 188 insertions(+), 10 deletions(-) diff --git a/src/parse/page_items/soft_mask_instruction.h b/src/parse/page_items/soft_mask_instruction.h index 653021148..59717b0aa 100644 --- a/src/parse/page_items/soft_mask_instruction.h +++ b/src/parse/page_items/soft_mask_instruction.h @@ -31,6 +31,22 @@ namespace pdflib bbox_quad_(bbox_quad) {} + // Two masks in force at once, the coverage being the product of both. + // A transparency group painted under a mask is masked as a whole, and a + // mask its content installs applies inside the group (11.6.6); with the + // group's parameters pushed down onto its content (see + // pdf_state::enter_transparency_group), each paint inside carries + // both masks. + soft_mask_instruction(std::shared_ptr outer, + std::shared_ptr inner): + key_(outer->get_key() + "*" + inner->get_key()), + luminosity_(true), + backdrop_rgb_({0, 0, 0}), + bbox_quad_({}), + outer_(std::move(outer)), + inner_(std::move(inner)) + {} + // The ExtGState resource name the mask came from, for diagnostics. const std::string& get_key() const { return key_; } @@ -53,6 +69,11 @@ namespace pdflib pdf_render_instructions& get_group() { return group_; } const pdf_render_instructions& get_group() const { return group_; } + // True for the product of two masks, which has no group of its own. + bool is_intersection() const { return outer_ != nullptr; } + const std::shared_ptr& get_outer() const { return outer_; } + const std::shared_ptr& get_inner() const { return inner_; } + private: std::string key_; @@ -62,6 +83,9 @@ namespace pdflib std::array bbox_quad_; pdf_render_instructions group_; + + std::shared_ptr outer_; + std::shared_ptr inner_; }; } diff --git a/src/parse/pdf_decoders/stream.h b/src/parse/pdf_decoders/stream.h index 689f607c0..a8017b870 100644 --- a/src/parse/pdf_decoders/stream.h +++ b/src/parse/pdf_decoders/stream.h @@ -212,9 +212,15 @@ namespace pdflib std::shared_ptr spec; std::shared_ptr mask; }; + // + // A mask installed inside a transparency group painted under another + // mask is intersected with it; the intersections are kept here too, keyed + // by the two masks, so every paint under the same pair shares one. struct soft_mask_page_cache { std::unordered_map decoded; + std::map, + std::shared_ptr> intersected; bool budget_reported = false; }; static constexpr std::size_t max_soft_mask_decodes = 512; @@ -2042,10 +2048,24 @@ namespace pdflib decoded[cache_key] = soft_mask_cache_entry{spec, mask}; } - if(mask) + if(not mask) { return; } + + std::shared_ptr paint_mask = mask; + + const std::shared_ptr& outer = + current_graphic_state().get_group_soft_mask(); + if(outer) { - current_graphic_state().set_soft_mask_instruction(mask); + auto& intersected = soft_mask_cache_->intersected; + auto& both = intersected[{outer.get(), mask.get()}]; + if(not both) + { + both = std::make_shared(outer, mask); + } + paint_mask = both; } + + current_graphic_state().set_soft_mask_instruction(mask, paint_mask); } // The group is decoded the way `Do` decodes a form, with two differences: diff --git a/src/parse/pdf_states/grph.h b/src/parse/pdf_states/grph.h index 9626d9480..5aebe6131 100644 --- a/src/parse/pdf_states/grph.h +++ b/src/parse/pdf_states/grph.h @@ -204,8 +204,9 @@ namespace pdflib // /SMask is part of the graphics state (it survives q/Q). A `gs` that // installs one leaves the parsed entry and the CTM of that moment here; // the stream decoder then decodes the group -- it has the resources and - // the state does not -- and hands back the instruction every paint under - // the mask carries. + // the state does not -- and hands back the decoded mask together with + // the instruction every paint under it carries: the mask itself, or its + // intersection with the mask of an enclosing transparency group. soft_mask_state get_soft_mask() const { return soft_mask; } const std::shared_ptr& get_soft_mask_spec() const { return soft_mask_spec_; } const std::array& get_soft_mask_ctm() const { return soft_mask_ctm_; } @@ -216,13 +217,23 @@ namespace pdflib return soft_mask_spec_ != nullptr and soft_mask_instr_ == nullptr; } - void set_soft_mask_instruction(std::shared_ptr instr) + void set_soft_mask_instruction(std::shared_ptr instr, + std::shared_ptr paint_instr) { soft_mask_instr_ = std::move(instr); + paint_soft_mask_ = std::move(paint_instr); } + + // The mask a paint carries now, null when nothing masks it. const std::shared_ptr& get_soft_mask_instruction() const { - return soft_mask_instr_; + return paint_soft_mask_; + } + + // The mask in force at the `Do` of the enclosing transparency groups. + const std::shared_ptr& get_group_soft_mask() const + { + return group_soft_mask_; } // Enters a transparency group XObject (11.6.6). The group is supposed to @@ -231,7 +242,9 @@ namespace pdflib // page with the alpha and blend mode in force at the `Do`. There is no // such buffer here, so the group's parameters are instead pushed down onto // everything it paints. That is the same picture whenever the group's - // contents do not overlap each other, and too strong where they do. + // contents do not overlap each other, and too strong where they do. A + // soft mask goes the same way: the one in force at the `Do` masks every + // paint inside, on top of any mask the group's content installs. void enter_transparency_group(); private: @@ -310,9 +323,14 @@ namespace pdflib std::array soft_mask_ctm_; std::shared_ptr soft_mask_instr_; + // what paints carry: soft_mask_instr_, group_soft_mask_ or the two + // intersected + std::shared_ptr paint_soft_mask_; + // accumulated parameters of the enclosing transparency groups double group_alpha; blend_mode_name group_blend_mode; + std::shared_ptr group_soft_mask_; }; pdf_state::pdf_state(std::array& trafo_matrix_, @@ -357,9 +375,11 @@ namespace pdflib 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}), soft_mask_instr_(nullptr), + paint_soft_mask_(nullptr), group_alpha(1.0), - group_blend_mode(BLEND_MODE_NORMAL) + group_blend_mode(BLEND_MODE_NORMAL), + group_soft_mask_(nullptr) {} pdf_state::pdf_state(const pdf_state& other): @@ -406,9 +426,11 @@ namespace pdflib this->soft_mask_spec_ = other.soft_mask_spec_; this->soft_mask_ctm_ = other.soft_mask_ctm_; this->soft_mask_instr_ = other.soft_mask_instr_; + this->paint_soft_mask_ = other.paint_soft_mask_; this->group_alpha = other.group_alpha; this->group_blend_mode = other.group_blend_mode; + this->group_soft_mask_ = other.group_soft_mask_; return *this; } @@ -416,14 +438,20 @@ namespace pdflib void pdf_state::enter_transparency_group() { // Fold what is in force at the `Do` into the group parameters, then reset - // the state itself: 11.6.6 starts a group's contents at alpha 1 and - // Normal, and the content's own ExtGState will say otherwise if it means to. + // the state itself: 11.6.6 starts a group's contents at alpha 1, Normal + // and no soft mask, and the content's own ExtGState will say otherwise if + // it means to. group_alpha = get_fill_alpha(); group_blend_mode = get_blend_mode(); + group_soft_mask_ = paint_soft_mask_; stroke_alpha = 1.0; fill_alpha = 1.0; blend_mode = BLEND_MODE_NORMAL; + + soft_mask = SOFT_MASK_NONE; + soft_mask_spec_ = nullptr; + soft_mask_instr_ = nullptr; } bool pdf_state::verify(std::vector& instructions, @@ -708,6 +736,7 @@ namespace pdflib soft_mask = SOFT_MASK_NONE; soft_mask_spec_ = nullptr; soft_mask_instr_ = nullptr; + paint_soft_mask_ = group_soft_mask_; } else if(grph.get_soft_mask()==SOFT_MASK_PRESENT) { @@ -718,6 +747,7 @@ namespace pdflib soft_mask_spec_ = grph.get_soft_mask_spec(); soft_mask_ctm_ = trafo_matrix; soft_mask_instr_ = nullptr; + paint_soft_mask_ = group_soft_mask_; } LOG_S(INFO) << "gs " << key diff --git a/src/render/blend2d_renderer.h b/src/render/blend2d_renderer.h index 78f4b4835..bde9a395f 100644 --- a/src/render/blend2d_renderer.h +++ b/src/render/blend2d_renderer.h @@ -909,6 +909,9 @@ namespace pdflib // cached per mask for the page. Null when it could not be built. const BLImage* soft_mask_plane(soft_mask_instruction& mask); + // The plane of an intersection: the product of its two masks' planes. + const BLImage* soft_mask_intersection_plane(soft_mask_instruction& mask); + // Runs `paint` (which draws through page_context()) into a layer over // `bbox`, knocks the layer back with the mask and blits it with `mode`. template @@ -3981,6 +3984,11 @@ namespace pdflib return found->second.is_empty() ? nullptr : &found->second; } + if (mask.is_intersection()) + { + return soft_mask_intersection_plane(mask); + } + const int w = canvas_width_; const int h = canvas_height_; if (w <= 0 or h <= 0 or w > 8192 or h > 8192) @@ -4130,6 +4138,70 @@ namespace pdflib return &plane; } + inline const BLImage* renderer::soft_mask_intersection_plane(soft_mask_instruction& mask) + { + // BLImage copies share pixels, so these hold both planes even if building + // the second one drops the cache. + BLImage outer; + if (const BLImage* plane = soft_mask_plane(*mask.get_outer())) { outer = *plane; } + + BLImage inner; + if (const BLImage* plane = soft_mask_plane(*mask.get_inner())) { inner = *plane; } + + // A part that could not be built was reported, and paints unmasked on + // its own; here it drops out of the product the same way. + if (outer.is_empty() or inner.is_empty()) + { + BLImage& plane = soft_mask_planes_[&mask]; + plane = outer.is_empty() ? inner : outer; + return plane.is_empty() ? nullptr : &plane; + } + + const int w = outer.width(); + const int h = outer.height(); + const std::size_t plane_bytes = static_cast(w) * static_cast(h); + if (soft_mask_plane_bytes_ + plane_bytes > max_soft_mask_plane_bytes) + { + LOG_S(WARNING) << "soft_mask_plane: " << soft_mask_planes_.size() + << " cached mask planes reach the " << (max_soft_mask_plane_bytes >> 20) + << " MB budget, dropping them"; + soft_mask_planes_.clear(); + soft_mask_plane_bytes_ = 0; + } + + BLImage& plane = soft_mask_planes_[&mask]; + + BLImageData o, i, p; + if (plane.create(w, h, BL_FORMAT_A8) != BL_SUCCESS or + outer.get_data(&o) != BL_SUCCESS or + inner.get_data(&i) != BL_SUCCESS or + plane.make_mutable(&p) != BL_SUCCESS) + { + LOG_S(WARNING) << "soft_mask_plane: could not allocate the plane of " + << mask.get_key() << "; paints unmasked"; + plane.reset(); + return nullptr; + } + + for (int y = 0; y < h; y++) + { + const uint8_t* orow = static_cast(o.pixel_data) + y * o.stride; + const uint8_t* irow = static_cast(i.pixel_data) + y * i.stride; + uint8_t* prow = static_cast(p.pixel_data) + y * p.stride; + + for (int x = 0; x < w; x++) + { + prow[x] = static_cast((orow[x] * irow[x] + 127) / 255); + } + } + + soft_mask_plane_bytes_ += plane_bytes; + + LOG_S(INFO) << "soft_mask_plane: built " << w << "x" << h + << " plane for the intersection " << mask.get_key(); + return &plane; + } + template inline void renderer::paint_through_soft_mask(soft_mask_instruction& mask, const BLRect& bbox, diff --git a/tests/test_unit_soft_masks.py b/tests/test_unit_soft_masks.py index 2e250e4c2..d90a9deba 100644 --- a/tests/test_unit_soft_masks.py +++ b/tests/test_unit_soft_masks.py @@ -429,6 +429,38 @@ def test_mask_inside_the_mask_group_is_not_applied(): _assert_color(_pixel(image, 100, 50), HALF_BLUE_ON_RED, "outer mask only") +@pytest.mark.parametrize( + ("inner_gs", "expected", "description"), + [ + ("", HALF_BLUE_ON_RED, "no mask of its own"), + ("/GN gs", HALF_BLUE_ON_RED, "/SMask /None inside"), + ("/G1 gs", (191, 0, 64), "the same 50% mask again"), + ], +) +def test_mask_at_the_do_masks_a_transparency_group( + inner_gs: str, expected: Tuple[int, int, int], description: str +): + """A transparency group painted under a mask is masked as a whole (11.6.6). + A mask its content installs, or clears, acts inside the group only: a + 50% mask inside a group drawn under a 50% mask leaves a quarter of the + blue, which is what pdfium shows.""" + pdf = _build_pdf( + f"1 0 0 rg 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f /G1 gs /Fm1 Do", + "/ExtGState << /G1 5 0 R /GN << /SMask /None >> >> /XObject << /Fm1 7 0 R >>", + [ + _smask("6 0 R"), + _group(f"0.5 g 0 0 {PAGE_WIDTH} {PAGE_HEIGHT} re f"), + _stream( + f"/Type /XObject /Subtype /Form /BBox {FULL_BBOX} " + "/Group << /S /Transparency /CS /DeviceRGB >>", + f"{inner_gs} {BOX}", + ), + ], + ) + image = _render(pdf) + _assert_color(_pixel(image, 100, 50), expected, description) + + # --------------------------------------------------------------------------- # the switch # --------------------------------------------------------------------------- From bfb443cbc21d8161a39107dc953e2054c5280277 Mon Sep 17 00:00:00 2001 From: Jeff Witt <1848307+wittjeff@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:33:57 -0400 Subject: [PATCH 4/4] test: pin the regression corpus to the regenerated soft-mask groundtruth complex_invisible_fonts_01.pdf page 1 paints a transparency group under a soft mask with a second mask inside; its stored full_page.png predates soft masks and is outside the render tolerance now. Only that image changes. Dataset PR: https://huggingface.co/datasets/docling-project/regression-dataset-for-docling-parse/discussions/9 Re-point HF_DATASET_REVISION at the merge commit once that PR lands. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com> --- tests/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/constants.py b/tests/constants.py index 982c0d144..2ad3ec730 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -1,7 +1,7 @@ from tests.regression_page_selection import REGRESSION_PAGE_SELECTION HF_DATASET_REPO_ID = "docling-project/regression-dataset-for-docling-parse" -HF_DATASET_REVISION = "6d3458e2a6a729defd700a7b91e57b6cb2ce0290" +HF_DATASET_REVISION = "10c8edbf482df3f00612455c13e791a2dd8be9e6" REGRESSION_DIR = "tests/data/regression"