Repository navigation
Page renderer ignores a soft mask set in the graphics state (/SMask in ExtGState) #384
Description
Bug
When an object is painted under a graphics state whose ExtGState sets an /SMask, the page image rendered by docling-parse paints the object fully opaque, as if the mask were not there.
This matters for docling's PDF pipeline, since docling 2.128 takes its page images from this renderer: layout detection, OCR and every figure crop work on that image. InDesign builds photo effects (drop shadows, feathered and faded edges) from exactly this kind of soft mask. On a 2025 textbook, photos came back near-black under solid grey boxes, two figures on one page were detected as one, and a text box's words sat under a grey fill.
In our earlier checks (hand-built pages compared with pdfium), constant alpha (/ca), blend modes and an image's own /SMask entry rendered correctly; the graphics-state soft mask was the one we found ignored.
Steps to reproduce
A hand-built one-page PDF: a red page, then a blue box drawn under /G1, whose soft mask is a 50% grey luminosity group, so half of the blue should come through.
from pathlib import Path
import pypdfium2 as pdfium
from docling_parse.pdf_parser import DoclingThreadedPdfParser, RenderConfig, ThreadedPdfParserConfig
content = b"1 0 0 rg 0 0 200 100 re f /G1 gs 0 0 1 rg 50 25 100 50 re f"
objects = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources"
b" << /ExtGState << /G1 << /SMask << /S /Luminosity /G 5 0 R >> >> >> >> >>",
b"<< /Length %d >>\nstream\n" % len(content) + content + b"\nendstream",
b"<< /Type /XObject /Subtype /Form /BBox [0 0 200 100]"
b" /Group << /S /Transparency /CS /DeviceGray >> /Length 22 >>\n"
b"stream\n0.5 g 0 0 200 100 re f\nendstream",
]
pdf = bytearray(b"%PDF-1.7\n")
offsets = []
for number, obj in enumerate(objects, 1):
offsets.append(len(pdf))
pdf += b"%d 0 obj\n" % number + obj + b"\nendobj\n"
xref = len(pdf)
pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objects) + 1)
pdf += b"".join(b"%010d 00000 n \n" % o for o in offsets)
pdf += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objects) + 1, xref)
Path("soft-mask.pdf").write_bytes(pdf)
parser = DoclingThreadedPdfParser(ThreadedPdfParserConfig(render_config=RenderConfig()))
parser.load("soft-mask.pdf")
result = next(iter(parser.iterate_results()))
print("docling-parse:", result.get_image(scale=1).convert("RGB").getpixel((100, 50)))
page = pdfium.PdfDocument("soft-mask.pdf")[0]
print("pdfium: ", page.render(scale=1).to_pil().convert("RGB").getpixel((100, 50)))Output:
docling-parse: (0, 0, 255)
pdfium: (127, 0, 128)
Expected
The masked box is composited through its soft mask, as pdfium draws it: (127, 0, 128) in the middle of the box, not opaque blue.
Environment
- docling-parse 7.22.2 (also 7.22.0), pypdfium2 5.14.0
- Python 3.14, Linux x86_64
Activity
wittjeff commentedon Oct 16, 2026
Confirmed on 7.22.1 with your script, and the relevant code is unchanged on
main(7.22.3). Two independent renderers agree on the expected pixel: pdfium gives (127, 0, 128) and MuPDF gives (128, 0, 126).Root cause
pdf_resource<PAGE_GRPH>::set_transparency_parameters(src/parse/pdf_resources/page_grph.h) reduces/SMaskto thesoft_mask_stateenum that #306 introduced and logs "soft mask is not applied".pdf_state<GRPH>::gscarries that enum throughq/Q, but no render instruction has a soft-mask field, so the renderer has nothing to composite./ca,/BMand an image's own/SMasktake a different path, which is why those rendered correctly in your checks.How common it is
In the regression corpus, 12 of 482 files carry a graphics-state soft mask. All are
/S /Luminosity;/TRappears in one file and/BCin one. In those files the masks draw drop shadows, so the per-page difference against pypdfium2 is small. The InDesign case you describe, a shadow or feather object over a photo, is where the gap does real damage.Proposed fix
The renderer already paints non-rectangular clips by filling a layer, multiplying it by an A8 coverage plane and blitting it (
build_clip_mask,multiply_prgb32_by_a8). A soft mask is the same operation with a different A8 source, so the change stays inside that structure./SMaskdictionary onpdf_resource<PAGE_GRPH>(/G,/S,/BC,/TR) with the same presence-flag convention as the other entries.pdf_state<GRPH>::gssnapshots the CTM at that point, because 11.6.5 fixes the mask's coordinate system whengsexecutes, not when the masked object paints./Gform into its ownpdf_render_instructions, the way widget appearance streams are handled: seeded withset_base_matrix(ctm_at_gs × /Matrix), with the group's/Resourcesas child resources, clipped to/BBox. One decode per ExtGState entry, shared by reference from each instruction likeclip_state./BCbackdrop (black by default), convert luminosity to an A8 plane through/TR(pdf_function.halready evaluates the function types), with the area outside/BBoxtaking the backdrop luminosity. The masked object then goes through the existing layer path: fill, multiply by the soft-mask plane and by the clip mask if there is one, blit. The A8 plane is cached per ExtGState entry for the page render, since one corpus file carries 138 masks, and the area is bounded the waybuild_clip_maskbounds it. Gated by arender_soft_masksflag next torender_non_rect_clip_masks, with a timing key under the render phase tree.Scope limits, each reported with a warning as #306 does:
/S /Alphatakes the layer's alpha instead of its luminosity, same path.Tests: extend
tests/test_unit_transparency.pywith your page, a gradient mask (feathered edge), a mask whose/BBoxis smaller than the object, and/SMask /Noneinsideq/Q. The 12 corpus groundtruth renders will shift slightly, and the pypdfium2 quality benchmark should show the delta.I can open the PR if this direction works for you.
dlaszlo commentedon Oct 16, 2026
Hi @wittjeff,
Thanks for looking into this! Yes, please go ahead with the PR. Once it's up, I'm happy to test it on the documents where I ran into the problem.
wittjeff commentedon Oct 16, 2026
The PR is up: #413. On your page it now gives (127, 0, 128), the pdfium value, and the regression corpus moves closer to pdfium on every page that changed.
Your InDesign pages are the real test, since the corpus masks are only drop shadows and small gradients. If you can build the branch,
RenderConfig.render_soft_maskslets you switch the compositing on and off for a side-by-side. If building is not practical, a page or two of the textbook (or a hand-reduced copy) that I can run here would do as well.dlaszlo commentedon Oct 17, 2026
Hi Jeff,
Thanks! I built the branch and ran it on the textbook pages. Most of the photos now look right.
What remains on my pages comes from the "transparency groups stay inlined" limit you listed: a soft mask is set inside a form XObject that is itself painted under a soft mask, and only the inner mask takes effect. I think that is why grey boxes remain behind the photos. Unlike the mask-inside-the-mask-group case,
nothing is logged for it.
On the left is pypdfium2: the statue stands on the plain page background. On the right is this PR: a grey box is drawn behind the statue.
A small page with the same structure, built with the repo's
tests/pdf_builder.py:The page is red. A group is painted on it under a 50% mask, and inside the group the same 50% mask is set again before it paints blue. pdfium shows (191, 0, 64) in the centre, a quarter of the blue; the PR shows (127, 0, 128), half of the blue.
fix(render): keep the outer soft mask inside a masked transparency group
wittjeff commentedon Oct 17, 2026
Thank you for the test and for the small page. Your diagnosis is correct.
Cause. When a transparency group starts, the decoder moved the alpha and the blend mode in force at the
Doonto the paints inside the group, but it did not move the soft mask. Thus agsinside the group replaced the outer mask, and only the inner mask had an effect. Also,/SMask /Noneinside a group removed the outer mask.Fix. Commit a2722e1 on #413:
Doof a transparency group, the graphics state keeps the mask in force as the group mask, and starts the group's content with no mask of its own (11.6.6).Result.
/SMask /Noneinside, and a second mask inside.complex_invisible_fonts_01.pdf): 17.19 to 3.55 mean absolute difference.Limit. The group is still not painted into a buffer of its own. Its mask is applied to each paint inside it. This is exact when the paints inside the group do not overlap. Where they overlap under a partial outer mask, the result is a little different from pdfium. The group alpha has the same limit. I did not add a warning for this case, because it is not a gap: the mask is applied.
Could you rebuild the branch and run the textbook pages again? If grey boxes remain, a page or a crop of the content stream will help me find the next cause.
dlaszlo commentedon Oct 17, 2026
Hi Jeff,
Thanks, that fixed it. I rebuilt the branch at a2722e1 and ran my test cases again. They all improved: the grey boxes behind the photos are gone, and the small nested page now gives (191, 0, 64) in the centre, the same as pdfium.
Thanks again for the quick fix.
fix(render): composite ExtGState soft masks (/SMask)
fix(render): keep the outer soft mask inside a masked transparency group