refactor: renderer resolution pipeline#649
Conversation
…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).
…d text/path, multi-font
…clip-add modes 4-7
…/Td plus Tz+TJ-kern bug pin
…and-in, built-in, ToUnicode
…, q/Q, smask, blend, clip
…space, extreme TJ offsets, all-numeric TJ
…e-per-Tj invariant
…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.
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.
17483ea to
b337b02
Compare
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.
|
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
Pre-refactor, the same work would have duplicated the precedence rule across No urgency intended on this review — just wanted to make the substrate value visible while it's in flight. |
Description
Replaces the inline, copy-and-edit paint-resolution arms in
page_renderer.rsandseparation_renderer.rswith a single layered resolution pipeline owned by a newsrc/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//DeviceNspot colours overDeviceCMYKorICCBasedalternates) silently fell through to the1.0 - tintgreyscale fallback atpage_renderer.rs:690, so spot artwork that should have come out magenta came out grey or black.ICCBasedcolour spaces withN = 4(the common CMYK case) had their last channel truncated.DeviceNwith multiple Type 4 colorants behaved the same way. Three shading bugs sat dormant in the inline arms:/Domainwas ignored,/Extendwas 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::functionshad a full PostScript calculator,crate::colorhad aqcmsICC pipeline, andcrate::document::output_intent_cmyk_profilealready shipped — they were capabilities the inline arms never consumed.The migration introduces:
Both renderers now route through
ResolutionPipeline::resolvefor 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 (DoforImageandImageMask), and shading paint (sh). The composite RGB renderer inpage_renderer.rscalls into the composite backend; the per-plate walker inseparation_renderer.rsdispatches/Separation,/DeviceN, and/ICCBasedcompound spaces throughSeparationBackendwhile keepingDeviceCMYK/DeviceGray/DeviceRGBdirect sources on the existingtint_for_inkfast 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
Related Issues
1.0 - tintgreyscale fallback inpage_renderer.rs:690for Type 4 PostScript tint transforms.ICCBasedN=4 truncation on the composite RGB path.DeviceNmulti-colorant Type 4 fallback on the composite RGB path./Coordsnon-concentric handling,/Domainextraction, and/Extendasymmetric clipping.Changes Made
src/rendering/resolution/— new module (~4,000 lines, 13 files)mod.rs— module entry,dead_code/unused_importsallow narrowed once production callers wired through walker integration. Status documentation describes the shipped pipeline shape and the capabilities each stage closes.intent.rs—PaintIntent/PaintKind/LogicalColor/DeviceColor/PaintSide. Operator dispatch's input shape: logical colour, graphics-state borrow, path / glyph / image kind, clip refs.PaintKind::ColorOnlylets the pipeline run for capabilities that consult colour but don't paint geometry (used by the separation walker for the resolution-only path).pipeline.rs—ResolutionPipeline::resolvecomposes the five stages, pluspipeline_resolve_paint_gs/pipeline_resolve_text_colors/pipeline_resolve_componentsentry points used by the renderers' different call sites. Shared core helperrun_pipeline_for_logicalis the single point that drives the stage composition for both gs-bound and gs-free callers.color.rs—ColorResolverconsumesLogicalColorand returnsResolvedColor. Handles tint transforms (Type 2 exponential, Type 4 PostScript calculator) for/Separationand/DeviceNcolorants over any supported alternate.ICCBasedflows throughqcmsfor N=1/3/4 sources.Indexedlooks up the base, then recurses. FoldsDeviceCMYKto 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.rs—OverprintResolverreads/OP,/op,/OPMfromParsedExtGStateand emits anOverprintPlanwith per-channel masks. Handles the §11.7.4 OPM=1 nonzero rule.blend.rs—BlendResolverclassifies aBlendModeas native (tiny_skia variant) vs simulated.clip.rs—ClipResolvercomposes the active clip stack into a single mask reference.ink.rs—InkRoutertranslates aResolvedColorplus channel descriptors intoInkAction::{Paint(tint), Skip}per plate. Honors/All(paints every plate at one tint) and/None(paints no plates) per §8.6.6.3. NewInkSelectorenum onOverprintPlancarriesListed/All/Nonewithall_tint: f32andspot_source: Option<SpotSource>so the composer stamps these from the source colour space rather than per-route guessing.backend.rs—PaintBackendtrait. Composite backend lives in the renderers' existing tiny_skia surfaces; separation backend ships here.separation_backend.rs—SeparationBackendandSeparationSurface. Implements the per-platePaintBackendoperations against a multi-plate pixmap target. Byte-for-byte equivalence with the inlinefill_separationpath is unit-tested across three input shapes (CMYK Cyan-only, mixed CMYK at(0.5, 0.25, 0, 0.7), rotated-CTM rect).resolved.rs—ResolvedPaintCmd,ResolvedColor,BlendPlan,ClipPlan,OverprintPlan,ParticipatingChannel,InkName. Backend-agnostic, fully evaluated shapes.context.rs—ResolutionContextborrows 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 pipelinePath-fill / stroke / fill-stroke combos, text-painting operators, image
Do(includingImageMask), and shadingshall build aPaintIntent, hand it toResolutionPipeline::resolve, and apply the resultingResolvedPaintCmdthrough 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 throughSeparationBackendexecute_separation_operatorsFill / Stroke / FillEvenOdd / FillStroke / CloseFillStroke / FillStrokeEvenOdd arms each checkside_uses_pipeline(fill, gs, color_spaces, resources, doc)and, when the colour space is/Separation,/DeviceN, or/ICCBasedcompound, dispatch throughResolutionPipeline::resolve+SeparationBackend::paint.DeviceCMYK,DeviceGray, andDeviceRGBdirect sources stay on the inlinetint_for_inkfast path — they don't need the pipeline because the inline path was already correct for direct device colour. Text-sidetint_for_inkcalls 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)
/Coordsnon-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 consumex0/y0/r0as specified./Domainextraction: the inline path always evaluated the shading function att = 0andt = 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.rs—SeparationBackendImplements the per-plate
PaintBackendoperations. Same per-plate routing decision astint_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 /DeviceCMYKwith a Type 4 tint transform now writes255to the Magenta plate at peak, where the inline path wrote0.src/content/graphics_state.rs— minorAdds 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}.rs165 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//Nonecolorant-name pins and the SeparationBackend byte-for-byte equivalence withfill_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
mainthat became hard errors under the tighter local verification:a358921— WASM clippyduplicated_attributes×5 acrosssrc/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 clippyduplicated_attributesinsrc/crypto/aws_lc_provider.rsand threeclippy::search_is_someinsrc/signatures/sign_bytes.rs:956-958(.find(...).is_some()→.contains(...)). Same pattern as a358921 plus a small assertion idiom cleanup.17483ea—src/extractors/images.rs:1452-1456CMYK 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/mainarchaeology); they surface here because this PR's pre-push verification ran more CI gates locally than upstreammainhas had to satisfy.Testing
cargo test --all-featurescargo clippy -- -D warningscargo fmtFull CI matrix verified locally (every gate from
.github/workflows/{ci,php,ruby,python}.yml):cargo fmt -- --check(workspace)make c-header-check(cbindgen 0.29.2)cargo clippy --all-targets --workspace -- -D warnings(default features)cargo doc --no-deps --workspacewith-D warningscargo build --no-default-featurescargo build --no-default-features --features pythoncargo build --verbose(default features)cargo test --verbose(default features, full integration suite)cargo build --examples+ tutorial smoke-runcargo build -p pdf_oxide_cli+--helpcargo build -p pdf_oxide_mcpwasm32-unknown-unknownbuild + clippy +wasm32-wasip1buildmain, not introduced here)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)
ruff formatruff checkDocumentation
The new module's
src/rendering/resolution/mod.rsships 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
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:
Architectural foundation — start with
src/rendering/resolution/mod.rs. The module-level docstring describes the stage decomposition and why each stage exists. Then walkintent.rs→pipeline.rs→color.rsto see one full resolution path.By capability migration step — the 82 commits group naturally:
page_renderer.rs.Tj,TJ,',"): text-side pipeline integration including thepipeline_resolve_text_colorshelper.Do(includingImageMask): image-side pipeline integration; closes the silent-drop extractor bug.sh(axial / radial / function-based): shading-side pipeline integration plus the three pre-existing shading bug fixes.SeparationBackendintroduction, separation walker wiring.The fix-pass commits at the head of the branch (
386097f…53da7e1) collect the closing capabilities surfaced during review and QA: byte-for-byteSeparationBackend↔fill_separationparity,/All//Noneper §8.6.6.3, the docstring rewrite, the 74 parity-assertion → positive-probe conversions across the test suite, and thedead_code/unused_importsnarrowing now that the SeparationBackend has production callers.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 returning0for 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:
ColorResolver—PdfDocument::output_intent_cmyk_profile()already ships; wiring it throughResolutionContextintoColorResolver::cmyk_to_rgbis a small follow-on that closes the press-vs-screen colour divergence on/DeviceCMYKpaint commands. The architecture leaves a cleanOption<Arc<IccProfile>>slot for it.SeparationBackendhas production callers,/Separationoverprint 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.tint_for_inkcalls 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./Separation//DeviceCMYKtint transform — once inColorResolverfor the composite RGBA, once ineval_separation_alt_cmykfor the per-plate fallback. Known optimisation opportunity; deferred to avoid changing the resolver's public surface in this PR.Known non-regressions (pre-existing on
main)python/tests/test_api_coverage.py::TestHtmlCssCreation(test_from_html_css_content_extractable,test_css_font_size_reflected_in_extracted_chars) fail undercargo run -p maturin build && uv run pytest. Reproduced onupstream/main(commit 2196dea). Not introduced by this branch (diff stat shows zero edits tosrc/writer/,src/html/, orsrc/text/).unknown lint: clippy::manual_checked_opswarning sprays fromCargo.toml:57on every clippy run. Cleanup candidate, not a blocker.