feat(rendering): §11 transparency surface + IccBackend trait with optional lcms2 backend#674
Conversation
…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.
…form construction (failing)
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.
…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
|
Thank you @RayVR — this is an impressive piece of work, and the non-separable blend implementation is spec-exact: I checked Two things before merge: 1. CI blocker (one-liner).
2. Minor spec caveat (non-blocking). 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.
|
@yfedoseev the docs have been fixed. The php CI job failed again. |
yfedoseev
left a comment
There was a problem hiding this comment.
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-lcms2subset), 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:381and the mirrored comment insidecar.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:429compute_retarget_referencecalls 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 == 255rather 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.
…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.
|
@yfedoseev — thanks for the careful pass. All 8 findings addressed (commits
Parallel finding surfaced while validating M3 — Local verification (CI re-run on |
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
left a comment
There was a problem hiding this comment.
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_spacerecursion) — now threads avisitedset +depththrough 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_transforminstead ofTransform::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. ✅ - H2 —
icc_transform_cache.get_or_build(...)is now hoisted out of the per-pixel loop in bothapply_cmyk_compose_after_paint_with_coverage(:4817, loop at:4820) andapply_overprint_after_paint_with_coverage(:5131, loop at:5137). ✅ - H3b — the K=0 RGB→CMYK fallback now emits a
log::warnwhen/OutputIntentsis declared but the profile lookup returnsNone(resolve_rgb_paint_to_cmyk,:5281), so a broken declared profile no longer degrades press output silently. ✅
🟡 Medium
- M1 —
compose_in_placenow 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_countcounter; per-(src,dst,intent)memoization). ✅ - M3 —
ext_g_states_signal_transparency*now resolves indirect refs for/CA /ca /SMask /BMand 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.
|
@yfedoseev the rendering tests pass locally. |
… 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.
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/Separationunderlying, a DeviceN/Processpaint 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
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
CmykSidecarstruct (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.rscomposite path populates the sidecar through every paint operator;separation_renderer.rsroutes 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_transparencydetection gate (narrower thanpage_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
/Hue,/Saturation,/Color,/Luminosity) implemented per §11.3.5.3 in HSL/HSY space via a newsrc/rendering/blend_nonsep.rsmodule. BT.601 luma weights,SetLum/SetSatper spec._ =>arm inpage_rendererthat silently degraded non-separable modes toSourceOveris 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/Normalper the dispositive spec rule.SMask handling
/SMask /S /Alpha,/S /Luminosity,/BCbackdrop colour,/TRtransfer function — previously documented as "intentionally ignored" — fully implemented with properSoftMaskForm/SoftMaskSubtypeplumbing through the graphics state. Image-attached SMask continues to work.MAX_SMASK_DEPTH=32cap on cyclic/Grecursion (documented policy choice)./BCbackdrop 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 /CSand evaluates the tint transform with the backdrop tints before projection./TRtransfer 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 atsrc/functions/mod.rsthat already serves Separation / DeviceN tint transforms.Knockout groups
/K/Kgroup 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)./RIrendering intenttry_retarget_cmyk_via_embedded_profilefor ICC retargeting.§11.7.4.3 CompatibleOverprint (per-channel)
(src + dst).min(1.0)additive merge with spec-correct §11.7.4.3 Table 149 per-channel REPLACE / PRESERVE / SKIP logic.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).gs.fill_color_cmykcleared onSetFillRgb/SetFillGray/SetStrokeRgb/SetStrokeGrayto prevent leakage across paint operations.RGB-source paint into CMYK sidecar (§11.3.4 single blend space)
icc-lcms2, conversion goes sRGB → Lab → OutputIntent CMYK with full ICC accuracy; under defaulticc-qcmsor 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).DeviceN
/Processsource CMYKSetFillColorN/SetStrokeColorNfor DeviceN sources with/Processattribution populategs.fill_color_cmykandgs.fill_color_rgbfrom the actualscntint values via the/Process /ColorSpacetint transform — replacing the previous tint-blind path that produced constant(1, 1, 1, 0)source CMYK regardless of tints./Process /ColorSpace=/DeviceCMYK(tints as natural-form),/DeviceRGB//DeviceGray(§10.3.5 additive-clamp inverse),/ICCBased(natural-form fallback under default features; full retargeting undericc-lcms2)./Process:cs /CS_Npopulates a CMYK identity built fromextract_process_paint_cmyk(all-1.0_tints)instead of leavingfill_color_cmyk = None.Pattern colour spaces
/Separationor/DeviceNunderlying CSes recurse correctly through ink discovery (sidecar + separation_renderer parity). Indirect-ref underlying CSes deref viadoc.resolve_object(previously fell through toResolvedSpace::Unknown).Real coverage rasterisation for text-show / Image Do / shading sh
Tj,TJ,',"), Image / ImageMask Do, and shading sh paint sites.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.3 Trper §9.3.6) correctly produces no spot-lane write (the coverage scratch honours the user'srender_modeinstead of overriding it to 0)./Decodearray honoured per §8.9.6.2 (default[0 1]= bit-0 paints).ICC backend trait + lcms2 backend
IccBackendtrait 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_retargetandbuild_srgb_to_cmykreturnNone(qcms 0.3.0 has no CMYK output pipeline).Lcms2Backend(new, opt-in behindicc-lcms2feature, FFI wrapper around Little CMS 2.x) implements the full surface including CMYK→CMYK retargeting with BPC + rendering intent dispatch.DisallowCacheforSyncsoArc<Transform>can cross rayon workers under theparallelfeature.ActiveIccBackendtype alias; lcms2 wins when both features are enabled.try_retarget_cmyk_via_embedded_profileshort-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'scontent_hashmatches the OutputIntent's (identity skip). Otherwise retargetssrc.AToB → Lab → dst.BToAwith BPC for the press-default intent.gs.rendering_intent(set by the/RIoperator) throughextract_process_paint_cmykto the lcms2 transform builder —/Perceptual rifollowed by a /DeviceN /Process /ICCBased paint retargets with perceptual intent,/Saturation riwith saturation, etc. §8.6.5.8 default (RelativeColorimetric, the press standard) applies when no/RIis 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
±Xtolerances. 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:
/All//Nonereserved-name exclusion / hex-escaped names / NChannel //Processattribution / mismatched names)/BCn=1/3/4 +/BCn≥5 (DeviceN with tint-transform projection) +/TRType 0 + Type 2 + Type 3 stitching + Type 4 PostScript + cyclic/G+ nested/K+ multi-glyph TJ + invisible texticc-lcms2ICC path AND theicc-qcms/ no-CMM §10.3.5 fallback path probedicc-lcms2(real retargeting verified against independent lcms2 reference values), both features enabled (lcms2 wins),--no-default-features(no-CMM §10.3.5 fallback)Architectural deviation honestly surfaced
The CMYK sidecar on
PageRendereris used instead of routing every paint throughSeparationBackend's plate-emit shape, because the latter would require emittingResolvedPaintCmdfrom 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— cleancargo clippy --all-targets --features "rendering icc test-support" -- -D warnings— cleancargo clippy --all-targets --features "rendering icc-lcms2 test-support" -- -D warnings— cleancargo check --lib --no-default-features— cleancargo test(default features) — full suite greencargo test --features "rendering icc-lcms2 test-support"— greencargo 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"— cleanPython Bindings
No binding changes. The existing
render_page/render_separationsPython API surface is unchanged. Users enablingicc-lcms2at build time get the press-grade CMM behaviour transparently through the same calls.Documentation
Checklist
unsafeblocks in production code (the optionalicc-lcms2feature pulls a C dep through a safe Rust wrapper)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 /DeviceNas 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/DeviceCMYKand 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_RECURSION—MAX_SMASK_DEPTH=32cap to prevent OOM / stack-overflow on cyclic/Greferences. Spec doesn't require a depth limit; real implementations must have one.HONEST_GAP_DEVICEN_PROCESS_ICC_PROFILE_MISMATCH/_R7— three-state matrix: closes undericc-lcms2(real CMYK→CMYK retargeting through Lab PCS with BPC); remains a documented feature-level limitation under defaulticc-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/BCarray'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-lcms2featurePure-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-lcms2feature pulls Little CMS 2 via thelcms2crate (well-maintained safe Rust wrapper, ~2.4M crates.io downloads). C build dep cost: comparable to the existingfips(AWS-LC) andocr(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.