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..59717b0aa --- /dev/null +++ b/src/parse/page_items/soft_mask_instruction.h @@ -0,0 +1,93 @@ +//-*-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) + {} + + // 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_; } + + // /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_; } + + // 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_; + bool luminosity_; + std::array backdrop_rgb_; + std::vector transfer_; + std::array bbox_quad_; + + pdf_render_instructions group_; + + std::shared_ptr outer_; + std::shared_ptr inner_; + }; + +} + +#endif diff --git a/src/parse/pdf_decoders/stream.h b/src/parse/pdf_decoders/stream.h index 12c8f38e6..a8017b870 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,40 @@ 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. + // + // 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; + }; + // + // 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; + 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 +1066,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 +1387,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 +1584,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 +1688,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 +1812,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 +2000,177 @@ 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; + } + + // 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(std::round(v * 1000.0) / 1000.0); + } + + std::shared_ptr mask; + + 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); + decoded[cache_key] = soft_mask_cache_entry{spec, 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) + { + 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: + // 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 +2433,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..5aebe6131 100644 --- a/src/parse/pdf_states/grph.h +++ b/src/parse/pdf_states/grph.h @@ -201,9 +201,40 @@ 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 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_; } + + // 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, + 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 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 // be composited as a unit: its contents paint into their own buffer with a @@ -211,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: @@ -284,9 +317,20 @@ 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_; + + // 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_, @@ -326,8 +370,16 @@ 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), + 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): @@ -371,9 +423,14 @@ 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->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; } @@ -381,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, @@ -666,13 +729,25 @@ 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; + paint_soft_mask_ = group_soft_mask_; + } + 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; + paint_soft_mask_ = group_soft_mask_; } 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..bde9a395f 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,41 @@ 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_; + + // 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) @@ -811,6 +847,79 @@ 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); + + // 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 + 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 +1847,11 @@ namespace pdflib inline BLContext& renderer::page_context() { + if (context_override_ != nullptr) + { + return *context_override_; + } + if (context_active_) { return context_; @@ -2735,6 +2849,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 +3517,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 +3966,321 @@ 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; + } + + 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) + { + 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; + 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); + } + } + + 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") << ")"; + 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, + 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 +4469,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/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" diff --git a/tests/test_unit_soft_masks.py b/tests/test_unit_soft_masks.py new file mode 100644 index 000000000..d90a9deba --- /dev/null +++ b/tests/test_unit_soft_masks.py @@ -0,0 +1,514 @@ +#!/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") + + +@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 +# --------------------------------------------------------------------------- + + +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) + + +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")