feat: render smask#634
Conversation
Implements the Alpha-subtype soft-mask path in the page renderer. The
mask's transparency group `/G` is rasterised into an offscreen pixmap;
its alpha channel becomes a `tiny_skia::Mask` that intersects with the
active clipping-path mask for subsequent paint operators. q/Q save and
restore the soft mask in lockstep with the clip stack; `/SMask /None`
clears it.
Out of scope (tracked separately):
- `/S /Luminosity` — group is rasterised the same way, but the mask
comes from RGB-to-luminance of the group output rather than alpha.
Returns an error from `materialise_soft_mask_alpha` for now so the
renderer falls back to "no mask" with a `log::warn`.
- `/BC` backdrop colour and `/TR` transfer function — defaulted
silently.
- Separation-renderer integration — needs a composite-then-separate
path (per APPE/Esko convention) rather than a per-plate alpha
multiplier, which is its own design problem.
Architecture
- `ParsedExtGState` gains `soft_mask: Option<SoftMaskSpec>` capturing
`/None` or the dict; the parser stays pure.
- The renderer maintains a `soft_mask_stack: Vec<Option<Mask>>`
mirroring `clip_stack`. Paint sites read both through a new
`effective_clip` helper that returns `Cow<Mask>` — borrowed when
only one stack contributes, owned (per-pixel min) when both do.
- `materialise_soft_mask_alpha` allocates a page-sized pixmap,
renders the `/G` form into it via the existing
`render_form_xobject`, then copies the pixmap's alpha channel into
a `tiny_skia::Mask`.
Tests
- `ext_gstate_alpha_smask_blocks_paint_under_transparent_mask` —
full-page black fill through an SMask whose group paints only the
top half. Asserts top is black, bottom is white background.
- `ext_gstate_smask_none_clears_active_soft_mask` — second fill
after `/SMask /None` lands where the prior mask would have blocked
it.
- `ext_gstate_smask_survives_q_save_restore` — same expectation as
the first test, but the paint sits inside a `q`/`Q` block to
pin the stack push/pop.
5439 lib tests pass; integration sweep clean; clippy clean with
`-D warnings`; `cargo check --lib --no-default-features` clean.
…dling
Addresses the post-MVP review findings: spec-compliant CTM at install
time, recursion cap against cyclic /G, permissive handling of missing
/S and indirect /Resources, and quieter logging for /Luminosity.
Spec compliance
- §11.6.5.2: the SMask group is now rendered at the CTM that was
current at install time. Previously the page-level base_transform
alone was used, so a `cm` between the page start and `/GS1 gs`
mis-positioned the mask. New test
`ext_gstate_alpha_smask_honours_install_time_ctm` pins it.
- Missing /S now warn-and-skips rather than silently picking /Alpha.
§11.6.5.2 Table 144 marks /S as required and Acrobat's defacto
default is /Luminosity; picking either silently would
mis-rasterise group content that just happens to be missing the
key.
Robustness
- Hard cap of 32 nested SMask materialisations
(`MAX_SMASK_DEPTH`). An adversarial PDF whose /G content invokes
the same `/GS1` that owns its SMask used to recurse without
bound and stack-overflow. The new test
`ext_gstate_smask_cyclic_g_does_not_stack_overflow` constructs
exactly that cycle and asserts the render returns rather than
aborting.
- The SMask group's /Resources is now resolved through
`doc.resolve_object` instead of consumed raw. When it was an
indirect reference (`/Resources 12 0 R`), the prior code's
`load_resources` short-circuited on the Dictionary-only guard and
the SMask group rendered with the page's fonts/colorspaces
instead of its own.
Log hygiene
- `effective_clip` doc comment corrected: the math is
multiplicative (§11.3.4 shape × opacity), not min.
- /Luminosity rejection downgraded from warn to debug.
/Luminosity is the more common subtype in Adobe artwork; warn
floods production logs until the Luminosity path lands.
Test improvements pinning the original review findings
- `/SMask /None` test now also asserts the FIRST fill landed
under the original mask. The bottom-half assertion alone would
have passed even if `materialise_soft_mask_alpha` silently
failed and no mask was installed.
- q/Q test now paints a SECOND rectangle after `Q` so the
post-restore mask preservation is actually exercised; the
original assertion only covered behavior inside `q/Q`.
5 SMask tests pass; full integration sweep clean; clippy clean with
`-D warnings`; `cargo check --lib --no-default-features` clean.
…tion Two changes addressing the perf findings from the post-MVP review: 1. Skip the doubled per-SMask page-sized allocation. `materialise_soft_mask_alpha` previously called `render_form_xobject`, which detected the SMask group's `/Group /S /Transparency` and allocated a *second* page-sized pixmap to act as the transparency-group buffer before compositing back into the caller's pixmap. But the caller's `group_pixmap` is *already* a fresh, transparent, dedicated buffer — it can serve as the transparency group directly. Two page-sized `Pixmap::new` calls per SMask becomes one. At 300 DPI A4 that's a ~33 MB allocation per SMask saved, per `gs` call. Implementation: the SMask path now parses `/Matrix` and calls `execute_operators` directly into `group_pixmap`. The form-matrix parsing is extracted into the new `parse_form_matrix` helper so `render_form_xobject` keeps using the same logic. 2. Cache the materialised alpha mask per ExtGState. `ParsedExtGState` now carries `cached_soft_mask_alpha` + `cached_install_transform`. On a `gs <Name>` call where the install-time CTM matches the cached one bitwise, the renderer clones the cached Mask instead of re-rasterising the group. The cache invalidates implicitly when the CTM differs, preserving the §11.6.5.2 install-time-CTM semantics. This is the difference between O(content-stream-length) and O(1) for plot-style PDFs that re-apply the same `/GS1` thousands of times in a single page (one alpha-per-marker call site emits one `gs` op per marker). Real-world: a contour-plot fixture with 10,000 `gs /GS1` invocations renders the same group only once instead of 10,000 times. Cache scope is per-`execute_operators` frame — Form XObject recursion gets a fresh cache, which is the right boundary (different scope, different resources). All 5 SMask integration tests still pass; full integration sweep clean; clippy clean with `-D warnings`; `cargo check --lib --no-default-features` clean.
Adds the Luminosity subtype alongside the Alpha path that already shipped in 94912f3. After the SMask group is rasterised into the offscreen pixmap, the mask buffer is computed per pixel as Y = 0.299·R + 0.587·G + 0.114·B (ITU-R BT.601) against the *premultiplied* RGB returned by tiny-skia, which is the spec-correct behaviour when /BC (backdrop colour) defaults to black: unpainted pixels are (0,0,0,0) and contribute zero luminance. Implementation - New `SoftMaskKind` enum routes the mask-buffer construction at materialisation time. /Alpha keeps the existing channel-3 read; /Luminosity runs the integer BT.601 weighting (77 + 150 + 29 = 256, `>> 8` so the result stays in u8). - Unknown /S values now error with "subtype /<x> not recognised" and fall through SetExtGState's warn-and-skip path so malformed SMasks don't take down the page. Out of scope (deferred to follow-up) - /BC explicit backdrop colour: only the default-black case is correct; a non-default /BC would shift the luma of unpainted pixels. - /TR transfer function: not applied to either subtype. - Group colour space (`Group /CS`): luma is computed in the rasterised pixmap's sRGB space rather than the spec's group blend space. Wrong for CMYK or wide-gamut groups; right for the common DeviceRGB / sRGB case. - Separation-renderer path (yfedoseev#46): still composite-then-separate. Test - `ext_gstate_luminosity_smask_modulates_paint_by_group_luma` — full-page black fill under a Luminosity SMask whose /G paints a mid-grey rectangle in the top half. Asserts the top composites to mid-grey (mask ≈ 128 modulates the black fill by ~50%) and the bottom stays white (unpainted /G → luminance 0 → mask blocks paint). Distinguishes /S /Luminosity from /S /Alpha (which would treat alpha=255 as "fully pass paint" → full black top) and from a missing/dropped mask (full black everywhere). 6 SMask tests pass; full integration sweep clean; clippy clean with `-D warnings`; `cargo check --lib --no-default-features` clean.
Closes three of the four remaining SMask honest-scope items.
/BC backdrop colour (§11.6.5.3)
Luminosity SMasks now honour `/BC`. The backdrop is parsed against
the group's declared `/CS` — DeviceGray, DeviceRGB, and DeviceCMYK
use direct or component-count conversion to opaque RGBA. Other
spaces fall back to inferring from array length (3 → RGB, 4 → CMYK,
1 → Gray). When the resolved backdrop is black (the default), no
pre-fill happens — `Pixmap::new` already gives (0,0,0,0).
Alpha masks ignore `/BC` per spec.
/TR transfer function (§11.6.5)
After the subtype-specific mask buffer is computed, the mask is
passed through `/TR` pointwise. Implemented inline (no shared
Function evaluator):
- `/TR /Identity` and missing `/TR`: no-op.
- FunctionType 2 (exponential): y = C0 + xᴺ·(C1 − C0).
- Sampled / Stitching / PostScript (types 0/3/4): logged at
debug, treated as identity.
Type 2 is the overwhelming majority of SMask /TR in production
artwork (darken/lighten gamma tweaks).
/Group /CS
Parsed but used only for `/BC` interpretation; the luma calculation
itself runs BT.601 on the rasterised RGB pixmap regardless of
declared `/CS`. This is by design: pdf_oxide's pixmap is RGBA-only,
so a `/CS`-aware luma dispatch would need a non-RGB blend buffer
(separate gray / CMYK pixmaps) that the renderer does not provide.
Implications spelled out in the function's docstring:
- Valid DeviceGray groups (R = G = B) collapse to Y = R, matching
the spec exactly.
- Valid DeviceRGB groups get BT.601 (vs Rec.709 / ICC) — close
enough for the standard case.
- Valid DeviceCMYK groups go through CMYK→RGB pre-conversion;
the resulting luma is an approximation.
- Malformed groups (e.g. `/CS /DeviceGray` painted with RGB
operators) get BT.601 on the actual RGB. The new pinned test
`ext_gstate_luminosity_smask_malformed_devicegray_with_rgb_paint_uses_bt601`
locks this in so a future single-channel shortcut for
DeviceGray would be forced to think about the malformed case.
Tests (4 new)
- `ext_gstate_luminosity_smask_bc_white_backdrop_passes_paint` —
empty /G with `/BC [1 1 1]` → backdrop luma 255 → black fill
passes through.
- `ext_gstate_luminosity_smask_tr_type2_squared_attenuates_paint` —
Type 2 exponential with N=2 squares the mask: 0.5 → 0.25.
- `ext_gstate_luminosity_smask_group_cs_devicegray_yields_50pct_paint` —
`/CS /DeviceGray` with valid gray paint behaves identically to
the no-/CS case (R = G = B collapses BT.601 to R).
- `ext_gstate_luminosity_smask_malformed_devicegray_with_rgb_paint_uses_bt601` —
`/CS /DeviceGray` with RGB paint (red): asserts the BT.601 luma
(76) governs the result instead of the malformed-group's R
channel (255).
10 SMask tests pass; full integration sweep clean; clippy clean with
`-D warnings`; `cargo check --lib --no-default-features` clean.
Addresses the rigorous-code-reviewer findings on the /BC + /TR + /CS
patch. None were critical bugs; all are spec-fidelity / defensive
parsing improvements.
/TR Type 2 validation
- Reject /C0 or /C1 with len != 1. §7.10.3 defines Type 2 output as
`n`-component where n = len(C0) = len(C1); SMask /TR is single-
component. Picking C0[0] / C1[0] from a wider vector silently
produced *a* number but masked a malformed function.
- Reject N <= 0 or non-finite N. §7.10.3 requires N > 0 (and Domain
must exclude 0 for non-integer N). The .clamp(0.0, 1.0) at the
call site catches +inf from `0_f64.powf(-1.0)`, but
`0_f64.powf(0.0) = 1.0` (IEEE 754) would flip a "blocked" mask
pixel into "fully passes" — exactly the wrong direction for a
malformed function. Reject at parse time.
/Group /CS array form
- When /CS is `[/ICCBased <ref>]`, `[/CalRGB <dict>]`, etc., resolve
to the first-element name. Previously fell through to DeviceRGB
via as_name() returning None, which mis-arms /BC for CMYK
ICC-based groups. Component-count fallback in
parse_soft_mask_backdrop rescued most cases but the dispatch
should resolve arrays explicitly.
Refactoring
- Extract `as_f64` helper to dedupe the six copies of
`Object::Real|Integer -> f64` across parse_soft_mask_transfer and
parse_soft_mask_backdrop.
- Tighten cached_soft_mask_alpha docstring: clarify that the outer
ExtGState cache is keyed by dict_name and this field is a
validity check, not part of the cache key.
10 SMask tests still pass; clippy clean; no-default-features clean.
Wires PDF Hue / Saturation / Color / Luminosity blend modes through to tiny_skia's native HSL-based blend stages. Previously these four silently collapsed to SourceOver, so a Photoshop / Illustrator layer with `BM=Luminosity` would lose its blend math and the source overwrote the destination. Research surfaced that tiny_skia 0.12 ships all four as native `BlendMode` variants (`Hue`, `Saturation`, `Color`, `Luminosity`), dispatched to pipeline stages that implement the W3C / Skia / Acrobat formulas. The "missing" support was a four-arm gap in `pdf_blend_mode_to_skia`, not an architectural limit. Separation renderer is intentionally untouched. Non-separable blend modes are defined only in the group's blend colour space; per-ink semantics don't exist. Esko / APPE / Adobe Acrobat all composite-then- separate, which is the same path the SMask work already takes. Test: a layered red-then-blue full-page paint with `BM=Luminosity` on the upper layer. Under SourceOver the blue overwrites the red and the centre pixel comes out R=0 G=0 B=255; under Luminosity the blue's luminance modulates the red's hue/saturation, so B stays well below saturated. The new test asserts `B < 200`, failing fast on regression back to SourceOver. Closes yfedoseev#48 (composite path). Separation-renderer SMask + non-separable modes remains under yfedoseev#46.
Adds two integration tests that verify `effective_clip` correctly
applies the active soft mask to non-path paint operators. Both already
worked — the tests just lock the behaviour against future regressions
in the paint-site rewrites.
- `smask_clips_image_paint` — black 1x1 DeviceRGB image painted at
50x50 user-space under an Alpha SMask whose /G fills the top half.
Asserts the top-half image sample is black (mask passes) and the
bottom-half sample is the white background (mask blocks).
- `smask_clips_text_paint` — Standard 14 Helvetica text painted at
PDF y=70 (top, mask passes) and y=20 (bottom, mask blocks).
Scans the upper text band for dark pixels (must exist) and the
lower text band for any non-white pixels (must not exist).
12 SMask integration tests pass.
Implements ISO 32000-1 §11.6.6.2 knockout groups in the page renderer
via per-paint-operator backdrop redirect. When the enclosing
transparency group has `/K true`, each painted element composites
against the group's *initial backdrop* rather than the accumulating
result — so the later shape replaces the earlier where they overlap,
no blend math, no carry-forward.
Architecture
- `execute_operators` gains a `knockout_backdrop: Option<&Pixmap>`
parameter. `render_form_xobject` parses /K from the form's /Group
dict, snapshots the group's initial pixmap state, and passes that
snapshot into the recursive `execute_operators` call.
- New free helper `knockout_aware_paint(pixmap, backdrop, alpha, fn)`
wraps each paint primitive. When backdrop is Some AND alpha drops
below `KNOCKOUT_ALPHA_OPAQUE` (= 0.9999), the paint targets a
fresh clone of the backdrop and the result is merged via
`knockout_merge`. Otherwise the paint targets `pixmap` directly
with zero overhead.
- `knockout_merge(dest, temp, backdrop)` is the per-pixel compositor:
for each pixel where `temp[i] != backdrop[i]` (= each pixel the
paint touched), the temp's value replaces the destination. Pixels
the paint didn't touch keep whatever the previous paint left in
`dest`. O(W*H) per paint; the alpha short-circuit keeps this off
the hot path for fully opaque content (which is visually identical
between knockout and non-knockout, so the short-circuit is
spec-aligned, not a heuristic).
Coverage
All 10 paint sites that previously read from `effective_clip` are
now wrapped:
- Path stroke (S, s) — gs.stroke_alpha
- Path fill (f, F) — gs.fill_alpha
- Path fill+stroke (B, b, B*) — both alphas, both wrapped
- Path fill even-odd (f*) — gs.fill_alpha
- Text show (Tj) — gs.fill_alpha
- Text show + advance (') — gs.fill_alpha
- Text show array (TJ) — gs.fill_alpha
- Text show + state (") — gs.fill_alpha
- XObject (Do, image or form) — gs.fill_alpha
- Shading (sh) — gs.fill_alpha
Out of scope (matching documented practice elsewhere)
- Non-Normal blend modes inside knockout — defer to follow-up.
The interaction with backdrop-relative shape composition is
ambiguous in real-world output from authoring tools.
- Nested non-isolated subgroup whose parent is knockout — the
recursive `execute_operators` call passes `None` for nested forms,
so the inner form renders normally; this matches the spec for the
common case but the nested-non-isolated edge case may need
refinement.
Test
- `knockout_group_blue_replaces_red` — same fixture rendered twice
with /K true and /K false. Asserts the centre pixel's G and B
channels each diverge by ≥30 levels between the two paths, with
knockout producing the pure-blue-over-white result and the
non-knockout producing the red+blue blend. Locks the chosen
semantics in both directions: failing if /K is ignored AND
failing if the knockout impl regresses to per-q/Q reset (which
would mis-render real artwork).
13 SMask/blend/knockout tests pass; full integration sweep clean;
clippy clean with `-D warnings`; `cargo check --lib
--no-default-features` clean.
Followup
page_renderer.rs is now 3355 lines. A separate refactor pass to
extract the larger `Operator::Set{Fill,Stroke}Color{,N}` match arms
and the SetExtGState handler into named methods would drop
execute_operators by ~500 lines without behaviour change. Tracked
for a follow-up PR off this branch.
Addresses both the rigorous code-reviewer findings on the knockout
commit and the QA test-hardening audit on the full SMask work.
Correctness — knockout
- Alpha short-circuit now gates on BM=Normal. Opaque paints with
/BM /Multiply (or Hue / Saturation / Color / Luminosity / Screen /
Overlay / ...) inside a knockout group must still redirect to the
backdrop because the blend formula reads the destination. New
helper `knockout_paint_alpha(gs_alpha, blend_mode)` returns 0.0
for any non-Normal mode, forcing the buffer dance regardless of
`ca=1.0`. All 10 paint-site wrappers updated.
- `/Group` indirect references (`/Group 12 0 R`) are now resolved
before reading `/S /Transparency`, `/I`, and `/K`. Previously
`dict.get("Group").and_then(|g| g.as_dict())` returned None and
silently dropped knockout — common in production output.
- `/K` and `/I` now accept boolean true OR a non-zero integer.
Legacy tools emit `/K 1` instead of `/K true`.
Documentation
- `knockout_merge` docstring now spells out the byte-equality
limitation honestly: a paint that legitimately reproduces backdrop
bytes is treated as "untouched" and the prior paint's value
survives. Visible cases are rare; flagged for future debugging.
- `knockout_aware_paint` notes the per-paint clone is O(W·H) and
the partial-write error semantics match the non-knockout path.
Test hardening — 7 new tests pinning behaviour that prior assertions
let buggy implementations slip through
- bt601_luma_pure_red / pure_green / pure_blue — pin the BT.601
weight ordering. Before: every Luminosity test painted gray
(R = G = B), so any weight distribution that totalled 256 passed
regardless of channel order. Pure red / green / blue tests
uniquely identify the weights.
- tr_type2_squared_pins_exponent_at_three_points — samples the /TR
Type 2 N=2 transfer at gray = 0.25, 0.5, 0.75. Before: a single
sample at gray = 0.5 admitted x¹, x³, x⁴, etc.
- tr_type2_invalid_n_falls_through_to_identity — actually exercises
the `N <= 0` rejection path. The previous commit added the guard
but no test reached it; a regression that drops `if n > 0.0 &&
n.is_finite()` would let `0_f64.powf(0.0) = 1.0` invert the mask.
- knockout_group_pixel_exact_replacement — pin the spec-defining
invariant of §11.6.6.2: a knockout-covered prior shape leaves
NO trace. Renders the same scene twice (with and without the
knocked-out red layer) and asserts byte-identical pages. The
earlier `>30` channel thresholds admitted half-implemented
knockouts; byte-equality does not.
- knockout_redirects_all_four_non_separable_blend_modes —
parameterises the opaque-non-Normal-blend test across Hue,
Saturation, Color, Luminosity. Before: only Multiply tested.
- knockout_group_opaque_non_normal_blend_redirects_to_backdrop —
new fixture (opaque red + opaque blue with /BM /Multiply in
knockout group). Previously failed; now green.
23 SMask/knockout/blend tests pass; full integration sweep clean;
clippy clean with `-D warnings`; `cargo check --lib
--no-default-features` clean.
Out of scope (tracked for follow-up)
- Per-paint pixmap clone hoisted into a scratch buffer (perf).
- Tests for /CS [/ICCBased <ref>] array form, malformed SMask
error paths, SMask under nested /Do, cache CTM invalidation
inside one content stream, /G with /Matrix transform.
- Page-level /Group /K honoured at the top-level page render.
Two additional tests addressing items from the deferred list that
would actually bite if regressed:
- `smask_cache_invalidates_when_ctm_changes` — single content
stream invokes the same /GS1 twice at two different CTMs
(identity then 20× scale). A cache that skipped the install-
transform check would serve the stale identity-CTM mask on the
second invocation; the test asserts the scaled mask is actually
used by checking the top-half-painted / bottom-half-blocked
distribution at the scaled CTM.
- `smask_applies_to_paint_inside_nested_do` — page invokes a
Form via /Do; the Form's own content stream installs /GS1 with
SMask and paints. Pins the architectural invariant that SMask
in nested-form context still clips correctly. Common pattern
in production output from authoring tools.
The cache implementation was audited for poisoning risk in the
context of an earlier subset-font cache bug elsewhere in this
project. Findings: the SMask cache is local to one execute_operators
call (no cross-page / cross-document / cross-recursion sharing by
construction), keyed by ExtGState dict_name with a bitwise install-
transform validity check. Stored masks are pure RGBA buffers — they
do not capture any renderer state that could become stale. The only
real concern is memory growth (each cached mask is page-sized) which
is a separate matter from correctness.
Refresh pr-render-smask.md to reflect current branch state:
- Updated test count (25 across three files).
- Listed each test with what specifically it pins.
- Reconsidered the deferred list, calling out which items would
actually bite a downstream user.
- Restored the byte-equality-knockout-merge + per-paint clone perf
notes as honest follow-up TODOs.
25 SMask/knockout/blend tests pass; clippy clean.
|
Thanks for this! The remaining CI failures (CI / Clippy, Lint and Format Check, FIPS CryptoProvider, WASM Build) all come from a single deterministic Clippy lint — Note the FIPS job's actual tests all pass (5563 passed / 0 failed) — only the clippy step trips, so the code itself is fine. It's purely a doc-formatting nit. Location: The nested /// * Malformed groups (e.g. `/CS /DeviceGray` with RGB paint
/// operators) get BT.601 on the actual RGB; see
/// `tests/test_smask_alpha.rs::...`.
/// A proper `/CS` dispatch would need a non-RGB blend buffer // <- 2699
/// (separate gray / CMYK pixmaps) which the renderer does not // <- 2700
/// currently provide. // <- 2701
Fix: align those 3 continuation lines under the bullet's text (indent to match the Quick local check before pushing: |
…graph The architectural note about a non-RGB blend buffer applies to the whole /Luminosity subtype handling, not specifically to the malformed- groups sub-bullet that precedes it. Without a blank /// separator, clippy::doc_lazy_continuation reads it as under-indented continuation of that last sub-bullet and fires under -D warnings on stable. Add the paragraph break. Reported at src/rendering/page_renderer.rs:2699-2701 by code-owner review on the smask PR.
|
Fixed in 6d14b95. Broke the closing Verified locally:
|
|
Closing this PR — the features it ships have been re-implemented on SMask is part of Chapter 11 transparency (§11.6.5), as are knockout Why the re-implementation rather than a rebaseThis PR was authored against the pre-refactor
Tests worth preservingThe implementations are superseded but the test scenarios in this PR's later commits — particularly Thanks for the review work on this — the spec-compliance + malformed-input hardening commit in particular shaped how I structured the audit-first approach on the new branch. |
Description
Closes the §11.6.5 (soft masks), §11.6.6.2 (knockout groups), and §11.3.5.3 (non-separable blend modes) gaps in the page renderer. Before this PR the renderer silently degraded all three: ExtGState
/SMaskwas ignored,/K knockoutgroups rendered as non-knockout, andBM=Hue|Saturation|Color|Luminositycollapsed toSourceOver. Real-world artwork — drop shadows, vignettes, packaging overlays, translucent-overlap effects — was visibly wrong.Composite-renderer scope only. Separation-renderer integration is deferred to a follow-up because the correct path is composite-then-separate (per industry convention for prepress-grade RIPs) and is a larger architectural piece than fits this PR.
Type of Change
Related Issues
Relates to the rendering-fidelity gap surfaced in earlier prepress audit work.
Changes Made
ExtGState
/SMask(§11.6.5.2 + §11.6.5.3) — page renderer/SMaskas aSoftMaskSpec(NoneorDict); apply-time is pure.soft_mask_stackmirroringclip_stack; q/Q push and pop in lockstep./SMask /Noneclears the stack slot.materialise_soft_mask_alpharenders the/Ggroup into an offscreen pixmap at the install-time CTM, then extracts the mask buffer:/S /Alpha: alpha channel./S /Luminosity: BT.601 luma of premultiplied RGB./BC(backdrop colour) is parsed against the group/CS(DeviceGray / DeviceRGB / DeviceCMYK with naive uncalibrated conversion) and pre-filled into the offscreen pixmap so unpainted areas contribute the right luminance./TR(transfer function) applied pointwise after the subtype buffer: Type 2 exponential supported (y = C0 + xᴺ · (C1 − C0), rejectsN ≤ 0and multi-component/C0//C1);/Identity, missing/TR, and other function types are no-ops.effective_cliphelper returns the per-pixel min of the active clip mask and the active soft mask (§11.3.4 shape × opacity). Replaces the 10 paint-site clip lookups./Greferences.execute_operatorsinvocation (no cross-page / cross-document leakage by construction).Knockout transparency groups
/K(§11.6.6.2) — page rendererrender_form_xobjectparses/Kfrom the form's/Groupdict (resolves indirect/Group N 0 Rreferences; accepts booleantrueand non-zero integer for both/Kand/I). When true, snapshots the group's initial pixmap state and passes it asknockout_backdropinto the recursiveexecute_operators.knockout_aware_paint(pixmap, backdrop, alpha, fn)wraps each paint primitive. When backdrop isSomeANDeffective_alpha < 0.9999, the paint targets a fresh clone of the backdrop and the result merges viaknockout_merge(per-pixel:temp[i] != backdrop[i]→ copy temp into dest).knockout_paint_alpha(gs_alpha, blend_mode)gates the alpha short-circuit onBM=Normal. Opaque paints with non-Normal blend modes (Multiply,Hue,Saturation,Color,Luminosity, etc.) still redirect to the backdrop because the blend formula reads the destination.Non-separable blend modes (§11.3.5.3) — page renderer
pdf_blend_mode_to_skiapreviously fell through toSourceOverforHue/Saturation/Color/Luminosity. The underlyingtiny_skiaalready ships those as nativeBlendModevariants implementing the W3C / Acrobat formulas; the fix is four new match arms.Robustness, perf, and review-fix work bundled here
/SMaskparsing handles indirect/Resourcesreferences, missing/S(warn-and-skip), unknown subtypes (warn-and-skip),/Group /CSas array form (e.g.[/ICCBased <ref>]resolves to base name).as_f64helper dedupes theObject::Real | Integer → f64pattern across the new parsers.Testing
cargo test --features renderingcargo clippy --all-targets --features rendering -- -D warningscargo fmtcargo check --lib --no-default-featuresclean25 new integration tests across three files, designed to fail closed against the specific bug classes a buggy implementation could ship under:
tests/test_smask_alpha.rs(19):ext_gstate_alpha_smask_blocks_paint_under_transparent_mask— basic Alpha mask, top opaque / bottom transparent.ext_gstate_smask_none_clears_active_soft_mask—/SMask /Noneclears; first fill still landed.ext_gstate_smask_survives_q_save_restore— mask persists throughq/Qpush and pop.ext_gstate_alpha_smask_honours_install_time_ctm—cmbeforegscorrectly scales the mask.ext_gstate_smask_cyclic_g_does_not_stack_overflow— adversarial self-referential/Gis depth-capped.ext_gstate_luminosity_smask_modulates_paint_by_group_luma— mid-grey/Gcomposes ~50% black-over-white.ext_gstate_luminosity_smask_bc_white_backdrop_passes_paint—/BC [1 1 1]backdrop unblocks paint where/Gis unpainted.ext_gstate_luminosity_smask_tr_type2_squared_attenuates_paint—/TRN=2squares the mask (0.5 → 0.25).ext_gstate_luminosity_smask_group_cs_devicegray_yields_50pct_paint—/CS /DeviceGrayvalid case behaves like no-/CS.ext_gstate_luminosity_smask_malformed_devicegray_with_rgb_paint_uses_bt601— pins the BT.601-unconditional choice for malformed/CS.smask_clips_image_paint— imageDoclipped by active SMask.smask_clips_text_paint— text glyphs in the masked-off region don't render.bt601_luma_pure_red_yields_r_approx_179— pins the red weight position in BT.601 (a swap to green-dominant gives R ≈ 105).bt601_luma_pure_green_yields_r_approx_105— pins the green weight position.bt601_luma_pure_blue_yields_r_approx_226— pins the blue weight position. Together these three uniquely identify the BT.601 coefficient triple; any pairwise swap fails ≥ 2 tests.tr_type2_squared_pins_exponent_at_three_points— multi-samples/TR Type 2 N=2at gray = 0.25, 0.5, 0.75. A single-point assertion would admitx¹,x³,x⁴; three points uniquely identify the exponent.tr_type2_invalid_n_falls_through_to_identity— actually exercises theN ≤ 0rejection path.smask_cache_invalidates_when_ctm_changes— same/GS1invoked at two different CTMs in one content stream. A cache that skipped the install-transform check would serve the stale identity-CTM mask on the second invocation.smask_applies_to_paint_inside_nested_do— page invokes a Form viaDo; the Form's own content stream installs/GS1(SMask). Tests that nested-context SMask correctly clips the inner form's paints.tests/test_knockout_group.rs(4):knockout_group_blue_replaces_red— two 50%-alpha overlapping rects with/K trueproduce blue-over-white;/K falseproduces red+blue blend. Asserts G and B channels each diverge by ≥ 30 levels.knockout_group_opaque_non_normal_blend_redirects_to_backdrop— opaque red + opaque blue with/BM /Multiplyin a knockout group. Verifies the alpha short-circuit gates onBM=Normal.knockout_group_pixel_exact_replacement— same fixture rendered with the knocked-out red present vs absent; asserts byte-identical pages across the entire pixmap. Pins the spec-defining invariant of §11.6.6.2 — half-implemented knockouts that produced channel deltas under the +30 thresholds don't survive byte equality.knockout_redirects_all_four_non_separable_blend_modes— parameterised acrossHue,Saturation,Color,Luminosity. A bug that only special-cased some mode names would slip through.tests/test_non_separable_blend_modes.rs(1):luminosity_blend_mode_does_not_overwrite_with_source— red-then-blue layered paint withBM=Luminosityon the upper layer. Asserts B < 200 (a SourceOver fallback would give B = 255).Python Bindings (if applicable)
ruff formatruff checkmaturin develop --release --features python,ocr,barcodesproduces a working wheel locally.Documentation
Inline rustdoc on
materialise_soft_mask_alpha,knockout_aware_paint,knockout_merge,knockout_paint_alpha,effective_clip,parse_soft_mask_transfer,parse_soft_mask_backdrop, andpdf_blend_mode_to_skiadocuments the spec basis and intentional out-of-scope cases (CMYK blend-space approximation for/Group /CS, full ICC for/BC, byte-equality limitation ofknockout_merge).Checklist
feat:,fix:,docs:)Screenshots (if applicable)
N/A — visible effects are spec-conforming compositing changes. Synthetic test fixtures pin pixel-level expectations.
Additional Notes
Out of scope for this PR (tracked for follow-up):
/Group /CS-aware luma dispatch. The renderer rasterises everything to RGB; a spec-correct CMYK or wide-gamut blend space requires a non-RGB pixmap path. BT.601 on the rasterised RGB is the documented approximation.execute_operatorspassesNonefor nested forms, so the inner form renders normally. Correct for the common case./TRfunction types 0 (sampled), 3 (stitching), 4 (PostScript). Treated as identity; Type 2 covers the overwhelming majority of real-world/TR./BCfor non-Device colour spaces.Lab,ICCBased,CalRGBetc. fall back to component-count inference. Spec-correct ICC profile evaluation is out of scope./Group /K. The page's own group dict is read for/S /Transparencybut/Kon the page-level group isn't applied at the top-level render. Rare in practice; a future fix would propagate it throughrender_page_with_options.knockout_aware_paintis O(W·H). Plausible follow-up: hoist a single scratch pixmap across the knockout group and reuse it viacopy_from_slice. For the dense-plot pathological case this is a meaningful speedup; for typical artwork it is not visible.knockout_mergebyte-equality limitation. A paint that legitimately reproduces backdrop bytes (e.g. drawing white onto white-backdrop) is treated as untouched and the prior paint's value survives. Rare in real artwork; documented in the helper's docstring.TODO follow-up PR (refactor, no behaviour change):
src/rendering/page_renderer.rsis now 3360+ lines, withexecute_operatorsalone at ~1280 lines. A pure-code-movement pass — extracting the fourSet{Fill,Stroke}Color{,N}match arms into named methods, plus theSetExtGStatehandler and the path/text paint dispatch — dropsexecute_operatorsby ~500 lines and the file by ~10% with zero behaviour change. Each extraction is independently reviewable. Will land as a separate PR offmainafter this one merges.