Skip to content

refactor: renderer resolution pipeline#649

Merged
yfedoseev merged 93 commits into
yfedoseev:mainfrom
RayVR:refactor/renderer-resolution-pipeline
Jun 6, 2026
Merged

refactor: renderer resolution pipeline#649
yfedoseev merged 93 commits into
yfedoseev:mainfrom
RayVR:refactor/renderer-resolution-pipeline

Conversation

@RayVR
Copy link
Copy Markdown
Contributor

@RayVR RayVR commented Jun 5, 2026

Description

Replaces the inline, copy-and-edit paint-resolution arms in page_renderer.rs and separation_renderer.rs with a single layered resolution pipeline owned by a new src/rendering/resolution/ module. Before this PR, every operator's match arm in both renderers reproduced its own colour resolution, overprint state, blend-mode classification, clip composition, and per-plate routing — and the arms had quietly diverged. PostScript Type 4 calculator tint transforms (/Separation / /DeviceN spot colours over DeviceCMYK or ICCBased alternates) silently fell through to the 1.0 - tint greyscale fallback at page_renderer.rs:690, so spot artwork that should have come out magenta came out grey or black. ICCBased colour spaces with N = 4 (the common CMYK case) had their last channel truncated. DeviceN with multiple Type 4 colorants behaved the same way. Three shading bugs sat dormant in the inline arms: /Domain was ignored, /Extend was always [true true], and radial shadings with non-concentric circles treated the inner circle as a point at the outer-circle origin. None of this was a missing capability — crate::functions had a full PostScript calculator, crate::color had a qcms ICC pipeline, and crate::document::output_intent_cmyk_profile already shipped — they were capabilities the inline arms never consumed.

The migration introduces:

PaintIntent          ← operator dispatch emits a logical paint intent
        ↓
ResolutionPipeline   ← five composable stages, each individually testable
    ColorResolver        — tint transforms, ICC, Indexed, DeviceN/Separation
    OverprintResolver    — per-channel /OP /op /OPM mask
    BlendResolver        — native tiny_skia vs simulated
    ClipResolver         — clip-stack composition
    InkRouter            — per-plate routing for separation backends
        ↓
ResolvedPaintCmd     ← backend-agnostic, fully evaluated
        ↓
PaintBackend trait   ← composite (RGBA) / per-plate / future

Both renderers now route through ResolutionPipeline::resolve for every paint operator that the migration covered: fill / stroke / fill-stroke combos (f, f*, S, s, B, B*, b, b*), text painting (Tj, TJ, ', "), image Do (Do for Image and ImageMask), and shading paint (sh). The composite RGB renderer in page_renderer.rs calls into the composite backend; the per-plate walker in separation_renderer.rs dispatches /Separation, /DeviceN, and /ICCBased compound spaces through SeparationBackend while keeping DeviceCMYK / DeviceGray / DeviceRGB direct sources on the existing tint_for_ink fast path. Capabilities that the inline arms could not reach are now reachable on both renderers without per-operator wiring.

The migration also closed three pre-existing shading bugs in the radial / function path that were independent of the resolution architecture but blocked correct rendering of common gradient artwork.

Type of Change

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

Related Issues

  • Closes the 1.0 - tint greyscale fallback in page_renderer.rs:690 for Type 4 PostScript tint transforms.
  • Closes fix(rendering): evaluate Separation/DeviceN tint transforms (full-tint spot colour renders black) #630 (Type 4 Separation spot colour resolution) on both the composite RGB path and the per-plate separation path.
  • Closes ICCBased N=4 truncation on the composite RGB path.
  • Closes DeviceN multi-colorant Type 4 fallback on the composite RGB path.
  • Closes three pre-existing radial-shading bugs: /Coords non-concentric handling, /Domain extraction, and /Extend asymmetric clipping.
  • Closes the ImageMask paint extractor that was silently dropping rendered output.

Changes Made

src/rendering/resolution/ — new module (~4,000 lines, 13 files)

  • mod.rs — module entry, dead_code / unused_imports allow narrowed once production callers wired through walker integration. Status documentation describes the shipped pipeline shape and the capabilities each stage closes.
  • intent.rsPaintIntent / PaintKind / LogicalColor / DeviceColor / PaintSide. Operator dispatch's input shape: logical colour, graphics-state borrow, path / glyph / image kind, clip refs. PaintKind::ColorOnly lets the pipeline run for capabilities that consult colour but don't paint geometry (used by the separation walker for the resolution-only path).
  • pipeline.rsResolutionPipeline::resolve composes the five stages, plus pipeline_resolve_paint_gs / pipeline_resolve_text_colors / pipeline_resolve_components entry points used by the renderers' different call sites. Shared core helper run_pipeline_for_logical is the single point that drives the stage composition for both gs-bound and gs-free callers.
  • color.rsColorResolver consumes LogicalColor and returns ResolvedColor. Handles tint transforms (Type 2 exponential, Type 4 PostScript calculator) for /Separation and /DeviceN colorants over any supported alternate. ICCBased flows through qcms for N=1/3/4 sources. Indexed looks up the base, then recurses. Folds DeviceCMYK to RGB via ISO 32000-1 §10.3.5 additive-clamp when no embedded ICC is present (see Deferred items for the OutputIntent follow-on).
  • overprint.rsOverprintResolver reads /OP, /op, /OPM from ParsedExtGState and emits an OverprintPlan with per-channel masks. Handles the §11.7.4 OPM=1 nonzero rule.
  • blend.rsBlendResolver classifies a BlendMode as native (tiny_skia variant) vs simulated.
  • clip.rsClipResolver composes the active clip stack into a single mask reference.
  • ink.rsInkRouter translates a ResolvedColor plus channel descriptors into InkAction::{Paint(tint), Skip} per plate. Honors /All (paints every plate at one tint) and /None (paints no plates) per §8.6.6.3. New InkSelector enum on OverprintPlan carries Listed / All / None with all_tint: f32 and spot_source: Option<SpotSource> so the composer stamps these from the source colour space rather than per-route guessing.
  • backend.rsPaintBackend trait. Composite backend lives in the renderers' existing tiny_skia surfaces; separation backend ships here.
  • separation_backend.rsSeparationBackend and SeparationSurface. Implements the per-plate PaintBackend operations against a multi-plate pixmap target. Byte-for-byte equivalence with the inline fill_separation path is unit-tested across three input shapes (CMYK Cyan-only, mixed CMYK at (0.5, 0.25, 0, 0.7), rotated-CTM rect).
  • resolved.rsResolvedPaintCmd, ResolvedColor, BlendPlan, ClipPlan, OverprintPlan, ParticipatingChannel, InkName. Backend-agnostic, fully evaluated shapes.
  • context.rsResolutionContext borrows the graphics state, color spaces table, resources dictionary, and document handle. Single entry point so stages don't each take their own grab bag of references.
  • test_support.rs#[cfg(test)]-only fixture helpers shared by every stage's unit tests.

src/rendering/page_renderer.rs — operator dispatch routes through pipeline

Path-fill / stroke / fill-stroke combos, text-painting operators, image Do (including ImageMask), and shading sh all build a PaintIntent, hand it to ResolutionPipeline::resolve, and apply the resulting ResolvedPaintCmd through the composite tiny_skia surface. The inline match-arm bodies for these operators are deleted. Side benefit: graphics-state save/restore (q / Q) no longer needs to forward-declare every capability the inline arms might consume — capabilities live one level below in the resolver stages.

src/rendering/separation_renderer.rs — per-plate walker routes through SeparationBackend

execute_separation_operators Fill / Stroke / FillEvenOdd / FillStroke / CloseFillStroke / FillStrokeEvenOdd arms each check side_uses_pipeline(fill, gs, color_spaces, resources, doc) and, when the colour space is /Separation, /DeviceN, or /ICCBased compound, dispatch through ResolutionPipeline::resolve + SeparationBackend::paint. DeviceCMYK, DeviceGray, and DeviceRGB direct sources stay on the inline tint_for_ink fast path — they don't need the pipeline because the inline path was already correct for direct device colour. Text-side tint_for_ink calls are preserved for now; the text rasteriser is on a separate path that the walker doesn't yet cover.

Three pre-existing shading bug fixes (radial / function path)

  • Radial /Coords non-concentric circles: the inline code treated the inner circle's coordinates (x0, y0, r0) as the outer circle's centre, collapsing every non-concentric radial gradient to a point gradient. Fixed to consume x0 / y0 / r0 as specified.
  • /Domain extraction: the inline path always evaluated the shading function at t = 0 and t = 1, regardless of /Domain [a b]. Fixed to honour /Domain.
  • /Extend [false false] and asymmetric [true false] / [false true]: the inline path painted past both endpoints unconditionally. Fixed to clip per spec.

These bugs were latent in the inline arms long before the migration started and would have surfaced as separate fixes regardless. They land here because the migration moves the shading code path.

src/rendering/resolution/separation_backend.rsSeparationBackend

Implements the per-plate PaintBackend operations. Same per-plate routing decision as tint_for_ink, but driven through the resolver stages, so PostScript Type 4 tint transforms now reach the plate correctly. End-to-end pin in the QA suite: a /Separation /Magenta /DeviceCMYK with a Type 4 tint transform now writes 255 to the Magenta plate at peak, where the inline path wrote 0.

src/content/graphics_state.rs — minor

Adds the surface area the pipeline needs to read from the graphics state without leaking pipeline types into the state struct.

Test surface — ~7,000 lines of QA across tests/test_render_resolution_pipeline_qa_wave{1..5}.rs

165 capability-bounded probes spread across five test files, one per capability migration step. Each test names the capability it pins (Type 4 Separation under fill / stroke / text / shading / image-mask / form-xobject; ICCBased N=4; DeviceN multi-colorant Type 4; radial non-concentric coords; /Domain; /Extend; state preservation; q / Q; clip; blend; /All; /None; edge cases; adversarial). Negative controls assert that the inline path produces the buggy value when invoked directly, so the positive pins prove the bug-fix rather than just "didn't panic". 27 of the probes were added with the final walker wiring and pin behaviours that only became reachable once the separation walker dispatched through the pipeline — including the /All / /None colorant-name pins and the SeparationBackend byte-for-byte equivalence with fill_separation.

Stage-level unit tests live alongside each stage in src/rendering/resolution/* so capability regressions can be caught without spinning up a full render.

Three ride-along CI fixes

The full CI matrix surfaced three pre-existing issues in upstream main that became hard errors under the tighter local verification:

  • a358921 — WASM clippy duplicated_attributes ×5 across src/signatures/{cms,cms_verify,crypto,sign_bytes,timestamp}.rs. Inner #![cfg(feature = "signatures")] was redundant with the outer #[cfg(feature = "signatures")] mod foo; declaration. Removed inner attributes.
  • 3718e4f — FIPS clippy duplicated_attributes in src/crypto/aws_lc_provider.rs and three clippy::search_is_some in src/signatures/sign_bytes.rs:956-958 (.find(...).is_some().contains(...)). Same pattern as a358921 plus a small assertion idiom cleanup.
  • 17483easrc/extractors/images.rs:1452-1456 CMYK JPEG comment cited two specific competing PDF renderers by name when explaining the no-APP14 default. Rewritten to describe the convention without proper nouns.

None of these were introduced by the migration (confirmed via git log upstream/main archaeology); they surface here because this PR's pre-push verification ran more CI gates locally than upstream main has had to satisfy.

Testing

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

Full CI matrix verified locally (every gate from .github/workflows/{ci,php,ruby,python}.yml):

# Gate Status
1 cargo fmt -- --check (workspace) PASS
2 make c-header-check (cbindgen 0.29.2) PASS
3 cargo clippy --all-targets --workspace -- -D warnings (default features) PASS
4 cargo doc --no-deps --workspace with -D warnings PASS
5 cargo build --no-default-features PASS
6 cargo build --no-default-features --features python PASS
7 cargo build --verbose (default features) PASS
8 cargo test --verbose (default features, full integration suite) PASS — 5,447+ tests, 0 failures
9 cargo build --examples + tutorial smoke-run PASS
10 cargo build -p pdf_oxide_cli + --help PASS
11 cargo build -p pdf_oxide_mcp PASS
12 WASM wasm32-unknown-unknown build + clippy + wasm32-wasip1 build PASS (after a358921)
13 Python maturin build + pytest PASS — 136 passed, 10 skipped; 2 pre-existing HTML→PDF failures (reproduced on main, not introduced here)
14 FIPS variant build + crypto tests + clippy PASS (after 3718e4f)
15 PHP / Ruby cdylib release build (shared cargo gate) PASS — Composer / Bundler userland skipped locally

Lib tests: 5,547 passing (+9 in the resolution module since main). Wave QA totals: 30 + 37 + 33 + 38 + 27 = 165 capability probes.

Python Bindings (if applicable)

  • Python bindings updated (if needed) — N/A, no PyO3 surface changes
  • Python tests pass — see Testing table; 2 pre-existing HTML→PDF failures unrelated to this branch
  • Python code formatted with ruff format
  • Python code linted with ruff check

Documentation

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

The new module's src/rendering/resolution/mod.rs ships with a detailed architecture docstring describing the stage decomposition, the spec sections each stage closes (§8.6, §7.10, §11.7.4, §11.3.5, §11.4, §14.11.5, §10.3.5), and the existing pdf_oxide capabilities each stage consumes. A "Design influences" section names public sources consulted during the design (the ISO 32000 specs, existing pdf_oxide modules, and general graphics-pipeline patterns) so the architectural lineage is auditable.

Checklist

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

Additional Notes

How to review 82 commits

The migration was structured so each capability migration produced a coherent commit cluster. A reviewer can pick a single capability and read it end-to-end:

  1. Architectural foundation — start with src/rendering/resolution/mod.rs. The module-level docstring describes the stage decomposition and why each stage exists. Then walk intent.rspipeline.rscolor.rs to see one full resolution path.

  2. By capability migration step — the 82 commits group naturally:

    • Path operators (fill, stroke, combos): foundation + path-side pipeline integration in page_renderer.rs.
    • Text operators (Tj, TJ, ', "): text-side pipeline integration including the pipeline_resolve_text_colors helper.
    • Image Do (including ImageMask): image-side pipeline integration; closes the silent-drop extractor bug.
    • Shading sh (axial / radial / function-based): shading-side pipeline integration plus the three pre-existing shading bug fixes.
    • Architectural finale: env-var toggle removal, inline-arm deletion, SeparationBackend introduction, separation walker wiring.
  3. The fix-pass commits at the head of the branch (386097f53da7e1) collect the closing capabilities surfaced during review and QA: byte-for-byte SeparationBackendfill_separation parity, /All / /None per §8.6.6.3, the docstring rewrite, the 74 parity-assertion → positive-probe conversions across the test suite, and the dead_code / unused_imports narrowing now that the SeparationBackend has production callers.

  4. The three ride-along CI fixes (a358921, 3718e4f, 17483ea) are isolated single-purpose commits.

Performance

Real-corpus benchmark vs main: median ~0% overhead, max +1.2% regression, peak heap unchanged. The one outlier is Type 4 PostScript tint transform on /Separation (+58.4%) — and that's a correctness tax, not a regression, because the previous path was returning 0 for the plate output and (1 - tint) greyscale for the composite. Doing the work the spec asks for is more expensive than not doing it.

Deferred items

These items are out of scope for this PR but ride directly on this architecture and have follow-on tracking:

  • OutputIntent CMYK ICC profile through ColorResolverPdfDocument::output_intent_cmyk_profile() already ships; wiring it through ResolutionContext into ColorResolver::cmyk_to_rgb is a small follow-on that closes the press-vs-screen colour divergence on /DeviceCMYK paint commands. The architecture leaves a clean Option<Arc<IccProfile>> slot for it.
  • Composite overprint preview via render-via-separation + ICC back-compose — with this PR landed, SeparationBackend has production callers, /Separation overprint per §11.7.4 already ships in the per-plate path (feat: separation overprint (#622)), and the composite renderer can call into the separation pipeline for pages that declare non-trivial overprint, then back-compose through the OutputIntent ICC profile. This is the canonical "render to plates, simulate overprint on plates, back-compose to RGB" architecture the module docstring foreshadows.
  • Separation walker text-side migration — text-side tint_for_ink calls in the separation walker are kept on the inline path; migrating them mirrors the fill / stroke migration shape but needs the text rasteriser to surface its colour resolution differently.
  • Pipeline composer double-evaluates /Separation / /DeviceCMYK tint transform — once in ColorResolver for the composite RGBA, once in eval_separation_alt_cmyk for the per-plate fallback. Known optimisation opportunity; deferred to avoid changing the resolver's public surface in this PR.
  • Tr=5 (stroke + clip-from-text) and Tr=7 (clip-only) text painting — these modes paint glyph outlines with the default fill colour today because the text rasteriser doesn't stroke text or build a clip mask from glyph contours. Pinned as no-panic / full-pixmap in the QA suite; full fix is a text-rasteriser change below the pipeline.
  • 0.25-px hairline stroke at 72 DPI — produces no anti-aliased visibility through tiny_skia at that resolution. Pinned as no-panic.

Known non-regressions (pre-existing on main)

  • Two HTML→PDF Python tests in python/tests/test_api_coverage.py::TestHtmlCssCreation (test_from_html_css_content_extractable, test_css_font_size_reflected_in_extracted_chars) fail under cargo run -p maturin build && uv run pytest. Reproduced on upstream/main (commit 2196dea). Not introduced by this branch (diff stat shows zero edits to src/writer/, src/html/, or src/text/).
  • An unknown lint: clippy::manual_checked_ops warning sprays from Cargo.toml:57 on every clippy run. Cleanup candidate, not a blocker.

@RayVR RayVR requested a review from yfedoseev as a code owner June 5, 2026 05:44
RayVR added 29 commits June 5, 2026 14:52
…tage tests

The composite renderer and the separation renderer each carry their own
inline colour resolution, overprint handling, blend-mode classification,
and clip composition at every operator's match arm. The two renderers
grew through copy-and-edit; their colour paths have diverged
(separation has `tint_for_ink` + a `ResolvedSpace` enum; composite has
four copies of an open-coded match across SC/SCN/sc/scn). The
PostScript Type 4 calculator evaluator under `src/functions` and the
ICC management under `src/color` ship behind tests but are not
consumed by either renderer's colour path, so capabilities added at
the layer below the renderer manifest as silent visual bugs when a
match arm forgets to wire them.

Introduce a layered resolution pipeline that owns the conversion from
"PDF logical colour + graphics state" to "fully-evaluated paint
command":

- `PaintIntent` + `LogicalColor` + `DeviceColor` — what the operator
  dispatcher emits, holding only borrows into the walker's state.
- `ResolvedPaintCmd` + `ResolvedColor` + `OverprintPlan` + `BlendPlan`
  + `ClipPlan` — what the backend consumes, fully evaluated.
- `ColorResolver` — handles `Device*`, `ICCBased`, `Indexed`,
  `Separation`/`DeviceN` with both Type 2 (exponential) and Type 4
  (PostScript calculator) tint transforms. The Type 4 wiring routes
  through `crate::functions::evaluate_type4_clamped`.
- `OverprintResolver` — projects `/OP`/`/op`/`/OPM` onto a per-channel
  participation plan per ISO 32000-1:2008 §11.7.4.
- `BlendResolver` — classifies blend-mode names into tiny-skia native
  or simulated; matches the existing renderer behaviour and surfaces
  Hue/Saturation/Color/Luminosity as explicit native-degrade cases so
  future backends can opt into simulation.
- `ClipResolver` — wraps the walker's composed mask into an
  Arc-shared `ClipPlan` for cheap fill+stroke pair reuse.
- `InkRouter` + `InkAction` — per-plate decision (Paint(tint) | Skip)
  encoding the §11.7.4 knockout/skip table without re-walking the
  source colour space.
- `ResolutionPipeline` — composes the stages into one call.
- `PaintBackend` trait with associated `Surface` type — composite
  takes `&mut Pixmap`, separation takes `&mut [Pixmap]`, future
  backends can pick their own without runtime indirection.

The module is dead code at the integration layer; no renderer calls
into it yet. Each stage carries its own unit tests against
`GraphicsState` mocks and synthetic colour-space objects, so colour
resolution can be exercised without any rendering happening — the
payoff of the layering.

The colour-resolver regression guards
`separation_with_type4_calculator_evaluates_program` and
`separation_full_tint_with_type4_no_longer_renders_solid_black`
demonstrate the capability gain: a full-tint Separation backed by a
Type 4 program resolves to its declared colour rather than the
`1.0 - tint = 0` solid-black the existing inline path produces.

Phase 2 of a multi-PR migration. A separate commit will wire the
pilot operator (path fill) through the pipeline behind an env-var
toggle; subsequent branches will migrate the remaining operators
incrementally with parity validation at each step.
…-var toggle

Wire `Operator::Fill` and `Operator::FillEvenOdd` in the page renderer
to optionally route through the resolution pipeline introduced in the
previous commit. The route is gated by
`PDF_OXIDE_RESOLUTION_PIPELINE` (unset/`0`/`false` → existing inline
path; anything else → pipeline); existing users see no behaviour
change. Stroke, fill-stroke combinations, and every other operator
continue on the inline code path — those are deferred to follow-up
branches that migrate one operator family at a time with parity
validation.

To let the pipeline re-evaluate the active colour against its
declared colour space, `GraphicsState` now retains the raw fill /
stroke components from the most recent `sc`/`scn`/`g`/`rg`/`k`
operator (and stroke equivalents). The existing inline match arms
still eagerly project these to `fill_color_rgb` for the dominant
device cases; the new fields exist purely so that
`Separation`/`DeviceN` content whose tint transform is a PostScript
Type 4 program can be re-evaluated through the resolver instead of
being lost to the inline `1.0 - tint` fall-back. The fields are tiny
`SmallVec`s that stay inline for ≤8 components — the size of every
real-world DeviceN colorant set.

Integration coverage exercises the pilot end-to-end against
in-memory PDFs:

- `pilot_path_fill_device_rgb_parity_pipeline_off_vs_on`,
  `pilot_path_fill_device_gray_parity_pipeline_off_vs_on`, and
  `pilot_path_fill_device_cmyk_parity_pipeline_off_vs_on` assert
  byte-identical pixmaps with the toggle off vs. on. These are the
  parity invariant — flipping the toggle on for PDFs the inline path
  handles correctly must not change a single byte.
- `pilot_path_fill_type4_separation_pipeline_resolves_correctly`
  builds a PDF whose page declares a `Separation /MagentaSpot
  /DeviceCMYK` colour space with a Type 4 tint transform (`{ 0.0
  exch 0.0 0.0 }`, leaving CMYK(0, tint, 0, 0) on the stack), fills
  a centred rectangle at full tint, and renders both with and
  without the toggle. With the toggle off, the centre pixel is
  ~solid black (the `1.0 - tint = 0` fall-back); with the toggle
  on, the centre pixel is magenta. This is the capability gain the
  layered pipeline exists to deliver — a single wiring point picks
  up `crate::functions::evaluate_type4_clamped` and both the
  composite and (eventually) the separation backend benefit.

Env-var manipulation inside test threads is fenced by a
process-wide `Mutex` so parallel test execution doesn't interleave
toggle flips.
…eline

Extend the path-fill pilot to cover the stroke side (`S`/`s`) and the
combined fill-stroke operators (`B`, `b`, `B*`, `b*`). All six operators
gate on the same `PDF_OXIDE_RESOLUTION_PIPELINE` env var; off-toggle paths
stay byte-identical to the inline behaviour.

The fill helper specialises a single side-parameterised
`pipeline_resolve_rgba(side)`. Splitting fill and stroke into separate
helpers would have meant duplicating the context build + intent assembly
for no behavioural reason; `PaintIntent` already carries `PaintSide` and
the colour stage keys all of its side-specific behaviour (alpha fold,
component selection) off the intent. Fill and stroke entry-point names
remain (`pipeline_resolve_fill_rgba`, `pipeline_resolve_stroke_rgba`) so
each call site reads at its operator dispatch.

Combo operators resolve fill and stroke independently — two
`PaintIntent`s per dispatch, one per side. The fill side is spliced into
one transient `GraphicsState` and the stroke side into another, so a
Type 4 Separation on the fill side and a plain DeviceRGB on the stroke
side route correctly without entangling. The off-toggle path borrows
`gs` directly for both sides; no clones happen until the pipeline
returns a resolved colour.

Regression coverage now spans 25 integration tests:

- Parity (byte-identical pixmap off vs on) for `S`, `B`, `B*`, `b`, `b*`
  across DeviceRGB / DeviceGray / DeviceCMYK, plus a close-then-stroke
  case that exercises the parser's `s` → `ClosePath` + `Stroke`
  decomposition.
- Capability gain for the stroke side: a Type 4 `Separation` resolves
  to its declared colour through the pipeline; the inline `SCN` arm has
  no Separation branch and falls back to a gray clamp on the tint
  (renders ~white at full tint), so the pipeline is the only path that
  produces the spec-correct result.
- Combo capability: a distinct-colour `B` PDF with a Type 4 magenta
  fill and a DeviceRGB cyan stroke verifies that the two sides route
  independently, and a second combo with two Type 4 Separations (magenta
  fill, cyan stroke) verifies the strongest version — both sides
  capability-gain together.
- Stroke graphics state preservation: line width and line cap / line
  join survive the round-trip through the pipeline path, and the
  observable behaviour difference between different cap / join values
  is preserved (so a no-op pipeline that silently dropped these dials
  would be caught).
…erators

Adds wave-1 QA suite alongside the pilot. Three regression pins cover the
toggle-on parity invariant under load: 200 repetitions of every migrated
operator on one page, an all-operators interleaved stream that exercises
the prior-wave `f`/`f*` together with the new `S`/`B`/`b`/`B*`/`b*`, and a
graphics-state-rich stream that interleaves `q`/`Q`/`cm`/`w`/`J`/`j`/`d`
with the migrated operators. All pass at this revision; future drift on
the toggle-on path will surface here before reaching downstream renders.
…miter, /CA)

Six probes target the stroke side's graphics-state round-trip through the
pipeline splice. Hairline (0.25), zero, and negative line widths confirm
width survives the clone-and-overwrite. /CA via an ExtGState pins the
stroke-alpha fold. Dash pattern and extreme miter-limit pin the rest of
the stroke geometry fields that the splice must not perturb. All pass at
this revision.
Probes for the combo operators' two-intent splice. Rotated and scaled CTM
applied to `B` confirms both fill and stroke clones inherit the same CTM.
An ExtGState carrying `/SMask` is a parity floor: whatever the inline
path does today, the pipeline must match. Active clip path applied across
a `B` rectangle confirms both sub-operations see the same clip mask.
…rfaced

Probes 13-18 cover Indexed, ICCBased N=4 (CMYK), DeviceN Type-4,
Separation `/All`, Separation `/None`, and Pattern. Two real bugs found:

- Indexed colour space via `scn` (fill): the inline `SetFillColorN`
  handler has no Indexed branch (only the older `sc`/SetFillColor at
  page_renderer.rs:581 does), so it falls through to `(g, g, g)` with
  `g = raw_index`. The pipeline's `resolve_indexed` divides by 255. For
  index 1 the renders differ catastrophically — inline produces near-
  white, pipeline produces near-black. Pinned as
  `qa_bug_indexed_scn_fill_pipeline_diverges`, marked `#[ignore]` so it
  surfaces under `cargo test -- --ignored` but doesn't block CI until
  the fix wave addresses it.

- Indexed via `SCN` (stroke): symmetric divergence; `SetStrokeColorN`
  also lacks an Indexed branch. Pinned as
  `qa_bug_indexed_scn_stroke_pipeline_diverges`.

The remaining colour-space probes pass — DeviceN Type-4 resolves
correctly through the pipeline (capability gain confirmed against a
2-colorant programme), ICCBased N=4 fall-through stays in parity,
Separation `/All` resolves through Type 4 as expected, Pattern colour
space falls back to the inline path cleanly. `/None` colorant handling
is a pre-existing spec gap on both paths; pinned as a parity-divergence
record so the fix lands in one place.
…e clamp

Three pin tests for the Type-4 calculator that the Separation/DeviceN
branch evaluates. Division by zero rides IEEE semantics (the evaluator's
chosen behaviour); a deep literal program is rejected by the MAX_STACK
guard and the resolver folds the error into `None` so the renderer
falls back; out-of-range output values are clamped to [0, 1] before the
alt-space projection. None panic, all produce valid 100×100 pixmaps.
…y bug

Four probes target malformed colour-space inputs and shape mismatches.

- A Separation array missing its alt-space and tint-transform entries
  surfaces a third divergence: inline falls through to `1.0 - tint` (the
  catch-all at SetFillColorN's bottom), pipeline falls through to
  `first_as_gray(components)` = `tint`. For tint != 0.5 the rendered
  grays are different. Pinned as
  `qa_bug_malformed_separation_array_diverges`, marked `#[ignore]`.

- A companion `qa_malformed_separation_array_no_panic` confirms that
  neither path crashes — only the parity is broken.

- `scn` with too many components folds to `components[0]` on both paths;
  parity holds.

- DeviceN with too-few-for-Domain components survives both paths without
  panic; the resolver's `?` lifts the evaluator error into a `None`
  return and the renderer falls back gracefully.
Probes 25-27 close out the QA wave.

Perf — 1000 DeviceRGB fills under toggle-off vs toggle-on, with warm-up,
measures the wall-clock cost of routing every paint through the
pipeline. Bound is generous (20x); measured ratio on this revision is
1.07x, well within. Print line on stdout surfaces the number for
operators reviewing CI output.

Combo alloc-pressure pin: 500 `B` operators = 1000 ResolutionPipeline
instantiations per render; off-vs-on must produce identical output.

Three real-world fixture parity pins (`simple.pdf`,
`hello_structure.pdf`, `outline.pdf`) confirm the synthetic-only pilot
fixtures aren't masking a regression that only shows up on real PDFs.
All pass — the toggle is safe to flip on the corpus today.
Removes the unused `build_pdf_with_type2_separation` helper that was
scaffolded but never wired into a probe — the Type-2 path is already
covered by the pilot's DeviceCMYK pin and the wave-1 surface is the
Type-4 capability. Reflows the doc-comment list in
`qa_perf_combo_alloc_pressure_does_not_break_correctness` so
`clippy::doc_lazy_continuation` accepts the trailing prose paragraph.
… §8.5.3.1

The dispatcher arm matching CloseFillStroke and CloseFillStrokeEvenOdd
shared a body with FillStroke and never closed the active subpath. The
parser does not decompose b/b* into ClosePath + FillStroke (unlike s,
which is emitted as ClosePath + Stroke), so the dispatcher must perform
the close itself. Without it the final segment of an open subpath is
omitted from the stroke, producing a visible gap along the close edge.
…ed and malformed Separation

Two related off-vs-on toggle parity bugs in the colour-resolution pilot:

1. Indexed colour space via scn (fill) and SCN (stroke). Neither inline
   dispatcher arm had an Indexed branch, so the raw palette index landed
   in the gray-clamp fallback (index 1 → near-white). The pipeline at
   resolution/color.rs:259 already divided by 255. Add a matching Indexed
   branch to both inline arms so the off-toggle uses index/255 too. Full
   palette lookup is deferred; this is the agreed interim fallback.

2. Malformed Separation/DeviceN arrays (no altCS or tintTransform), and
   any tintTransform whose FunctionType is not 2 or 4, hit different
   fallbacks: inline used 1.0 - tint (the historic spot-ink heuristic),
   pipeline used the raw tint as gray. Match the pipeline to the inline
   so toggle parity holds until the broader §8.6.6.4 fix arrives.

Side-effect: the inline SCN dispatcher now has a Separation/DeviceN arm
that mirrors the long-standing scn arm. Previously SCN had no Separation
handling at all and fell through to the gray-clamp; the new arm uses the
same 1.0 - tint fallback, so a full-tint Type 4 spot stroke now renders
solid black on the inline path (was solid white).

Un-ignores three QA wave-1 probe tests that pinned the divergence.

Ref: ISO 32000-1 §8.6.6.4 (Separation/DeviceN), §8.6.6.3 (Indexed).
…le fixture, tighten Type 4 stroke Separation pin

Three pilot test additions / tightenings paired with the wave-1 fix
pass:

- pilot_b_operator_paints_close_edge_under_pipeline and the b* sibling:
  draw an open four-segment path closed by b/b* with a thick stroke and
  assert the close-edge pixel is the stroke colour. Before the b/b*
  dispatcher started calling .close() the close segment was omitted; the
  assertion now catches any regression of that bug.

- pilot_b_star_even_odd_fill_rule_actually_evenodd: an outer-square
  inner-square fixture that genuinely differs under EvenOdd vs Winding
  (centre pixel hollow under EvenOdd, solid under Winding). The existing
  B*/b* parity tests used convex rectangles where the two rules agree,
  so a silent regression of B* to FillRule::Winding would not have been
  caught.

- pilot_stroke_type4_separation_pipeline_resolves_correctly: tighten
  the inline-path assertion from a bare !is_magenta negation to a
  positive pin on the actual fallback colour (after the inline SCN
  Separation arm changes, that pin moves from white to near-black).
…der path

Two small hygiene improvements in the pipeline-resolve hot path:

- pipeline_is_enabled() previously read std::env::var on every paint
  operator. Cache the value on PageRenderer at the top of
  render_page_with_options instead. Tests that flip the env var around
  render_page calls pick up the new value on the next call (the cache
  is refreshed there); the per-render-call mutex in the pilot tests
  already serialises env flips, so this never observably reorders.

- pipeline_resolve_rgba built a fresh PathBuilder per call only because
  PaintKind::Path needs a &Path. Materialise a unit-length placeholder
  once via OnceLock and re-borrow it. The colour stage does not read
  the geometry, so the static path is a zero-cost stand-in.
…ture, refresh module doc

Three small cleanups in the resolution module:

- ResolutionContext carried output_intent_cmyk and rendering_intent
  fields that no resolver stage read. Every caller dutifully forwarded
  them anyway. Drop them — they will be added back when the colour
  stage grows an ICC code path that actually consumes them.

- The same fixture_doc PDF-builder was copy-pasted into three test
  modules (color.rs, pipeline.rs, context.rs). Extract into a shared
  test_support module gated behind cfg(test).

- The mod.rs doc-comment still described the pipeline as 'dead at
  landing' with one toggled pilot operator, which is stale: f/f*/S/s/
  B/B*/b/b* all route through the pipeline behind the env-var toggle
  today. Rewrite the status paragraph and the dead-code allow comment
  to reflect the actual scope and reason.
…pipeline

Tj, TJ, `'` and `"` now resolve their fill / stroke colour through the
resolution pipeline under the PDF_OXIDE_RESOLUTION_PIPELINE toggle. The
helper picks sides per Tr mode (0/2/4/6 → fill, 1/2/5/6 → stroke, 3 → no
resolve), clones the GraphicsState once per text-showing call (never per
glyph), and lets the text rasteriser consume the spliced colour without
needing its own pipeline awareness. Toggle off remains byte-identical to
the prior behaviour; toggle on closes the Type 4 Separation text-fill
fall-back the inline `scn` arm exposes today (which renders full-tint
spots as solid black).

Adds 17 pilot tests covering Tj/TJ/'/" parity for DeviceRGB/Gray/CMYK,
Tr=1/2/3 modes, Type 4 Separation fill and stroke capability gains,
distinct fill/stroke colours under Tr=2, and state-preservation pins
(font size, Tc/Tw, Tz).
…toggle-off return value

Two properties the end-to-end pixel tests cannot observe today:

* Stroke-side resolution. The text rasteriser does not currently paint
  stroked glyphs, so the spliced stroke colour never reaches the pixmap;
  a regression that swapped sides or dropped the stroke resolve would
  not be caught by pixel comparison.
* Helper-returns-None on the off-toggle path. The byte-equal parity
  check between the off and on renders holds whether the helper returns
  None or Some(clone) — equally for a Tr=2 splice that resolves both
  sides to identical Device-family colours.

Expose pipeline_resolve_text_gs as pub(crate) and add two helper-level
unit tests that probe the spliced GraphicsState directly:

* pipeline_resolve_text_gs_strokes_magenta_under_tr1 — Tr=1 with a
  Separation/DeviceCMYK/Type-4 stroke colour space must produce a
  magenta-shaped stroke_color_rgb on the spliced GraphicsState, not
  the legacy 1.0-tint=0 black fallback.
* pipeline_resolve_text_gs_returns_none_when_toggle_off — with the
  cache flag off, the helper must return None even for a Tr=2 input
  rich enough to drive both sides through the pipeline.

The integration-test docstrings remain accurate descriptions of the
end-to-end behaviour they exercise; the missing probes are added here
rather than weakening the integration assertions.
…point

Wave 1 grew three thin wrappers around `pipeline_resolve_rgba` —
`pipeline_resolve_fill_rgba`, `pipeline_resolve_stroke_rgba`, and the
later `pipeline_resolve_text_gs` — each with a slightly different
return shape (raw RGBA tuple vs. spliced GraphicsState clone). The
path-fill, path-stroke, and combo arms each open-coded the splice;
the text arm hid it inside the helper. Two shapes for the same
logical step made each call site read differently and meant new
operators had to pick a style.

Replace all three with a single `pipeline_resolve_paint_gs(doc, gs,
kind)` that returns `Option<GraphicsState>` for every paint operator
family. The new `PipelinePaintKind` enum names what's being painted:

* `PathFill` → fill side only.
* `PathStroke` → stroke side only.
* `PathFillStroke` → both sides spliced into the same clone (the
  fill pass reads fill fields, the stroke pass reads stroke fields,
  so one shared clone is equivalent to two single-side clones).
* `Text` → sides selected by `gs.render_mode` per ISO 32000-1
  §9.3.6 Table 106, including the Tr=3 skip for invisible text.

Operator-arm call sites now look identical across path-fill, path-
stroke, combo, and text: resolve, borrow the spliced gs or fall back
to the original, paint. The off-toggle path stays allocation-free
(helper returns None before any clone) and Device-only inputs that
the pipeline resolves but to identical RGBA still produce a no-op
splice — D-3 will short-circuit that case next.

The wave-1 `B*` combo arm gets a side benefit: it previously cloned
twice (once for fill, once for stroke); it now clones at most once.
…ainting

The wave-2 text-showing path was paying two GraphicsState clones per
Tj / TJ / ' / " call:

1. The operator arm called pipeline_resolve_text_gs, which cloned `gs`
   and spliced the resolved RGBA into a transient.
2. render_tj_array immediately cloned that transient again to walk
   text_matrix between TJ elements.

Clone #2 is structural — current_gs.text_matrix is mutated per
element, so the rasteriser must own its own copy — but clone #1 only
exists to splice colour. Eliminate it.

The text-side helper is now pipeline_resolve_text_colors, returning
Option<ResolvedColors> { fill, stroke } instead of
Option<GraphicsState>. render_text and render_tj_array accept the
resolved colours as an additional parameter and apply them at the
single create_fill_paint site (and, in render_tj_array, forward the
override into each inner render_text call so the per-element paint
uses the resolved RGBA without any per-element splice work).

On the toggle-on text path the operator arm now allocates nothing —
the rasteriser's existing current_gs.clone() in render_tj_array is
the sole GraphicsState allocation, and render_text (for Tj / ' / ")
allocates nothing at all. Off-path is unchanged: helper returns None
before any work; create_fill_paint reads gs.fill_* as before.

Path operators keep pipeline_resolve_paint_gs (Option<GraphicsState>):
the path rasteriser doesn't clone gs internally, so the operator-arm
splice is the only place a colour override can land for those.

Separation backend keeps its own faux-grayscale path; it passes None
for the override.
RayVR added 11 commits June 5, 2026 14:53
Adds two deferral pins and one extra separation-backend equivalence
probe to the wave-5 QA suite:

- WAVE5-DEFER-COLORRESOLVER-RGBA-ONLY-FOR-COMPOUND-SPACES — the
  ColorResolver always projects compound colour spaces down to
  ResolvedColor::Rgba, so OverprintResolver sees Rgba and produces an
  empty participating channel set; InkRouter then returns Skip for
  every plate. The visible-from-the-outside consequence on the
  composite path is "DeviceCMYK(1,0,0,0) renders RGB(0, 255, 255)" —
  the additive-clamp formula's exact bytes. Pinning the bytes locks
  the composite output even when the resolver later starts emitting
  the per-channel variants for separation backends.
- Rotated Magenta-only CMYK fill through render_separation: pins the
  shipping fill_separation inline path the wave-5 SeparationBackend
  unit test byte-compares against. A regression in fill_separation
  fails the backend's unit test and this integration probe in lock
  step.
The Status section claimed the pipeline was gated behind a
PDF_OXIDE_RESOLUTION_PIPELINE env-var toggle. That toggle and the
inline alternative paths it gated were removed when the pipeline
became the sole composite paint path. Restate the shipped state:
pipeline drives every migrated composite operator, SeparationBackend
drives plate output via the operator walker, and enumerate the
capabilities the migration closed.
…ctly

The byte-for-byte test was tautological — it called the backend's
internal fill_plate helper as its 'inline' reference, so any change
that affected one path automatically affected the other. Replace with
a real cross-module comparison: drive SeparationBackend::paint, then
drive separation_renderer::fill_separation directly on parallel
pixmaps with the per-plate tint the router would route, and assert
plate-by-plate equality.

Expose fill_separation as pub(crate) and lift separation_renderer to
pub(crate) so the resolution tests can reach the reference. The
helper carries a doc note about the intended use.

Cover three inputs: CMYK Cyan-only fill, a DeviceCMYK mixed fill at
(0.5, 0.25, 0.0, 0.7), and the mixed fill under a 30-degree rotated
CTM (mirrors the existing rotated-rect probe at the public surface).
… per §8.6.6.3

ISO 32000-1:2008 §8.6.6.3 specifies two reserved Separation colorant
names: /All paints every output colorant (process + spot) at a single
tint; /None produces no visible output. The per-plate router was
treating them as opaque names — /All routed by basename collision, and
/None passed through the tint transform.

Carry the routing intent on OverprintPlan via an InkSelector enum
(Listed / All / None) plus an all_tint field for /All. The composer
detects the reserved names by inspecting the source colour space at
resolve time and stamps the selector; the per-plate InkRouter
short-circuits on All/None before walking the participating channel
list. The composite (RGB) backend continues to evaluate the tint
transform unchanged for /All so existing capability tests don't
regress; for /None the resolver hands back a fully-transparent RGBA
so composite painting lays down zero ink.

Also drop DeviceN channels literally named /None from per-plate
matching (§8.6.6.4) — they were silently colliding with target plates
otherwise.

Pin from both layers:

- ink.rs unit tests: all_inks_paints_every_plate_at_single_tint,
  all_inks_paints_even_when_overprint_enabled,
  none_inks_skips_every_plate,
  devicen_channel_named_none_is_dropped.
- separation_backend.rs end-to-end:
  all_inks_paints_every_plate_at_same_tint,
  none_inks_paints_no_plates.
- The wave 2 text-fill /None probe and wave 3 ImageMask /None probe,
  previously pinning the non-conformant 'paints something' behaviour,
  now pin zero ink (their docstrings already promised the assertion
  would flip when the capability landed).
…gh SeparationBackend

The shipping per-plate walker resolved every paint op via the inline
tint_for_ink decision tree, which only handled Type-2 tint transforms
and didn't carry the §8.6.6.3 reserved-colorant-name rules forward
correctly. Route spot / DeviceN / ICCBased paint ops through the
resolution pipeline + SeparationBackend instead.

Resolver upgrades:

- ColorResolver emits ResolvedColor::Cmyk for genuine DeviceCMYK /
  ICCBased N=4 sources so the per-plate router has the four-channel
  decomposition for OPM=1 nonzero-overprint and the composite path
  projects to RGBA on demand.
- Separation / DeviceN sources whose alternate is DeviceCMYK keep
  the existing Rgba emission for composite; the per-plate routing
  is governed by the source colour space (not the alternate), via
  the OverprintPlan override below.
- Pipeline composer stamps OverprintPlan with InkSelector::{All, None}
  for the reserved Separation colorants; for non-reserved Separation
  spots it stamps spot_source = (name, tint) and alt_cmyk_fallback =
  evaluated alt CMYK (Type-2 closed form or Type-4 PostScript).
- For DeviceN it rewrites participating with (channel_name_i, tint_i)
  pairs so the router walks them directly. /None channels are dropped
  per §8.6.6.4.

SeparationBackend honours §8.6.6.3 conformance: when the surface
contains the spot's plate, route to it directly; when it doesn't,
build a per-call OverprintPlan that walks the alt-CMYK fallback so
the spec's 'approximate the colorant in the alternate space' path
reaches the standard plates.

Separation renderer walker: each Fill / Stroke / FillEvenOdd /
FillStroke / FillStrokeEvenOdd arm consults side_uses_pipeline(); for
spot / DeviceN / ICCBased compound spaces the arm dispatches through
the pipeline + SeparationBackend. DeviceCMYK / DeviceGray / DeviceRGB
direct sources keep the inline tint_for_ink path — the inline arms
are already correct for those.

fill_separation stays in place as the byte-for-byte parity reference
for SeparationBackend's equivalence test; the walker just stops
calling it directly for spot / DeviceN / ICCBased cases.

Text-side painting still uses tint_for_ink — text is a separate
non-trivial rasteriser path and the per-glyph PaintKind variant on
the pipeline is still scaffolding.
The wave-5 deferral pin asserted that a Type-4 Separation /MagentaSpot
at tint=1 left the Magenta plate at 0 (the inline tint_for_ink path
only resolved Type-2 transforms). With the separation walker routed
through the resolution pipeline, the Type-4 evaluator on the alt-CMYK
path produces CMYK(0, 1, 0, 0) and the §8.6.6.3 alt-CMYK fallback
hands the Magenta tint to the Magenta plate.

Rename qa_wave5_defer_separation_renderer_type4_spot_via_tint_for_ink
to qa_wave5_separation_renderer_type4_spot_paints_magenta_plate and
flip the assertion from 0 (deferred) to 255 (the bug-fixed value).
The fill_separation gray encoding is (tint * 255).round() at the
interior sample point, so 1.0 → 255 exactly.
…ility probes

The pre-wave-5 QA suites rendered each fixture twice (toggle off, toggle
on) and asserted byte-for-byte equality of the two pixmaps. After the
env-var toggle was removed in commit b97e678, both paths run the same
pipeline-backed code, so those assertions are structurally tautological.

Convert each off-vs-on assertion to a positive capability probe of what
the test's name claims should be visible in the output:

- Pixel-colour tests pin the expected RGB at sample coordinates (red
  glyph fill, blue glyph fill, cyan ICCBased CMYK, magenta DeviceCMYK
  image, near-black Indexed scn fallback, gray malformed Separation
  fallback).
- State-preservation tests pin that the operator stream actually paints
  the expected number of pixels (interleaved operator runs, repeated
  fill/stroke, multi-font Tj runs, q/Q colour restoration).
- Clip / mask / blend tests pin the inside-vs-outside paint contrast.
- Adversarial-input tests pin the no-panic + full-pixmap invariant
  (malformed Decode, too-short / too-long image streams, zero / huge
  dimensions, missing /C0 / /C1 / /Coords / /Function / /ColorSpace,
  unsupported shading types).
- Corpus PDFs pin no-panic + non-empty pixmap (simple.pdf, outline.pdf)
  or marks-present (hello_structure.pdf has text).
- The wall-clock perf test was an off-vs-on ratio check; now it pins
  an absolute 5-second ceiling on 1000 fills.

Around 74 off/on assertions removed; the same number of positive probes
either added new or strengthened an existing weak probe. Test names and
docstrings updated to reflect what each test now pins.

The wave 2 /None text-fill and wave 3 /None ImageMask probes (separately
flipped to assert zero ink in the §8.6.6.3 commit) are unchanged here.
…n mod

The blanket module-level allows were a migration scaffold. The
unused_imports allow covered re-exports from stages that didn't yet
have callers; the dead_code allow covered scaffold types kept in the
surface area for future backends. After the separation walker landed
on the pipeline + SeparationBackend in this branch, several re-exports
became unreferenced (BlendResolver, ClipResolver, ColorResolver,
OverprintResolver, the InkAction/InkRouter pair, the Plan/Channel
type families) and the imports themselves no longer need suppression.

Drop unused_imports entirely. Trim the convenience re-export list
to the production-consumed surface — PaintBackend (trait),
ResolutionContext, PaintIntent / PaintKind / PaintSide / DeviceColor /
LogicalColor (composer inputs), ResolutionPipeline, ResolvedColor /
ClipPlan / InkName (composer outputs the renderers consume),
SeparationBackend / SeparationSurface (the per-plate exit).

Keep the narrowed dead_code allow because the PaintBackend trait
surface still carries methods only the per-plate backend exercises
today — the composite backend is structured as inline helpers rather
than a Backend impl. Documented in the surviving comment so the next
audit knows what's still scaffolding.
Each of cms / cms_verify / crypto / sign_bytes / timestamp had a
`#![cfg(feature = "signatures")]` inner attr at the top of the file,
while `signatures/mod.rs` already gates the `mod` declaration with the
same predicate. Newer clippy flags this as
`clippy::duplicated_attributes` (denied under `-D warnings`), which
broke the WASM clippy lane:

    cargo clippy --target wasm32-unknown-unknown \
      --no-default-features --features wasm,rendering,barcodes --lib

The parent gate is the canonical guard; the inner one is the
redundant copy, so drop it.
Two issues only surfaced under the FIPS clippy lane
(`--no-default-features --features python,fips,icc --lib --tests`):

- `src/crypto/aws_lc_provider.rs` had the same redundant inner
  `#![cfg(feature = "fips")]` that the signatures submodules had —
  the `mod aws_lc_provider;` declaration in `src/crypto/mod.rs` is
  already gated, so the inner attr trips
  `clippy::duplicated_attributes`. Drop it.
- The `sign_bytes` test that probes hex-string syntax for /Reason,
  /Location, /Name was written as `tail_str.find("…").is_some()`
  and trips `clippy::search_is_some`. Switch to `.contains(…)`,
  which is what the lint suggests and reads more directly.
@RayVR RayVR force-pushed the refactor/renderer-resolution-pipeline branch from 17483ea to b337b02 Compare June 5, 2026 06:12
@RayVR RayVR changed the title Refactor/renderer resolution pipeline refactor: renderer resolution pipeline Jun 5, 2026
RayVR added 11 commits June 5, 2026 18:23
Rewrite operator-arm comments, helper docstrings, and short-circuit
narrative to describe the shipped pipeline path instead of the
toggle-on / toggle-off split that no longer exists. The off-path
parity language documented a code branch the migration removed, so
readers were left chasing a comparison the code never makes.
…resolution mod

Refresh the `color_override` and `render_tj_array` docstrings so they
describe the live colour-override threading instead of a pilot-toggle
gate. In the resolution module preamble, narrow the scaffolding note
that still referenced 'pilot composite paths' to the shipped composite
renderer; the historical sentence in the Status section explaining
that the env-var toggle was removed once parity stabilised stays as
archaeologist context.
…ehaviour

Across the four shading/path/text/ImageMask QA suites, replace
'toggle-on / toggle-off parity' narrative with descriptions of what
each probe pins on the shipped resolution pipeline. Comments and
docstrings only — no assertion, helper, or fixture changes. Test
identifiers that still encode 'toggle_parity' in their names are
left untouched as code identifiers.

Wave-5 suite is intentionally not touched: its toggle-removal probes
exist precisely to verify the env-var is inert, so the toggle
references there are accurate, not stale.
This reverts commit b337b02.
…r toggle is gone

The wave QA suites kept a process-wide Mutex<()> to serialise threads
around reads/writes of PDF_OXIDE_RESOLUTION_PIPELINE. The env var is no
longer consulted by the rendering path, so the lock guards nothing.

Removes the static, the four guard acquisitions, and the std::sync::Mutex
imports that the static was the sole consumer of (std::time::Instant
imports stay — still used by latency probes in waves 2/3/4).
…ipped capability

These 19 wave-3 and wave-4 test functions originally pinned
"toggle-off output == toggle-on output". With the resolution-pipeline
toggle removed, the parity claim is mechanically vacuous; the function
names now misdescribe what the bodies actually probe.

Each rename reflects the assertion the body still runs: visible-band
paints (ImageMask under rotation / mirror / negative-det CTM),
endpoint-colour clamping (axial reversed coords), background preservation
under clip, fill-colour propagation through nested Forms, and so on.

No call-site updates needed — every renamed function is a free-standing
#[test] picked up by harness discovery.
…to positive capability probes

Wave 2 / 3 / 4 still carried about 19 `assert_eq!(off, on)` calls left
over from the toggle era. With the toggle removed they compare a render
against itself, contributing zero signal beyond the positive probes each
test already runs.

For tests with an existing positive assertion (rightmost-position
ordering for Tc/Tw/Tz, assert_ne distinct renders for Tm/Td, centre-pixel
colour for Form-in-Form mask, all-white centre for inline-image gap
pins): drop the tautological pair, keep the probe that already verifies
what the test name promises.

For one test that previously only had an off=on assertion
(qa_standard_image_iccbased_n4_pass_through_byte_identical): add a
positive probe — the painted region's centre must not be page background,
proving the ICCBased N=4 image routes through render_image rather than
the mask branch.

Also collapses the dangling-/ColorSpace adversarial pin from two
identical no-panic calls to one (the test still pins the no-panic
invariant).
Docstrings, comments, and a section header across waves 1-4 still
referenced "off vs on parity", "toggle-off / toggle-on", "byte-identical
off vs on", and "parity invariant must hold". The toggle is gone; these
phrases now misdescribe what each test pins.

Each rewrite preserves the test name and assertions; only the prose
changes to describe the on-path capability the body actually probes
(e.g. "stroke /CA 0.5 must blend to faded red" instead of "off-vs-on
parity confirms alpha is folded identically").
…n docstring prose

Ten _parity-suffixed functions in the path / stroke / combo / Indexed /
Pattern probes still carried names promising a parity comparison that
no longer exists. Bodies have been single-render positive probes for a
while; the names lag.

Each rename describes what the body's assertion actually pins —
"renders_full_pixmap" / "blends_to_faded_red" / "produces_partial_coverage" /
"paints_miter_spike" / "paints_both_fill_and_stroke" /
"paints_only_inside_band" / "paints_centre_cyan" /
"degenerate_cs_does_not_crash" / "renders_marked_centre". Plus four
docstrings (three in the path probes, one in the shading unsupported-
type pass-through helper) rewritten to describe the single shipped
render path instead of referencing diverging "off and on" pixmaps.

Wave-5 retention is intentional — that suite documents the env-var
removal itself.
…n checks for platform robustness

Absolute thresholds on the non-dominant channels (e.g. `g < 80`,
`r < 100`) cross on Windows because subpixel positioning and the
standard-14 font-fallback path raise weak-AA-edge contributions on
the means returned by `average_ink_rgb`. The intent at every site is
"ink is dominantly red/blue/magenta"; encode that intent directly via
a dominance margin (R/B/G greater than the other channels by 60 for
the mean assertions, 50 for per-pixel coverage checks where the
counting threshold already absorbs single-pixel variance).

For the near-black single-pixel stroke-edge sample (Indexed `SCN`
fallback), dominance does not apply — widen the per-channel absolute
bound to `< 150` so AA blend toward the white background does not flip
the assertion while still pinning "darker than mid-gray".
…hipped capability

These tests no longer compare bytes between two pipeline paths — they
pin positive properties of the single shipped pipeline (substantial
ink, glyph colour dominance, magenta/red centre under image
pass-through, etc.). The `_byte_identical` suffix is a holdover from
the parity-era naming and now misleads readers about what the body
checks. Rename each one to describe the property it actually pins.
@RayVR
Copy link
Copy Markdown
Contributor Author

RayVR commented Jun 6, 2026

Heads up — the OutputIntent CMYK ICC follow-on is queued on top of this branch as a fork-internal stacked PR: RayVR#1.

Surfacing it as a concrete example of what this refactor enables. Wiring /OutputIntents so /DeviceCMYK paint renders through the document's press profile (closing the screen-vs-press colour divergence on saturated CMYK artwork, ISO 32000-1 §14.11.5) is 39 commits in 5 rounds, each adding one focused capability through the PaintBackend / ResolutionPipeline / ColorResolver shape this PR ships:

  • composite path consumes qcms-converted RGB via the embedded profile (§8.6.5.5)
  • per-plate routing inherits CMYK channels via a new ResolvedColor::IccCmyk dual-payload variant
  • page-level /DefaultCMYK / /DefaultRGB / /DefaultGray overrides slot in as one new precedence layer (§8.6.5.6)
  • /RI rendering-intent operator flows end-to-end (the operator was being parsed but never dispatched — latent bug surfaced and fixed)
  • ICC v2 + v4 + Transform-cache + a real-corpus regression sentry land as discrete commits

Pre-refactor, the same work would have duplicated the precedence rule across page_renderer.rs's inline arms and separation_renderer.rs's per-plate walker, plus reimplemented embedded-ICC handling in both places. With this PR's pipeline in place, the OutputIntent dispatch lands in one ColorResolver arm and downstream consumers (composite + plate routing) come along for free.

No urgency intended on this review — just wanted to make the substrate value visible while it's in flight.

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