Skip to content

feat: render smask#634

Closed
RayVR wants to merge 14 commits into
yfedoseev:mainfrom
RayVR:feature/render-smask
Closed

feat: render smask#634
RayVR wants to merge 14 commits into
yfedoseev:mainfrom
RayVR:feature/render-smask

Conversation

@RayVR

@RayVR RayVR commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

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 /SMask was ignored, /K knockout groups rendered as non-knockout, and BM=Hue|Saturation|Color|Luminosity collapsed to SourceOver. 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

  • New feature (non-breaking change which adds functionality)
  • Bug fix (non-breaking change which fixes an issue)
  • Performance improvement
  • Tests

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

  • Parser captures /SMask as a SoftMaskSpec (None or Dict); apply-time is pure.
  • Renderer maintains soft_mask_stack mirroring clip_stack; q/Q push and pop in lockstep. /SMask /None clears the stack slot.
  • materialise_soft_mask_alpha renders the /G group 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), rejects N ≤ 0 and multi-component /C0 / /C1); /Identity, missing /TR, and other function types are no-ops.
  • effective_clip helper 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.
  • Hard cap of 32 nested SMask materialisations against cyclic /G references.
  • Cache for materialised alpha keyed by ExtGState dict_name with bitwise install-time CTM validity check. Scoped to one execute_operators invocation (no cross-page / cross-document leakage by construction).

Knockout transparency groups /K (§11.6.6.2) — page renderer

  • render_form_xobject parses /K from the form's /Group dict (resolves indirect /Group N 0 R references; accepts boolean true and non-zero integer for both /K and /I). When true, snapshots the group's initial pixmap state and passes it as knockout_backdrop into the recursive execute_operators.
  • New knockout_aware_paint(pixmap, backdrop, alpha, fn) wraps each paint primitive. When backdrop is Some AND effective_alpha < 0.9999, the paint targets a fresh clone of the backdrop and the result merges via knockout_merge (per-pixel: temp[i] != backdrop[i] → copy temp into dest).
  • knockout_paint_alpha(gs_alpha, blend_mode) gates the alpha short-circuit on BM=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.
  • All 10 paint sites covered: stroke, fill, fill+stroke (both passes), fill even-odd, four text variants (Tj/Quote/TJ/DoubleQuote), Do (image + form), shading.

Non-separable blend modes (§11.3.5.3) — page renderer

  • pdf_blend_mode_to_skia previously fell through to SourceOver for Hue / Saturation / Color / Luminosity. The underlying tiny_skia already ships those as native BlendMode variants implementing the W3C / Acrobat formulas; the fix is four new match arms.

Robustness, perf, and review-fix work bundled here

  • /SMask parsing handles indirect /Resources references, missing /S (warn-and-skip), unknown subtypes (warn-and-skip), /Group /CS as array form (e.g. [/ICCBased <ref>] resolves to base name).
  • Skip the doubled per-SMask page-sized pixmap allocation (~33 MB saved per SMask at 300 DPI A4).
  • as_f64 helper dedupes the Object::Real | Integer → f64 pattern across the new parsers.

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • All new and existing tests pass locally
  • I have run cargo test --features rendering
  • I have run cargo clippy --all-targets --features rendering -- -D warnings
  • I have run cargo fmt
  • cargo check --lib --no-default-features clean

25 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 /None clears; first fill still landed.
  • ext_gstate_smask_survives_q_save_restore — mask persists through q/Q push and pop.
  • ext_gstate_alpha_smask_honours_install_time_ctmcm before gs correctly scales the mask.
  • ext_gstate_smask_cyclic_g_does_not_stack_overflow — adversarial self-referential /G is depth-capped.
  • ext_gstate_luminosity_smask_modulates_paint_by_group_luma — mid-grey /G composes ~50% black-over-white.
  • ext_gstate_luminosity_smask_bc_white_backdrop_passes_paint/BC [1 1 1] backdrop unblocks paint where /G is unpainted.
  • ext_gstate_luminosity_smask_tr_type2_squared_attenuates_paint/TR N=2 squares the mask (0.5 → 0.25).
  • ext_gstate_luminosity_smask_group_cs_devicegray_yields_50pct_paint/CS /DeviceGray valid 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 — image Do clipped 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=2 at gray = 0.25, 0.5, 0.75. A single-point assertion would admit , , x⁴; three points uniquely identify the exponent.
  • tr_type2_invalid_n_falls_through_to_identity — actually exercises the N ≤ 0 rejection path.
  • smask_cache_invalidates_when_ctm_changes — same /GS1 invoked 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 via Do; 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 true produce blue-over-white; /K false produces 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 /Multiply in a knockout group. Verifies the alpha short-circuit gates on BM=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 across Hue, 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 with BM=Luminosity on the upper layer. Asserts B < 200 (a SourceOver fallback would give B = 255).

Python Bindings (if applicable)

  • Python bindings updated (if needed) — no surface change; all behaviour is internal to the rendering pipeline.
  • Python tests pass
  • Python code formatted with ruff format
  • Python code linted with ruff check

maturin develop --release --features python,ocr,barcodes produces a working wheel locally.

Documentation

  • I have updated the documentation (README, docs/, code comments)
  • I have added/updated examples (if applicable)
  • I have updated CHANGELOG.md

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, and pdf_blend_mode_to_skia documents the spec basis and intentional out-of-scope cases (CMYK blend-space approximation for /Group /CS, full ICC for /BC, byte-equality limitation of knockout_merge).

Checklist

  • My code follows the project's coding guidelines (see CONTRIBUTING.md)
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have checked my code and corrected any misspellings
  • The PR title follows conventional commits format (e.g., 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):

  • Separation-renderer integration. Per earlier research, the correct path is composite-then-separate — never per-plate alpha multiplier, which would put shadow tint on plates the colour never referenced. Architectural piece, not a paint-site change.
  • /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.
  • Non-Normal blend modes inside knockout groups beyond the redirect dance. Real-world output from authoring tools is ambiguous about backdrop-relative blending semantics; reference fixtures would help pin the right answer.
  • Nested non-isolated subgroup whose parent is knockout. The recursive execute_operators passes None for nested forms, so the inner form renders normally. Correct for the common case.
  • /TR function types 0 (sampled), 3 (stitching), 4 (PostScript). Treated as identity; Type 2 covers the overwhelming majority of real-world /TR.
  • /BC for non-Device colour spaces. Lab, ICCBased, CalRGB etc. fall back to component-count inference. Spec-correct ICC profile evaluation is out of scope.
  • Page-level /Group /K. The page's own group dict is read for /S /Transparency but /K on the page-level group isn't applied at the top-level render. Rare in practice; a future fix would propagate it through render_page_with_options.
  • Per-paint pixmap clone in knockout_aware_paint is O(W·H). Plausible follow-up: hoist a single scratch pixmap across the knockout group and reuse it via copy_from_slice. For the dense-plot pathological case this is a meaningful speedup; for typical artwork it is not visible.
  • knockout_merge byte-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.rs is now 3360+ lines, with execute_operators alone at ~1280 lines. A pure-code-movement pass — extracting the four Set{Fill,Stroke}Color{,N} match arms into named methods, plus the SetExtGState handler and the path/text paint dispatch — drops execute_operators by ~500 lines and the file by ~10% with zero behaviour change. Each extraction is independently reviewable. Will land as a separate PR off main after this one merges.

RayVR added 11 commits June 3, 2026 22:24
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.
@RayVR RayVR requested a review from yfedoseev as a code owner June 4, 2026 00:25
@RayVR RayVR changed the title Feature/render smask feat: render smask Jun 4, 2026
@yfedoseev

Copy link
Copy Markdown
Owner

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 — clippy::doc_lazy_continuation, fatal here under -D warnings. A re-run won't help; it'll fail identically until the doc comment is adjusted.

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: src/rendering/page_renderer.rs:2699–2701

The nested * bullet list is followed by a continuation paragraph that's under-indented relative to the list item:

///       * 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 Malformed groups text), or drop them to a standalone top-level paragraph with a blank /// line before them. Either clears all four jobs at once.

Quick local check before pushing: cargo clippy --all-targets -- -D warnings. Thanks again!

…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.
@RayVR

RayVR commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 6d14b95. Broke the closing /CS-dispatch architectural note out
of the bullet list with a blank /// separator — semantically right
(it applies to the whole /Luminosity block, not just the malformed-
groups sub-bullet that preceded it), and it satisfies
clippy::doc_lazy_continuation under -D warnings.

Verified locally:

  • cargo clippy --features rendering --all-targets --no-deps -- -D warnings: clean
  • cargo clippy --target wasm32-unknown-unknown --no-default-features --features wasm,rendering,barcodes --lib --no-deps -- -D warnings: clean
  • cargo check --lib --no-default-features: clean
  • rustfmt --check src/rendering/page_renderer.rs: clean

@RayVR

RayVR commented Jun 6, 2026

Copy link
Copy Markdown
Contributor Author

Closing this PR — the features it ships have been re-implemented on feature/transparency-flattening atop the post-refactor architecture (#649 is now merged). That branch is in progress (round 3 of a multi-round design/QA/fix loop is running as I post this); the upstream PR will follow once the work stabilizes.

SMask is part of Chapter 11 transparency (§11.6.5), as are knockout /K (§11.4.6.2) and non-separable blend modes (§11.3.5.3). It made sense to fold all of this PR's features into a single transparency implementation pass on top of the new pipeline rather than rebase and ship piecemeal.

Why the re-implementation rather than a rebase

This PR was authored against the pre-refactor page_renderer.rs inline-arm pattern. The renderer-resolution-pipeline migration (#649) replaced that pattern with a ResolutionPipeline + PaintBackend shape. Conflict resolution here would have meant rewriting each implementation against the new shape — which is effectively what I did on the new branch, with the addition of a feature-coverage audit that surfaced gaps this PR didn't catch:

  • qa_round1_form_xobject_smask_alpha: §11.4.7 SMask /S /Alpha on Form XObjects (this PR's 94912f3) — re-implemented as part of round 2 gap 3
  • qa_round1_form_xobject_smask_luminosity: §11.4.7 SMask /S /Luminosity (this PR's c0ab679) — re-implemented, with explicit BT.601 luma reference values
  • qa_round1_smask_bc_tr: SMask /BC + /TR (this PR's da0b0d7) — re-implemented with Type-2 transfer functions evaluated per pixel
  • qa_round1_nonseparable_blend_modes: §11.3.5.3 non-separable blend modes (this PR's 4849dec) — round 1 audit found these were silently degrading to SourceOver in tiny_skia; round 2 implemented them out-of-band in HSL/HSY space per spec, not as tiny_skia BlendMode variants (which don't expose Hue/Saturation/Color/Luminosity natively)
  • qa_round1_knockout_group_K: §11.4.6.2 /K knockout (this PR's d069ae2) — re-implemented via per-paint-operator backdrop-replay segmentation

Tests worth preserving

The implementations are superseded but the test scenarios in this PR's later commits — particularly 1084cfe (SMask under nested Do + CTM cache invalidation), 87457d4 (SMask clipping across image and text paints), 17cee28 (spec-compliance + malformed-input hardening), and 4d82947 (review-feedback test additions) — pin properties that aren't yet probed in the new audit suite. I've filed a follow-up to cherry-pick or rewrite them against the new architecture so the coverage doesn't regress.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants