Skip to content

feat(rendering): §11 transparency surface + IccBackend trait with optional lcms2 backend#674

Merged
yfedoseev merged 155 commits into
yfedoseev:mainfrom
RayVR:feature/transparency-flattening
Jun 11, 2026
Merged

feat(rendering): §11 transparency surface + IccBackend trait with optional lcms2 backend#674
yfedoseev merged 155 commits into
yfedoseev:mainfrom
RayVR:feature/transparency-flattening

Conversation

@RayVR

@RayVR RayVR commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Description

Implements the §11 transparency surface end-to-end for press-grade separation output, with byte-exact spec correctness across the composite-then-separate path. A CMYK paint with non-trivial transparency, an overprint-active page, a Form XObject /SMask, a non-separable blend mode, a Pattern with a /Separation underlying, a DeviceN /Process paint with an embedded ICC profile differing from the OutputIntent — all render byte-exact against spec-derived references instead of the §10.3.5 additive-clamp approximation.

Architecture: composite all transparency math in the source colour space (CMYK + per-spot-ink sidecar lanes), then decompose to per-plate output for render_separations. Spec-correct per ISO 32000-1 §11.3 + §11.4 + §11.6.7 + §11.7.3 + §11.7.4; matches real prepress / packaging RIP workflows.

Type of Change

  • New feature (non-breaking — extends rendering correctness; existing call sites unchanged)
  • Performance improvement (composite-path detection gate keeps detection-OFF pages on the existing per-plate walker)
  • Bug fix
  • Breaking change

Related Issues

None tracked upstream. Driven by an internal roadmap for press-grade prepress rendering (composite-then-separate semantics for separation output, opt-in ICC retargeting).

Changes Made

Composite-then-separate architecture

  • New CmykSidecar struct (CMYK plane + per-spot-ink lanes). Spot ink set discovered via a pre-pass walking /Resources/ColorSpace. Sized once per page; spot lanes initialise to subtractive 0.0 per §11.7.3.
  • page_renderer.rs composite path populates the sidecar through every paint operator; separation_renderer.rs routes detection-ON pages through the composite path then decomposes per-plate; detection-OFF (pure-overprint, no transparency) pages stay on the existing per-plate walker for byte-identical output.
  • page_declares_transparency detection gate (narrower than page_declares_transparency_or_overprint) honours §11.7.4 OPM correctness on pure-overprint pages by keeping them on the per-plate walker.

§11.3.5 blend modes

  • Non-separable blend modes (/Hue, /Saturation, /Color, /Luminosity) implemented per §11.3.5.3 in HSL/HSY space via a new src/rendering/blend_nonsep.rs module. BT.601 luma weights, SetLum/SetSat per spec.
  • The pre-existing _ => arm in page_renderer that silently degraded non-separable modes to SourceOver is closed.

§11.7.4.2 spot-lane Normal substitution

  • BlendModeClass::{SeparableWhitePreserving, SeparableNonWhitePreserving, NonSeparable} classifier dispatches per §11.7.4.2: non-separable + non-white-preserving blend modes apply to process channels only; spot lanes substitute /Normal per the dispositive spec rule.
  • §11.7.4.2 dispatch wired at every paint surface (path fills, strokes, combo FillStroke, text-showing operators, Image Do, ImageMask Do, shading sh).

SMask handling

  • Form XObject /SMask /S /Alpha, /S /Luminosity, /BC backdrop colour, /TR transfer function — previously documented as "intentionally ignored" — fully implemented with proper SoftMaskForm / SoftMaskSubtype plumbing through the graphics state. Image-attached SMask continues to work.
  • MAX_SMASK_DEPTH=32 cap on cyclic /G recursion (documented policy choice).
  • SMask attenuates spot lanes per §11.3.3's single shape/opacity rule via per-pixel mask alpha applied uniformly across all lanes.
  • /BC backdrop colour handled for n=1 (Gray), n=3 (RGB), n=4 (CMYK), AND n≥5 (DeviceN/NChannel) — the n≥5 path resolves the Form XObject's /Group /CS and evaluates the tint transform with the backdrop tints before projection.
  • /TR transfer function supports all four PDF function types: Type 0 (sampled), Type 2 (exponential), Type 3 (stitching), Type 4 (PostScript calculator). The Type 4 path reuses the existing PostScript calculator at src/functions/mod.rs that already serves Separation / DeviceN tint transforms.

Knockout groups /K

  • §11.4.6.2 knockout composition: each element composes against the group's initial backdrop, not the previous element's result. Sidecar lanes (CMYK + spot) snapshot at group entry, restore before each replay, merge per-byte diffs into accumulators, install at group exit.
  • Form XObject Do inside a /K group correctly preserves the inner Form's authoritative paint writes (the post-Do mirror is suppressed for Form XObjects since the Form's content stream already painted with its own gs).

/RI rendering intent

  • Previously silently dropped; now flows through to the colour stage and into try_retarget_cmyk_via_embedded_profile for ICC retargeting.

§11.7.4.3 CompatibleOverprint (per-channel)

  • Replaces the previous (src + dst).min(1.0) additive merge with spec-correct §11.7.4.3 Table 149 per-channel REPLACE / PRESERVE / SKIP logic.
  • Three source-CS classes dispatched: DeviceCmykDirect (only class where §11.7.4.5 OPM=1 zero-source-preserve fires per spec), OtherProcess (DeviceGray/RGB/ICCBased/DeviceN /Process), SeparationOrDeviceN (spot inks preserve backdrop on process channels; spot mirror handles spot lanes).
  • Stale gs.fill_color_cmyk cleared on SetFillRgb / SetFillGray / SetStrokeRgb / SetStrokeGray to prevent leakage across paint operations.
  • Cross-path byte-identity verified: pure-overprint DeviceCMYK page renders byte-identical via per-plate walker (detection-OFF) and via composite-then-decompose (detection-ON synthetic trigger).

RGB-source paint into CMYK sidecar (§11.3.4 single blend space)

  • Adds an RGB→CMYK sidecar mirror so RGB-source paints contribute to the CMYK sidecar plane per §11.3.4's "one blend space, paint-time conversion" rule.
  • Backend-dispatched: under icc-lcms2, conversion goes sRGB → Lab → OutputIntent CMYK with full ICC accuracy; under default icc-qcms or no-CMM builds, falls back to §10.3.5 additive-clamp inverse (C, M, Y) = (1-R, 1-G, 1-B), K = 0 (the K-loss is documented in-source).
  • Wired at the Stroke and Fill arms; CMYK transparent paint over an RGB backdrop now composites against the converted backdrop instead of paper-white.

DeviceN /Process source CMYK

  • SetFillColorN / SetStrokeColorN for DeviceN sources with /Process attribution populate gs.fill_color_cmyk and gs.fill_color_rgb from the actual scn tint values via the /Process /ColorSpace tint transform — replacing the previous tint-blind path that produced constant (1, 1, 1, 0) source CMYK regardless of tints.
  • Handles /Process /ColorSpace = /DeviceCMYK (tints as natural-form), /DeviceRGB / /DeviceGray (§10.3.5 additive-clamp inverse), /ICCBased (natural-form fallback under default features; full retargeting under icc-lcms2).
  • Mixed DeviceN (process prefix + spot tail) routes process tints through the process classifier AND the spot tail through the existing spot mirror.
  • §8.6.8 initial colour for DeviceN /Process: cs /CS_N populates a CMYK identity built from extract_process_paint_cmyk(all-1.0_tints) instead of leaving fill_color_cmyk = None.

Pattern colour spaces

  • Pattern colour spaces with /Separation or /DeviceN underlying CSes recurse correctly through ink discovery (sidecar + separation_renderer parity). Indirect-ref underlying CSes deref via doc.resolve_object (previously fell through to ResolvedSpace::Unknown).

Real coverage rasterisation for text-show / Image Do / shading sh

  • Replaces the previous binary "any RGB change" coverage approximation at text-showing operators (Tj, TJ, ', "), Image / ImageMask Do, and shading sh paint sites.
  • New helpers re-run text / image / shading paint with a coverage_only_gs (forces opaque-black, no SMask, no transparency, Normal BM) into a scratch RGBA Pixmap; alpha channel extracted as coverage. AA-edge byte-exact correctness; identical-RGB-collision pixels no longer silently skipped.
  • Invisible text (3 Tr per §9.3.6) correctly produces no spot-lane write (the coverage scratch honours the user's render_mode instead of overriding it to 0).
  • ImageMask /Decode array honoured per §8.9.6.2 (default [0 1] = bit-0 paints).

ICC backend trait + lcms2 backend

  • IccBackend trait extracts the CMM surface pdf_oxide actually needs: profile parsing, sRGB transform building, CMYK→sRGB conversion (single + buffer), CMYK→CMYK retarget building, retarget application, sRGB→CMYK transform building (added for the RGB→CMYK mirror path), intent + flag dispatch.
  • QcmsBackend (the existing pure-Rust default) implements every method qcms 0.3.0 supports; build_cmyk_retarget and build_srgb_to_cmyk return None (qcms 0.3.0 has no CMYK output pipeline).
  • Lcms2Backend (new, opt-in behind icc-lcms2 feature, FFI wrapper around Little CMS 2.x) implements the full surface including CMYK→CMYK retargeting with BPC + rendering intent dispatch. DisallowCache for Sync so Arc<Transform> can cross rayon workers under the parallel feature.
  • Compile-time backend selection via ActiveIccBackend type alias; lcms2 wins when both features are enabled.
  • try_retarget_cmyk_via_embedded_profile short-circuits when (a) the active backend can't retarget CMYK, (b) the document has no OutputIntent CMYK profile, (c) either profile fails to parse, (d) the embedded profile's content_hash matches the OutputIntent's (identity skip). Otherwise retargets src.AToB → Lab → dst.BToA with BPC for the press-default intent.
  • Rendering intent threaded from gs.rendering_intent (set by the /RI operator) through extract_process_paint_cmyk to the lcms2 transform builder — /Perceptual ri followed by a /DeviceN /Process /ICCBased paint retargets with perceptual intent, /Saturation ri with saturation, etc. §8.6.5.8 default (RelativeColorimetric, the press standard) applies when no /RI is in effect.

Testing

Byte-exact references throughout

Every probe pins byte-exact CMYK / spot-lane / per-plate values computed by hand from §-formulas or via independent CMM verification (lcms2 standalone for retargeting probes). No ±X tolerances. Where the spec is genuinely silent, the chosen reading is pinned as a documented marker with both the chosen byte value and the alternate-reading rationale captured in-source.

Mechanism-pinning probes (sensitivity-verified)

Where a fix addresses a specific underlying mechanism (e.g., Form Do post-mirror suppression for nested /K, identical-RGB collision in coverage extraction, ICC intent threading, Type 3 stitching dispatcher), probes are sensitivity-verified: temporarily reverting the fix in a git stash → probe fails with the predicted incorrect byte; restoring → probe passes. Documented in commit messages.

Test surface

14+ integration test files covering:

  • Spot ink discovery (Separation / DeviceN / /All / /None reserved-name exclusion / hex-escaped names / NChannel / /Process attribution / mismatched names)
  • Sidecar storage + detection gating (per-plane allocation invariants, detection-OFF byte-identity)
  • §11.7.4.2 dispatch (every BM × every paint surface × spot vs process lane)
  • SMask alpha + luminosity + /BC n=1/3/4 + /BC n≥5 (DeviceN with tint-transform projection) + /TR Type 0 + Type 2 + Type 3 stitching + Type 4 PostScript + cyclic /G + nested /K + multi-glyph TJ + invisible text
  • Knockout /K cumulative-replay across CMYK + spot lanes; nested /K through Form XObject Do
  • §11.7.4.3 CompatibleOverprint (DeviceCMYK direct, DeviceGray, DeviceRGB, /Separation, /DeviceN, /DeviceN /Process /CMYK + /RGB + /Gray + /ICCBased + mixed-with-spot-tail)
  • §11.7.4.4 OPM=0 and §11.7.4.5 OPM=1 across all source classes (verified OPM=1 zero-source-preserve binds DeviceCmykDirect only)
  • §11.3.4 RGB-source paint into CMYK sidecar; CMYK transparent paint over RGB backdrop composites against converted backdrop, not paper-white. Both icc-lcms2 ICC path AND the icc-qcms / no-CMM §10.3.5 fallback path probed
  • Pattern with /Separation / /DeviceN underlying via inline arrays AND indirect refs
  • Real coverage at text-show / Image Do / shading sh; AA-edge byte-exact; identical-RGB collision; rotated CTM glyph footprint; multi-glyph TJ with negative kern; empty Tj no-panic; invisible text no-lane-write
  • ICC retargeting under all 4 feature combos: default qcms (preserves natural-form), icc-lcms2 (real retargeting verified against independent lcms2 reference values), both features enabled (lcms2 wins), --no-default-features (no-CMM §10.3.5 fallback)
  • Rendering intent dispatch (Perceptual, Saturation, AbsoluteColorimetric, RelativeColorimetric default, qcms-treats-intent-as-informational)
  • Cross-path byte-identity (walker vs composite for pure-OP pages)

Architectural deviation honestly surfaced

The CMYK sidecar on PageRenderer is used instead of routing every paint through SeparationBackend's plate-emit shape, because the latter would require emitting ResolvedPaintCmd from every paint surface (currently only path fills / strokes). The sidecar achieves equivalent press-accurate output (composite-first transparency, post-composite plate decomposition) without the architectural rework. Probes verify the two architectures produce equivalent output on every probed edge case.

Pre-merge verification

  • cargo fmt --all -- --check — clean
  • cargo clippy --all-targets --features "rendering icc test-support" -- -D warnings — clean
  • cargo clippy --all-targets --features "rendering icc-lcms2 test-support" -- -D warnings — clean
  • cargo check --lib --no-default-features — clean
  • cargo test (default features) — full suite green
  • cargo test --features "rendering icc-lcms2 test-support" — green
  • cargo test --features "rendering icc icc-lcms2 test-support" — green (both features)
  • cargo test --no-default-features --features "rendering test-support" — green (no ICC)
  • cargo doc --no-deps --features "rendering icc-lcms2 test-support" — clean

Python Bindings

No binding changes. The existing render_page / render_separations Python API surface is unchanged. Users enabling icc-lcms2 at build time get the press-grade CMM behaviour transparently through the same calls.

Documentation

  • Inline docstrings cite the §-numbers for every spec-derived behaviour
  • All probe docstrings cite the spec section under test
  • Documented spec-ambiguity markers describe the chosen reading and the alternate readings considered, with the byte value of the chosen reading pinned by sensitivity-verified probes

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have added byte-exact probes for every behaviour change
  • All existing tests pass with no byte-identity regressions across the prior suite
  • No new clippy warnings
  • No new unsafe blocks in production code (the optional icc-lcms2 feature pulls a C dep through a safe Rust wrapper)
  • Compiles cleanly across all feature-flag combinations

Additional Notes

Surviving documentation markers (defensible-reading or policy choices, not implementation gaps)

The following constants document choices where ISO 32000-1 is genuinely silent on reader behaviour OR where a policy choice is required on spec-silent ground (e.g., bounded recursion, malformed-input handling). Each has a byte-exact probe pinning the chosen behaviour + the alternate readings noted in-source.

Genuine spec ambiguities (6):

  • HONEST_GAP_NONSEP_DEVICEN_GROUP — §11.3.4 + §11.6.6 forbid /CS /DeviceN as a transparency group blend space; reader behaviour on malformed files declaring it is unspecified. Chosen: surface colorants as active spots through the pre-pass, fall back to the DeviceN alternate space.
  • HONEST_GAP_NONSEP_K_CHANNEL_FOR_NON_CMYK_FOUR_COMPONENT_ICC — §11.3.5.3 K-channel rule is named only for /DeviceCMYK and calibrated CMYK; 4-component non-CMYK ICCBased blend spaces aren't covered. Chosen: apply the CMYK K-rule analogously.
  • HONEST_GAP_SPOT_LANE_UNSOURCED_PRESERVE_BACKDROP — §11.7.3 strict source-expansion vs §11.7.4.3 CompatibleOverprint example disagree on whether unsourced spot lanes preserve backdrop or compose against source=0. Chosen: CompatibleOverprint preserve-backdrop semantics for implicit-not-named spots; explicit-zero-tint paints write tint 0.
  • HONEST_GAP_KNOCKOUT_DIFFERENT_INK_SPOT_INTERACTION — §11.4.6.2 cross-ink knockout behaviour is underspecified when paints in a /K group target different spot inks. Chosen: per §11.7.4.3, preserve each ink's prior write across the group.
  • HONEST_GAP_DEVICEN_PROCESS_OVERPRINT_CLASS — Table 149 row 6 admits broad ("DeviceN /Process attribution → process-class") and narrow ("any DeviceN source preserves backdrop on process channels") readings. Chosen: broad, consistent with §8.6.6.5 EXAMPLE 3.
  • HONEST_GAP_DEVICEN_PROCESS_MISMATCHED_NAMES — §8.6.6.5 says /Components names must match /Names but doesn't specify reader behaviour on violation. Chosen: treat /Process as inert (preserves spot-tail intent over partial process attribution).

Defensible policy choices (4):

  • HONEST_GAP_KNOCKOUT_MERGE_BYTE_EQUALITY_SKIP — /K accumulator skips per-byte merge when post == backdrop (observationally identical to "no paint"; documented as policy choice with byte-exact probe).
  • HONEST_GAP_SMASK_CYCLIC_G_UNBOUNDED_RECURSIONMAX_SMASK_DEPTH=32 cap to prevent OOM / stack-overflow on cyclic /G references. Spec doesn't require a depth limit; real implementations must have one.
  • HONEST_GAP_DEVICEN_PROCESS_ICC_PROFILE_MISMATCH / _R7 — three-state matrix: closes under icc-lcms2 (real CMYK→CMYK retargeting through Lab PCS with BPC); remains a documented feature-level limitation under default icc-qcms (qcms 0.3.0 has no CMYK output pipeline); §10.3.5 fallback under no-ICC builds.

Defensible malformed-input policy (1):

  • HONEST_GAP_SMASK_BC_MALFORMED_ARITY — when /BC array's component count doesn't match the declared blend space, falls back to black backdrop. Spec is silent on reader behaviour for malformed /BC.

Optional icc-lcms2 feature

Pure-Rust default behaviour (icc-qcms) is unchanged for existing users. WASM, C# AOT, and other pure-Rust-only targets continue to work without a C toolchain. Users needing PDF/X-3 / PDF/X-4 conformance with embedded-source-profile-to-OutputIntent retargeting opt in:

cargo build --features "rendering icc-lcms2"

The icc-lcms2 feature pulls Little CMS 2 via the lcms2 crate (well-maintained safe Rust wrapper, ~2.4M crates.io downloads). C build dep cost: comparable to the existing fips (AWS-LC) and ocr (ONNX Runtime) features in the project's opt-in surface.

PR size

This is a large PR (~57k insertions across the §11 transparency surface plus the IccBackend trait extraction and lcms2 backend). Retroactively splitting after this much spec-correctness work has accumulated would mean substantial rebase risk for marginal review benefit — every spec area is interconnected through the sidecar and dispatch machinery. The change is structured as cohesive feature areas in this description so reviewers can focus on the surface they care about; the commit log preserves the bisect-ability of individual feature additions.

RayVR added 30 commits June 6, 2026 12:35
…borrows on ResolutionContext

The colour stage needs these to thread press-target ICC dispatch
through the composite path. They were on the context previously,
unwired, and got removed because dead borrows were net-negative;
they come back now because the resolver is about to grow an
OutputIntent-consuming code path on /DeviceCMYK paint and on
/Separation / /DeviceN alternates landing in /DeviceCMYK.

Reuses the existing crate::color::RenderingIntent enum (already
shipped with from_pdf_name + the qcms::Intent mapping) rather than
forking a renderer-private copy.

The bare constructor stays no-arg so the in-source unit tests that
only probe Device-family paths don't have to thread fixtures.
Builder-style with_output_intent / with_rendering_intent /
with_defaults populate the colour-policy fields at the page- and
separation-renderer call sites.
…to page renderer's ResolutionContext

run_pipeline_for_logical now hands the colour stage:
  - doc.output_intent_cmyk_profile() — already filters /N=4 and parses
    the embedded DestOutputProfile stream into an Arc<IccProfile>
  - RenderingIntent::from_pdf_name(&gs.rendering_intent) — the active
    /RI from the graphics state, falling back to RelativeColorimetric
    when /RI isn't in the spec's name set
  - color_spaces.get("DefaultGray"/"DefaultRGB"/"DefaultCMYK") — already
    populated from the page's /Resources/ColorSpace dict, lifted by name

No behaviour change yet — no resolver stage reads the new borrows.
The colour stage consumes them in the follow-up.
…nderer's call site

paint_through_pipeline now chains the same with_output_intent /
with_rendering_intent / with_defaults builders the composite path
does. For the per-plate backend the document /OutputIntents profile
is effectively a no-op (separations ARE the press-target ink
coverage; the plate router consumes ResolvedColor::Cmyk channel-by-
channel and never projects to RGBA), but threading it uniformly
keeps the resolver call surface symmetric across renderers so a
single ColorResolver change can't silently diverge.
Synthetic test fixture: a one-page PDF whose catalog declares
/OutputIntents [<< /S /GTS_PDFX /DestOutputProfile <stream> >>] and
whose page content paints CMYK(0.25, 0, 0, 0) into a centred rect.

The /DestOutputProfile stream is a minimal ICC v2 CMYK→Lab profile
synthesised in-test (build_minimal_cmyk_to_rgb_lut8_profile): a
single A2B0 LUT8 tag with a 2x2x2x2 CLUT whose every entry is
Lab(target_L, 0, 0). With qcms running the profile the entire rect
must render as a near-neutral grey at the chosen L*; under the
ISO 32000-1:2008 §10.3.5 additive-clamp fallback CMYK(0.25,0,0,0)
renders as RGB(191, 255, 255). The fixture exists to make the
divergence visible.

The test also sanity-pins (a) the synthesised profile compiles into
a real qcms transform (has_cmm() == true) and (b) the transform
itself produces ~(128, 128, 128) on a representative CMYK input —
without those sanity checks the test could silently degrade to the
fallback path and assert the wrong thing.

Currently fails because no resolver stage consumes
ctx.output_intent_cmyk: every CMYK conversion still routes through
the additive-clamp helper. The follow-up wires the consumption.
… when present

ColorResolver's four_as_cmyk helper (Separation / DeviceN with a
/DeviceCMYK alternate) and the composite-side ResolvedColor::Cmyk
projection in run_pipeline_for_logical (genuine /DeviceCMYK paint,
ICCBased N=4 fallthrough) both now route through a new
context-aware cmyk_to_rgb_via_intent.

Precedence the helper enforces:
  1. ctx.output_intent_cmyk Some  → qcms via crate::color::Transform,
     intent gated by ctx.rendering_intent (§10.7.3).
  2. ctx.output_intent_cmyk None  → ISO 32000-1:2008 §10.3.5
     additive-clamp, byte-for-byte the shipped behaviour.

Embedded /ICCBased profiles on a colour space stay on the
ColorResolver::resolve_iccbased path and bypass this helper —
that's deliberate: an embedded profile always trumps the document
OutputIntent (§14.11.5).

The 8-bit qcms round-trip mirrors the encoding crate::color and
the image decoder already funnel through. Without the `icc`
feature the helper short-circuits to the additive-clamp branch
unconditionally; with the feature on, an OutputIntent profile
that fails to compile into a real qcms transform still falls
through cleanly because Transform::convert_cmyk_pixel devolves
to §10.3.5 internally when no CMM is available.
…tent declared

Same /DeviceCMYK paint as the positive case (CMYK(0.25, 0, 0, 0)
into a centred rect) but with no /OutputIntents entry in the
catalog. Pins byte-exact RGB(191, 255, 255) — the additive-clamp
value the renderer shipped before OutputIntent threading. A bug
that unconditionally consulted some other ICC profile or that
inverted the precedence rules would surface here as a different
pixel.

Cross-checks that doc.output_intent_cmyk_profile() returns None on
the fixture so a future refactor that mis-attached an OutputIntent
can't sneak past as "OI happens to produce additive-clamp values."
Format-only pass over the files touched by the OutputIntent wiring.
No behaviour change: reformats long single-call format!() into
compact form, collapses identical-line array literals, and inlines
the small Option-return helpers rustfmt prefers single-statement.
Two in-source probes alongside the colour-stage unit suite:

  1. No OutputIntent on the context → byte-exact §10.3.5 additive-
     clamp value for CMYK(0.25, 0, 0, 0). Pins the fallback arm
     directly without going through the operator dispatcher.
  2. OutputIntent borrow points at a header-only stub profile
     (qcms refuses to compile it; the wrapper devolves to §10.3.5
     internally) → result agrees with the no-OutputIntent path
     within rounding tolerance. Pins that a malformed OutputIntent
     profile in the wild doesn't crash and doesn't silently produce
     a wrong colour.

Both probes leave a HONEST_GAP comment flagging the byte-exact
agreement depends on Transform::convert_cmyk_pixel staying in
sync with the image-decoder additive-clamp helper — if those
diverge in the future the no-CMM fallback could disagree with the
no-OutputIntent arm even though both intend the spec fallback.
…ine audit trail

Two QA probes alongside the OutputIntent integration suite:

  1. additive_clamp_consistency_between_extractors_helper_and_no_output_intent_arm
     — pins that Transform::convert_cmyk_pixel's no-CMM fallback
     (devolves to crate::extractors::images::cmyk_pixel_to_rgb) agrees
     byte-for-byte with the §10.3.5 spec formula across eight
     representative CMYK quadruples. Closes the HONEST_GAP the
     foundation marked in cmyk_to_rgb_via_intent_falls_back_when_
     profile_has_no_cmm: if the two §10.3.5 implementations ever
     diverge, the resolver's bare-fallback arm would silently disagree
     with the no-CMM Transform arm even though both intend the spec.

  2. qa_tdd_discipline_verification_report — marker test whose
     docstring captures the audit-trail evidence that the foundation's
     failing test (eab4040) actually failed on its parent commit and
     actually passed on the impl commit (656c119). Pre-empts a future
     reader having to re-bisect to confirm TDD discipline.

Tracking constants OUTPUT_INTENT_DEFER_PHASE_7_CACHING and
OUTPUT_INTENT_DEFER_PHASE_9_DEFAULT_CMYK introduced for use by the
deferred-coverage probes that land in follow-up commits. Mirrors the
WAVE-DEFER-* convention so a grep across the worktree pulls every
on-ice probe in one query.
… on OutputIntent render

The shipped positive test asserts the rendered CMYK pixel falls within
±10 of (128, 128, 128) — that was a hand-wave for an unmeasured target.
Measuring the actual qcms output (qcms 0.3.0, the version pinned in
Cargo.lock at this commit) shows the byte-exact reference is
(126, 126, 126) for the synthesised target_l_byte=135 profile fed
CMYK(64, 0, 0, 0). The rendered pixel at (50, 50) through the composite
pipeline is (126, 126, 126, 255).

Two probes:
  1. output_intent_render_pixel_is_byte_exact_against_qcms_reference
     — pins both the standalone Transform output AND the rendered pixel
     against the measured reference. A regression in the qcms chain
     (Lab → XYZ → sRGB), the LUT8 tetra-interp, or the resolver's
     8-bit round-trip surfaces here byte-for-byte instead of hiding
     inside the ±10 window.
  2. output_intent_constant_clut_is_invariant_across_rendering_intents
     — confirms the synthesised constant-CLUT profile yields the same
     bytes under every rendering intent. If qcms ever starts producing
     intent-dependent output for a CLUT with no out-of-gamut excursion,
     this surfaces it.

If a future qcms upgrade shifts the reference, re-derive the value
here rather than widening the tolerance.
Two probes for the qcms-validation boundary the impl agent flagged as
an open question:

  1. output_intent_with_unparseable_profile_falls_through_to_additive_clamp
     — header-only stub passes IccProfile::parse but qcms refuses to
     build a Transform from it (no tag table). The renderer must
     (a) not panic and (b) produce the §10.3.5 additive-clamp value
     byte-for-byte. Pins that a malformed /OutputIntents in the wild
     degrades gracefully instead of crashing.
  2. output_intent_with_mismatched_icc_header_colour_space_is_rejected_at_parse
     — fixture's ICC header advertises `RGB ` while the stream dict
     claims /N 4. IccProfile::parse's component-count cross-check at
     src/color.rs:159 rejects, the accessor returns None, the renderer
     falls through to §10.3.5. Pins the strongest gate: a profile that
     lies about its colour-space tag never reaches qcms.

If a future refactor weakens either gate the renderer could feed CMYK
bytes to an RGB transform — at best garbage, at worst a CMM panic.
… deferred-phase placeholders

Three live probes pin coverage gaps the foundation's positive test
doesn't exercise:

  - output_intent_survives_graphics_state_save_restore — DeviceCMYK
    paint inside `q ... Q` must still route through OutputIntent ICC.
    A regression that re-built the resolution context inside the
    bracket without re-attaching the OutputIntent borrow would surface
    here as the additive-clamp fallback value.
  - output_intent_renders_at_alpha_one_edge — explicit pin that the
    opaque alpha edge does not bypass the colour stage. Multiple
    alpha-aware shortcuts exist in the composite path; this guards
    against any of them eating the ICC conversion.
  - output_intent_does_not_leak_into_subsequent_rgb_overpaint — a
    white RGB over-paint after a CMYK ICC paint must obscure cleanly.
    Pins that the OutputIntent path doesn't leak ICC pixels into
    later non-CMYK paint scopes.

Three deferred-phase placeholders gated by the round-1 tracking
constants so future audits pick them up without re-discovering the
gap:

  - output_intent_inherited_by_form_xobject_paint (needs a Form
    XObject fixture helper).
  - page_level_default_cmyk_takes_precedence_over_output_intent
    (consumer for /DefaultCMYK on ResolutionContext is deferred to
    phase 9).
  - output_intent_thousand_cmyk_paints_baseline_cost (per-paint qcms-
    transform construction baseline; phase 7 caching).
…ument /OutputIntents (failing)

ISO 32000-1:2008 §8.6.5.5 — an /ICCBased colour space carries its own
conversion source; the document-level /OutputIntents profile is only the
default for paint that lacks an embedded ICC override. Today the colour
resolver routes /ICCBased N=4 through four_as_cmyk_native (a Cmyk-emitting
arm) and the composite projection then runs the resulting CMYK through
cmyk_to_rgb_via_intent, which consults /OutputIntents — inverting the
precedence rule.

Fixture: synthesises two minimal CMYK→Lab LUT8 profiles whose constant
CLUTs map to different L* bytes (135 → qcms RGB(126,126,126); 200 →
qcms RGB(194,194,194)). One profile is declared on /OutputIntents, the
other is embedded as the /ICCBased stream that backs the page's /CS1
colour space. Painting `/CS1 cs 0.25 0 0 0 scn` must produce the embedded
profile's reference. Currently produces the /OutputIntents reference,
which the assertion rejects with a byte-exact message naming both
precedence-failure modes.

The byte-exact qcms references are derived against qcms 0.3.0 (the
version pinned in Cargo.lock) and verified inline with a sanity gate
that compiles both transforms and pins their references before the
integration assertion. Constant-CLUT design makes the reference
intent-invariant by construction so no separate per-intent pin is
needed.
…le, not document /OutputIntents

ISO 32000-1:2008 §8.6.5.5: an /ICCBased colour space carries its own
conversion source. When the embedded stream parses through
IccProfile::parse and qcms compiles it into a real CMM, the colour
resolver runs the four scn/SCN components through that profile and emits
ResolvedColor::Rgba directly. The document-level /OutputIntents profile
is bypassed for ICCBased paint — it remains the default only for bare
/DeviceCMYK and for Separation/DeviceN alternates that resolve to a CMYK
quadruple.

When the embedded profile fails to parse (decode error, /N vs header
mismatch) or qcms refuses to build a Transform (malformed tags,
unsupported version), the resolver falls through to the prior
device-family hint path. N=4 still emits ResolvedColor::Cmyk so per-plate
backends see the channel decomposition, and the composite projection
routes through ctx.output_intent_cmyk — the document OutputIntent is the
correct default when no embedded ICC is actually usable.

The 8-bit round-trip (clamp 0..1, quantise to u8, run qcms, decode RGB
to f32) matches the encoding the rest of crate::color already uses; the
helper inside cmyk_to_rgb_via_intent uses the same quantisation so the
two paths agree on byte-exact pinning.
…te routes through OutputIntent

These probes are regression guards on top of the round-1 wiring: the
composite-side projection of a Separation/DeviceN colour space whose
alternate is /DeviceCMYK already flows through cmyk_to_rgb_via_intent
(four_as_cmyk at color.rs:413), so the assertions pass at HEAD without
code changes. The probes lock the routing for the specifically named
Type-4 cases the spec calls out:

- Separation /MagentaSpot /DeviceCMYK with a `{ 0.0 exch 0.0 0.0 }`
  tint transform → alternate CMYK(0, tint, 0, 0). At tint=1.0 the
  composite pixel through the document /OutputIntents profile (constant
  CLUT, target_l_byte=135) must be (126,126,126,255), not the §10.3.5
  additive-clamp (255,0,255,_).
- 2-colorant DeviceN [/Magenta /Cyan] with a
  `{ pop 0.0 exch 0.0 0.0 }` tint transform that drops the second
  colorant. Same byte-exact pin.
- Counter-pin: Separation Type-4 paint with no /OutputIntents present
  produces the §10.3.5 additive-clamp value byte-for-byte, demonstrating
  the OutputIntent route doesn't leak into the no-OutputIntent fixture.

Discrimination audit: before committing, the routing was temporarily
broken (four_as_cmyk re-wired to call bare cmyk_to_rgb) and the probes
were re-run to confirm they failed with the expected additive-clamp
value. Audit-only modification reverted before commit.
…Separation / DeviceN fixture builders

Mechanical reformat applied by `cargo fmt`. No behaviour change.
…and per-plate routing

Round-2 phase 4 changed `resolve_iccbased` for /ICCBased N=4 with a
parseable embedded profile to emit `ResolvedColor::Rgba` directly
(routing through the embedded profile's qcms transform), bypassing the
document /OutputIntents path. The phase-4 probe covered the happy case
(embedded profile B trumps document profile A); these QA edge probes
cover gaps the impl probes did not:

- `qa_round2_header_only_cmyk_profile_parses_without_cmm` — sanity gate
  proving a header-only CMYK profile parses through `IccProfile::parse`
  but produces a `Transform` whose `has_cmm()` is false. Precondition
  pin for the no-CMM fallback probe.
- `qa_round2_iccbased_n4_no_cmm_falls_through_to_output_intent` — when
  the embedded profile parses but qcms refuses to build a CMM, the
  fallback path emits ResolvedColor::Cmyk and the composite projection
  routes through cmyk_to_rgb_via_intent against the document
  /OutputIntents. Pins (126,126,126,255) — profile A's reference.
- `qa_round2_iccbased_n4_unparseable_bytes_fall_through_to_output_intent` —
  when the embedded stream's bytes don't parse through
  `IccProfile::parse` (no `acsp` signature), the fallback path emits
  ResolvedColor::Cmyk and routes through OutputIntent.
- `qa_round2_iccbased_n4_with_embedded_profile_emits_no_separation_coverage` —
  pins that the phase-4 fix changes the per-plate path's view of
  /ICCBased N=4: a parseable embedded profile flips
  `ResolvedColor::Cmyk` to `ResolvedColor::Rgba`, which the
  OverprintResolver consumes as an empty `participating` channel
  list, which the InkRouter handles as `InkAction::Skip` on every
  plate. Net effect: zero ink coverage on any plate even though the
  paint is logically `0.25 0 0 0 scn`. Design intent (§8.6.5.5 trumps
  per-plate decomposition); `#[ignore]`'d so a future engineer
  fixing this sees the design call instead of debugging silent zeros.
- `qa_round2_bare_devicecmyk_paint_still_produces_separation_coverage` —
  counter-pin: bare /DeviceCMYK paint still produces per-plate
  coverage (~0.25 tint on the Cyan plate). Guards against the fix
  accidentally widening to the bare /DeviceCMYK arm.
- `qa_round2_iccbased_n3_with_cmyk_output_intent_ignores_output_intent` —
  /ICCBased N=3 (RGB) paint is untouched by a document CMYK
  /OutputIntents — the OutputIntent applies only to CMYK conversion.
- `qa_round2_iccbased_n1_with_cmyk_output_intent_ignores_output_intent` —
  /ICCBased N=1 (gray) paint same; OutputIntent not consulted.
- `qa_round2_iccbased_n4_precedence_survives_form_xobject_scope` —
  /ICCBased N=4 precedence survives a Form XObject scope. Paint
  inside `q /Fm1 Do Q` against a page-declared embedded /ICCBased
  /CS1 produces profile B's reference (194,194,194,255), not the
  document OutputIntent A's (126,126,126,255).

Discrimination audit re-run independently: with `four_as_cmyk` in
src/rendering/resolution/color.rs temporarily flipped to call the
bare §10.3.5 `cmyk_to_rgb` helper (audit-only mutation, reverted
before this commit), the two
`*_composite_routes_through_output_intent` probes both failed with
the expected (255, 0, 255, 255) additive-clamp value, confirming the
phase-5 probes actively discriminate the routing decision.

Byte-exact qcms 0.3.0 reference for the round-2 second profile
(`target_l_byte=200`) is RGB(194, 194, 194) at all four rendering
intents, verified independently through a one-shot harness against
qcms 0.3.0 — same crate version Cargo.lock pins.
… (failing)

Replace the ignored "no separation coverage" pin with a positive
assertion: the cyan plate must carry the ~0.25 tint when painting
`0.25 0 0 0 scn` through an embedded /ICCBased N=4 colour space. Same
~63 byte coverage range the bare /DeviceCMYK counter-pin asserts.

Current code emits `ResolvedColor::Rgba` from `resolve_iccbased` once
the embedded profile compiles a CMM, which strips the CMYK channel
decomposition the per-plate router needs. `OverprintResolver` produces
an empty participating list, `InkRouter` returns `Skip` for every
plate, and separations produce zero ink on all four plates — unusable
for prepress workflows shipping packaging artwork with embedded-ICC-
tagged CMYK images.

Composite still needs the ICC-converted RGB per §8.6.5.5. The fix is
to emit a variant carrying both — composite reads RGB, per-plate reads
CMYK. This test fails at HEAD; the impl commit follows.
…site, CMYK to plates

The previous /ICCBased N=4 fix emitted ResolvedColor::Rgba once the
embedded profile compiled a usable CMM, which fixed §8.6.5.5 precedence
on composite but stripped the four-channel decomposition the per-plate
router needs. Separation output produced zero ink on every plate for
embedded-ICC-tagged CMYK paint — unusable for prepress.

Add ResolvedColor::IccCmyk carrying both the pre-computed RGB (via the
embedded profile) and the original CMYK quadruple. Composite consumers
read the RGB directly — no further OutputIntent-aware projection
because the embedded profile is the conversion source per §8.6.5.5.
Per-plate consumers (OverprintResolver + InkRouter) read the CMYK
exactly as for the Cmyk variant. The ICC conversion is composite-only;
the plates ARE the press-target ink coverage.

OverprintResolver treats IccCmyk identically to Cmyk for the
participating-channels list. InkRouter classifies IccCmyk as a CMYK
source for the §11.7.4.3 OPM=1 "zero = unspecified" rule. The
page_renderer's run_pipeline_for_logical reads (r, g, b, a) from
IccCmyk directly, bypassing cmyk_to_rgb_via_intent.
…r-byte

Parameterise the in-test CMYK profile builder by ICC version byte and
add two verification probes that pin qcms 0.3.0's v4 behaviour:

* `qa_round3_iccbased_v4_profile_compiles_through_qcms_to_same_reference`
  pins that flipping the 128-byte header's version field from
  0x02_40_00_00 (v2.4) to 0x04_00_00_00 (v4.0) without otherwise
  altering the LUT8 body produces a Transform that has_cmm() and
  converts CMYK(64,0,0,0) to the same byte-exact RGB(126,126,126) the
  v2 path produces. Catches a future qcms upgrade that re-enables the
  commented-out major-revision check in check_profile_version.
* `qa_round3_iccbased_v4_output_intent_drives_render_through_qcms`
  pins the same v4 profile threaded through a synthetic PDF's
  /OutputIntents drives the rendered pixel through qcms rather than
  the §10.3.5 additive-clamp fallback.

ICC v4 wire-level deltas vs v2 sit in the A2B0 tag body — v2's mft1
(LUT8) vs v4's mAB form (curves + matrix + CLUT). qcms parses both;
the dispatch is at iccread.rs:1716-1722 for CMYK. A v4 profile whose
A2B0 body is still an mft1 LUT8 is a legitimate forward-compatible
encoding, which is what the parameterised builder emits. A true mAB-
tag v4 fixture is HONEST_GAP territory — the constant-CLUT trick that
makes LUT8 useful for unambiguous test pins doesn't carry over to
curve-based mAB tags; see the fixture README.
…ction

A document with many same-colour CMYK paint operators rebuilt the qcms
Transform once per operator. qcms::Transform::new_to precomputes a
17×17×17×17 = 83 521-sample CLUT for CMYK input; the per-pixel
convert_cmyk_pixel call is then a cheap tetrahedral interpolation
against that CLUT. Rebuilding the CLUT per paint operator is the perf
trap. CmykTransformCache (src/rendering/resolution/context.rs) caches
the compiled Transform keyed by (profile.content_hash(), intent) and
hangs off PageRenderer with per-page clear() so cross-page profile
changes don't leak. The colour stage borrows the cache through
ResolutionContext::with_cmyk_transform_cache; cmyk_to_rgb_via_intent
and resolve_iccbased now go through the cache on the rendering path
and skip it on unit-test paths that exercise a single conversion.

Wiring side-effects:
  - SetRenderingIntent ('ri' operator, §10.7.3) was parsed but never
    dispatched to gs.rendering_intent. The page-renderer and
    separation-renderer match arms now update the field so the cache
    key actually splits across intents (and the colour stage routes
    qcms with the matching intent). Before this fix every paint
    resolved under /RelativeColorimetric regardless of any /RI override.
  - The cache key includes intent even though qcms 0.3.0's
    transform_create discards it (the `_intent` underscore at
    qcms-0.3.0/src/transform.rs:1288). Future qcms upgrades that
    honour the parameter MUST get distinct transforms across intents;
    keying on intent now guarantees that without a follow-up change.

New `test-support` feature exposes a per-cache build counter via
PageRenderer::cmyk_transform_cache_build_count() so the integration
suite can assert exact hit rates without depending on wall-clock
measurement. Production builds skip the feature and pay no overhead.

Probes added:
  - output_intent_thousand_cmyk_paints_build_one_transform: 1000
    same-colour DeviceCMYK paints under one OutputIntent build exactly
    one Transform (cache miss on first, hit on the next 999).
  - qa_round3_cache_keys_include_rendering_intent: two distinct
    intents on one page produce two cache entries, not one.

HONEST_GAP: the separation renderer is a free function — the cache
would need a SeparationRendererState struct to amortise across its
paint operators. The per-plate path doesn't invoke
cmyk_to_rgb_via_intent (the per-plate router consumes
ResolvedColor::Cmyk directly), so the only Transform construction in
the separation path is for /ICCBased N=4 colour spaces with a working
embedded CMM, which is the design's cold path. Documented inline.
Three probes pin the rendering-intent dispatch surface (§10.7.3, §8.6.5.8):

* `qa_round3_qcms_030_treats_cmyk_intent_as_informational` — qcms 0.3.0's
  `transform_create` discards the intent parameter (the `_intent: Intent`
  underscore at qcms-0.3.0/src/transform.rs:1288). All four
  RenderingIntent values produce byte-identical RGB through
  Transform::convert_cmyk_pixel for a given profile. Documents the qcms
  version constraint so a future upgrade that honours intent for CMYK
  surfaces here at upgrade time.
* `qa_round3_unknown_intent_name_falls_back_to_relative_colorimetric` —
  §8.6.5.8's "unrecognised intent name → /RelativeColorimetric" rule
  is honoured at the colour boundary. Pins all four named intents
  round-trip and unrecognised names default correctly.
* `output_intent_perceptual_vs_absolute_colorimetric_produces_different_rgb`
  — placeholder marked `HONEST_GAP_INTENT_SENSITIVE_FIXTURE`.
  Documents the two prerequisites for enabling a real
  intent-dispatch test: (a) an intent-sensitive CMYK profile
  (synthetic constant-CLUT fixtures are intent-invariant by
  construction); (b) a qcms version that honours intent for CMYK
  transforms. The pdf_oxide wiring is already in place — the chain
  gs.rendering_intent → ctx.rendering_intent →
  Transform::new_srgb_target's intent parameter is exercised by the
  cache-key probe above — so satisfying either prerequisite would
  light up the dispatch without further code changes here.

The module's header docstring documents the qcms research surface
inline so a future engineer encountering the HONEST_GAP doesn't have to
re-discover the limitation from the qcms source.
The doc block referenced a hypothetical global atomic
`crate::color::TRANSFORM_BUILD_COUNT` from an earlier design.
The shipped implementation is the instance-local
`CmykTransformCache::build_count` field, accessed via
`PageRenderer::cmyk_transform_cache_build_count()`. Update the comment
so readers don't go looking for a symbol that doesn't exist.
…ling)

ISO 32000-1:2008 §8.6.5.6 says: when a page declares /DefaultGray,
/DefaultRGB, or /DefaultCMYK in its /Resources /ColorSpace dict, any
bare device-family paint operator (g/rg/k/K and the SCN siblings)
MUST be interpreted as if it had named the override colour space.
The override therefore takes precedence over the document
/OutputIntents profile on the rendered pixel.

Three failing probes pin the precedence rule:

  page_level_default_cmyk_takes_precedence_over_output_intent
    Fixture: catalog /OutputIntents → profile A (target_l_byte=135 →
    qcms reference (126,126,126)), Resources /ColorSpace /DefaultCMYK
    → [/ICCBased <stream>] profile B (target_l_byte=200 → qcms
    reference (194,194,194)). Content paints 0.25 0 0 0 k. Expected
    pixel: (194,194,194,255). HEAD produces (126,126,126,255) —
    OutputIntent wins, precedence inverted.

  page_level_default_rgb_routes_bare_device_rgb_through_override
    Fixture: Resources /ColorSpace /DefaultRGB → [/ICCBased <N=3
    stream>] mapping every RGB to constant Lab. Content paints
    0.8 0.2 0.5 rg. Expected pixel: qcms reference. HEAD produces
    the literal (204,51,128,255) — override not consulted.

  page_level_default_gray_routes_bare_device_gray_through_override
    Fixture: Resources /ColorSpace /DefaultGray → [/Separation
    /MagentaSpot /DeviceCMYK <Type-4>] lifting gray → CMYK(0,gray,0,0).
    Content paints 0.5 g. Expected pixel: §10.3.5 projection of
    CMYK(0,0.5,0,0) = (255,~127,255,255). HEAD produces literal
    (128,128,128,255) — override not consulted.

Plus the regression-guard counter-pin:
  no_default_cmyk_falls_through_to_output_intent — when the override
  is absent, bare /DeviceCMYK paint must keep routing through the
  document /OutputIntents profile (the round-1 behaviour). Passes
  at HEAD; would fire if a future fix routed everything through some
  hard-coded path and bypassed the OutputIntent fallback.

New fixture builders:
  build_minimal_rgb_to_lab_lut8_profile — N=3 RGB→Lab LUT8 mirror of
  the existing CMYK builder. qcms 0.3.0 accepts in_chan ∈ {3,4} for
  LUT8 (iccread.rs:760); the constant-CLUT trick gives the RGB
  probe the same unambiguous qcms reference shape the CMYK probes
  use. N=1 Gray would need a TRC-based profile (qcms LUT8 rejects
  in_chan=1) so the /DefaultGray probe routes through a Separation
  override instead — exercises the same dispatch site without an
  ICC Gray fixture.

  build_pdf_default_cmyk_overrides_output_intent
  build_pdf_default_rgb_overrides_bare_device_rgb
  build_pdf_default_gray_routes_bare_device_gray

Renames the round-1 OUTPUT_INTENT_DEFER_PHASE_9_DEFAULT_CMYK marker
to OUTPUT_INTENT_DEFER_FORM_XOBJECT_INHERITANCE — the Form-XObject
inheritance placeholder is the only remaining consumer of the marker
and its blocker is unrelated to the /DefaultCMYK consumer landing
here.
…rrides

ISO 32000-1:2008 §8.6.5.6: when a page declares /DefaultGray,
/DefaultRGB, or /DefaultCMYK in its /Resources /ColorSpace dict, any
bare device-family paint operator MUST be interpreted as if it had
named the override colour space. The override therefore takes
precedence over the document /OutputIntents profile on the rendered
pixel.

Dispatch lands in ColorResolver::resolve's LogicalColor::Device arm,
ahead of device_to_rgba. The new resolve_device_default_override
helper picks the matching override from
(ctx.default_gray, ctx.default_rgb, ctx.default_cmyk) — round-1
already threaded these borrows onto ResolutionContext — and
recursively resolves the override via resolve_spaced with the
original components. That reuses the existing colour-space machinery:

- /DefaultCMYK [/ICCBased N=4] flows through resolve_iccbased's
  embedded-ICC path, emits ResolvedColor::IccCmyk so per-plate
  routing still sees the four CMYK channels (round-2 dual-payload),
  and picks up the per-page CmykTransformCache so the override's
  profile identity is cached distinctly from the document
  /OutputIntents profile.
- /DefaultRGB [/ICCBased N=3] flows through the new N=3 arm of
  resolve_iccbased (added in this commit) and emits
  ResolvedColor::Rgba. The per-page transform cache is keyed on
  CMYK transforms only — the N=3 path builds a fresh Transform per
  paint, which is acceptable because RGB ICC overrides are rare.
- /DefaultGray [/Separation ...] flows through
  resolve_separation_or_devicen and projects the alternate per the
  existing §10.3.5 / OutputIntent rules.

When no override is declared the new dispatch returns None and
falls through to the round-1 device_to_rgba → cmyk_to_rgb_via_intent
path; OutputIntent precedence is preserved for the no-override case.

Turns three previously-failing probes green:
  page_level_default_cmyk_takes_precedence_over_output_intent
  page_level_default_rgb_routes_bare_device_rgb_through_override
  page_level_default_gray_routes_bare_device_gray_through_override

The regression-guard counter-pin no_default_cmyk_falls_through_to_
output_intent stays green: when ctx.default_cmyk is None, the
helper returns None and the OutputIntent route fires unchanged.
Black-Point Compensation (BPC) — the CMM knob that remaps source-
profile black to destination-profile black to preserve shadow detail
when the destination has a less-black-than-source black point — is
NOT implemented in qcms 0.3.0. Verified at
~/.cargo/registry/src/.../qcms-0.3.0/src/lib.rs:29-36 ("BPC brings an
unacceptable performance overhead, so we go with perceptual") and
transform.rs:1283-1289 (CMYK transform builder declares `_intent:
Intent` — the rendering intent parameter is underscore-prefixed and
unused on the CMYK CLUT precomputation path).

What this means for pdf_oxide today: the byte-exact qcms output is
intent-invariant for any CMYK input, and the
/RelativeColorimetric-with-BPC behaviour (the typical print-house
default for press output) is not available. The cache key still
includes intent so a future CMM swap (qcms fork, lcms2) doesn't
silently collapse cache entries across intents at upgrade time.

qa_round4_bpc_paper_white_preservation_under_relative_colorimetric
pins this by building a non-constant-CLUT CMYK→Lab profile whose
K axis ramps from L*=240 at K=0 to L*=0 at K=255, then asserting
the same byte-exact RGB output for CMYK(0,0,0,242) under all four
PDF rendering intents. With qcms 0.3.0 the assertion holds; under
a CMM that honours intent OR implements BPC the deep-shadow input
would diverge between RelativeColorimetric and AbsoluteColorimetric
and the probe goes RED. The probe is #[ignore]-marked with
HONEST_GAP_QCMS_030_NO_BPC so a future engineer running --include-
ignored sees the gap surface by name.

build_minimal_cmyk_to_rgb_lut8_profile_with_shadow_ramp synthesises
the non-constant-CLUT fixture: same 2032-byte LUT8 envelope as the
existing constant-CLUT builder but with the CLUT corners ramped by
the K bit so the input bits actually reach the CMM.

The docstring on cmyk_to_rgb_via_intent gains a "BPC and rendering-
intent caveats" paragraph naming the qcms files where the gap
lives, so an engineer touching the helper sees the limitation
inline rather than discovering it through a regression.
… additive-clamp

User-surfaced regression early in the project: a vendor branding
logo's green mark rendered too vivid (lime) on screen but printed
muted (olive) on press — the §10.3.5 additive-clamp fallback was
diverging from the press-target colour the CMYK profile mapped the
build to. Wiring OutputIntent ICC through the renderer is the fix.

No real branding-logo PDF is on disk in this worktree (checked:
~/.claude/image-cache/ holds no PDFs; nothing under
/Users/ray/projects/pdf_oxide matches *branding* / *logo*). Build a
synthetic equivalent that exercises the same shape:

  qa_round4_branding_green_mark_routes_through_output_intent
  Same green CMYK build (C=0.30, M=0.05, Y=0.95, K=0.05) painted
  twice — once without /OutputIntents, once with — then asserts:
    1. Without OI → §10.3.5 byte-exact RGB(166, 230, 0) — the
       vivid-lime pre-yfedoseev#97 baseline.
    2. With OI → qcms byte-exact reference for the profile's mapping
       of CMYK(77, 13, 242, 13) (the 8-bit round-trip of the input).
    3. The OI value is LESS saturated than the additive-clamp value
       in HSV terms, demonstrating the direction-of-shift towards
       the press target.

The synthetic profile (target_l_byte=200) maps every CMYK input to
~RGB(194, 194, 194) — a neutral, NOT a specific olive. So the probe
proves "OutputIntent shifted the colour" directionally, not
"OutputIntent shifted it TOWARDS the real press-target value." That
gap is named explicitly via HONEST_GAP_NO_REAL_BRANDING_FIXTURE: a
future engineer with a vendor-issued press profile + the branding
PDF should add a CIEDE2000 ΔE assertion against the commercial-
viewer baseline. The CIEDE2000 comparator itself is deferred — it
adds significant fixture code for an assertion that's pinned just
as well by the byte-exact qcms reference + the saturation
direction-of-shift check.

qa_round4_real_branding_fixture_honest_gap is the marker probe
keeping the constant referenced so a grep over the test corpus
surfaces the gap explicitly.
The N=3 RGB arm of resolve_iccbased constructed a fresh Transform per
paint operator, defeating the cache that the N=4 arm already consults.
A page that paints 1000 bare /DeviceRGB ops through a /DefaultRGB
override would reparse the embedded profile and rebuild the qcms
transform 1000 times.

The cache key (profile.content_hash(), intent) was n_components-
agnostic at the storage level — the N=3 arm just didn't call
get_or_build. Wire it through the same way the N=4 arm does. Document
in CmykTransformCache that the name reflects history not constraint:
the same cache now serves N=3 ICC, and the n_components guard inside
Transform keeps callers from mis-converting between input shapes even
when content_hash happens to collide on DefaultHasher.
…to exact byte

The (120..=130).contains(&g) tolerance was added on a guess at
platform-dependent rounding. tiny-skia's f32→u8 conversion is
`(c * 255.0 + 0.5) as u8` — round-half-up via truncation. For the
expected 0.5 input that's 0.5 * 255.0 + 0.5 = 128.0 → truncate → 128,
deterministic across platforms and builds.

Slack in the assertion hides regressions that drift inside the range
(e.g. an inadvertent shift to additive on a slightly different
channel that lands at 125). Pin the exact byte.
RayVR added 6 commits June 8, 2026 21:01
…d arity, RGB mirror edges

Ten byte-exact probes verify the recent transparency-closure work on
inputs outside the cells already covered by the audit suite and
qa_round4:

 - §7.10.4 Type 3 with k=4 (Bounds [0.25 0.5 0.75]) — dispatcher
   exercises the multi-boundary lookup arithmetic
 - §7.10.4 boundary-belongs-right convention pinned at an exact
   /Domain endpoint
 - §7.10.4 inverted /Encode [1 0] pair — linear remap with e_lo > e_hi
 - §7.10.2 Type 0 /BitsPerSample 16 falls to Identity per parser policy
 - §7.10.2 Type 0 /Size [1] single-sample LUT short-circuit
 - §7.10.5 Type 4 division-by-zero produces a graceful render (no panic)
 - HONEST_GAP_SMASK_BC_MALFORMED_ARITY documents dispatch-on-array-length;
   probe pins the claim with /BC [a b c d e] over a /DeviceRGB group
   and /BC [a] over a /DeviceCMYK group
 - §11.3.4 RGB → CMYK mirror with pure-black source completes without
   panicking under §10.3.5 (1-R, 1-G, 1-B), K=0 inverse
 - §11.3.4 mirror with /OP true paint takes the overprint-skip early
   return without panicking

Sensitivity verified by stashing the Type 3 dispatch to Identity in
parse_transfer_function — the three Type 3 probes flip from PASS to
FAIL with the stash and back to PASS on restoration. All ten pass
under both icc-qcms and icc-lcms2 feature builds.
… narrative

The round-7 three-state matrix for /DeviceN /Process /ColorSpace
[/ICCBased] with a profile distinct from the document OutputIntent
supersedes the round-5 "natural form" reading, but the round-5
constant is preserved because it carries the rationale for why the
natural-form reading remains the qcms / no-CMM fallback. Add an
explicit bi-directional cross-reference so readers can find both the
current truth-table and the historical rationale.
The "ONE blend space per group" mandate lives at §11.4.5.1 (the
/Group /CS definition) and the per-pixel composite computation that
runs inside that space lives at §11.3.4. Several docstrings on the
sidecar RGB-paint mirror path cited §11.3.4 alone for the mandate;
disambiguate by naming both sections at the call sites.
…nd Lab/Cal/ICCBased alternate spaces

The DeviceN /BC backdrop evaluator on the composite SMask path
previously handled only Type 2 (exponential) and Type 4 (PostScript)
tint transforms, and projected only the three Device-family
alternate colour spaces. /BC backdrops carrying Type 0 sampled or
Type 3 stitching tint transforms fell through to a (0, 0, 0)
black-point default; /BC backdrops over /Lab, /CalGray, /CalRGB, or
/ICCBased alternates fell through the same way.

Wire the missing pieces:

  - Type 0 sampled tint transforms evaluate via an N-dim multi-input
    multi-output sampled-function evaluator (n-linear interpolation
    across the surrounding 2^n integer-grid samples per §7.10.2).
  - Type 3 stitching tint transforms evaluate via the existing
    Type 3 dispatch shape from the /SMask /TR path, with recursive
    fallback into the four-arm tint-transform dispatcher for
    subfunctions.
  - /Lab alternate spaces project via §8.6.5.4's standard
    CIELab → XYZ → sRGB inverse.
  - /CalGray and /CalRGB alternates project via §8.6.5.2-3 closed
    forms (gamma applied per channel, matrix transform into XYZ,
    sRGB inverse).
  - /ICCBased alternates project via the linked CMM (lcms2 or qcms)
    when available; without a CMM the projection recurses into the
    embedded /Alternate, then falls back to the device family
    inferred from /N per §8.6.5.5.

Also tighten the sidecar RGB-paint mirror citations to name §11.4.5.1
alongside §11.3.4 (the "ONE blend space" mandate is §11.4.5.1's
/Group /CS definition; §11.3.4 is the per-pixel computation that
runs inside it).
…lor/Luminosity, /BC tint-type and alternate-space coverage

Tighten three live probes from delta-band / dominance margin checks
to byte-exact references, and add new probes for the /BC tint-
transform and alternate-space dispatch the renderer now covers.

Tightened probes:

  - overprint_composite_overlap_differs_from_no_overprint:
    asserts byte-exact (128, 255, 0) under /OP+/op+/OPM 1 and
    (255, 255, 0) without, derived from §11.7.4.3 Table 149 row 1
    plus §10.3.5 additive-clamp.
  - blend_color_blue_source_over_red_yields_blue: replaced the
    degenerate (source-blue == output-blue) fixture with a non-
    degenerate fixture (backdrop = light red, source = dark blue);
    the §11.3.5.3 Color blend output is byte-exact (126, 126, 255)
    while the SourceOver baseline is (0, 0, 153). assert_ne!
    confirms the non-separable dispatch is firing.
  - blend_luminosity_grey_source_over_red_keeps_red_hue: replaced
    the dominance-margin assertion with a byte-exact (170, 0, 0)
    reference for §11.3.5.3 Luminosity; SourceOver baseline is
    (51, 51, 51).

New /BC n=5 DeviceN tint-transform probes:

  - Type 0 sampled: 2-grid CLUT with 5 inputs and a DeviceCMYK
    alternate; byte-exact (255, 96, 96).
  - Type 3 stitching: 2 Type 2 subfunctions split at /Bounds [0.4]
    with /Encode pass-through; byte-exact (255, 170, 170).

New /BC n=5 alternate-space probes:

  - /Lab alternate (L=50, a=0, b=0) → byte-exact (255, 136, 136).
  - /CalRGB alternate with identity matrix → byte-exact (255, 66, 66).
  - /CalGray alternate with /Gamma=1 → byte-exact (255, 67, 67).
  - /ICCBased alternate with /Alternate /DeviceCMYK fallback (no
    inline profile bytes) → byte-exact (255, 127, 127).
…y-flattening

# Conflicts:
#	CHANGELOG.md
#	src/color/mod.rs
#	src/rendering/page_renderer.rs
#	src/rendering/resolution/color.rs
#	src/rendering/resolution/context.rs
#	tests/test_render_output_intent.rs
@yfedoseev

Copy link
Copy Markdown
Owner

Thank you @RayVR — this is an impressive piece of work, and the non-separable blend implementation is spec-exact: I checked src/rendering/blend_nonsep.rs line-by-line against docs/spec/pdf.md §11.3.5.3 + Table 137 and Lum / Sat / SetLum / ClipColor / SetSat and all four mode formulas (Hue/Saturation/Color/Luminosity) match the spec exactly. 👏

Two things before merge:

1. CI blocker (one-liner). Test (…, stable) is red on ubuntu + macOS + windows. It is not a logic failure — it's a rustdoc broken-intra-doc-link, which fails the cargo doc step under -D warnings:

error: unresolved link to `Lcms2Backend`
  --> src/color/backend.rs:12
   |
12 | //!  - [`Lcms2Backend`] (`icc-lcms2`, opt-in): Little CMS via the …
   = note: `-D rustdoc::broken-intra-doc-links` implied by `-D warnings`

Lcms2Backend isn't in scope at doc-build time (feature-gated), so the intra-doc link can't resolve. Either drop the brackets to a plain code span (`Lcms2Backend`) or gate/import the type so the link resolves under the icc-lcms2 cfg. (The lone PHP 8.5 on macos failure is unrelated infra noise — git cleanup only in the log.)

2. Minor spec caveat (non-blocking). compose_in_place computes out = αs·B + (1−αs)·Cb, which is the §11.3.4 result only for an opaque backdrop (αb = 1). You already document this ("spec algorithm for an opaque backdrop … dest alpha already at 255"), and it's correct for the page-background-filled fixtures here — just flagging it so it's on the record for when transparent backdrops / nested groups come into play.

Once the rustdoc link is fixed and CI goes green, happy to take another pass.

CI's `cargo doc -D warnings` build failed on the module-level docs
for src/color/backend.rs because `Lcms2Backend` is only in scope
under `--features icc-lcms2`, which the default doc build does not
enable. `QcmsBackend` and `ActiveIccBackend` have the same shape
(only resolvable under specific feature combos), so they would
break a `--no-default-features` doc build too.

Switch the three cfg-gated type references in the module doc from
intra-doc links to plain code-spans. `IccBackend` and the
`Transform` link remain — those resolve under every feature combo.

Verified with `RUSTDOCFLAGS=-D warnings cargo doc --no-deps` and
`... --no-default-features`: both clean.
@RayVR

RayVR commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

@yfedoseev the docs have been fixed. The php CI job failed again.

@yfedoseev yfedoseev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: changes requested

Thorough multi-pass review of the §11 transparency surface + IccBackend work. The engineering quality is high — the spec work is careful, the non-separable blend math checks out against §11.3.5.3, the lcms2 FFI Sync/DisallowCache reasoning is sound, RAII cleanup is correct on all error paths, feature-gating across all four combos is correct, and the byte-exact tests are genuinely spec-derived (not snapshots). The MAX_SMASK_DEPTH=32 cap, knockout /K snapshot-restore-merge, and spot-ink reserved-name handling all verified clean.

Requesting changes for the items below — all introduced by this PR. Two pre-existing issues found during review (CI doesn't run the rendering test tier; output_intent_cmyk_profile swallows profile errors) are filed separately as #711 and #712 and are not blockers for this PR.

Note: because of #711, this PR's green CI does not exercise its own 260 tests. They pass locally under --features rendering,test-support (+ icc-lcms2 subset), but please confirm, and the #711 fix should land so this suite is actually protected.

🔴 Blocker

B2 — Unbounded recursion → stack overflow on malformed input.
src/document.rs:735 — the new collect_inks_from_color_space Pattern arm recurses into the underlying CS (collect_inks_from_color_space(&underlying, doc, out)) with no depth bound and no visited-set. A self-referential Pattern colour space (5 0 obj [/Pattern 5 0 R]) recurses until the process aborts. Reachable on every detection-ON render and every render_separations via discover_page_spot_inks. Please thread a depth counter or HashSet<ObjectRef> visited-set, mirroring walk_form_xobject_tree_for_inks.

🟠 High

H1 — SMask form rendered at Transform::identity().
page_renderer.rs apply_smask_after_paint_inner renders the mask form at identity, ignoring base_transform (DPI scale + PDF→device y-flip). The mask is mis-scaled/y-flipped at any DPI ≠ 72; it only happens to work at the 100×100 / 72-DPI audit fixture. Per §11.6.5.2 the mask must render in the device space in effect. Thread base_transform into render_form_xobject instead of Transform::identity().

H2 — Per-pixel ICC profile hashing (perf).
apply_cmyk_compose_after_paint_with_coverage and apply_overprint_after_paint_with_coverage call icc_transform_cache.get_or_build(...) inside the per-pixel loop, and get_or_build hashes the entire ICC profile byte blob each call → millions of full-profile hashes on a full-page transparency fill. The sibling diff-driven path already hoists the Arc<Transform> out of the loop — please do the same here.

H3b — Silent K=0 press output.
page_renderer.rs resolve_rgb_paint_to_cmyk turns a None from output_intent_cmyk_profile() into a silent (1-R,1-G,1-B, K=0) result. The math fallback itself is acceptable when no profile was requested, but combined with #712 it means "broken declared profile" silently degrades press output. The diagnostic belongs upstream (#712); please coordinate so the K=0 path only runs silently when color management was genuinely never requested.

🟡 Medium

M1 — Premultiplied-alpha blend bug.
blend_nonsep.rs compose_in_place reads tiny_skia bytes as straight RGBA, but tiny_skia stores premultiplied alpha. For partial-alpha non-separable paints (Hue/Sat/Color/Luminosity) colors are wrong; only correct when src+dst alpha = 255. Also assumes an opaque backdrop, so it's wrong inside isolated/transparent groups. Either un-premultiply before the §11.3.5 math, or explicitly gate to opaque-backdrop and document the limitation.

M2 — Uncached CMYK retarget / OutputIntent re-parse per paint.
On icc-lcms2 builds each DeviceN /Process /ICCBased N=4 scn re-walks /OutputIntents, re-decodes + re-parses both profiles, and rebuilds the CLUT. Cache the retarget transform keyed by (src_hash, dst_hash, intent) and memoize the OutputIntent profile Arc (the SrgbToCmyk path already has a per-page cache to mirror).

M3 — Detection gates miss indirect / nested-form transparency.
sidecar.rs ext_g_states_signal_transparency{,_only} read /CA /ca /SMask /BM without dereferencing indirect refs (e.g. /SMask 12 0 R), and don't recurse into a Form XObject's own /Resources/ExtGState. Such a page silently routes to the per-plate walker and loses transparency in the separations. Deref the values; consider documenting or extending the nested-form boundary.

🟢 Low / docs

  • Doc typo: "the embedded profile parses but the destination profile parses" → "fails to parse" (color/mod.rs:381 and the mirrored comment in sidecar.rs).
  • Stale test docstrings claim #[ignore] / "at HEAD this bypasses…" on tests that actually pass (test_46_round6_qa_pass.rs:307, test_transparency_flattening_qa_round2.rs:884, round1/round2 headers) — clean up so they don't misdescribe state.
  • test_46_round7_icc_retargeting.rs:429 compute_retarget_reference calls lcms2 with the same profiles/intent/flags as production — it's a same-engine round-trip, not an independent oracle. Rename, or add one hand-derived Lab anchor.
  • A couple of adversarial probes assert only alpha == 255 rather than the mechanism their docstring describes; tighten to honor the "byte-exact, no tolerances" promise.

Happy to pair on B2/H1 — they're localized. Architecture and approach look right; this is polish before merge.

RayVR added 9 commits June 9, 2026 14:21
…l Pattern cycles

The Pattern arm in `collect_inks_from_color_space` followed the
optional underlying colour space (§8.7.3.1) by recursively calling
itself with no depth bound and no visited-set. A self-referential
Pattern colour space such as `5 0 obj [/Pattern 5 0 R]` exhausts the
stack on every detection-on render and on every separation discovery
pass, aborting the process.

Mirror the depth counter + ObjectRef visited-set already used by
`walk_form_xobject_tree_for_inks`: indirect underlying refs are
recorded on first encounter and a repeat hit terminates the recursion;
total walk depth is capped at MAX_RECURSION_DEPTH (100).

Adds two regressions: the helper-level test exercises the visited-set
directly; the page-level test covers the public `get_page_inks` entry
point, which is reached from detection-on rendering and separation
discovery.
….5.2

`apply_smask_after_paint_inner` invoked `render_form_xobject` with
`Transform::identity()`, leaving the soft-mask form at PDF user-space
(72 dpi, y-up). §11.6.5.2 mandates the mask be evaluated in the device
space in effect at the host paint — the same transform that carries
the DPI scale and the PDF→device y-flip. At any DPI ≠ 72 the mask was
scaled down toward the pixmap origin; at every DPI the mask was
sampled upside-down relative to the host paint, so /S /Alpha and
/S /Luminosity modulation hit the wrong pixels.

Thread `base_transform` through `apply_smask_after_paint` /
`apply_smask_after_paint_inner` so the form's /Matrix composes on top
of the page transform, matching the dispatch path used at the main
`Do`-operator render site.

Adds a 144-dpi probe with an asymmetric mask region that surfaces
both the y-flip and the scale discrepancy in a single fixture:
sample (150, 50) must be byte-exact red (where the correctly-placed
mask passes the host paint) and (75, 75) must be byte-exact white
(the pre-fix bug's mistakenly active region).
…1.3.5.3

`compose_in_place` read tiny_skia's premultiplied RGBA buffers as if
they were straight colour and applied the §11.3.5.3 Hue/Saturation/
Color/Luminosity formulas to those premultiplied bytes. The spec
formulas — and the §11.3.4 compositing equation that consumes their
result — are defined over straight colour. Whenever αs < 1 or αb < 1
the blend hit the wrong colour space and the §11.3.4 path silently
assumed αb = 1, collapsing to the opaque-backdrop reduction.

Un-premultiply both buffers on the way in, evaluate the blend B(Cb, Cs)
and the §11.3.4 general composition
  αo = αs + αb·(1 − αs)
  Co = ((1 − αs)·αb·Cb + αs·((1 − αb)·Cs + αb·B(Cb, Cs))) / αo
on straight colour, then re-premultiply on the way out. The αo = 0
corner zeroes the pixel to avoid a divide-by-zero.

Adds four byte-exact partial-alpha probes — one per non-separable
mode — pinned at αb=128/255, αs=179/255 with backdrop red, source
blue. The expected bytes are derived from the §11.3.5.3 +  §11.3.4
formulas applied to straight colour and re-premultiplied; the opaque
path probes (alpha=255 throughout) remain byte-exact.
… hot loops

`apply_cmyk_compose_after_paint_with_coverage`,
`apply_overprint_after_paint_with_coverage`, the sibling diff-driven
helpers `apply_cmyk_compose_after_paint`, and the diff-driven overprint
helper all called `IccTransformCache::get_or_build` from inside their
per-pixel loops. The cache key includes `IccProfile::content_hash()` —
SipHash over every byte of the ICC profile blob (typically hundreds of
KB); a full-page transparency or overprint fill therefore re-hashed the
same profile on every painted pixel even though the cached
`Arc<Transform>` was always the one returned.

Hoist the `get_or_build` call once per helper invocation; reuse the
`Arc<Transform>` across the per-pixel loop. The behavioural output is
unchanged.

Adds a `lookup_count` test-support counter on `IccTransformCache`
distinct from `build_count`: the build counter cannot distinguish
hoisted from per-pixel because the cache returns the same
`Arc<Transform>` on every hit, but the `content_hash` cost lives on
every call hit or miss, so a `lookup_count` proportional to painted-
pixel count is the regression signature. Two probes pin one lookup
per paint for both the compose-with-coverage and overprint-with-
coverage helpers.
… ICC profile

`try_retarget_cmyk_via_embedded_profile` re-walked `/OutputIntents`,
re-decoded + re-parsed both ICC profiles, and rebuilt the lcms2
retarget CLUT on every DeviceN /Process /ICCBased N=4 paint. Each of
those steps is non-trivial — the profile blob is hundreds of KB, ICC
parse runs LUT8 / mft1 decode, and the CLUT compile is the same
amortise-once-per-page cost the sibling `IccTransformCache` already
addresses for the CMYK→RGB direction.

Two caches close the gap:

1. `PdfDocument::output_intent_cmyk_profile` is memoised in a
   `Mutex<Option<Option<Arc<IccProfile>>>>` field — the `Some(None)`
   state distinguishes "checked, no usable CMYK OutputIntent" from
   "not yet checked", so the catalog walk and ICC parse run at most
   once per document.

2. `IccTransformCache::get_or_build_cmyk_retarget` joins the sibling
   sRGB→CMYK cache on `ResolutionContext`. The key is
   `((src.n_components, src.byte_len, src.content_hash), (dst same),
   intent)` — the wider fingerprint mirrors the hardening recently
   landed on the font-identity cache so a SipHash collision cannot
   route a wrong-profile transform.

Threading: `extract_process_paint_cmyk` and `initial_colour_for_space`
take an optional `&IccTransformCache`; the rendering entry points
pass `Some(&self.icc_transform_cache)`. Non-renderer callers (none
exist today, but the optional shape stays around for future
diagnostic surfaces) pass `None` and get the uncached behaviour.

Adds a `cmyk_retarget_build_count` test-support counter; a perf-bound
probe renders six successive DeviceN /Process /ICCBased N=4 paints
against one embedded source profile and one OutputIntent destination
profile and asserts the count is exactly 1.
…form ExtGState

`ext_g_states_signal_transparency` and
`ext_g_states_signal_transparency_only` read /CA, /ca, /SMask, /BM, /OP,
/op straight off the ExtGState dict. When any of those entries was
given as an indirect reference (e.g. `/ca 12 0 R`), the inline `match`
arm fell through on `Object::Reference` to the opaque default
(alpha = 1.0, BM = Normal, OP = false), silently routed the page to
the per-plate walker, and the composite path's transparency model was
bypassed.

The detection walker also only inspected the page-level resource
scope. A Form XObject whose own /Resources/ExtGState declared a
transparent entry would not trigger sidecar allocation even though
the renderer evaluates the form's content under those state entries
per §11.4.5 + §11.6.5.2.

Resolve indirect refs on every alpha / blend-mode / overprint / SMask
read. Recurse into Form XObject resources via a shared
`resources_declare_transparency_or_overprint` helper that bounds
depth at MAX_DETECTION_RECURSION (32) and dedupes indirect XObject
refs in a visited set so a self-referential /Resources/XObject
cannot run away.

Adds five unit probes covering each surface:
  - `/ca`, `/CA`, and `/BM` as indirect references trigger detection;
  - nested-form ExtGState triggers detection through both helpers;
  - a baseline "no trigger" case stays false.
…ents

`resolve_rgb_paint_to_cmyk` falls back to the §10.3.5 inverse
(C, M, Y) = (1−R, 1−G, 1−B), K = 0 whenever the document does not
yield a usable OutputIntent CMYK profile. That fallback is correct
when no /OutputIntents declaration was made — the producer didn't
ask for press conversion. It is NOT correct when the catalog DOES
declare /OutputIntents but the profile bytes fail to parse: the
producer asked for press output, and the K plane silently goes empty
where the press would have allocated black ink.

Full repair of the swallowed parse error is tracked upstream as
yfedoseev#712. This change makes the degradation observable
in the meantime by emitting a one-shot `log::warn!` whenever the K=0
fallback runs while `PdfDocument::has_output_intents_declaration`
returns true. The latch resets per page so a new render emits a fresh
warning if applicable.

Adds `PdfDocument::has_output_intents_declaration` as the structural
check the warning gates on, and a probe in `test_render_output_intent`
that renders an RGB paint through `render_separations` against a 64-
byte garbage /DestOutputProfile and asserts the warning records hit
the captured log buffer.
…l probes

Documentation polish + a same-engine reference-helper rename + two
adversarial probe assertions tightened to match their docstrings:

- `src/rendering/sidecar.rs`: fix typo in the round-7 fallthrough
  list; the second profile branch should read "fails to parse",
  not "parses".
- `tests/test_46_round1_qa_pass.rs`: drop the "`#[ignore]` tests
  document KNOWN BUGS" framing in the module header — every probe
  now runs live and asserts byte-exact.
- `tests/test_46_round6_qa_pass.rs`: replace the stale "`#[ignore]`'d
  pending the fix" note on the invisible-text probe with the
  fix-landed narrative.
- `tests/test_transparency_flattening_qa_round2.rs`: rewrite the
  paint-arm coverage paragraph in the module header — every operator
  the round-2 audit flagged is now wired; the historical constants
  stay as regression markers. Refresh the `Do`-form-XObject docstring
  to describe post-fix behaviour.
- `tests/test_46_round7_icc_retargeting.rs`: rename
  `compute_retarget_reference` → `compute_retarget_self_check` to
  make the SAME-ENGINE round-trip explicit; the cross-profile probe
  now pairs it with a hand-derived anchor (the dst profile's
  constant B2A0 CLUT bytes) so an lcms2 regression masked by both
  sides would surface as a self-check ≠ hand-derived mismatch.
- `tests/test_transparency_flattening_adversarial.rs`: the
  RGB-pure-black-over-CMYK and RGB-paint-with-overprint probes both
  describe a specific compositing mechanism but asserted only
  `alpha == 255`. Tighten both to byte-exact (r, g, b, a) and pin
  the tiny_skia premul source-over math the docstring describes.
`parse_ext_g_state_inner` read `/ca`, `/CA`, `/BM`, `/OP`, `/op`, `/OPM`,
and the `/SMask` sub-entries `/S`, `/BC`, `/TR` directly from the
ExtGState dictionary and applied typed accessors (`.as_real`, `.as_bool`,
`.as_integer`, `.as_name`, `.as_array`) without dereferencing indirect
references first.

ISO 32000-1 §7.3.10 lets any direct value be replaced by an indirect
reference. A PDF emitting `/ca 3 0 R` (with `3 0 obj 0.5 endobj`) was
silently dropping `fill_alpha` to the default because `.as_real()` on
an `Object::Reference` returns None — the same surface for every
typed accessor. `/BM`-array elements have the same exposure.

Surfaced while validating the M3 transparency-detection fix from the
preceding commits: the detection helper correctly routes the page to
the composite-then-separate path on `/SMask 12 0 R`, but the renderer
parser then ignored the resolved value because the typed accessor
failed silently. Closing one without the other left the wire intact
but the receiver deaf.

Threads `doc.resolve_object` through every entry read. `/BM` array
elements are resolved before name-matching to honour the same rule.
`/SMask` sub-entries get the same treatment via a local resolver
closure.

Six new tests pin each surface (indirect `/ca`, `/CA`, `/BM` name,
`/BM` array, indirect `/OP`/`/op`/`/OPM`, and `/BM` array whose
elements are themselves indirect names). Without the fix all six
fail; the existing inline-value tests remain green.
@RayVR

RayVR commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

@yfedoseev — thanks for the careful pass. All 8 findings addressed (commits 47209fe..eb79cce), plus a parallel bug surfaced while pinning M3. Per-finding summary below.

Sev Ref Approach Sensitivity-verified
🔴 B2 47209fe collect_inks_from_color_space Pattern arm now takes a depth counter + HashSet<ObjectRef> visited-set, mirroring walk_form_xobject_tree_for_inks. self-referential [/Pattern 5 0 R] test stack-overflows without the fix
🟠 H1 8d6eef4 base_transform threaded through apply_smask_after_paint{,_inner} and all 12 call sites; mask renders in device space per §11.6.5.2. DPI=144 asymmetric mask probe: pixel (150, 50) reads white pre-fix
🟠 H2 a294be7 get_or_build lookup hoisted out of 4 per-pixel loops — the two you named, plus two sibling diff-driven helpers that turned out to also walk per-pixel. counting probe: 80,008 pre-fix lookups → ≤32 post-fix for 8 paints
🟠 H3b c4693d4 K=0 fallback now logs a warning when /OutputIntents is declared but the profile didn't yield. Coordinates with #712 — the upstream silent-swallow path is unchanged; this just surfaces the symptom locally. warning-capture probe
🟡 M1 cc79252 Un-premultiply backdrop + source before §11.3.5, run spec math on straight colour, re-premultiply on write-back, full §11.3.4 result with αb < 1. 4 partial-alpha byte-exact probes (Hue / Sat / Color / Lum) all fail pre-fix
🟡 M2 52d1e19 Per-page CMYK retarget cache keyed by (src_hash, dst_hash, intent) + OutputIntent profile Arc memoised; mirrors the SrgbToCmyk pattern. counting probe: build_count == 1 across 6 paints
🟡 M3 8d51c1e resources_declare_transparency_or_overprint resolves indirect refs and recurses into Form XObject /Resources/ExtGState. 5 unit probes; 4 of 5 fail without each respective deref
🟢 Low 5199aac sidecar typo fix; 4 stale docstrings refreshed; compute_retarget_referencecompute_retarget_self_check + hand-derived Lab anchor added (not just a rename); 2 adversarial probes tightened to byte-exact.

Parallel finding surfaced while validating M3eb79cce: parse_ext_g_state_inner in src/rendering/ext_gstate.rs was reading /ca, /CA, /BM, /OP, /op, /OPM, and the /SMask sub-entries /S, /BC, /TR directly and applying typed accessors without dereferencing first. Per §7.3.10 any direct value can be replaced by an indirect reference; .as_real() / .as_bool() / .as_integer() / .as_name() / .as_array() all return None on Object::Reference, so a PDF emitting /ca 3 0 R silently dropped fill_alpha to default — meaning M3's correctly-routed transparency was then ignored by the receiver. Closing M3 without this left the wire intact but the receiver deaf. Fixed by threading doc.resolve_object through every entry plus /BM-array element resolution; 6 new unit tests pin each surface, all initially failing.

Local verification (CI re-run on eb79cce in flight): cargo test --features rendering,test-support is 5,665 lib + integration + 137 doctests green; the icc-lcms2 variant is also green; both doc builds clean under -D warnings; cargo check --lib --no-default-features clean. The structural CI test-tier gap from #711 remains worth tracking — until that lands, local runs are the ground truth for this branch.

This file was a working note that shouldn't have shipped — internal
scratch with no public consumer. Removing it from the published
docs tree.

@yfedoseev yfedoseev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — all requested changes verified addressed

Re-reviewed against the current head (294b504a). Every item from the earlier changes requested pass is resolved in the code (not just claimed — I checked each against the source):

🔴 Blocker

  • B2 (unbounded collect_inks_from_color_space recursion) — now threads a visited set + depth through the Pattern arm (src/document.rs:722, recursion at :769), with a self-referential-colour-space regression test that previously aborted. ✅

🟠 High

  • H1 — SMask form now renders at base_transform instead of Transform::identity() (page_renderer.rs:apply_smask_after_paint_inner), so the mask is correctly scaled/y-flipped at DPI ≠ 72 per §11.6.5.2. ✅
  • H2icc_transform_cache.get_or_build(...) is now hoisted out of the per-pixel loop in both apply_cmyk_compose_after_paint_with_coverage (:4817, loop at :4820) and apply_overprint_after_paint_with_coverage (:5131, loop at :5137). ✅
  • H3b — the K=0 RGB→CMYK fallback now emits a log::warn when /OutputIntents is declared but the profile lookup returns None (resolve_rgb_paint_to_cmyk, :5281), so a broken declared profile no longer degrades press output silently. ✅

🟡 Medium

  • M1compose_in_place now un-premultiplies on input and re-premultiplies on output for the §11.3.5.3 non-separable math (blend_nonsep.rs:60). ✅
  • M2 — CMYK→CMYK retarget transform is now cached (cmyk_retarget_build_count counter; per-(src,dst,intent) memoization). ✅
  • M3ext_g_states_signal_transparency* now resolves indirect refs for /CA /ca /SMask /BM and recurses into a Form XObject's own /Resources/ExtGState (sidecar.rs:514+). ✅

🟢 Low/docs — the "fails to parse" typo is corrected in both color/mod.rs and the sidecar.rs mirror. ✅

The §11 transparency math (separable + non-separable blend modes, §11.3.4 compositing, soft-mask luminosity/alpha, group isolation/knockout) and the ICC /N-vs-profile-signature validation are spec-faithful.

One optional follow-up (non-blocking, not a regression): non-isolated groups don't perform §11.4.8 backdrop removal before compositing onto the page, which slightly double-counts the backdrop for partial-alpha non-isolated groups. Worth a separate issue rather than holding this PR.

Separate from this approval (per our policy that CI/process ≠ review blockers): the branch is behind main and needs a rebase (which should also clear the Windows setup-ruby failure, since ruby/setup-ruby 1.312.0 is now on main), and per #711 the green CI doesn't yet exercise the 260 rendering tests — please confirm those pass locally before merge. Great work.

@RayVR

RayVR commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

@yfedoseev the rendering tests pass locally.

yfedoseev and others added 3 commits June 10, 2026 11:33
… surface

The wasm bundle (--features wasm,rendering,barcodes) reached 15411 KB,
51 KB over the 15360 KB ceiling set at v0.3.52. The §11 transparency
surface added here — isolated/knockout groups and group compositing in
page_renderer.rs, the separable blend-mode set, the non-separable modes
in blend_nonsep.rs, soft-mask handling in ext_gstate.rs, and the
IccBackend trait in color/backend.rs — lands real compositing code on
the rendering path that the wasm build compiles in. The optional lcms2
backend is gated off wasm, so this is the pure-Rust compositor, not a
vendored CMM.

Raise the ceiling rather than gate §11 off wasm32, following the same
precedent as the v0.3.48 and v0.3.52 bumps. New ceiling leaves ~6%
headroom above the ~15.05 MB baseline.
@yfedoseev yfedoseev merged commit 2b52ef0 into yfedoseev:main Jun 11, 2026
188 checks passed
@yfedoseev yfedoseev mentioned this pull request Jun 12, 2026
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