Skip to main content

stet_render/
skia_device.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! tiny-skia implementation of the `OutputDevice` trait.
6
7use std::collections::{HashMap, HashSet};
8use std::hash::{Hash, Hasher};
9use std::sync::{Arc, Mutex};
10
11#[cfg(feature = "parallel")]
12use rayon::prelude::*;
13use stet_tiny_skia::{
14    BlendMode, Color, FillRule as SkiaFillRule, LineCap as SkiaLineCap, LineJoin as SkiaLineJoin,
15    Mask, Paint, PathBuilder, Pixmap, Stroke, StrokeDash, Transform,
16};
17
18#[cfg(feature = "ps-device")]
19use stet_core::device::OutputDevice;
20use stet_fonts::geometry::{Matrix, PathSegment, PsPath};
21use stet_graphics::color::{DeviceColor, FillRule, LineCap, LineJoin};
22#[cfg(feature = "ps-device")]
23use stet_graphics::device::PageSinkFactory;
24use stet_graphics::device::{
25    AxialShadingParams, ClipParams, FillParams, ImageColorSpace, ImageParams, MeshShadingParams,
26    PatchShadingParams, RadialShadingParams, ShadingColorSpace, ShadingVertex, StrokeParams,
27    TintLookupTable,
28};
29use stet_graphics::icc::IccCache;
30use stet_graphics::layer_set::LayerSet;
31
32/// Axis-aligned rectangle in device pixel coordinates.
33#[derive(Clone, Copy)]
34struct ClipRect {
35    x0: u32,
36    y0: u32, // top-left (inclusive)
37    x1: u32,
38    y1: u32, // bottom-right (exclusive)
39}
40
41impl ClipRect {
42    /// Intersect two rectangles. Result may be empty.
43    fn intersect(&self, other: &ClipRect) -> ClipRect {
44        ClipRect {
45            x0: self.x0.max(other.x0),
46            y0: self.y0.max(other.y0),
47            x1: self.x1.min(other.x1),
48            y1: self.y1.min(other.y1),
49        }
50    }
51
52    fn is_empty(&self) -> bool {
53        self.x0 >= self.x1 || self.y0 >= self.y1
54    }
55
56    /// True if this rect covers the entire page.
57    fn is_full_page(&self, w: u32, h: u32) -> bool {
58        self.x0 == 0 && self.y0 == 0 && self.x1 == w && self.y1 == h
59    }
60
61    /// Create a mask with 255 inside the rect, 0 outside.
62    fn make_mask(self, w: u32, h: u32) -> Option<Mask> {
63        if self.is_empty() {
64            return None;
65        }
66        let mut mask = Mask::new(w, h)?;
67        let data = mask.data_mut();
68        let stride = w as usize;
69        for y in self.y0..self.y1 {
70            let row_start = y as usize * stride + self.x0 as usize;
71            let row_end = y as usize * stride + self.x1 as usize;
72            data[row_start..row_end].fill(255);
73        }
74        Some(mask)
75    }
76}
77
78/// Clip region: either a simple rectangle (fast) or a full rasterized mask.
79enum ClipRegion {
80    Rect(ClipRect),
81    Mask(Mask),
82}
83
84/// tiny-skia based raster device.
85// `SkiaDevice` exists only to be driven by the PostScript interpreter
86// through `OutputDevice`; the free rendering entry points work straight
87// from a `DisplayList` and never touch it. Gated with the trait impl so a
88// consumer that only rasterizes drops `stet-core` entirely.
89#[cfg(feature = "ps-device")]
90pub struct SkiaDevice {
91    pixmap: Pixmap,
92    /// Page dimensions in device pixels. Stored separately so we can shrink
93    /// the pixmap during banded rendering without losing page size info.
94    page_w: u32,
95    page_h: u32,
96    /// Device resolution in DPI (for hairline width decisions).
97    dpi: f64,
98    clip_region: Option<ClipRegion>,
99    /// Cache of rasterized clip masks keyed by path hash.
100    /// Only paths seen more than once are cached (cache-on-second-sight).
101    clip_mask_cache: HashMap<u64, Mask>,
102    clip_mask_seen: HashSet<u64>,
103    /// Recycled mask buffer to avoid repeated alloc/dealloc of large masks.
104    spare_mask: Option<Mask>,
105    /// Receiver for background render result (pipelined multi-page rendering).
106    /// Uses rayon::spawn + oneshot channel to avoid OS thread spawn overhead.
107    pending_render: Option<std::sync::mpsc::Receiver<Result<(), String>>>,
108    /// Factory for creating page sinks (PNG, viewer, etc.).
109    sink_factory: Box<dyn PageSinkFactory>,
110    /// Raw bytes of the system CMYK ICC profile (for building render-thread IccCaches).
111    system_cmyk_bytes: Option<std::sync::Arc<Vec<u8>>>,
112    /// Transient IccCache used during non-banded replay_to_device rendering.
113    render_icc_cache: Option<IccCache>,
114    /// Disable anti-aliasing for all fill/stroke operations (matches GhostScript).
115    no_aa: bool,
116    /// Route `replay_and_show` through the viewport code path instead of the
117    /// banded full-page path. Used by `--device viewport-png` to audit the
118    /// viewport pipeline against the banded PNG baselines — same display list,
119    /// different culling/epoch logic, same expected output.
120    use_viewport_path: bool,
121    /// OCG visibility overrides applied to every render that consults
122    /// the layer system. Defaults to empty (every layer falls back to
123    /// its `default_visible`); a consumer building a layer panel can
124    /// install an explicit set via `set_layer_set`.
125    layer_set: LayerSet,
126}
127
128#[cfg(feature = "ps-device")]
129impl SkiaDevice {
130    /// Create a new device with the given page dimensions and default PNG output.
131    ///
132    /// Defers the full-page pixmap allocation — only a 1×1 placeholder is
133    /// created here. The full pixmap is allocated lazily in `replay_and_show`
134    /// only when the non-banded rendering path is needed.
135    pub fn new(width: u32, height: u32) -> Self {
136        Self::with_sink_factory(width, height, Box::new(crate::PngSinkFactory))
137    }
138
139    /// Create a new device with a custom page sink factory.
140    pub fn with_sink_factory(
141        width: u32,
142        height: u32,
143        sink_factory: Box<dyn PageSinkFactory>,
144    ) -> Self {
145        // Only the lower bound is enforced here, and deliberately so.
146        //
147        // These dimensions are `page_points * dpi / 72`, and the two factors
148        // have different provenance: the points come from the file and are
149        // untrusted, but the DPI is the caller's explicit request. Capping the
150        // product punishes the caller for the file's exaggeration — a 1200 dpi
151        // prepress proof of a large-format page is a legitimate gigapixel
152        // render, and refusing it is worse than the attack it prevents. The
153        // page size is bounded upstream, in points, where the untrusted value
154        // actually enters (see `MAX_PAGE_SIZE_POINTS`).
155        //
156        // Zero, on the other hand, is never meaningful: `Pixmap::new` returns
157        // `None` for a zero dimension and the call below used to `.expect()`
158        // on it, so `<< /PageSize [-1 -1] >> setpagedevice` panicked the
159        // renderer outright.
160        let width = width.max(1);
161        let height = height.max(1);
162        // Estimate DPI from page height (assumes ~792pt US Letter as reference).
163        // Close enough for hairline width threshold decisions.
164        let dpi = height as f64 * 72.0 / 792.0;
165
166        // Start with a tiny placeholder. The full-page pixmap is allocated
167        // lazily only when the non-banded path is used (small pages / low DPI).
168        // For banded rendering, band-sized pixmaps are created in replay_and_show.
169        let pixmap = Pixmap::new(1, 1).expect("Failed to create placeholder pixmap");
170        Self {
171            pixmap,
172            page_w: width,
173            page_h: height,
174            dpi,
175            clip_region: None,
176            clip_mask_cache: HashMap::new(),
177            clip_mask_seen: HashSet::new(),
178            spare_mask: None,
179            pending_render: None,
180            sink_factory,
181            system_cmyk_bytes: None,
182            render_icc_cache: None,
183            no_aa: false,
184            use_viewport_path: false,
185            layer_set: LayerSet::new(),
186        }
187    }
188
189    /// Route rendering through the viewport pipeline. Used by the visual
190    /// test runner's `--device viewport-png` mode.
191    pub fn set_use_viewport_path(&mut self, on: bool) {
192        self.use_viewport_path = on;
193    }
194
195    /// Replace the device's OCG visibility overrides.
196    ///
197    /// The empty default has every layer fall back to its
198    /// `default_visible` baked into the display list. Callers building
199    /// a layer panel hand in a populated [`LayerSet`] each render
200    /// pass.
201    pub fn set_layer_set(&mut self, layer_set: LayerSet) {
202        self.layer_set = layer_set;
203    }
204
205    /// Read-only view of the device's current OCG visibility overrides.
206    pub fn layer_set(&self) -> &LayerSet {
207        &self.layer_set
208    }
209
210    /// Ensure `self.pixmap` is allocated at full page dimensions.
211    /// Called before non-banded rendering which operates on the full pixmap.
212    fn ensure_full_pixmap(&mut self) {
213        if self.pixmap.width() != self.page_w || self.pixmap.height() != self.page_h {
214            // Dimensions are clamped at construction, so this only fails when
215            // the allocation itself does — a page large enough to exhaust
216            // memory. Keep the existing pixmap and carry on: the page renders
217            // wrong, which is what a page that size was always going to do,
218            // rather than taking the process down.
219            let Some(pixmap) = Pixmap::new(self.page_w, self.page_h) else {
220                eprintln!(
221                    "Warning: could not allocate a {}x{} page pixmap; \
222                     rendering into the existing {}x{} buffer instead",
223                    self.page_w,
224                    self.page_h,
225                    self.pixmap.width(),
226                    self.pixmap.height()
227                );
228                return;
229            };
230            self.pixmap = pixmap;
231            self.pixmap.fill(Color::WHITE);
232        }
233    }
234
235    /// Get the underlying pixmap (for testing).
236    pub fn pixmap(&self) -> &Pixmap {
237        &self.pixmap
238    }
239
240    /// Set the system CMYK ICC profile bytes for ICC-aware rendering.
241    pub fn set_system_cmyk_bytes(&mut self, bytes: std::sync::Arc<Vec<u8>>) {
242        self.system_cmyk_bytes = Some(bytes);
243    }
244
245    /// Disable anti-aliasing for all fill/stroke operations.
246    pub fn set_no_aa(&mut self, no_aa: bool) {
247        self.no_aa = no_aa;
248    }
249}
250
251/// Convert a PostScript `Matrix` to tiny-skia `Transform` (f32).
252fn to_transform(m: &Matrix) -> Transform {
253    Transform::from_row(
254        m.a as f32,
255        m.b as f32,
256        m.c as f32,
257        m.d as f32,
258        m.tx as f32,
259        m.ty as f32,
260    )
261}
262
263/// Convert a `DeviceColor` to tiny-skia `Paint`.
264fn to_paint(color: &DeviceColor) -> Paint<'static> {
265    to_paint_alpha(color, 1.0, 0, false)
266}
267
268/// Convert a `DeviceColor` to tiny-skia `Paint` with the given opacity and blend mode.
269fn to_paint_alpha(color: &DeviceColor, alpha: f64, blend_mode: u8, no_aa: bool) -> Paint<'static> {
270    let mut paint = Paint::default();
271    let a = (alpha * 255.0).round().clamp(0.0, 255.0) as u8;
272    paint.set_color_rgba8(
273        (color.r * 255.0).round().clamp(0.0, 255.0) as u8,
274        (color.g * 255.0).round().clamp(0.0, 255.0) as u8,
275        (color.b * 255.0).round().clamp(0.0, 255.0) as u8,
276        a,
277    );
278    paint.anti_alias = !no_aa;
279    paint.blend_mode = u8_to_blend_mode(blend_mode);
280    paint
281}
282
283/// Map a blend mode byte (0–15) to the corresponding tiny-skia `BlendMode`.
284fn u8_to_blend_mode(mode: u8) -> BlendMode {
285    match mode {
286        1 => BlendMode::Multiply,
287        2 => BlendMode::Screen,
288        3 => BlendMode::Overlay,
289        4 => BlendMode::Darken,
290        5 => BlendMode::Lighten,
291        6 => BlendMode::ColorDodge,
292        7 => BlendMode::ColorBurn,
293        8 => BlendMode::HardLight,
294        9 => BlendMode::SoftLight,
295        10 => BlendMode::Difference,
296        11 => BlendMode::Exclusion,
297        12 => BlendMode::Hue,
298        13 => BlendMode::Saturation,
299        14 => BlendMode::Color,
300        15 => BlendMode::Luminosity,
301        _ => BlendMode::SourceOver,
302    }
303}
304
305/// Convert a `PsPath` to tiny-skia `Path`.
306/// Maximum coordinate magnitude for path rasterization.
307/// Coordinates beyond this cause integer overflow in the scanline rasterizer.
308/// 1e6 is well beyond any real page (e.g. 612×792 pt at 600 DPI = ~5100×6600 px)
309/// but safely within f32 precision and fixed-point limits.
310const MAX_PATH_COORD: f32 = 1e6;
311
312fn build_skia_path(path: &PsPath) -> Option<stet_tiny_skia::Path> {
313    let mut pb = PathBuilder::new();
314
315    for seg in &path.segments {
316        match seg {
317            PathSegment::MoveTo(x, y) => {
318                pb.move_to(*x as f32, *y as f32);
319            }
320            PathSegment::LineTo(x, y) => {
321                pb.line_to(*x as f32, *y as f32);
322            }
323            PathSegment::CurveTo {
324                x1,
325                y1,
326                x2,
327                y2,
328                x3,
329                y3,
330            } => {
331                pb.cubic_to(
332                    *x1 as f32, *y1 as f32, *x2 as f32, *y2 as f32, *x3 as f32, *y3 as f32,
333                );
334            }
335            PathSegment::ClosePath => {
336                pb.close();
337            }
338        }
339    }
340
341    let result = pb.finish()?;
342
343    // Reject paths with extreme coordinates that would overflow the scanline
344    // rasterizer's integer math. This handles corrupted PDF content streams
345    // with garbled coordinates.
346    let b = result.bounds();
347    if b.left().abs() > MAX_PATH_COORD
348        || b.top().abs() > MAX_PATH_COORD
349        || b.right().abs() > MAX_PATH_COORD
350        || b.bottom().abs() > MAX_PATH_COORD
351    {
352        return None;
353    }
354
355    Some(result)
356}
357
358/// Detect degenerate fill paths that have zero extent in one dimension.
359///
360/// PDFs commonly draw table grid lines as zero-width or zero-height filled
361/// rectangles (e.g., `8 0 1031 0 re f`). Since these have no area, the
362/// fill rasterizer produces zero pixels. This function detects such paths
363/// so they can be rendered as hairline strokes instead.
364///
365/// The check is performed in the path's own coordinate space (pre-transform)
366/// using a very tight epsilon, so only paths with *exactly* zero extent in
367/// one dimension are detected. Paths containing curves are never degenerate
368/// — only MoveTo/LineTo/ClosePath segments qualify.
369fn is_degenerate_fill(path: &PsPath) -> bool {
370    let mut x_min = f64::INFINITY;
371    let mut x_max = f64::NEG_INFINITY;
372    let mut y_min = f64::INFINITY;
373    let mut y_max = f64::NEG_INFINITY;
374
375    for seg in &path.segments {
376        let (x, y) = match seg {
377            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => (*x, *y),
378            // Paths with curves are real shapes, not degenerate lines
379            PathSegment::CurveTo { .. } => return false,
380            PathSegment::ClosePath => continue,
381        };
382        x_min = x_min.min(x);
383        x_max = x_max.max(x);
384        y_min = y_min.min(y);
385        y_max = y_max.max(y);
386    }
387
388    if x_min > x_max {
389        return false; // empty path
390    }
391
392    let w = x_max - x_min;
393    let h = y_max - y_min;
394
395    // Degenerate if one dimension is exactly zero (within f64 epsilon)
396    // while the other has real extent. This catches `re` rects with
397    // zero width or height but not legitimate small shapes.
398    let eps = 1e-6;
399    (w < eps && h > eps) || (h < eps && w > eps)
400}
401
402/// Convert a tiny-skia Path back to a PsPath.
403/// Used for overprint stroke handling where we convert a stroked outline to a fill.
404
405/// Convert PostScript FillRule to tiny-skia FillRule.
406fn to_fill_rule(rule: &FillRule) -> SkiaFillRule {
407    match rule {
408        FillRule::NonZeroWinding => SkiaFillRule::Winding,
409        FillRule::EvenOdd => SkiaFillRule::EvenOdd,
410        _ => SkiaFillRule::Winding,
411    }
412}
413
414/// Convert PostScript LineCap to tiny-skia LineCap.
415fn to_line_cap(cap: LineCap) -> SkiaLineCap {
416    match cap {
417        LineCap::Butt => SkiaLineCap::Butt,
418        LineCap::Round => SkiaLineCap::Round,
419        LineCap::Square => SkiaLineCap::Square,
420        _ => SkiaLineCap::Butt,
421    }
422}
423
424/// Convert PostScript LineJoin to tiny-skia LineJoin.
425fn to_line_join(join: LineJoin) -> SkiaLineJoin {
426    match join {
427        LineJoin::Miter => SkiaLineJoin::Miter,
428        LineJoin::Round => SkiaLineJoin::Round,
429        LineJoin::Bevel => SkiaLineJoin::Bevel,
430        _ => SkiaLineJoin::Miter,
431    }
432}
433
434/// Detect if a path is an axis-aligned rectangle. Returns pixel-coordinate ClipRect if so.
435/// Handles both CW and CCW winding, with optional trailing ClosePath.
436fn detect_rect(path: &PsPath, page_w: u32, page_h: u32) -> Option<ClipRect> {
437    let segs = &path.segments;
438    // Expect: MoveTo + 3 LineTo + ClosePath (5 segments)
439    // or MoveTo + 3 LineTo + LineTo(back to start) + ClosePath (6 segments)
440    // or MoveTo + 3 LineTo (4 segments, implicitly closed)
441    let (move_to, lines, _has_close) = match segs.len() {
442        5 => {
443            // MoveTo + 3 LineTo + ClosePath
444            if !matches!(segs[4], PathSegment::ClosePath) {
445                return None;
446            }
447            (&segs[0], &segs[1..4], true)
448        }
449        6 => {
450            // MoveTo + 4 LineTo + ClosePath (4th LineTo returns to start)
451            if !matches!(segs[5], PathSegment::ClosePath) {
452                return None;
453            }
454            (&segs[0], &segs[1..5], true)
455        }
456        4 => {
457            // MoveTo + 3 LineTo (no explicit close)
458            (&segs[0], &segs[1..4], false)
459        }
460        _ => return None,
461    };
462
463    let PathSegment::MoveTo(mx, my) = move_to else {
464        return None;
465    };
466
467    // Collect all corner points
468    let mut pts = vec![(*mx, *my)];
469    for seg in lines {
470        match seg {
471            PathSegment::LineTo(x, y) => pts.push((*x, *y)),
472            _ => return None,
473        }
474    }
475
476    // If 5 points (4 LineTos), last must return to start
477    if pts.len() == 5 {
478        let (fx, fy) = pts[0];
479        let (lx, ly) = pts[4];
480        if (fx - lx).abs() > 0.01 || (fy - ly).abs() > 0.01 {
481            return None;
482        }
483        pts.truncate(4);
484    }
485
486    // Check axis-aligned: each edge must be horizontal or vertical
487    for i in 0..4 {
488        let (x1, y1) = pts[i];
489        let (x2, y2) = pts[(i + 1) % 4];
490        let dx = (x2 - x1).abs();
491        let dy = (y2 - y1).abs();
492        if dx > 0.01 && dy > 0.01 {
493            return None; // diagonal edge
494        }
495    }
496
497    // Compute bounding box
498    let min_x = pts.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
499    let min_y = pts.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
500    let max_x = pts.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max);
501    let max_y = pts.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
502
503    // Convert to pixel coords: floor for top-left, ceil for bottom-right, clamp to page
504    let x0 = (min_x.floor().max(0.0) as u32).min(page_w);
505    let y0 = (min_y.floor().max(0.0) as u32).min(page_h);
506    let x1 = (max_x.ceil().max(0.0) as u32).min(page_w);
507    let y1 = (max_y.ceil().max(0.0) as u32).min(page_h);
508
509    Some(ClipRect { x0, y0, x1, y1 })
510}
511
512/// Zero out mask pixels outside the given rectangle bounds.
513fn intersect_mask_with_rect(mask: &mut Mask, rect: &ClipRect, w: u32, h: u32) {
514    let data = mask.data_mut();
515    let stride = w as usize;
516
517    // Zero rows above rect
518    if rect.y0 > 0 {
519        let end = (rect.y0 as usize * stride).min(data.len());
520        data[..end].fill(0);
521    }
522
523    // Zero rows below rect
524    if rect.y1 < h {
525        let start = (rect.y1 as usize * stride).min(data.len());
526        data[start..].fill(0);
527    }
528
529    // Zero left and right margins within rect rows
530    for y in rect.y0..rect.y1.min(h) {
531        let row_start = y as usize * stride;
532        // Left margin
533        if rect.x0 > 0 {
534            let end = row_start + rect.x0 as usize;
535            data[row_start..end].fill(0);
536        }
537        // Right margin
538        if rect.x1 < w {
539            let start = row_start + rect.x1 as usize;
540            let end = row_start + stride;
541            data[start..end].fill(0);
542        }
543    }
544}
545
546/// Resolve a ClipRegion to an Option<&Mask> for paint operations.
547/// Returns `None` if the clip is empty (caller should skip painting).
548/// Returns `Some(None)` if no mask is needed (full page or no clip).
549/// Returns `Some(Some(&Mask))` if a mask should be applied.
550fn resolve_clip_mask<'a>(
551    clip_region: &'a Option<ClipRegion>,
552    temp_mask: &'a mut Option<Mask>,
553    w: u32,
554    h: u32,
555) -> Option<Option<&'a Mask>> {
556    match clip_region {
557        None => Some(None),
558        Some(ClipRegion::Mask(m)) => Some(Some(m)),
559        Some(ClipRegion::Rect(rect)) => {
560            if rect.is_empty() {
561                return None; // empty clip → skip painting
562            }
563            if rect.is_full_page(w, h) {
564                return Some(None); // full page → no mask needed
565            }
566            *temp_mask = rect.make_mask(w, h);
567            Some(temp_mask.as_ref())
568        }
569    }
570}
571
572/// Hash a PsPath's segments for clip mask caching. Uses bit-exact f64 comparison
573/// since paths are already in device space.
574fn hash_clip_path(path: &PsPath, fill_rule: &FillRule) -> u64 {
575    let mut hasher = std::collections::hash_map::DefaultHasher::new();
576    std::mem::discriminant(fill_rule).hash(&mut hasher);
577    for seg in &path.segments {
578        match seg {
579            PathSegment::MoveTo(x, y) => {
580                0u8.hash(&mut hasher);
581                x.to_bits().hash(&mut hasher);
582                y.to_bits().hash(&mut hasher);
583            }
584            PathSegment::LineTo(x, y) => {
585                1u8.hash(&mut hasher);
586                x.to_bits().hash(&mut hasher);
587                y.to_bits().hash(&mut hasher);
588            }
589            PathSegment::CurveTo {
590                x1,
591                y1,
592                x2,
593                y2,
594                x3,
595                y3,
596            } => {
597                2u8.hash(&mut hasher);
598                x1.to_bits().hash(&mut hasher);
599                y1.to_bits().hash(&mut hasher);
600                x2.to_bits().hash(&mut hasher);
601                y2.to_bits().hash(&mut hasher);
602                x3.to_bits().hash(&mut hasher);
603                y3.to_bits().hash(&mut hasher);
604            }
605            PathSegment::ClosePath => {
606                3u8.hash(&mut hasher);
607            }
608        }
609    }
610    hasher.finish()
611}
612
613/// Pixel-multiply two masks: dst[i] = dst[i] * src[i] / 255.
614fn intersect_masks(dst: &mut Mask, src: &Mask) {
615    let dst_data = dst.data_mut();
616    let src_data = src.data();
617    for (d, s) in dst_data.iter_mut().zip(src_data.iter()) {
618        *d = ((*d as u16 * *s as u16 + 127) / 255) as u8;
619    }
620}
621
622// ---- Banded rendering support ----
623
624use stet_graphics::display_list::{DisplayElement, DisplayList};
625
626/// Band-local clip state, rebuilt for each band.
627struct BandState {
628    clip_region: Option<ClipRegion>,
629    spare_mask: Option<Mask>,
630    /// Per-band cache (cleared each band since masks are band-sized).
631    clip_mask_cache: HashMap<u64, Mask>,
632    /// Persists across bands for cache-on-second-sight.
633    clip_mask_seen: HashSet<u64>,
634    /// Pool of recycled masks to avoid alloc/dealloc (mmap/munmap) per band.
635    mask_pool: Vec<Mask>,
636    /// Per-pixel CMYK tracking buffer for overprint simulation.
637    /// Only allocated when the display list contains overprint elements.
638    /// Layout: [C, M, Y, K] as f32 per pixel, band_w * band_h * 4 entries.
639    cmyk_buffer: Option<Vec<f32>>,
640    /// Per-pixel snapshot of pixmap RGBA *before* the first overprint paint
641    /// touched that pixel in this band. Subsequent overprint paints at the
642    /// same pixel blend their result against this snapshot instead of the
643    /// current (already-overprinted) pixmap, so AA edges of stacked overprints
644    /// do not leak earlier colour through later paints.
645    /// Lazily allocated on first overprint paint. 4 bytes per pixel.
646    op_bg_snapshot: Option<Vec<u8>>,
647    /// Parallel to `op_bg_snapshot`: 1 byte per pixel, non-zero iff the
648    /// snapshot for that pixel has been captured. Reset to zero over the
649    /// paint bbox on non-overprint writes so a later non-overprint fill
650    /// establishes a fresh backdrop for subsequent overprints.
651    op_touched: Option<Vec<u8>>,
652    /// Per-pixel marker for "this pixel's pixmap colour includes spot-
653    /// colorant contribution not reflected in `cmyk_buffer`". Set by
654    /// DeviceN/Separation paints that include at least one spot colorant
655    /// (i.e. `process_cmyk != native_cmyk`). Consulted by CMYK overprint
656    /// rendering so the no-op-delta skip only fires on pixels where
657    /// preserving the pixmap actually preserves spot colour — other pixels
658    /// still go through the ICC(new_cmyk) replace path.
659    spot_mask: Option<Vec<u8>>,
660}
661
662/// Maximum masks to keep in the recycling pool. Enough to avoid alloc churn
663/// without accumulating unbounded memory across bands.
664const MAX_POOL_MASKS: usize = 8;
665
666impl BandState {
667    /// Recycle all cached masks into the pool, clearing the cache for the next band.
668    #[allow(dead_code)]
669    fn recycle_cache(&mut self) {
670        for (_, mask) in self.clip_mask_cache.drain() {
671            if self.mask_pool.len() < MAX_POOL_MASKS {
672                self.mask_pool.push(mask);
673            }
674            // else: drop mask, returning memory to OS
675        }
676    }
677
678    /// Return a mask to the pool if under capacity, otherwise drop it.
679    fn recycle_mask(&mut self, mask: Mask) {
680        if self.mask_pool.len() < MAX_POOL_MASKS {
681            self.mask_pool.push(mask);
682        }
683    }
684
685    /// Get a recycled mask or allocate a new one.
686    fn take_mask(&mut self, w: u32, h: u32) -> Mask {
687        self.spare_mask
688            .take()
689            .or_else(|| self.mask_pool.pop())
690            .unwrap_or_else(|| Mask::new(w, h).expect("Failed to create mask"))
691    }
692
693    /// Take (or lazily allocate) the overprint background snapshot and
694    /// touched-flag buffers. Caller must pass them back via
695    /// `restore_op_buffers`. Layout: snapshot is 4 bytes/pixel (RGBA),
696    /// touched is 1 byte/pixel.
697    fn take_op_buffers(&mut self, w: u32, h: u32) -> (Vec<u8>, Vec<u8>) {
698        let n = w as usize * h as usize;
699        let bg = self
700            .op_bg_snapshot
701            .take()
702            .unwrap_or_else(|| vec![0u8; n * 4]);
703        let touched = self.op_touched.take().unwrap_or_else(|| vec![0u8; n]);
704        (bg, touched)
705    }
706
707    /// Put the overprint buffers back after an overprint render pass.
708    fn restore_op_buffers(&mut self, bg: Vec<u8>, touched: Vec<u8>) {
709        self.op_bg_snapshot = Some(bg);
710        self.op_touched = Some(touched);
711    }
712
713    /// Take (or lazily allocate) the spot-contribution mask (1 byte/pixel).
714    fn take_spot_mask(&mut self, w: u32, h: u32) -> Vec<u8> {
715        let n = w as usize * h as usize;
716        self.spot_mask.take().unwrap_or_else(|| vec![0u8; n])
717    }
718
719    /// Put the spot-contribution mask back after a paint.
720    fn restore_spot_mask(&mut self, mask: Vec<u8>) {
721        self.spot_mask = Some(mask);
722    }
723
724    /// Clear the overprint touched flag for pixels in the given bbox. Called
725    /// by non-overprint paints so a subsequent overprint at those pixels
726    /// captures a fresh backdrop snapshot instead of reusing a stale one.
727    #[allow(dead_code)]
728    fn invalidate_op_snapshot(
729        &mut self,
730        bbox_x0: usize,
731        bbox_y0: usize,
732        bbox_x1: usize,
733        bbox_y1: usize,
734        stride: usize,
735    ) {
736        if let Some(touched) = self.op_touched.as_mut() {
737            for y in bbox_y0..bbox_y1 {
738                let row = y * stride;
739                for x in bbox_x0..bbox_x1 {
740                    touched[row + x] = 0;
741                }
742            }
743        }
744    }
745}
746
747/// Unified rendering context that parameterizes both band and viewport rendering.
748///
749/// Band rendering is viewport rendering with `scale_x = scale_y = 1.0`.
750/// `viewport_transform(t, vp_x, vp_y, 1.0, 1.0)` == `offset_transform_xy(t, vp_x, vp_y)`.
751struct RenderContext<'a> {
752    /// Viewport/band origin X in device space.
753    vp_x: f32,
754    /// Viewport/band origin Y in device space.
755    vp_y: f32,
756    /// Horizontal scale (1.0 for band rendering, zoom for viewport).
757    scale_x: f32,
758    /// Vertical scale (1.0 for band rendering, zoom for viewport).
759    scale_y: f32,
760    /// Output pixmap width in pixels.
761    out_w: u32,
762    /// Output pixmap height in pixels.
763    out_h: u32,
764    /// Effective DPI at output scale.
765    effective_dpi: f64,
766    /// ICC color profile cache (for CMYK conversions).
767    icc: Option<&'a IccCache>,
768    /// Pre-converted image data cache (for viewport rendering).
769    image_cache: Option<&'a ImageCache>,
770    /// Pre-converted and prescaled images (for banded rendering).
771    preprocessed: Option<&'a [Option<PreprocessedImage>]>,
772    /// Element index in parent display list (for image cache lookup).
773    elem_idx: usize,
774    /// Disable anti-aliasing for all fill/stroke operations.
775    no_aa: bool,
776    /// When true, CMYK(0,0,0,0) pixels in images produce alpha=0 (OPM=1).
777    opm_zero_transparent: bool,
778    /// Knockout group painter rendering pass override. The knockout group
779    /// renders each Group painter twice — once for the blended-color result
780    /// (`ColorPass`), once for the painter's coverage mask (`CoveragePass`).
781    /// Both passes need to override `render_group`'s usual decisions:
782    ///   * `ColorPass` expands the per-pixel CMYK composite-back gate to all
783    ///     non-Normal blend modes so painters with separable blends like
784    ///     Screen / ColorDodge / Overlay / SoftLight blend in DeviceCMYK
785    ///     (matching the spec for `/CS DeviceCMYK` knockout groups) instead
786    ///     of in tiny-skia's sRGB blend.
787    ///   * `CoveragePass` disables the CMYK composite-back (its
788    ///     "source==backdrop" guard would discard white-CMYK painters
789    ///     against the transparent coverage backdrop) and forces the
790    ///     painter's alpha to 1.0 with Normal blend so the coverage offscreen
791    ///     captures the painter's *shape* even when the original alpha was 0
792    ///     (Opacity 0% test) or its blend mode would erase the source.
793    knockout_painter_pass: KnockoutPainterPass,
794    /// True when the immediately enclosing transparency group was isolated.
795    /// GWG 16.2's nested CMYK painter pattern (Painter B → Sub A/B) only
796    /// requires CMYK math at the inner non-isolated layer when Painter B
797    /// itself is isolated; for non-isolated parents (the 907 p28 financial
798    /// chart pattern) the existing sRGB compositing path produces the right
799    /// result and the new CMYK math would over-darken anti-aliased gray
800    /// strokes.
801    parent_group_isolated: bool,
802    /// True when rendering an alpha-extraction pass for a non-isolated group
803    /// with non-Normal blend mode.  Nested groups must render as isolated
804    /// (no backdrop preload, no two-pass) so the alpha channel reflects
805    /// pure element coverage rather than backdrop-blended results.
806    alpha_extraction_pass: bool,
807    /// OCG visibility overrides. Empty (every layer at its
808    /// `default_visible`) when the caller didn't supply one.
809    layer_set: &'a LayerSet,
810}
811
812/// Override mode applied to `render_group` while the knockout group renders
813/// one of its painters; see [`RenderContext::knockout_painter_pass`].
814#[derive(Clone, Copy, PartialEq, Eq)]
815enum KnockoutPainterPass {
816    /// Default rendering — no knockout overrides.
817    None,
818    /// Pass 1 (color): widen `plan_cmyk_compose` to any non-Normal blend mode.
819    ColorPass,
820    /// Pass 2 (coverage): disable CMYK composite-back, force full alpha and
821    /// Normal blend so the coverage offscreen captures the painter's shape.
822    CoveragePass,
823}
824
825impl RenderContext<'_> {
826    /// Apply viewport transform to a PostScript matrix.
827    fn transform(&self, m: &Matrix) -> Transform {
828        viewport_transform(
829            to_transform(m),
830            self.vp_x,
831            self.vp_y,
832            self.scale_x,
833            self.scale_y,
834        )
835    }
836}
837
838/// Y-axis bounding box in device pixels.
839struct YBBox {
840    y_min: f64,
841    y_max: f64,
842}
843
844/// A group of display list elements between consecutive InitClip boundaries.
845/// Each epoch starts with an InitClip (except possibly the first) and contains
846/// all elements up to the next InitClip. Epochs whose paint elements don't
847/// overlap a band can be skipped entirely.
848struct ClipEpoch {
849    /// Index of the first element in this epoch (the InitClip, or 0).
850    start_idx: usize,
851    /// One past the last element in this epoch.
852    end_idx: usize,
853    /// Y bounding box of all paint elements (Fill/Stroke/Image) in this epoch.
854    /// None if the epoch has no paint elements (pure clip setup).
855    paint_bbox: Option<YBBox>,
856    /// True if this epoch contains an ErasePage element (must process for all bands).
857    has_erase_page: bool,
858}
859
860/// Choose band height so that band pixmap + 2 clip masks fit in ~2 MB (L2 cache).
861/// Returns `page_h` when banding is not worthwhile (≤2 bands).
862fn select_band_height(w: u32, h: u32) -> u32 {
863    if w == 0 || h == 0 {
864        return h;
865    }
866    // Per-row cost: w*4 (RGBA) + w*1 (clip mask) + w*1 (spare mask) = w*6
867    let per_row = w as u64 * 6;
868    let budget = 2 * 1024 * 1024u64; // 2 MB (L2)
869    let max_rows = budget / per_row;
870
871    // Floor to power of 2, clamp to [16, h]
872    let band = if max_rows >= h as u64 {
873        h
874    } else {
875        let mut p = 1u32;
876        while (p as u64) * 2 <= max_rows {
877            p *= 2;
878        }
879        // Minimum 128 rows per band. At very high DPI the L2 budget yields
880        // tiny bands (16 rows at 2400 DPI = 1650 bands) where display list
881        // replay overhead dominates. 128-row minimum balances L3 cache fit
882        // (~15 MB working set at 2400 DPI) against per-band overhead (207 bands).
883        // Benchmarked: 16→31.3s, 64→22.5s, 128→21.8s, 256→22.1s.
884        p.clamp(128, h)
885    };
886
887    // Skip banding if ≤2 bands
888    if h.div_ceil(band) <= 2 {
889        return h;
890    }
891    band
892}
893
894/// True if this display list contains any `Clip`/`InitClip` op, recursively
895/// descending into `OcgGroup` / `Group` / `SoftMasked` children. When an
896/// `OcgGroup` wraps clip ops, Y-bbox culling would skip the whole group for
897/// bands its paint content doesn't overlap, but the clip state changes inside
898/// must still be applied — otherwise subsequent top-level elements inherit a
899/// stale clip. Use this to force such `OcgGroup`s to always be processed.
900fn contains_clip_op(list: &DisplayList) -> bool {
901    list.elements().iter().any(|e| match e {
902        DisplayElement::Clip { .. } | DisplayElement::InitClip => true,
903        DisplayElement::OcgGroup { elements, .. } => contains_clip_op(elements),
904        DisplayElement::Group { elements, .. } => contains_clip_op(elements),
905        DisplayElement::SoftMasked { content, .. } => contains_clip_op(content),
906        _ => false,
907    })
908}
909
910/// Compute conservative Y bounding boxes for display list elements.
911/// Returns `None` for elements that must always be processed (Clip, InitClip, ErasePage).
912///
913/// All returned Y values are in **device space** (pixel coordinates) so they can be
914/// compared directly against band boundaries.
915fn precompute_bboxes(list: &DisplayList, dpi: f64) -> Vec<Option<YBBox>> {
916    list.elements()
917        .iter()
918        .map(|elem| match elem {
919            DisplayElement::Fill { path, params } => fill_device_y_bbox(path, &params.ctm),
920            DisplayElement::Stroke { path, params } => stroke_device_y_bbox(path, params, dpi),
921            DisplayElement::Image { params, .. } => image_y_bbox(params),
922            DisplayElement::AxialShading { params } => {
923                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
924            }
925            DisplayElement::RadialShading { params } => {
926                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
927            }
928            DisplayElement::MeshShading { params } => {
929                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
930            }
931            DisplayElement::PatchShading { params } => {
932                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
933            }
934            DisplayElement::PatternFill { params } => pattern_fill_y_bbox(params),
935            DisplayElement::Group { params, .. } => Some(YBBox {
936                y_min: params.bbox[1],
937                y_max: params.bbox[3],
938            }),
939            DisplayElement::SoftMasked { params, .. } => Some(YBBox {
940                y_min: params.bbox[1],
941                y_max: params.bbox[3],
942            }),
943            DisplayElement::OcgGroup {
944                elements,
945                visibility,
946            } => {
947                // Hidden groups without clip ops contribute nothing — cull.
948                // (Hidden + has clip ops is handled below: we return paint
949                // bounds so the epoch has correct extent, and the band loop
950                // skips per-element culling for OcgGroups so the clip ops
951                // always execute.)
952                if !visibility.default_visible() && !contains_clip_op(elements) {
953                    return None;
954                }
955                let child_bboxes = precompute_bboxes(elements, dpi);
956                let mut y_min = f64::INFINITY;
957                let mut y_max = f64::NEG_INFINITY;
958                for cb in child_bboxes.into_iter().flatten() {
959                    y_min = y_min.min(cb.y_min);
960                    y_max = y_max.max(cb.y_max);
961                }
962                if y_min <= y_max {
963                    Some(YBBox { y_min, y_max })
964                } else {
965                    None
966                }
967            }
968            _ => None, // Clip, InitClip, ErasePage: always process
969        })
970        .collect()
971}
972
973/// Compute device-space Y bounding box for a shading element.
974/// Uses the BBox if present, otherwise returns a full-page sentinel
975/// (y_min=0, y_max=very large) so the element is never culled.
976fn shading_y_bbox_from_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<YBBox> {
977    if let Some(bbox) = bbox {
978        let corners = [
979            (bbox[0], bbox[1]),
980            (bbox[2], bbox[1]),
981            (bbox[0], bbox[3]),
982            (bbox[2], bbox[3]),
983        ];
984        let mut y_min = f64::INFINITY;
985        let mut y_max = f64::NEG_INFINITY;
986        for (x, y) in &corners {
987            let (_, dy) = ctm.transform_point(*x, *y);
988            y_min = y_min.min(dy);
989            y_max = y_max.max(dy);
990        }
991        Some(YBBox { y_min, y_max })
992    } else {
993        // No BBox — shading covers unbounded area; return sentinel so it's
994        // never culled by band processing.
995        Some(YBBox {
996            y_min: 0.0,
997            y_max: 1e9,
998        })
999    }
1000}
1001
1002/// Compute device-space Y bounding box for a stroke element.
1003///
1004/// Isotropic strokes have paths already in device space (Identity CTM), so
1005/// `path_y_bbox` gives device-space bounds directly. Anisotropic strokes have
1006/// paths in user space with the full CTM — we must transform the bounding box
1007/// through the CTM to get device-space bounds.
1008fn stroke_device_y_bbox(path: &PsPath, params: &StrokeParams, dpi: f64) -> Option<YBBox> {
1009    let m = &params.ctm;
1010    let is_identity =
1011        m.a == 1.0 && m.b == 0.0 && m.c == 0.0 && m.d == 1.0 && m.tx == 0.0 && m.ty == 0.0;
1012
1013    // Use effective line width: actual width or hairline minimum, whichever is larger
1014    let effective_lw = params.line_width.max(hairline_min_width(&params.ctm, dpi));
1015
1016    if is_identity {
1017        // Path in device space — just read Y coords and expand for stroke width.
1018        return path_y_bbox(path).map(|mut bbox| {
1019            let expand = effective_lw * params.miter_limit * 0.5;
1020            bbox.y_min -= expand;
1021            bbox.y_max += expand;
1022            bbox
1023        });
1024    }
1025
1026    // Anisotropic: path in user space. Compute full XY bbox, transform corners
1027    // through CTM to get device-space Y range.
1028    let (mut x_min, mut x_max) = (f64::INFINITY, f64::NEG_INFINITY);
1029    let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY);
1030    for seg in &path.segments {
1031        match seg {
1032            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => {
1033                x_min = x_min.min(*x);
1034                x_max = x_max.max(*x);
1035                y_min = y_min.min(*y);
1036                y_max = y_max.max(*y);
1037            }
1038            PathSegment::CurveTo {
1039                x1,
1040                y1,
1041                x2,
1042                y2,
1043                x3,
1044                y3,
1045            } => {
1046                x_min = x_min.min(*x1).min(*x2).min(*x3);
1047                x_max = x_max.max(*x1).max(*x2).max(*x3);
1048                y_min = y_min.min(*y1).min(*y2).min(*y3);
1049                y_max = y_max.max(*y1).max(*y2).max(*y3);
1050            }
1051            PathSegment::ClosePath => {}
1052        }
1053    }
1054    if x_min > x_max {
1055        return None;
1056    }
1057
1058    // Transform all 4 corners of user-space bbox to device space
1059    let corners = [
1060        (x_min, y_min),
1061        (x_max, y_min),
1062        (x_min, y_max),
1063        (x_max, y_max),
1064    ];
1065    let mut dev_y_min = f64::INFINITY;
1066    let mut dev_y_max = f64::NEG_INFINITY;
1067    for (x, y) in &corners {
1068        let dy = m.b * x + m.d * y + m.ty;
1069        dev_y_min = dev_y_min.min(dy);
1070        dev_y_max = dev_y_max.max(dy);
1071    }
1072
1073    // Expand for stroke width + miter in device-space units.
1074    // ||[c,d]|| converts user-space line_width to device-space Y expansion.
1075    let col_y_len = (m.c * m.c + m.d * m.d).sqrt().max(1.0);
1076    let expand = effective_lw * col_y_len * params.miter_limit * 0.5;
1077    dev_y_min -= expand;
1078    dev_y_max += expand;
1079
1080    Some(YBBox {
1081        y_min: dev_y_min,
1082        y_max: dev_y_max,
1083    })
1084}
1085
1086/// Compute device-space Y bounds for a Fill element, accounting for CTM.
1087/// Mirrors `stroke_device_y_bbox` but without stroke-width expansion.
1088/// Paths may be stored either in device space (identity CTM, content streams)
1089/// or user space (non-identity CTM, synthesized annotation appearances).
1090fn fill_device_y_bbox(path: &PsPath, ctm: &Matrix) -> Option<YBBox> {
1091    let is_identity = ctm.a == 1.0
1092        && ctm.b == 0.0
1093        && ctm.c == 0.0
1094        && ctm.d == 1.0
1095        && ctm.tx == 0.0
1096        && ctm.ty == 0.0;
1097    if is_identity {
1098        return path_y_bbox(path);
1099    }
1100    let bbox = path_full_bbox(path)?;
1101    let corners = [
1102        (bbox.x_min, bbox.y_min),
1103        (bbox.x_max, bbox.y_min),
1104        (bbox.x_min, bbox.y_max),
1105        (bbox.x_max, bbox.y_max),
1106    ];
1107    let mut dev_y_min = f64::INFINITY;
1108    let mut dev_y_max = f64::NEG_INFINITY;
1109    for (x, y) in &corners {
1110        let dy = ctm.b * x + ctm.d * y + ctm.ty;
1111        dev_y_min = dev_y_min.min(dy);
1112        dev_y_max = dev_y_max.max(dy);
1113    }
1114    Some(YBBox {
1115        y_min: dev_y_min,
1116        y_max: dev_y_max,
1117    })
1118}
1119
1120/// Compute Y bounds from path segments (conservative: uses control points for curves).
1121fn path_y_bbox(path: &PsPath) -> Option<YBBox> {
1122    let mut y_min = f64::INFINITY;
1123    let mut y_max = f64::NEG_INFINITY;
1124    for seg in &path.segments {
1125        match seg {
1126            PathSegment::MoveTo(_, y) | PathSegment::LineTo(_, y) => {
1127                y_min = y_min.min(*y);
1128                y_max = y_max.max(*y);
1129            }
1130            PathSegment::CurveTo { y1, y2, y3, .. } => {
1131                y_min = y_min.min(*y1).min(*y2).min(*y3);
1132                y_max = y_max.max(*y1).max(*y2).max(*y3);
1133            }
1134            PathSegment::ClosePath => {}
1135        }
1136    }
1137    if y_min <= y_max {
1138        Some(YBBox { y_min, y_max })
1139    } else {
1140        None
1141    }
1142}
1143
1144/// Compute Y bounds for an image element from its transform.
1145fn image_y_bbox(params: &ImageParams) -> Option<YBBox> {
1146    let image_inv = params.image_matrix.invert()?;
1147    let combined = params.ctm.concat(&image_inv);
1148    let corners = [
1149        (0.0, 0.0),
1150        (params.width as f64, 0.0),
1151        (params.width as f64, params.height as f64),
1152        (0.0, params.height as f64),
1153    ];
1154    let mut y_min = f64::INFINITY;
1155    let mut y_max = f64::NEG_INFINITY;
1156    for (x, y) in &corners {
1157        let (_, dy) = combined.transform_point(*x, *y);
1158        y_min = y_min.min(dy);
1159        y_max = y_max.max(dy);
1160    }
1161    Some(YBBox { y_min, y_max })
1162}
1163
1164/// Pre-populate clip_mask_seen with hashes of clip paths that appear ≥2 times.
1165/// This lets the first band immediately cache repeated clip paths.
1166fn precompute_clip_seen(list: &DisplayList) -> HashSet<u64> {
1167    let mut counts: HashMap<u64, u32> = HashMap::new();
1168    for elem in list.elements() {
1169        if let DisplayElement::Clip { path, params } = elem {
1170            let hash = hash_clip_path(path, &params.fill_rule);
1171            *counts.entry(hash).or_insert(0) += 1;
1172        }
1173    }
1174    counts
1175        .into_iter()
1176        .filter(|(_, c)| *c > 1)
1177        .map(|(h, _)| h)
1178        .collect()
1179}
1180
1181/// Build clip epochs — groups of elements between InitClip boundaries.
1182/// Each epoch's paint_bbox is the union of Y ranges for all paint elements in it.
1183fn build_clip_epochs(list: &DisplayList, bboxes: &[Option<YBBox>]) -> Vec<ClipEpoch> {
1184    let elements = list.elements();
1185    let mut epochs = Vec::new();
1186    let mut epoch_start = 0;
1187    let mut y_min = f64::INFINITY;
1188    let mut y_max = f64::NEG_INFINITY;
1189    let mut has_erase = false;
1190
1191    for (i, element) in elements.iter().enumerate() {
1192        // InitClip starts a new epoch (close the previous one first)
1193        if matches!(element, DisplayElement::InitClip) && i > epoch_start {
1194            epochs.push(ClipEpoch {
1195                start_idx: epoch_start,
1196                end_idx: i,
1197                paint_bbox: if y_min <= y_max {
1198                    Some(YBBox { y_min, y_max })
1199                } else {
1200                    None
1201                },
1202                has_erase_page: has_erase,
1203            });
1204            epoch_start = i;
1205            y_min = f64::INFINITY;
1206            y_max = f64::NEG_INFINITY;
1207            has_erase = false;
1208        }
1209        if matches!(element, DisplayElement::ErasePage) {
1210            has_erase = true;
1211        }
1212        if let Some(ref bbox) = bboxes[i] {
1213            y_min = y_min.min(bbox.y_min);
1214            y_max = y_max.max(bbox.y_max);
1215        }
1216    }
1217    // Final epoch
1218    if epoch_start < elements.len() {
1219        epochs.push(ClipEpoch {
1220            start_idx: epoch_start,
1221            end_idx: elements.len(),
1222            paint_bbox: if y_min <= y_max {
1223                Some(YBBox { y_min, y_max })
1224            } else {
1225                None
1226            },
1227            has_erase_page: has_erase,
1228        });
1229    }
1230    epochs
1231}
1232
1233/// Apply a device-space Y offset to a tiny-skia Transform.
1234/// The original transform maps from path space to full-page device space;
1235/// we subtract `y_offset` from `ty` so band rows [y_start, y_start+band_h)
1236/// map to pixmap rows [0, band_h).
1237/// Composite premultiplied-alpha RGBA pixels onto a white background.
1238/// After this, all pixels are fully opaque (alpha=255).
1239fn composite_onto_white(data: &mut [u8]) {
1240    for pixel in data.chunks_exact_mut(4) {
1241        let a = pixel[3] as u16;
1242        if a == 255 {
1243            continue; // fully opaque — no compositing needed
1244        }
1245        let inv_a = 255 - a;
1246        pixel[0] = (pixel[0] as u16 + inv_a).min(255) as u8;
1247        pixel[1] = (pixel[1] as u16 + inv_a).min(255) as u8;
1248        pixel[2] = (pixel[2] as u16 + inv_a).min(255) as u8;
1249        pixel[3] = 255;
1250    }
1251}
1252
1253/// Extract the contribution of a non-isolated transparency group and composite
1254/// it onto the parent using the group's blend mode and alpha.
1255///
1256/// Composite a (possibly cropped) non-isolated group offscreen onto the parent pixmap.
1257///
1258/// Like `extract_and_composite_contribution`, but the offscreen and backdrop
1259/// are crop-sized (only covering the group's bounding box region), positioned
1260/// at `(crop_x, crop_y)` in the parent's coordinate system.
1261fn composite_non_isolated_group_cropped(
1262    target: &mut Pixmap,
1263    source: &Pixmap,
1264    backdrop: &[u8],
1265    params: &stet_graphics::display_list::GroupParams,
1266    clip_mask: Option<&stet_tiny_skia::Mask>,
1267    crop_x: i32,
1268    crop_y: i32,
1269) {
1270    let cw = source.width();
1271    let ch = source.height();
1272
1273    // Build a contribution pixmap: pixels that changed vs backdrop
1274    let Some(mut contribution) = Pixmap::new(cw, ch) else {
1275        return;
1276    };
1277    let src_data = source.data();
1278    let contrib_data = contribution.data_mut();
1279
1280    for (i, chunk) in contrib_data.chunks_exact_mut(4).enumerate() {
1281        let off = i * 4;
1282        if src_data[off] != backdrop[off]
1283            || src_data[off + 1] != backdrop[off + 1]
1284            || src_data[off + 2] != backdrop[off + 2]
1285            || src_data[off + 3] != backdrop[off + 3]
1286        {
1287            chunk.copy_from_slice(&src_data[off..off + 4]);
1288        }
1289    }
1290
1291    let paint = stet_tiny_skia::PixmapPaint {
1292        opacity: params.alpha as f32,
1293        blend_mode: u8_to_blend_mode(params.blend_mode),
1294        quality: stet_tiny_skia::FilterQuality::Nearest,
1295    };
1296    target.draw_pixmap(
1297        crop_x,
1298        crop_y,
1299        contribution.as_ref(),
1300        &paint,
1301        Transform::identity(),
1302        clip_mask,
1303    );
1304}
1305
1306/// Non-isolated group composite-back using the proper source-extraction
1307/// formula (ISO 32000-1 §11.4.8).
1308///
1309/// `source` was rendered against the `backdrop`; `isolated` was rendered
1310/// against transparent.  The isolated render's alpha channel gives the
1311/// group's shape, which lets us extract the source color:
1312///
1313///   C_g_premul = R - B · (1 - α_g)      (premultiplied source color)
1314///   α_g        = isolated alpha channel
1315///
1316/// The extracted contribution is then composited onto `target` with the
1317/// group's blend mode and opacity.
1318fn composite_non_isolated_extracted(
1319    target: &mut Pixmap,
1320    source: &Pixmap,
1321    isolated: &Pixmap,
1322    backdrop: &[u8],
1323    params: &stet_graphics::display_list::GroupParams,
1324    clip_mask: Option<&stet_tiny_skia::Mask>,
1325    crop_x: i32,
1326    crop_y: i32,
1327) {
1328    let cw = source.width();
1329    let ch = source.height();
1330
1331    let Some(mut contribution) = Pixmap::new(cw, ch) else {
1332        return;
1333    };
1334    let src_data = source.data();
1335    let iso_data = isolated.data();
1336    let contrib_data = contribution.data_mut();
1337
1338    for i in 0..(cw as usize * ch as usize) {
1339        let off = i * 4;
1340        let alpha_g = iso_data[off + 3];
1341        if alpha_g == 0 {
1342            continue; // no group contribution at this pixel
1343        }
1344
1345        // Extract premultiplied source: C_g_premul = R - B · (1 - α_g/255)
1346        let inv_alpha = 255 - alpha_g as i32;
1347        for c in 0..3 {
1348            let r = src_data[off + c] as i32;
1349            let b = backdrop[off + c] as i32;
1350            let raw = r - (b * inv_alpha + 127) / 255;
1351            contrib_data[off + c] = raw.clamp(0, 255) as u8;
1352        }
1353        contrib_data[off + 3] = alpha_g;
1354    }
1355
1356    let paint = stet_tiny_skia::PixmapPaint {
1357        opacity: params.alpha as f32,
1358        blend_mode: u8_to_blend_mode(params.blend_mode),
1359        quality: stet_tiny_skia::FilterQuality::Nearest,
1360    };
1361    target.draw_pixmap(
1362        crop_x,
1363        crop_y,
1364        contribution.as_ref(),
1365        &paint,
1366        Transform::identity(),
1367        clip_mask,
1368    );
1369}
1370
1371/// Apply a combined offset + scale to a tiny-skia Transform for viewport rendering.
1372/// Maps device-space coordinates into viewport-local pixel coordinates:
1373///   output_x = (device_x - vp_x) * scale_x
1374///   output_y = (device_y - vp_y) * scale_y
1375fn viewport_transform(t: Transform, vp_x: f32, vp_y: f32, scale_x: f32, scale_y: f32) -> Transform {
1376    // Post-compose: first apply `t` (path→device), then translate(-vp_x,-vp_y), then scale
1377    Transform::from_row(
1378        t.sx * scale_x,
1379        t.ky * scale_y,
1380        t.kx * scale_x,
1381        t.sy * scale_y,
1382        (t.tx - vp_x) * scale_x,
1383        (t.ty - vp_y) * scale_y,
1384    )
1385}
1386
1387/// Fast area-average box filter resample for downscaling.
1388///
1389/// Each output pixel averages all source pixels that fall within its footprint.
1390/// Two-pass separable (horizontal then vertical) for O(src) total work regardless
1391/// of scale ratio. Produces quality equivalent to Lanczos3 for downscaling at a
1392/// fraction of the cost.
1393fn box_resample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
1394    if dw == 0 || dh == 0 {
1395        return Vec::new();
1396    }
1397    let (sw, sh, dw, dh) = (sw as usize, sh as usize, dw as usize, dh as usize);
1398
1399    // Pass 1: horizontal (sw → dw) with fractional edge weights.
1400    // Each output pixel covers [left_f, right_f] in source space. Edge source
1401    // pixels get proportional weight; interior pixels get weight 1.0.
1402    let ratio_x = sw as f32 / dw as f32;
1403    let mut tmp = vec![0.0f32; dw * sh * 4];
1404    let tmp_stride = dw * 4;
1405
1406    for y in 0..sh {
1407        let row_off = y * sw * 4;
1408        let dst_row = y * tmp_stride;
1409        for dx in 0..dw {
1410            let left_f = dx as f32 * ratio_x;
1411            let right_f = (dx + 1) as f32 * ratio_x;
1412            let left = (left_f as usize).min(sw - 1);
1413            let right = (right_f.ceil() as usize).min(sw);
1414            let inv_area = 1.0 / (right_f - left_f);
1415            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1416            for sx in left..right {
1417                // Weight: fraction of this source pixel covered by the output pixel
1418                let pixel_left = sx as f32;
1419                let pixel_right = (sx + 1) as f32;
1420                let w = pixel_right.min(right_f) - pixel_left.max(left_f);
1421                let i = row_off + sx * 4;
1422                r += src[i] as f32 * w;
1423                g += src[i + 1] as f32 * w;
1424                b += src[i + 2] as f32 * w;
1425                a += src[i + 3] as f32 * w;
1426            }
1427            let di = dst_row + dx * 4;
1428            tmp[di] = r * inv_area;
1429            tmp[di + 1] = g * inv_area;
1430            tmp[di + 2] = b * inv_area;
1431            tmp[di + 3] = a * inv_area;
1432        }
1433    }
1434
1435    // Pass 2: vertical (sh → dh) with fractional edge weights, row-major order.
1436    let ratio_y = sh as f32 / dh as f32;
1437    let mut out = vec![0u8; dw * dh * 4];
1438    let out_stride = dw * 4;
1439
1440    for dy in 0..dh {
1441        let top_f = dy as f32 * ratio_y;
1442        let bottom_f = (dy + 1) as f32 * ratio_y;
1443        let top = (top_f as usize).min(sh - 1);
1444        let bottom = (bottom_f.ceil() as usize).min(sh);
1445        let inv_area = 1.0 / (bottom_f - top_f);
1446
1447        // Pre-compute row weights
1448        let n_rows = bottom - top;
1449        let mut row_weights_buf: [(usize, f32); 8] = [(0, 0.0); 8];
1450        let row_weights_vec: Vec<(usize, f32)>;
1451        let row_weights: &[(usize, f32)] = if n_rows <= 8 {
1452            for (i, sy) in (top..bottom).enumerate() {
1453                let pixel_top = sy as f32;
1454                let pixel_bottom = (sy + 1) as f32;
1455                let w = pixel_bottom.min(bottom_f) - pixel_top.max(top_f);
1456                row_weights_buf[i] = (sy, w);
1457            }
1458            &row_weights_buf[..n_rows]
1459        } else {
1460            row_weights_vec = (top..bottom)
1461                .map(|sy| {
1462                    let pixel_top = sy as f32;
1463                    let pixel_bottom = (sy + 1) as f32;
1464                    let w = pixel_bottom.min(bottom_f) - pixel_top.max(top_f);
1465                    (sy, w)
1466                })
1467                .collect();
1468            &row_weights_vec
1469        };
1470
1471        let dst_row = dy * out_stride;
1472        for dx in 0..dw {
1473            let col = dx * 4;
1474            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1475            for &(sy, w) in row_weights {
1476                let i = sy * tmp_stride + col;
1477                r += tmp[i] * w;
1478                g += tmp[i + 1] * w;
1479                b += tmp[i + 2] * w;
1480                a += tmp[i + 3] * w;
1481            }
1482            let di = dst_row + col;
1483            out[di] = (r * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1484            out[di + 1] = (g * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1485            out[di + 2] = (b * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1486            out[di + 3] = (a * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1487        }
1488    }
1489
1490    out
1491}
1492
1493/// Bicubic (Catmull-Rom) resample for upscaling — two-pass separable.
1494///
1495/// Pass 1: horizontal resample (sw → dw) at f32 precision.
1496/// Pass 2: vertical resample (sh → dh) and quantize to u8.
1497///
1498/// Separable approach: O(dw×sh + dw×dh) × 4 taps instead of O(dw×dh) × 16 taps.
1499fn bicubic_resample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
1500    if dw == 0 || dh == 0 {
1501        return Vec::new();
1502    }
1503
1504    let (sw, sh, dw, dh) = (sw as usize, sh as usize, dw as usize, dh as usize);
1505    let ratio_x = sw as f32 / dw as f32;
1506    let ratio_y = sh as f32 / dh as f32;
1507
1508    // Pass 1: horizontal (sw → dw), keep sh rows, store as f32.
1509    let mut tmp = vec![0.0f32; dw * sh * 4];
1510    for y in 0..sh {
1511        let src_row = y * sw * 4;
1512        let dst_row = y * dw * 4;
1513        for dx in 0..dw {
1514            let sx = (dx as f32 + 0.5) * ratio_x - 0.5;
1515            let sx_floor = sx.floor() as i32;
1516            let fx = sx - sx_floor as f32;
1517            let w0 = catmull_rom(fx + 1.0);
1518            let w1 = catmull_rom(fx);
1519            let w2 = catmull_rom(1.0 - fx);
1520            let w3 = catmull_rom(2.0 - fx);
1521            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1522            for (k, w) in [
1523                (sx_floor - 1, w0),
1524                (sx_floor, w1),
1525                (sx_floor + 1, w2),
1526                (sx_floor + 2, w3),
1527            ] {
1528                let px = k.clamp(0, sw as i32 - 1) as usize;
1529                let i = src_row + px * 4;
1530                r += src[i] as f32 * w;
1531                g += src[i + 1] as f32 * w;
1532                b += src[i + 2] as f32 * w;
1533                a += src[i + 3] as f32 * w;
1534            }
1535            let di = dst_row + dx * 4;
1536            tmp[di] = r;
1537            tmp[di + 1] = g;
1538            tmp[di + 2] = b;
1539            tmp[di + 3] = a;
1540        }
1541    }
1542
1543    // Pass 2: vertical (sh → dh) on the dw-wide tmp, quantize to u8.
1544    // Row-major order for cache-friendly access.
1545    let mut out = vec![0u8; dw * dh * 4];
1546    let tmp_stride = dw * 4;
1547    let out_stride = dw * 4;
1548    for dy in 0..dh {
1549        let sy = (dy as f32 + 0.5) * ratio_y - 0.5;
1550        let sy_floor = sy.floor() as i32;
1551        let fy = sy - sy_floor as f32;
1552        let w0 = catmull_rom(fy + 1.0);
1553        let w1 = catmull_rom(fy);
1554        let w2 = catmull_rom(1.0 - fy);
1555        let w3 = catmull_rom(2.0 - fy);
1556        let py0 = (sy_floor - 1).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1557        let py1 = sy_floor.clamp(0, sh as i32 - 1) as usize * tmp_stride;
1558        let py2 = (sy_floor + 1).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1559        let py3 = (sy_floor + 2).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1560        let dst_row = dy * out_stride;
1561        for dx in 0..dw {
1562            let col = dx * 4;
1563            let r = tmp[py0 + col] * w0
1564                + tmp[py1 + col] * w1
1565                + tmp[py2 + col] * w2
1566                + tmp[py3 + col] * w3;
1567            let g = tmp[py0 + col + 1] * w0
1568                + tmp[py1 + col + 1] * w1
1569                + tmp[py2 + col + 1] * w2
1570                + tmp[py3 + col + 1] * w3;
1571            let b = tmp[py0 + col + 2] * w0
1572                + tmp[py1 + col + 2] * w1
1573                + tmp[py2 + col + 2] * w2
1574                + tmp[py3 + col + 2] * w3;
1575            let a = tmp[py0 + col + 3] * w0
1576                + tmp[py1 + col + 3] * w1
1577                + tmp[py2 + col + 3] * w2
1578                + tmp[py3 + col + 3] * w3;
1579            let di = dst_row + col;
1580            out[di] = r.round().clamp(0.0, 255.0) as u8;
1581            out[di + 1] = g.round().clamp(0.0, 255.0) as u8;
1582            out[di + 2] = b.round().clamp(0.0, 255.0) as u8;
1583            out[di + 3] = a.round().clamp(0.0, 255.0) as u8;
1584        }
1585    }
1586
1587    out
1588}
1589
1590/// Catmull-Rom spline weight (a = -0.5).
1591#[inline]
1592fn catmull_rom(t: f32) -> f32 {
1593    let t = t.abs();
1594    if t < 1.0 {
1595        (1.5 * t - 2.5) * t * t + 1.0
1596    } else if t < 2.0 {
1597        ((-0.5 * t + 2.5) * t - 4.0) * t + 2.0
1598    } else {
1599        0.0
1600    }
1601}
1602
1603/// Pre-downsample an image when the transform indicates significant downscaling.
1604///
1605/// tiny-skia's bilinear filter only samples a 2×2 neighborhood — it has no mipmap
1606/// support, so large downscale ratios cause severe aliasing (e.g., 300 DPI bitmap
1607/// fonts rendered at screen resolution).
1608///
1609/// For axis-aligned transforms: box-filter resample to the exact target dimensions.
1610///
1611/// Build an `IccCache` from ICC profiles found in a display list.
1612///
1613/// Registers all unique ICCBased profiles and optionally the system CMYK
1614/// profile. When `proofing_enabled` is true, ICCBased profiles registered
1615/// while scanning the display list are color-managed *through* the system
1616/// CMYK (the PDF's OutputIntent), so a render-thread cache built from the
1617/// effective OutputIntent matches the bake-time cache that produced the
1618/// display list. PostScript callers should pass `false` (no
1619/// PDF/X OutputIntent semantics).
1620pub fn build_icc_cache_for_list(
1621    list: &DisplayList,
1622    system_cmyk_bytes: Option<&std::sync::Arc<Vec<u8>>>,
1623    proofing_enabled: bool,
1624) -> IccCache {
1625    let mut cache = IccCache::new();
1626    let mut seen = HashSet::new();
1627
1628    // Register system CMYK profile first. Proofing must stay off here: the
1629    // OutputIntent itself converts directly to sRGB, not through itself.
1630    if let Some(cmyk_bytes) = system_cmyk_bytes
1631        && let Some(hash) = cache.register_profile(cmyk_bytes)
1632    {
1633        seen.insert(hash);
1634        // Set the default CMYK hash so convert_image_8bit works for DeviceCMYK
1635        cache.set_default_cmyk_hash(hash);
1636        // Pre-warm the sRGB→CMYK reverse transform so band renderers, which
1637        // only hold an `&IccCache`, can use `convert_rgb_to_cmyk_readonly`
1638        // when populating the parallel CMYK buffer for non-CMYK painters.
1639        cache.prepare_reverse_cmyk();
1640        // Pre-build the per-intent Lab → OI CMYK samplers so Lab fills can
1641        // populate `native_cmyk` from `&IccCache` (mirrors the PNG path's
1642        // `apply_output_intent_as_default_cmyk`). Required for GWG 22.1.
1643        cache.prepare_lab_to_oi_cmyk();
1644    }
1645
1646    // Enable proofing AFTER the OutputIntent itself is registered so the
1647    // chain logic in `register_profile` sees `default_cmyk_hash` set when
1648    // subsequent ICCBased profiles arrive — those get chained through the
1649    // OutputIntent.
1650    cache.set_proofing_enabled(proofing_enabled);
1651
1652    // Scan display list for ICCBased images and shadings (recursing into Groups)
1653    fn scan_elements(
1654        elements: &[DisplayElement],
1655        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1656        cache: &mut IccCache,
1657    ) {
1658        for element in elements {
1659            // Recurse into groups
1660            if let DisplayElement::Group { elements: sub, .. } = element {
1661                scan_elements(sub.elements(), seen, cache);
1662            }
1663            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1664                scan_elements(content.elements(), seen, cache);
1665                scan_elements(mask.elements(), seen, cache);
1666            }
1667            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1668                scan_elements(sub.elements(), seen, cache);
1669            }
1670            // Shading color spaces
1671            let shading_cs = match element {
1672                DisplayElement::AxialShading { params } => Some(&params.color_space),
1673                DisplayElement::RadialShading { params } => Some(&params.color_space),
1674                DisplayElement::MeshShading { params } => Some(&params.color_space),
1675                DisplayElement::PatchShading { params } => Some(&params.color_space),
1676                _ => None,
1677            };
1678            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1679                n,
1680                profile_hash,
1681                profile_data,
1682            }) = shading_cs
1683            {
1684                if seen.insert(*profile_hash) {
1685                    cache.register_profile_with_n(profile_data, Some(*n));
1686                }
1687            }
1688            // Image color spaces
1689            if let DisplayElement::Image { params, .. } = element {
1690                match &params.color_space {
1691                    ImageColorSpace::ICCBased {
1692                        n,
1693                        profile_hash,
1694                        profile_data,
1695                    } if seen.insert(*profile_hash) => {
1696                        cache.register_profile_with_n(profile_data, Some(*n));
1697                    }
1698                    ImageColorSpace::Indexed { base, .. }
1699                        if matches!(base.as_ref(), ImageColorSpace::ICCBased { .. }) =>
1700                    {
1701                        if let ImageColorSpace::ICCBased {
1702                            n,
1703                            profile_hash,
1704                            profile_data,
1705                        } = base.as_ref()
1706                        {
1707                            if seen.insert(*profile_hash) {
1708                                cache.register_profile_with_n(profile_data, Some(*n));
1709                            }
1710                        }
1711                    }
1712                    _ => {}
1713                }
1714            }
1715        }
1716    }
1717    scan_elements(list.elements(), &mut seen, &mut cache);
1718
1719    cache
1720}
1721
1722/// Register ICC profiles from shading elements in a display list.
1723///
1724/// Recursively scans Groups and SoftMasks for ICCBased shading color spaces
1725/// and registers their profiles in the cache.
1726fn register_shading_icc_profiles(list: &DisplayList, cache: &mut IccCache) {
1727    fn register_image_iccs(
1728        cs: &ImageColorSpace,
1729        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1730        cache: &mut IccCache,
1731    ) {
1732        match cs {
1733            ImageColorSpace::ICCBased {
1734                n,
1735                profile_hash,
1736                profile_data,
1737            } => {
1738                if seen.insert(*profile_hash) {
1739                    cache.register_profile_with_n(profile_data, Some(*n));
1740                }
1741            }
1742            ImageColorSpace::Indexed { base, .. } => register_image_iccs(base, seen, cache),
1743            ImageColorSpace::Separation { alt_space, .. }
1744            | ImageColorSpace::DeviceN { alt_space, .. } => {
1745                register_image_iccs(alt_space, seen, cache)
1746            }
1747            _ => {}
1748        }
1749    }
1750    fn scan(
1751        elements: &[DisplayElement],
1752        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1753        cache: &mut IccCache,
1754    ) {
1755        for element in elements {
1756            if let DisplayElement::Group { elements: sub, .. } = element {
1757                scan(sub.elements(), seen, cache);
1758            }
1759            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1760                scan(content.elements(), seen, cache);
1761                scan(mask.elements(), seen, cache);
1762            }
1763            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1764                scan(sub.elements(), seen, cache);
1765            }
1766            let shading_cs = match element {
1767                DisplayElement::AxialShading { params } => Some(&params.color_space),
1768                DisplayElement::RadialShading { params } => Some(&params.color_space),
1769                DisplayElement::MeshShading { params } => Some(&params.color_space),
1770                DisplayElement::PatchShading { params } => Some(&params.color_space),
1771                _ => None,
1772            };
1773            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1774                n,
1775                profile_hash,
1776                profile_data,
1777            }) = shading_cs
1778                && seen.insert(*profile_hash)
1779            {
1780                cache.register_profile_with_n(profile_data, Some(*n));
1781            }
1782            if let DisplayElement::Image { params, .. } = element {
1783                register_image_iccs(&params.color_space, seen, cache);
1784            }
1785        }
1786    }
1787    let mut seen = HashSet::new();
1788    scan(list.elements(), &mut seen, cache);
1789}
1790
1791/// Convert raw image samples to RGBA for rasterization.
1792///
1793/// Handles all `ImageColorSpace` variants, producing width×height×4 RGBA bytes.
1794fn samples_to_rgba(
1795    data: &[u8],
1796    params: &ImageParams,
1797    icc: Option<&IccCache>,
1798    opm_zero_transparent: bool,
1799) -> Vec<u8> {
1800    let w = params.width as usize;
1801    let h = params.height as usize;
1802    let npixels = w * h;
1803    let bpc = params.bits_per_component;
1804    match &params.color_space {
1805        ImageColorSpace::PreconvertedRGBA => {
1806            // Already RGBA — just return as-is
1807            data.to_vec()
1808        }
1809        ImageColorSpace::DeviceGray => {
1810            let mut rgba = vec![255u8; npixels * 4];
1811            if bpc == 16 {
1812                for i in 0..npixels {
1813                    let g = data.get(i * 2).copied().unwrap_or(0);
1814                    let pi = i * 4;
1815                    rgba[pi] = g;
1816                    rgba[pi + 1] = g;
1817                    rgba[pi + 2] = g;
1818                }
1819            } else {
1820                for i in 0..npixels {
1821                    let g = data.get(i).copied().unwrap_or(0);
1822                    let pi = i * 4;
1823                    rgba[pi] = g;
1824                    rgba[pi + 1] = g;
1825                    rgba[pi + 2] = g;
1826                }
1827            }
1828            rgba
1829        }
1830        ImageColorSpace::DeviceRGB => {
1831            let mut rgba = vec![255u8; npixels * 4];
1832            if bpc == 16 {
1833                // 16 BPC: 6 bytes per pixel (R_hi R_lo G_hi G_lo B_hi B_lo)
1834                // Take high byte of each 16-bit sample
1835                for i in 0..npixels {
1836                    let si = i * 6;
1837                    let pi = i * 4;
1838                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1839                    rgba[pi + 1] = data.get(si + 2).copied().unwrap_or(0);
1840                    rgba[pi + 2] = data.get(si + 4).copied().unwrap_or(0);
1841                }
1842            } else {
1843                for i in 0..npixels {
1844                    let si = i * 3;
1845                    let pi = i * 4;
1846                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1847                    rgba[pi + 1] = data.get(si + 1).copied().unwrap_or(0);
1848                    rgba[pi + 2] = data.get(si + 2).copied().unwrap_or(0);
1849                }
1850            }
1851            rgba
1852        }
1853        ImageColorSpace::DeviceCMYK => {
1854            // Try ICC-based CMYK→RGB conversion via system CMYK profile.
1855            // Convert as many complete pixels as the data allows; PLRM-fallback
1856            // for any remaining pixels with insufficient data.
1857            if let Some(cache) = icc
1858                && let Some(cmyk_hash) = cache.default_cmyk_hash()
1859            {
1860                let avail_pixels = data.len() / 4;
1861                let icc_pixels = avail_pixels.min(npixels);
1862                if icc_pixels > 0
1863                    && let Some(rgb) = cache.convert_image_8bit(cmyk_hash, data, icc_pixels)
1864                {
1865                    let mut rgba = vec![255u8; npixels * 4];
1866                    for i in 0..icc_pixels {
1867                        rgba[i * 4] = rgb[i * 3];
1868                        rgba[i * 4 + 1] = rgb[i * 3 + 1];
1869                        rgba[i * 4 + 2] = rgb[i * 3 + 2];
1870                        // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1871                        if opm_zero_transparent {
1872                            let si = i * 4;
1873                            if data[si] == 0
1874                                && data[si + 1] == 0
1875                                && data[si + 2] == 0
1876                                && data[si + 3] == 0
1877                            {
1878                                rgba[i * 4 + 3] = 0;
1879                            }
1880                        }
1881                    }
1882                    // Remaining pixels (if data was short) stay white (0xFF)
1883                    return rgba;
1884                }
1885            }
1886            // Fallback: PLRM CMYK→RGB formula
1887            let mut rgba = vec![255u8; npixels * 4];
1888            for i in 0..npixels {
1889                let si = i * 4;
1890                let c = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1891                let m = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1892                let y = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1893                let k = data.get(si + 3).copied().unwrap_or(0) as f64 / 255.0;
1894                let r = (1.0 - c.min(1.0)) * (1.0 - k.min(1.0));
1895                let g = (1.0 - m.min(1.0)) * (1.0 - k.min(1.0));
1896                let b = (1.0 - y.min(1.0)) * (1.0 - k.min(1.0));
1897                let pi = i * 4;
1898                rgba[pi] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
1899                rgba[pi + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
1900                rgba[pi + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
1901                // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1902                if opm_zero_transparent
1903                    && data.get(si).copied().unwrap_or(0) == 0
1904                    && data.get(si + 1).copied().unwrap_or(0) == 0
1905                    && data.get(si + 2).copied().unwrap_or(0) == 0
1906                    && data.get(si + 3).copied().unwrap_or(0) == 0
1907                {
1908                    rgba[pi + 3] = 0;
1909                }
1910            }
1911            rgba
1912        }
1913        ImageColorSpace::ICCBased {
1914            n,
1915            profile_hash,
1916            profile_data,
1917        } => {
1918            // Try ICC-based conversion if cache is available. Routes through
1919            // the proofing chain (`chain_per_intent_8bit[intent]`) when the
1920            // chain has been populated for this intent — the proofing chain
1921            // is what `convert_color_with_intent` uses for vector paints,
1922            // so images need it too to match. Without this, an Adobe-RGB
1923            // image renders via the source profile's direct RGB→sRGB while
1924            // the surrounding CMYK paint goes through the OutputIntent
1925            // CMYK→sRGB; the two sRGB outputs diverge. GWG 17.2 calibrates
1926            // both so they match under correct CMS, and the test's "X"
1927            // appears whenever the image bypasses the OI roundtrip.
1928            let intent = stet_graphics::icc::intent_from_pdf_byte(params.rendering_intent);
1929            if let Some(cache) = icc
1930                && cache.has_profile(profile_hash)
1931                && let Some(rgb) =
1932                    cache.convert_image_8bit_with_intent(profile_hash, data, npixels, intent)
1933            {
1934                let mut rgba = vec![255u8; npixels * 4];
1935                for i in 0..npixels {
1936                    rgba[i * 4] = rgb[i * 3];
1937                    rgba[i * 4 + 1] = rgb[i * 3 + 1];
1938                    rgba[i * 4 + 2] = rgb[i * 3 + 2];
1939                    // OPM=1 on 4-component (CMYK) ICC profiles
1940                    if opm_zero_transparent && *n == 4 {
1941                        let si = i * *n as usize;
1942                        if si + 3 < data.len()
1943                            && data[si] == 0
1944                            && data[si + 1] == 0
1945                            && data[si + 2] == 0
1946                            && data[si + 3] == 0
1947                        {
1948                            rgba[i * 4 + 3] = 0;
1949                        }
1950                    }
1951                }
1952                return rgba;
1953            }
1954            // Fallback to device equivalent based on component count
1955            let _ = (profile_hash, profile_data);
1956            let fallback = match n {
1957                1 => ImageColorSpace::DeviceGray,
1958                4 => ImageColorSpace::DeviceCMYK,
1959                _ => ImageColorSpace::DeviceRGB,
1960            };
1961            let p = ImageParams {
1962                color_space: fallback,
1963                bits_per_component: 8,
1964                ..params.clone()
1965            };
1966            samples_to_rgba(data, &p, icc, opm_zero_transparent)
1967        }
1968        ImageColorSpace::Indexed {
1969            base,
1970            hival,
1971            lookup,
1972        } => {
1973            let base_ncomp = base.num_components() as usize;
1974            // Expand indexed samples to base color space, then convert
1975            let mut expanded = Vec::with_capacity(npixels * base_ncomp);
1976            for i in 0..npixels {
1977                let idx = data.get(i).copied().unwrap_or(0) as usize;
1978                let idx = idx.min(*hival as usize);
1979                let offset = idx * base_ncomp;
1980                for c in 0..base_ncomp {
1981                    expanded.push(lookup.get(offset + c).copied().unwrap_or(0));
1982                }
1983            }
1984            let p = ImageParams {
1985                color_space: *base.clone(),
1986                bits_per_component: 8,
1987                ..params.clone()
1988            };
1989            samples_to_rgba(&expanded, &p, icc, opm_zero_transparent)
1990        }
1991        ImageColorSpace::CIEBasedABC { params: cie_params } => {
1992            let mut rgba = vec![255u8; npixels * 4];
1993            for i in 0..npixels {
1994                let si = i * 3;
1995                let a = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1996                let b = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1997                let c = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1998                let color = DeviceColor::from_cie_abc(a, b, c, cie_params);
1999                let pi = i * 4;
2000                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
2001                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
2002                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
2003            }
2004            rgba
2005        }
2006        ImageColorSpace::CIEBasedA { params: cie_params } => {
2007            let mut rgba = vec![255u8; npixels * 4];
2008            for i in 0..npixels {
2009                let val = data.get(i).copied().unwrap_or(0) as f64 / 255.0;
2010                let color = DeviceColor::from_cie_a(val, cie_params);
2011                let pi = i * 4;
2012                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
2013                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
2014                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
2015            }
2016            rgba
2017        }
2018        ImageColorSpace::Lab { range, .. } => {
2019            let mut rgba = vec![255u8; npixels * 4];
2020            let a_span = range[1] - range[0];
2021            let b_span = range[3] - range[2];
2022            for i in 0..npixels {
2023                let si = i * 3;
2024                let l = data.get(si).copied().unwrap_or(0) as f64 / 255.0 * 100.0;
2025                let a = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0 * a_span + range[0];
2026                let b = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0 * b_span + range[2];
2027                let color = DeviceColor::from_lab(l, a, b, range);
2028                let pi = i * 4;
2029                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
2030                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
2031                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
2032            }
2033            rgba
2034        }
2035        ImageColorSpace::Separation {
2036            alt_space,
2037            tint_table,
2038            ..
2039        } => {
2040            // 1 byte per pixel → lookup in tint table → convert alt space to RGB
2041            // For CMYK alt space with ICC, build bulk CMYK data and convert via ICC
2042            if matches!(alt_space.as_ref(), ImageColorSpace::DeviceCMYK)
2043                && let Some(rgba) = tint_separation_via_icc(data, npixels, tint_table, icc)
2044            {
2045                return rgba;
2046            }
2047            let mut rgba = vec![255u8; npixels * 4];
2048            let no = tint_table.num_outputs as usize;
2049            let mut alt_comps = vec![0.0f32; no];
2050            for i in 0..npixels {
2051                let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
2052                tint_table.lookup_1d(tint, &mut alt_comps);
2053                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
2054                let pi = i * 4;
2055                rgba[pi] = r;
2056                rgba[pi + 1] = g;
2057                rgba[pi + 2] = b;
2058            }
2059            rgba
2060        }
2061        ImageColorSpace::DeviceN {
2062            alt_space,
2063            tint_table,
2064            ..
2065        } => {
2066            let ni = tint_table.num_inputs as usize;
2067            let no = tint_table.num_outputs as usize;
2068            // For CMYK alt space with ICC, build bulk CMYK data and convert via ICC
2069            if matches!(alt_space.as_ref(), ImageColorSpace::DeviceCMYK)
2070                && let Some(rgba) = tint_devicen_via_icc(data, npixels, ni, tint_table, icc)
2071            {
2072                return rgba;
2073            }
2074            let mut rgba = vec![255u8; npixels * 4];
2075            let mut inputs = vec![0.0f32; ni];
2076            let mut alt_comps = vec![0.0f32; no];
2077            for i in 0..npixels {
2078                let si = i * ni;
2079                for (c, inp) in inputs.iter_mut().enumerate() {
2080                    *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
2081                }
2082                tint_table.lookup_nd(&inputs, &mut alt_comps);
2083                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
2084                let pi = i * 4;
2085                rgba[pi] = r;
2086                rgba[pi + 1] = g;
2087                rgba[pi + 2] = b;
2088            }
2089            rgba
2090        }
2091        ImageColorSpace::Mask {
2092            color, polarity, ..
2093        } => {
2094            let mut rgba = vec![0u8; npixels * 4];
2095            let r = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
2096            let g = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
2097            let b = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
2098            let bytes_per_row = (w).div_ceil(8);
2099            for row in 0..h {
2100                for col in 0..w {
2101                    let byte_idx = row * bytes_per_row + col / 8;
2102                    let bit_offset = 7 - (col % 8);
2103                    let bit = if byte_idx < data.len() {
2104                        (data[byte_idx] >> bit_offset) & 1
2105                    } else {
2106                        0
2107                    };
2108                    let paint = if *polarity { bit == 1 } else { bit == 0 };
2109                    if paint {
2110                        let pi = (row * w + col) * 4;
2111                        rgba[pi] = r;
2112                        rgba[pi + 1] = g;
2113                        rgba[pi + 2] = b;
2114                        rgba[pi + 3] = 255;
2115                    }
2116                }
2117            }
2118            rgba
2119        }
2120        _ => vec![0u8; npixels * 4],
2121    }
2122}
2123
2124/// Convert Separation (1-input) tint table output through ICC CMYK profile.
2125/// Builds 4-byte CMYK data from tint table, then bulk-converts via ICC 8-bit transform.
2126fn tint_separation_via_icc(
2127    data: &[u8],
2128    npixels: usize,
2129    tint_table: &TintLookupTable,
2130    icc: Option<&IccCache>,
2131) -> Option<Vec<u8>> {
2132    let cache = icc?;
2133    let cmyk_hash = cache.default_cmyk_hash()?;
2134    // Build CMYK byte buffer from tint table
2135    let mut cmyk_data = vec![0u8; npixels * 4];
2136    let mut alt_comps = [0.0f32; 4];
2137    for i in 0..npixels {
2138        let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
2139        tint_table.lookup_1d(tint, &mut alt_comps);
2140        let si = i * 4;
2141        cmyk_data[si] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2142        cmyk_data[si + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2143        cmyk_data[si + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2144        cmyk_data[si + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2145    }
2146    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2147    let mut rgba = vec![255u8; npixels * 4];
2148    for i in 0..npixels {
2149        rgba[i * 4] = rgb[i * 3];
2150        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2151        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2152    }
2153    Some(rgba)
2154}
2155
2156/// Convert DeviceN (N-input) tint table output through ICC CMYK profile.
2157fn tint_devicen_via_icc(
2158    data: &[u8],
2159    npixels: usize,
2160    ni: usize,
2161    tint_table: &TintLookupTable,
2162    icc: Option<&IccCache>,
2163) -> Option<Vec<u8>> {
2164    let cache = icc?;
2165    let cmyk_hash = cache.default_cmyk_hash()?;
2166    let mut cmyk_data = vec![0u8; npixels * 4];
2167    let mut inputs = vec![0.0f32; ni];
2168    let mut alt_comps = [0.0f32; 4];
2169    for i in 0..npixels {
2170        let si = i * ni;
2171        for (c, inp) in inputs.iter_mut().enumerate() {
2172            *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
2173        }
2174        tint_table.lookup_nd(&inputs, &mut alt_comps);
2175        let di = i * 4;
2176        cmyk_data[di] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2177        cmyk_data[di + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2178        cmyk_data[di + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2179        cmyk_data[di + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2180    }
2181    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2182    let mut rgba = vec![255u8; npixels * 4];
2183    for i in 0..npixels {
2184        rgba[i * 4] = rgb[i * 3];
2185        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2186        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2187    }
2188    Some(rgba)
2189}
2190
2191/// Convert alt-space f32 component values to RGB bytes.
2192fn alt_comps_to_rgb(comps: &[f32], alt_space: &ImageColorSpace) -> (u8, u8, u8) {
2193    match alt_space {
2194        ImageColorSpace::DeviceGray => {
2195            let g = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2196            (g, g, g)
2197        }
2198        ImageColorSpace::DeviceRGB => {
2199            let r = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2200            let g = (comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2201            let b = (comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2202            (r, g, b)
2203        }
2204        ImageColorSpace::DeviceCMYK => {
2205            let c = comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0);
2206            let m = comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2207            let y = comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2208            let k = comps.get(3).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2209            let r = ((1.0 - (c + k).min(1.0)) * 255.0).round() as u8;
2210            let g = ((1.0 - (m + k).min(1.0)) * 255.0).round() as u8;
2211            let b = ((1.0 - (y + k).min(1.0)) * 255.0).round() as u8;
2212            (r, g, b)
2213        }
2214        _ => (0, 0, 0),
2215    }
2216}
2217
2218/// Apply ImageType 4 mask color transparency to RGBA data.
2219fn apply_mask_color_rgba(rgba: &mut [u8], sample_data: &[u8], params: &ImageParams) {
2220    let mask_color = match &params.mask_color {
2221        Some(mc) => mc,
2222        None => return,
2223    };
2224    let ncomp = params.color_space.num_components() as usize;
2225    let npixels = params.width as usize * params.height as usize;
2226    let is_range = mask_color.len() == 2 * ncomp;
2227
2228    for i in 0..npixels {
2229        let si = i * ncomp;
2230        let matched = if is_range {
2231            (0..ncomp).all(|c| {
2232                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2233                let min_val = mask_color.get(c * 2).copied().unwrap_or(0);
2234                let max_val = mask_color.get(c * 2 + 1).copied().unwrap_or(0);
2235                sample >= min_val && sample <= max_val
2236            })
2237        } else {
2238            (0..ncomp).all(|c| {
2239                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2240                let target = mask_color.get(c).copied().unwrap_or(0);
2241                sample == target
2242            })
2243        };
2244        if matched {
2245            let pi = i * 4;
2246            if pi + 3 < rgba.len() {
2247                rgba[pi] = 0;
2248                rgba[pi + 1] = 0;
2249                rgba[pi + 2] = 0;
2250                rgba[pi + 3] = 0;
2251            }
2252        }
2253    }
2254}
2255
2256/// Choose filter quality for image drawing.
2257///
2258/// When `interpolate` is false, use Nearest for upscaling (crisp pixel edges)
2259/// and Bilinear only for downscaling (proper area averaging). When `interpolate`
2260/// is true, use Bilinear for any scaling.
2261fn image_filter_quality(transform: Transform, interpolate: bool) -> stet_tiny_skia::FilterQuality {
2262    let eff_sx = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2263    let eff_sy = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2264    let min_scale = eff_sx.min(eff_sy);
2265    // Near-exact 1:1: Nearest is pixel-perfect and faster
2266    if (eff_sx - 1.0).abs() < 0.01 && (eff_sy - 1.0).abs() < 0.01 {
2267        stet_tiny_skia::FilterQuality::Nearest
2268    } else if !interpolate && min_scale >= 0.95 {
2269        // Non-interpolated upscaling: nearest-neighbor for crisp pixel edges
2270        stet_tiny_skia::FilterQuality::Nearest
2271    } else {
2272        stet_tiny_skia::FilterQuality::Bilinear
2273    }
2274}
2275
2276/// For rotated/sheared transforms: integer box-filter pre-downsample, leaving
2277/// the fractional remainder to tiny-skia's bilinear.
2278///
2279/// Returns `None` if no pre-scaling is needed.
2280fn prescale_image(
2281    rgba_data: &[u8],
2282    w: u32,
2283    h: u32,
2284    transform: Transform,
2285    interpolate: bool,
2286) -> Option<(Vec<u8>, u32, u32, Transform)> {
2287    // Compute effective scale factors from the 2×2 part of the transform.
2288    let scale_x = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2289    let scale_y = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2290    let min_scale = scale_x.min(scale_y);
2291
2292    // Upscaling: only apply bicubic resampling when Interpolate is true.
2293    // Per PLRM/PDF spec, non-interpolated images should use nearest-neighbor
2294    // for upscaling (crisp pixel boundaries, no smoothing).
2295    if min_scale > 1.05 {
2296        if interpolate {
2297            let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2298            if is_axis_aligned && w >= 2 && h >= 2 {
2299                let dw = (w as f32 * transform.sx.abs()).round().max(1.0) as u32;
2300                let dh = (h as f32 * transform.sy.abs()).round().max(1.0) as u32;
2301                if dw > w || dh > h {
2302                    let resampled = bicubic_resample(rgba_data, w, h, dw, dh);
2303                    let new_sx = transform.sx * w as f32 / dw as f32;
2304                    let new_sy = transform.sy * h as f32 / dh as f32;
2305                    let adjusted = Transform::from_row(
2306                        new_sx,
2307                        transform.ky,
2308                        transform.kx,
2309                        new_sy,
2310                        transform.tx,
2311                        transform.ty,
2312                    );
2313                    return Some((resampled, dw, dh, adjusted));
2314                }
2315            }
2316        }
2317        return None;
2318    }
2319
2320    // Near 1:1 — no prescaling needed.
2321    if min_scale >= 0.95 {
2322        return None;
2323    }
2324
2325    // Axis-aligned: use area-average box filter to target dimensions.
2326    // Much faster than Lanczos3 and produces equally good results for downscaling.
2327    let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2328    if is_axis_aligned && w >= 2 && h >= 2 {
2329        let dw = (w as f32 * transform.sx.abs()).ceil().max(1.0) as u32;
2330        let dh = (h as f32 * transform.sy.abs()).ceil().max(1.0) as u32;
2331        if dw < w || dh < h {
2332            let resampled = box_resample(rgba_data, w, h, dw, dh);
2333            // Adjust transform so scale ≈ ±1 (sign preserved), same translation.
2334            let new_sx = transform.sx * w as f32 / dw as f32;
2335            let new_sy = transform.sy * h as f32 / dh as f32;
2336            let adjusted = Transform::from_row(
2337                new_sx,
2338                transform.ky,
2339                transform.kx,
2340                new_sy,
2341                transform.tx,
2342                transform.ty,
2343            );
2344            return Some((resampled, dw, dh, adjusted));
2345        }
2346    }
2347
2348    // Fallback for rotated/sheared: integer box filter.
2349    let factor = (1.0 / min_scale) as u32;
2350    if factor < 2 || w < factor || h < factor {
2351        return None;
2352    }
2353    let nw = w / factor;
2354    let nh = h / factor;
2355    if nw == 0 || nh == 0 {
2356        return None;
2357    }
2358    let area = factor * factor;
2359    let half = area / 2;
2360    let stride = w as usize * 4;
2361    let mut out = vec![0u8; (nw * nh * 4) as usize];
2362    for dy in 0..nh {
2363        for dx in 0..nw {
2364            let (mut r, mut g, mut b, mut a) = (0u32, 0u32, 0u32, 0u32);
2365            let sy0 = (dy * factor) as usize;
2366            let sx0 = (dx * factor) as usize;
2367            for iy in 0..factor as usize {
2368                let row = (sy0 + iy) * stride + sx0 * 4;
2369                for ix in 0..factor as usize {
2370                    let i = row + ix * 4;
2371                    r += rgba_data[i] as u32;
2372                    g += rgba_data[i + 1] as u32;
2373                    b += rgba_data[i + 2] as u32;
2374                    a += rgba_data[i + 3] as u32;
2375                }
2376            }
2377            let di = (dy * nw + dx) as usize * 4;
2378            out[di] = ((r + half) / area) as u8;
2379            out[di + 1] = ((g + half) / area) as u8;
2380            out[di + 2] = ((b + half) / area) as u8;
2381            out[di + 3] = ((a + half) / area) as u8;
2382        }
2383    }
2384    let f = factor as f32;
2385    let adjusted = Transform::from_row(
2386        transform.sx * f,
2387        transform.ky * f,
2388        transform.kx * f,
2389        transform.sy * f,
2390        transform.tx,
2391        transform.ty,
2392    );
2393    Some((out, nw, nh, adjusted))
2394}
2395
2396/// Translate a device-space ClipRect into band-local coordinates.
2397fn translate_clip_rect(rect: &ClipRect, y_start: u32, band_h: u32) -> ClipRect {
2398    ClipRect {
2399        x0: rect.x0,
2400        y0: rect.y0.saturating_sub(y_start).min(band_h),
2401        x1: rect.x1,
2402        y1: rect.y1.saturating_sub(y_start).min(band_h),
2403    }
2404}
2405
2406/// Ensure an image transform maps to at least 1 device pixel in each dimension.
2407///
2408/// PDFs commonly draw rules and borders using tiny image masks (1×1 or 4×1 pixels)
2409/// scaled via the CTM to thin rectangles. At low DPI these can map to sub-pixel
2410/// device dimensions and vanish. This adjusts the transform's scale components
2411/// so the image covers at least 1 pixel in each direction.
2412fn enforce_min_image_size(transform: Transform, img_w: u32, img_h: u32) -> Transform {
2413    // Effective device-space dimensions
2414    let eff_w =
2415        ((transform.sx * img_w as f32).powi(2) + (transform.ky * img_w as f32).powi(2)).sqrt();
2416    let eff_h =
2417        ((transform.kx * img_h as f32).powi(2) + (transform.sy * img_h as f32).powi(2)).sqrt();
2418
2419    if eff_w >= 1.0 && eff_h >= 1.0 {
2420        return transform;
2421    }
2422
2423    // Only boost if the image is a thin rule (large aspect ratio).
2424    // Small images that are sub-pixel in both dimensions (e.g. tiny dots)
2425    // are left as-is — boosting them would create visible artifacts.
2426    let ratio = eff_w.max(eff_h) / eff_w.min(eff_h).max(0.001);
2427    if ratio < 3.0 {
2428        return transform;
2429    }
2430
2431    let mut t = transform;
2432    if eff_w < 1.0 && eff_w > 0.001 {
2433        let boost = 1.0 / eff_w;
2434        t.sx *= boost;
2435        t.ky *= boost;
2436    }
2437    if eff_h < 1.0 && eff_h > 0.001 {
2438        let boost = 1.0 / eff_h;
2439        t.kx *= boost;
2440        t.sy *= boost;
2441    }
2442    t
2443}
2444
2445/// Compute minimum line width for hairline strokes at a given DPI and CTM.
2446/// Returns the minimum width in user-space units that ensures at least
2447/// 0.5 device pixels at ≤150 DPI or 1.0 device pixel above 150 DPI.
2448fn hairline_min_width(ctm: &Matrix, dpi: f64) -> f64 {
2449    let (a, b, c, d) = (ctm.a, ctm.b, ctm.c, ctm.d);
2450    let sum_sq = a * a + b * b + c * c + d * d;
2451    let diff = ((a * a + b * b - c * c - d * d).powi(2) + 4.0 * (a * c + b * d).powi(2)).sqrt();
2452    let s_max = (0.5 * (sum_sq + diff)).max(0.0).sqrt();
2453    let min_px = if dpi <= 150.0 { 0.5 } else { 1.0 };
2454    if s_max > 1e-10 {
2455        min_px / s_max
2456    } else {
2457        min_px
2458    }
2459}
2460
2461/// True when the paint's source CMYK is K-only (C=M=Y=0, any K).
2462/// Used to route OPM 0 DeviceCMYK paints that encode "K-only" — like
2463/// `0 0 0 0.5 k` — through the per-pixel overprint path, so the no-op delta
2464/// skip can preserve a spot-painted backdrop at pixels where K already equals
2465/// the source value.
2466fn is_k_only_src(color: &DeviceColor) -> bool {
2467    if let Some((c, m, y, _k)) = color.native_cmyk {
2468        c == 0.0 && m == 0.0 && y == 0.0
2469    } else {
2470        false
2471    }
2472}
2473
2474/// Detect a DeviceGray paint that should be promoted to CMYK_K for overprint.
2475///
2476/// DeviceGray `g` sets `painted_channels = 0` and leaves `native_cmyk = None`,
2477/// so overprint dispatch can't see it as a K-ink paint. When overprint is
2478/// active we re-describe the paint as DeviceCMYK `(0, 0, 0, 1-g)` with
2479/// `painted_channels = CMYK_K`: it flows through the subset path, only the K
2480/// plate is touched, and the pixmap is updated multiplicatively so any
2481/// backdrop spot contribution survives.
2482fn needs_gray_promotion(
2483    overprint: bool,
2484    painted_channels: u8,
2485    is_device_cmyk: bool,
2486    color: &DeviceColor,
2487) -> Option<f64> {
2488    if !overprint
2489        || painted_channels != 0
2490        || is_device_cmyk
2491        || color.native_cmyk.is_some()
2492        || color.process_cmyk.is_some()
2493    {
2494        return None;
2495    }
2496    let r = color.r;
2497    if (r - color.g).abs() > f64::EPSILON || (r - color.b).abs() > f64::EPSILON {
2498        return None;
2499    }
2500    Some(r.clamp(0.0, 1.0))
2501}
2502
2503/// Promote a gray `FillParams` to a DeviceCMYK K-only overprint description if
2504/// the paint qualifies (see [`needs_gray_promotion`]).
2505fn maybe_promote_gray_fill<'a>(
2506    params: &'a FillParams,
2507    buf: &'a mut Option<FillParams>,
2508) -> &'a FillParams {
2509    if let Some(gray) = needs_gray_promotion(
2510        params.overprint,
2511        params.painted_channels,
2512        params.is_device_cmyk,
2513        &params.color,
2514    ) {
2515        let mut promoted = params.clone();
2516        promoted.is_device_cmyk = true;
2517        promoted.painted_channels = stet_graphics::device::CMYK_K;
2518        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2519        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2520        *buf = Some(promoted);
2521        return buf.as_ref().unwrap();
2522    }
2523    params
2524}
2525
2526/// Promote a gray `StrokeParams` to a DeviceCMYK K-only overprint description.
2527fn maybe_promote_gray_stroke<'a>(
2528    params: &'a StrokeParams,
2529    buf: &'a mut Option<StrokeParams>,
2530) -> &'a StrokeParams {
2531    if let Some(gray) = needs_gray_promotion(
2532        params.overprint,
2533        params.painted_channels,
2534        params.is_device_cmyk,
2535        &params.color,
2536    ) {
2537        let mut promoted = params.clone();
2538        promoted.is_device_cmyk = true;
2539        promoted.painted_channels = stet_graphics::device::CMYK_K;
2540        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2541        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2542        *buf = Some(promoted);
2543        return buf.as_ref().unwrap();
2544    }
2545    params
2546}
2547
2548/// Build a stroke with minimum line-width enforcement (shared by trait impl and band rendering).
2549/// `dpi` is the device resolution, used to select the hairline minimum width:
2550/// at ≤150 DPI use 0.6 device pixels; above 150 DPI use 1.0 device pixel.
2551fn build_stroke(params: &StrokeParams, dpi: f64) -> Stroke {
2552    let min_lw = hairline_min_width(&params.ctm, dpi);
2553    let mut stroke = Stroke {
2554        width: (params.line_width as f32).max(min_lw as f32),
2555        line_cap: to_line_cap(params.line_cap),
2556        line_join: to_line_join(params.line_join),
2557        miter_limit: params.miter_limit as f32,
2558        ..Stroke::default()
2559    };
2560    if !params.dash_pattern.array.is_empty() {
2561        let mut dash_array: Vec<f32> = params
2562            .dash_pattern
2563            .array
2564            .iter()
2565            .map(|&v| v as f32)
2566            .collect();
2567        // PostScript allows odd-length dash arrays (implicitly doubled),
2568        // but tiny-skia requires even length. Double odd arrays to match PS semantics.
2569        if dash_array.len() % 2 == 1 {
2570            let clone = dash_array.clone();
2571            dash_array.extend_from_slice(&clone);
2572        }
2573        if let Some(dash) = StrokeDash::new(dash_array, params.dash_pattern.offset as f32) {
2574            stroke.dash = Some(dash);
2575        }
2576    }
2577    stroke
2578}
2579
2580/// Apply stroke adjustment: snap axis-aligned path segments to device pixel
2581/// centers so thin strokes render with consistent weight.
2582///
2583/// For a stroke of width W in device pixels:
2584/// - Odd-integer width (1, 3, ...): snap to half-pixel (floor(x) + 0.5)
2585/// - Even-integer width or non-integer: snap to pixel edge (round(x))
2586/// - For hairlines (device width < 1.5): always snap to half-pixel
2587///
2588/// Only axis-aligned segments (horizontal/vertical lines) are snapped.
2589/// Diagonal/curved segments are left as-is since snapping would distort them.
2590///
2591/// Check whether a CTM indicates the path is already in device space (identity
2592/// or simple Y-flip/translation). Stroke adjustment snaps coordinates to pixel
2593/// boundaries, which only makes sense when path coordinates are device pixels.
2594/// PDF Form XObjects with large scale factors (e.g. [405, 0, 0, 283, ...]) would
2595/// cause catastrophic snapping if treated as device-space paths.
2596fn ctm_is_device_space(ctm: &Matrix) -> bool {
2597    (ctm.a.abs() - 1.0).abs() < 0.01
2598        && ctm.b.abs() < 0.01
2599        && ctm.c.abs() < 0.01
2600        && (ctm.d.abs() - 1.0).abs() < 0.01
2601}
2602
2603/// Apply stroke adjustment for viewport rendering.
2604///
2605/// Path coordinates are in reference-DPI device space. The viewport transform
2606/// maps them to output pixels: out = (ref - vp_origin) * scale.
2607/// We snap in output pixel space then map back to reference space.
2608fn stroke_adjust_path_viewport(
2609    path: &PsPath,
2610    device_width: f64,
2611    scale_x: f64,
2612    scale_y: f64,
2613    vp_x: f64,
2614    vp_y: f64,
2615) -> PsPath {
2616    let use_half_pixel = device_width < 1.5 || (device_width.round() as i32) % 2 == 1;
2617
2618    // Snap a reference-space coordinate to the output pixel grid, then map back
2619    let snap_x = |v: f64| -> f64 {
2620        let out = (v - vp_x) * scale_x;
2621        let snapped = if use_half_pixel {
2622            out.floor() + 0.5
2623        } else {
2624            out.round()
2625        };
2626        snapped / scale_x + vp_x
2627    };
2628    let snap_y = |v: f64| -> f64 {
2629        let out = (v - vp_y) * scale_y;
2630        let snapped = if use_half_pixel {
2631            out.floor() + 0.5
2632        } else {
2633            out.round()
2634        };
2635        snapped / scale_y + vp_y
2636    };
2637
2638    let mut result = PsPath::new();
2639    let mut prev_x = 0.0_f64;
2640    let mut prev_y = 0.0_f64;
2641
2642    for seg in &path.segments {
2643        match *seg {
2644            PathSegment::MoveTo(x, y) => {
2645                prev_x = x;
2646                prev_y = y;
2647                result.segments.push(PathSegment::MoveTo(x, y));
2648            }
2649            PathSegment::LineTo(x, y) => {
2650                let is_horizontal = (y - prev_y).abs() < 1e-6;
2651                let is_vertical = (x - prev_x).abs() < 1e-6;
2652
2653                if is_horizontal {
2654                    let snapped_y = snap_y(y);
2655                    if let Some(PathSegment::MoveTo(_, ly) | PathSegment::LineTo(_, ly)) =
2656                        result.segments.last_mut()
2657                    {
2658                        *ly = snapped_y;
2659                    }
2660                    result.segments.push(PathSegment::LineTo(x, snapped_y));
2661                    prev_x = x;
2662                    prev_y = snapped_y;
2663                } else if is_vertical {
2664                    let snapped_x = snap_x(x);
2665                    if let Some(PathSegment::MoveTo(lx, _) | PathSegment::LineTo(lx, _)) =
2666                        result.segments.last_mut()
2667                    {
2668                        *lx = snapped_x;
2669                    }
2670                    result.segments.push(PathSegment::LineTo(snapped_x, y));
2671                    prev_x = snapped_x;
2672                    prev_y = y;
2673                } else {
2674                    result.segments.push(PathSegment::LineTo(x, y));
2675                    prev_x = x;
2676                    prev_y = y;
2677                }
2678            }
2679            PathSegment::CurveTo {
2680                x1,
2681                y1,
2682                x2,
2683                y2,
2684                x3,
2685                y3,
2686            } => {
2687                result.segments.push(PathSegment::CurveTo {
2688                    x1,
2689                    y1,
2690                    x2,
2691                    y2,
2692                    x3,
2693                    y3,
2694                });
2695                prev_x = x3;
2696                prev_y = y3;
2697            }
2698            PathSegment::ClosePath => {
2699                result.segments.push(PathSegment::ClosePath);
2700            }
2701        }
2702    }
2703    result
2704}
2705
2706/// Process a single display list element into a pixmap using the given render context.
2707///
2708/// This unified function handles both band rendering (scale=1.0) and viewport
2709/// rendering (arbitrary scale). Band rendering is viewport rendering with
2710/// `scale_x = scale_y = 1.0`.
2711fn render_element(
2712    pixmap: &mut Pixmap,
2713    band_state: &mut BandState,
2714    element: &DisplayElement,
2715    ctx: &RenderContext<'_>,
2716) {
2717    match element {
2718        DisplayElement::Fill { path, params } => {
2719            // DeviceGray with overprint behaves as a K-only process paint —
2720            // promote it to DeviceCMYK (0, 0, 0, 1-gray) with painted_channels
2721            // set to CMYK_K so it flows through the overprint subset path,
2722            // preserving backdrop CMY plates and the spot-derived visual
2723            // instead of knocking the pixmap out with plain RGB gray.
2724            let mut promoted_fill: Option<FillParams> = None;
2725            let params = maybe_promote_gray_fill(params, &mut promoted_fill);
2726            // Use the overprint compositing path whenever the fill needs
2727            // per-channel CMYK rendering. Five cases trigger it:
2728            //   1. Subset painted_channels (Separation /Magenta, DeviceN, etc.)
2729            //      — only the named channels touch the buffer; the rest are
2730            //      preserved from the backdrop.
2731            //   2. DeviceCMYK + OPM 1 — zero-valued components don't paint, so
2732            //      a per-pixel filter is required.
2733            //   3. Custom spot (painted_channels=0, non-CMYK, with native_cmyk)
2734            //      under overprint — process plates must be preserved; the
2735            //      spot's alt-CMYK only contributes multiplicatively to RGB.
2736            //   4. DeviceCMYK + overprint (any OPM) with CMYK_ALL — the per-
2737            //      pixel path lets us recognise a "no-op" overprint (src CMYK
2738            //      == backdrop CMYK) and leave the pixmap untouched, which
2739            //      preserves any spot-derived colour already visible there.
2740            //   5. (Combinations of the above.)
2741            // Only fires for Normal blend; non-Normal blend modes handle zero
2742            // values through their blend math, not through overprint filtering.
2743            // Includes text glyphs: when overprint is meaningful (the test
2744            // suite's GWG 1.0 swatches f/a use Separation /Magenta + glyphs),
2745            // correctness wins over the slight AA difference vs tiny-skia.
2746            let painted = params.painted_channels;
2747            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2748            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2749            // Real Separation/DeviceN custom spots set `process_cmyk` (even pure
2750            // spots set it to `(0, 0, 0, 0)`); ICCBased RGB routed through the
2751            // proofing chain has `native_cmyk` populated but leaves
2752            // `process_cmyk == None`. Per PDF 1.7 §11.7.4.5 a non-process source
2753            // colour space (CalGray/CalRGB/Lab/ICCBased) must paint as if /OP
2754            // were false — gating on `process_cmyk.is_some()` keeps ICCBased RGB
2755            // out of the overprint path so GWG 13.3 (ICC RGB X over CMYK BG)
2756            // knocks out instead of preserving the backdrop's CMYK plates.
2757            let custom_spot = painted == 0
2758                && !params.is_device_cmyk
2759                && params.color.native_cmyk.is_some()
2760                && params.color.process_cmyk.is_some();
2761            // A "near-K-only" DeviceCMYK paint under OPM 0 — e.g. `0 0 0 0.5 k`
2762            // — matches the Black-component plate of a DeviceN [Black, spot]
2763            // backdrop exactly. Routing it through the per-pixel path lets the
2764            // no-op-delta skip preserve the spot-derived colour instead of
2765            // wiping it with plain grey (GWG 3.0 "50% K over spot").
2766            let is_k_only_cmyk =
2767                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2768            let needs_overprint = params.overprint
2769                && band_state.cmyk_buffer.is_some()
2770                && params.blend_mode == 0
2771                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2772
2773            if needs_overprint {
2774                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2775                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2776                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2777                render_overprint_fill(
2778                    pixmap,
2779                    &mut cmyk_buf,
2780                    &mut op_bg,
2781                    &mut op_touched,
2782                    &spot_mask,
2783                    band_state,
2784                    path,
2785                    params,
2786                    ctx.vp_x,
2787                    ctx.vp_y,
2788                    ctx.scale_x,
2789                    ctx.scale_y,
2790                    ctx.out_w,
2791                    ctx.out_h,
2792                    ctx.icc,
2793                    ctx.no_aa,
2794                );
2795                band_state.cmyk_buffer = Some(cmyk_buf);
2796                band_state.restore_op_buffers(op_bg, op_touched);
2797                band_state.restore_spot_mask(spot_mask);
2798            } else {
2799                let Some(skia_path) = build_skia_path(path) else {
2800                    return;
2801                };
2802                let mut temp_mask = None;
2803                let Some(mask_ref) = resolve_clip_mask(
2804                    &band_state.clip_region,
2805                    &mut temp_mask,
2806                    ctx.out_w,
2807                    ctx.out_h,
2808                ) else {
2809                    return;
2810                };
2811                let paint =
2812                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2813                let transform = ctx.transform(&params.ctm);
2814
2815                // Detect degenerate fill paths: rectangles/lines with zero extent
2816                // in one dimension. These are commonly used in PDFs to draw table
2817                // grid lines as zero-width or zero-height filled rectangles.
2818                // Since they have no area, fill_path produces nothing. Render them
2819                // as hairline strokes instead.
2820                if is_degenerate_fill(path) {
2821                    let stroke = Stroke {
2822                        width: 1.0,
2823                        ..Stroke::default()
2824                    };
2825                    pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2826                } else {
2827                    let fill_rule = to_fill_rule(&params.fill_rule);
2828                    pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
2829                }
2830
2831                // Update CMYK tracking buffer for non-overprint fills
2832                if band_state.cmyk_buffer.is_some() {
2833                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2834                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2835                    update_cmyk_buffer_for_fill(
2836                        &mut cmyk_buf,
2837                        &mut spot_mask,
2838                        path,
2839                        params,
2840                        ctx.vp_x,
2841                        ctx.vp_y,
2842                        ctx.scale_x,
2843                        ctx.scale_y,
2844                        ctx.out_w,
2845                        ctx.out_h,
2846                        &band_state.clip_region,
2847                        ctx.no_aa,
2848                        ctx.icc,
2849                    );
2850                    band_state.cmyk_buffer = Some(cmyk_buf);
2851                    band_state.restore_spot_mask(spot_mask);
2852                }
2853            }
2854        }
2855        DisplayElement::Stroke { path, params } => {
2856            let mut promoted_stroke: Option<StrokeParams> = None;
2857            let params = maybe_promote_gray_stroke(params, &mut promoted_stroke);
2858            let transform = ctx.transform(&params.ctm);
2859            // Build stroke using the composited transform so hairline width
2860            // calculations account for the actual output resolution.
2861            let vp_ctm = Matrix {
2862                a: transform.sx as f64,
2863                b: transform.ky as f64,
2864                c: transform.kx as f64,
2865                d: transform.sy as f64,
2866                tx: 0.0,
2867                ty: 0.0,
2868            };
2869            let vp_params = StrokeParams {
2870                ctm: vp_ctm,
2871                ..params.clone()
2872            };
2873            let stroke = build_stroke(&vp_params, ctx.effective_dpi);
2874
2875            // Apply stroke adjustment — snap in output device space
2876            let adjusted;
2877            let draw_path = if params.stroke_adjust
2878                && stroke.width <= 2.0
2879                && ctm_is_device_space(&params.ctm)
2880            {
2881                adjusted = stroke_adjust_path_viewport(
2882                    path,
2883                    stroke.width as f64,
2884                    ctx.scale_x as f64,
2885                    ctx.scale_y as f64,
2886                    ctx.vp_x as f64,
2887                    ctx.vp_y as f64,
2888                );
2889                &adjusted
2890            } else {
2891                path
2892            };
2893
2894            // Mirror the Fill gating: per-channel CMYK rendering kicks in for
2895            // subset painted_channels (Separation /Magenta, DeviceN, etc.), for
2896            // DeviceCMYK + OPM 1 (zero-valued source components don't paint),
2897            // or for a custom spot (painted=0, non-CMYK) under overprint — so
2898            // the spot applies multiplicatively to RGB without disturbing the
2899            // process plates. GWG 1.0 swatch a/b/f/g need this for the magenta
2900            // X stroke that overlays the same path the fill already drew.
2901            let painted = params.painted_channels;
2902            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2903            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2904            // Mirror the Fill custom-spot gate: ICCBased RGB (proofing-chain
2905            // `native_cmyk`, no `process_cmyk`) must not reach the overprint
2906            // path. PDF 1.7 §11.7.4.5: non-process source spaces paint as if
2907            // /OP were false.
2908            let custom_spot = painted == 0
2909                && !params.is_device_cmyk
2910                && params.color.native_cmyk.is_some()
2911                && params.color.process_cmyk.is_some();
2912            let is_k_only_cmyk =
2913                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2914            let needs_overprint = params.overprint
2915                && band_state.cmyk_buffer.is_some()
2916                && params.blend_mode == 0
2917                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2918
2919            let Some(skia_path) = build_skia_path(draw_path) else {
2920                return;
2921            };
2922            let mut temp_mask = None;
2923            let Some(mask_ref) = resolve_clip_mask(
2924                &band_state.clip_region,
2925                &mut temp_mask,
2926                ctx.out_w,
2927                ctx.out_h,
2928            ) else {
2929                return;
2930            };
2931
2932            if needs_overprint {
2933                // Convert the stroke outline to a fill path and route it
2934                // through the same per-channel CMYK compositing logic the
2935                // fill path uses, so the post-overprint result lands in the
2936                // pixmap (not the raw source colour).
2937                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2938                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2939                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2940                render_overprint_stroke(
2941                    pixmap,
2942                    &mut cmyk_buf,
2943                    &mut op_bg,
2944                    &mut op_touched,
2945                    &spot_mask,
2946                    band_state,
2947                    &skia_path,
2948                    &stroke,
2949                    transform,
2950                    params,
2951                    ctx.out_w,
2952                    ctx.out_h,
2953                    ctx.icc,
2954                    ctx.no_aa,
2955                );
2956                band_state.cmyk_buffer = Some(cmyk_buf);
2957                band_state.restore_op_buffers(op_bg, op_touched);
2958                band_state.restore_spot_mask(spot_mask);
2959            } else {
2960                let paint =
2961                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2962                pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2963
2964                if band_state.cmyk_buffer.is_some() {
2965                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2966                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2967                    update_cmyk_buffer_for_stroke(
2968                        &mut cmyk_buf,
2969                        &mut spot_mask,
2970                        draw_path,
2971                        params,
2972                        &stroke,
2973                        transform,
2974                        ctx.out_w,
2975                        ctx.out_h,
2976                        &band_state.clip_region,
2977                        ctx.no_aa,
2978                        ctx.icc,
2979                    );
2980                    band_state.cmyk_buffer = Some(cmyk_buf);
2981                    band_state.restore_spot_mask(spot_mask);
2982                }
2983            }
2984        }
2985        DisplayElement::Clip { path, params } => {
2986            clip_path_unified(band_state, path, params, ctx);
2987        }
2988        DisplayElement::InitClip => {
2989            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2990                band_state.recycle_mask(mask);
2991            }
2992            band_state.clip_region = None;
2993        }
2994        DisplayElement::ErasePage => {
2995            pixmap.fill(Color::TRANSPARENT);
2996            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2997                band_state.recycle_mask(mask);
2998            }
2999            band_state.clip_region = None;
3000        }
3001        DisplayElement::Image {
3002            sample_data,
3003            params,
3004        } => {
3005            let iw = params.width;
3006            let ih = params.height;
3007            if iw == 0 || ih == 0 {
3008                return;
3009            }
3010
3011            let needs_overprint = params.overprint
3012                && band_state.cmyk_buffer.is_some()
3013                && image_supports_overprint(&params.color_space);
3014
3015            if needs_overprint {
3016                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
3017                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
3018                render_overprint_image(
3019                    pixmap,
3020                    &mut cmyk_buf,
3021                    &mut op_bg,
3022                    &mut op_touched,
3023                    band_state,
3024                    sample_data,
3025                    params,
3026                    ctx.vp_x,
3027                    ctx.vp_y,
3028                    ctx.scale_x,
3029                    ctx.scale_y,
3030                    ctx.out_w,
3031                    ctx.out_h,
3032                    ctx.icc,
3033                );
3034                band_state.cmyk_buffer = Some(cmyk_buf);
3035                band_state.restore_op_buffers(op_bg, op_touched);
3036            } else if let Some(pp) = ctx
3037                .preprocessed
3038                .and_then(|pp| pp.get(ctx.elem_idx))
3039                .and_then(|e| e.as_ref())
3040            {
3041                // Fast path: use pre-converted and prescaled image data.
3042                // Only the per-band translation differs; scale factors are cached.
3043                let Some(image_inv) = params.image_matrix.invert() else {
3044                    return;
3045                };
3046                let combined = params.ctm.concat(&image_inv);
3047                let raw_transform = ctx.transform(&combined);
3048                let transform = Transform::from_row(
3049                    pp.adj_sx,
3050                    pp.adj_ky,
3051                    pp.adj_kx,
3052                    pp.adj_sy,
3053                    raw_transform.tx,
3054                    raw_transform.ty,
3055                );
3056
3057                let Some(img_pixmap) =
3058                    stet_tiny_skia::PixmapRef::from_bytes(&pp.data, pp.width, pp.height)
3059                else {
3060                    return;
3061                };
3062                #[allow(unused_assignments)]
3063                let mut temp_mask = None;
3064                let mask_ref = match &band_state.clip_region {
3065                    None => None,
3066                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
3067                    Some(ClipRegion::Rect(rect)) => {
3068                        if rect.is_empty() {
3069                            return;
3070                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
3071                            None
3072                        } else {
3073                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
3074                            temp_mask.as_ref()
3075                        }
3076                    }
3077                };
3078                let img_paint = stet_tiny_skia::PixmapPaint {
3079                    quality: pp.quality,
3080                    opacity: params.alpha as f32,
3081                    blend_mode: u8_to_blend_mode(params.blend_mode),
3082                };
3083                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
3084
3085                // Update CMYK tracking buffer for non-overprint images on the
3086                // fast path. Reading from the post-draw pixmap means the same
3087                // helper handles native-CMYK and non-CMYK source images, even
3088                // though `pp.data` is prescaled and we no longer have a
3089                // matching native RGBA buffer.
3090                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
3091                    update_cmyk_buffer_for_image(
3092                        cmyk_buf,
3093                        sample_data,
3094                        pixmap.data(),
3095                        params,
3096                        ctx.vp_x,
3097                        ctx.vp_y,
3098                        ctx.scale_x,
3099                        ctx.scale_y,
3100                        ctx.out_w,
3101                        ctx.out_h,
3102                        &band_state.clip_region,
3103                        ctx.icc,
3104                    );
3105                }
3106            } else {
3107                // Use pre-converted RGBA from image cache when available
3108                let owned_rgba;
3109                let rgba_data: &[u8] = if let Some(cached) =
3110                    ctx.image_cache.and_then(|c| c.get(ctx.elem_idx))
3111                {
3112                    cached
3113                } else {
3114                    owned_rgba = {
3115                        let mut rgba =
3116                            samples_to_rgba(sample_data, params, ctx.icc, ctx.opm_zero_transparent);
3117                        if params.mask_color.is_some() {
3118                            apply_mask_color_rgba(&mut rgba, sample_data, params);
3119                        }
3120                        rgba
3121                    };
3122                    &owned_rgba
3123                };
3124                let expected = (iw * ih * 4) as usize;
3125                if rgba_data.len() < expected {
3126                    return;
3127                }
3128                let Some(image_inv) = params.image_matrix.invert() else {
3129                    return;
3130                };
3131                let combined = params.ctm.concat(&image_inv);
3132                let raw_transform = enforce_min_image_size(ctx.transform(&combined), iw, ih);
3133
3134                // Pre-scale images that are being downscaled. Even non-interpolated
3135                // images need proper area averaging when shrinking — "no interpolation"
3136                // means don't smooth when *upscaling*, but downscaling without averaging
3137                // produces aliased garbage.
3138                let prescaled =
3139                    prescale_image(rgba_data, iw, ih, raw_transform, params.interpolate);
3140                let (img_data, img_w, img_h, transform) = match &prescaled {
3141                    Some((data, w, h, t)) => (data.as_slice(), *w, *h, *t),
3142                    None => (rgba_data, iw, ih, raw_transform),
3143                };
3144
3145                let Some(img_pixmap) =
3146                    stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h)
3147                else {
3148                    return;
3149                };
3150                #[allow(unused_assignments)]
3151                let mut temp_mask = None;
3152                let mask_ref = match &band_state.clip_region {
3153                    None => None,
3154                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
3155                    Some(ClipRegion::Rect(rect)) => {
3156                        if rect.is_empty() {
3157                            return;
3158                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
3159                            None
3160                        } else {
3161                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
3162                            temp_mask.as_ref()
3163                        }
3164                    }
3165                };
3166                let img_paint = stet_tiny_skia::PixmapPaint {
3167                    quality: image_filter_quality(transform, params.interpolate),
3168                    opacity: params.alpha as f32,
3169                    blend_mode: u8_to_blend_mode(params.blend_mode),
3170                };
3171                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
3172
3173                // Update CMYK tracking buffer for non-overprint images. Sample
3174                // the now-composited pixmap so non-CMYK source images can be
3175                // reverse-converted to CMYK via the system profile.
3176                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
3177                    update_cmyk_buffer_for_image(
3178                        cmyk_buf,
3179                        sample_data,
3180                        pixmap.data(),
3181                        params,
3182                        ctx.vp_x,
3183                        ctx.vp_y,
3184                        ctx.scale_x,
3185                        ctx.scale_y,
3186                        ctx.out_w,
3187                        ctx.out_h,
3188                        &band_state.clip_region,
3189                        ctx.icc,
3190                    );
3191                }
3192            }
3193        }
3194        DisplayElement::AxialShading { params } => {
3195            let mut temp_mask = None;
3196            let Some(mask_ref) = resolve_clip_mask(
3197                &band_state.clip_region,
3198                &mut temp_mask,
3199                ctx.out_w,
3200                ctx.out_h,
3201            ) else {
3202                return;
3203            };
3204            render_axial_shading(
3205                pixmap,
3206                params,
3207                ctx.vp_x,
3208                ctx.vp_y,
3209                ctx.scale_x,
3210                ctx.scale_y,
3211                mask_ref,
3212                ctx.no_aa,
3213                band_state.cmyk_buffer.as_deref_mut(),
3214                ctx.icc,
3215            );
3216        }
3217        DisplayElement::RadialShading { params } => {
3218            let mut temp_mask = None;
3219            let Some(mask_ref) = resolve_clip_mask(
3220                &band_state.clip_region,
3221                &mut temp_mask,
3222                ctx.out_w,
3223                ctx.out_h,
3224            ) else {
3225                return;
3226            };
3227            render_radial_shading(
3228                pixmap,
3229                params,
3230                ctx.vp_x,
3231                ctx.vp_y,
3232                ctx.scale_x,
3233                ctx.scale_y,
3234                mask_ref,
3235                ctx.no_aa,
3236                band_state.cmyk_buffer.as_deref_mut(),
3237                ctx.icc,
3238            );
3239        }
3240        DisplayElement::MeshShading { params } => {
3241            let mut temp_mask = None;
3242            let Some(mask_ref) = resolve_clip_mask(
3243                &band_state.clip_region,
3244                &mut temp_mask,
3245                ctx.out_w,
3246                ctx.out_h,
3247            ) else {
3248                return;
3249            };
3250            render_mesh_shading(
3251                pixmap,
3252                params,
3253                ctx.vp_x,
3254                ctx.vp_y,
3255                ctx.scale_x,
3256                ctx.scale_y,
3257                mask_ref,
3258                band_state.cmyk_buffer.as_deref_mut(),
3259                ctx.icc,
3260            );
3261        }
3262        DisplayElement::PatchShading { params } => {
3263            let mut temp_mask = None;
3264            let Some(mask_ref) = resolve_clip_mask(
3265                &band_state.clip_region,
3266                &mut temp_mask,
3267                ctx.out_w,
3268                ctx.out_h,
3269            ) else {
3270                return;
3271            };
3272            render_patch_shading(
3273                pixmap,
3274                params,
3275                ctx.vp_x,
3276                ctx.vp_y,
3277                ctx.scale_x,
3278                ctx.scale_y,
3279                mask_ref,
3280                band_state.cmyk_buffer.as_deref_mut(),
3281                ctx.icc,
3282            );
3283        }
3284        DisplayElement::PatternFill { params } => {
3285            render_pattern_fill(pixmap, band_state, params, ctx);
3286        }
3287        DisplayElement::Group { elements, params } => {
3288            render_group(pixmap, band_state, elements, params, ctx);
3289        }
3290        DisplayElement::SoftMasked {
3291            mask,
3292            content,
3293            params,
3294            mask_cache,
3295        } => {
3296            render_soft_masked(pixmap, band_state, mask, content, params, mask_cache, ctx);
3297        }
3298        DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
3299        DisplayElement::OcgGroup {
3300            elements,
3301            visibility,
3302        } => {
3303            // Visible groups render every child. OFF-by-default groups still
3304            // apply Clip/InitClip so the band's clip state stays in sync —
3305            // otherwise a transient clip from the previous group would leak
3306            // into the next visible one. Paint ops are skipped; that's what
3307            // "hidden layer" means.
3308            let visible = ctx.layer_set.evaluate(visibility);
3309            for (idx, elem) in elements.elements().iter().enumerate() {
3310                if !visible
3311                    && !matches!(elem, DisplayElement::Clip { .. } | DisplayElement::InitClip)
3312                {
3313                    continue;
3314                }
3315                let elem_ctx = RenderContext {
3316                    elem_idx: idx,
3317                    ..*ctx
3318                };
3319                render_element(pixmap, band_state, elem, &elem_ctx);
3320            }
3321        }
3322        _ => {}
3323    }
3324}
3325
3326/// Compute the cropped output-pixel region for a group's device-space bounding box.
3327///
3328/// Returns `(crop_x, crop_y, crop_w, crop_h)` in output pixels, or `None` if
3329/// the group is entirely outside the viewport or cropping isn't worthwhile.
3330fn compute_group_crop(bbox: &[f64; 4], ctx: &RenderContext<'_>) -> Option<(i32, i32, u32, u32)> {
3331    // Transform device-space bbox to output pixel coords
3332    let px_min = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
3333    let py_min = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
3334    let px_max = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
3335    let py_max = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
3336
3337    // Clip to output bounds
3338    let x0 = px_min.max(0);
3339    let y0 = py_min.max(0);
3340    let x1 = px_max.min(ctx.out_w as i32);
3341    let y1 = py_max.min(ctx.out_h as i32);
3342
3343    if x0 >= x1 || y0 >= y1 {
3344        return None;
3345    }
3346
3347    let crop_w = (x1 - x0) as u32;
3348    let crop_h = (y1 - y0) as u32;
3349
3350    // Only crop if it saves at least 25% of pixels
3351    let crop_pixels = crop_w as u64 * crop_h as u64;
3352    let full_pixels = ctx.out_w as u64 * ctx.out_h as u64;
3353    if crop_pixels * 4 >= full_pixels * 3 {
3354        return None;
3355    }
3356
3357    Some((x0, y0, crop_w, crop_h))
3358}
3359
3360/// Apply a separable PDF blend mode in DeviceCMYK using the spec's "effective"
3361/// inversion convention (PDF 1.7 §11.3.5.2): the inverse value `1−c` is used as
3362/// input to the RGB-style blend function, and the result is inverted back.
3363fn blend_cmyk_separable_channel(cb: f64, cs: f64, mode: u8) -> f64 {
3364    let cbi = 1.0 - cb;
3365    let csi = 1.0 - cs;
3366    let result_inv = match mode {
3367        1 => cbi * csi,             // Multiply
3368        2 => cbi + csi - cbi * csi, // Screen
3369        3 => {
3370            // Overlay(b, s) = HardLight(s, b)
3371            if cbi <= 0.5 {
3372                2.0 * cbi * csi
3373            } else {
3374                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3375            }
3376        }
3377        4 => cbi.min(csi), // Darken
3378        5 => cbi.max(csi), // Lighten
3379        6 => {
3380            // ColorDodge
3381            if csi >= 1.0 {
3382                1.0
3383            } else {
3384                (cbi / (1.0 - csi)).min(1.0)
3385            }
3386        }
3387        7 => {
3388            // ColorBurn
3389            if csi <= 0.0 {
3390                0.0
3391            } else {
3392                1.0 - ((1.0 - cbi) / csi).min(1.0)
3393            }
3394        }
3395        8 => {
3396            // HardLight
3397            if csi <= 0.5 {
3398                2.0 * cbi * csi
3399            } else {
3400                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3401            }
3402        }
3403        9 => {
3404            // SoftLight (Adobe formulation)
3405            let d = if cbi <= 0.25 {
3406                ((16.0 * cbi - 12.0) * cbi + 4.0) * cbi
3407            } else {
3408                cbi.sqrt()
3409            };
3410            if csi <= 0.5 {
3411                cbi - (1.0 - 2.0 * csi) * cbi * (1.0 - cbi)
3412            } else {
3413                cbi + (2.0 * csi - 1.0) * (d - cbi)
3414            }
3415        }
3416        10 => (cbi - csi).abs(),           // Difference
3417        11 => cbi + csi - 2.0 * cbi * csi, // Exclusion
3418        _ => csi,                          // Normal/fallback
3419    };
3420    1.0 - result_inv.clamp(0.0, 1.0)
3421}
3422
3423/// Apply a non-separable HSL-style PDF blend mode (Hue, Saturation, Color,
3424/// Luminosity) in DeviceCMYK. Per the spec, the inverted CMY components are
3425/// treated as "effective RGB" and the standard non-separable formulas are
3426/// applied; the K channel is taken from the source (it acts as the source's
3427/// luminosity contribution for the purposes of the blend).
3428fn blend_cmyk_nonseparable(cb: [f64; 4], cs: [f64; 4], mode: u8) -> [f64; 4] {
3429    fn lum(c: [f64; 3]) -> f64 {
3430        0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
3431    }
3432    fn clip_color(mut c: [f64; 3]) -> [f64; 3] {
3433        let l = lum(c);
3434        let n = c[0].min(c[1]).min(c[2]);
3435        let x = c[0].max(c[1]).max(c[2]);
3436        if n < 0.0 {
3437            for ci in c.iter_mut() {
3438                *ci = l + (*ci - l) * l / (l - n);
3439            }
3440        }
3441        if x > 1.0 {
3442            for ci in c.iter_mut() {
3443                *ci = l + (*ci - l) * (1.0 - l) / (x - l);
3444            }
3445        }
3446        c
3447    }
3448    fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
3449        let d = l - lum(c);
3450        clip_color([c[0] + d, c[1] + d, c[2] + d])
3451    }
3452    fn sat(c: [f64; 3]) -> f64 {
3453        c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
3454    }
3455    fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
3456        // Index components by rank: min, mid, max.
3457        let mut idx = [0usize, 1, 2];
3458        idx.sort_by(|a, b| {
3459            c[*a]
3460                .partial_cmp(&c[*b])
3461                .unwrap_or(std::cmp::Ordering::Equal)
3462        });
3463        let (i_min, i_mid, i_max) = (idx[0], idx[1], idx[2]);
3464        let mut out = c;
3465        if c[i_max] > c[i_min] {
3466            out[i_mid] = (c[i_mid] - c[i_min]) * s / (c[i_max] - c[i_min]);
3467            out[i_max] = s;
3468        } else {
3469            out[i_mid] = 0.0;
3470            out[i_max] = 0.0;
3471        }
3472        out[i_min] = 0.0;
3473        out
3474    }
3475
3476    let cb_rgb = [1.0 - cb[0], 1.0 - cb[1], 1.0 - cb[2]];
3477    let cs_rgb = [1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2]];
3478    let result_rgb = match mode {
3479        12 => set_lum(set_sat(cs_rgb, sat(cb_rgb)), lum(cb_rgb)), // Hue
3480        13 => set_lum(set_sat(cb_rgb, sat(cs_rgb)), lum(cb_rgb)), // Saturation
3481        14 => set_lum(cs_rgb, lum(cb_rgb)),                       // Color
3482        15 => set_lum(cb_rgb, lum(cs_rgb)),                       // Luminosity
3483        _ => cs_rgb,
3484    };
3485    // Hue/Saturation/Color preserve the backdrop's luminosity, which in CMYK
3486    // is carried primarily by the K channel. Luminosity transfers the source's
3487    // luminosity, so it takes K from the source.
3488    let result_k = if mode == 15 { cs[3] } else { cb[3] };
3489    [
3490        (1.0 - result_rgb[0]).clamp(0.0, 1.0),
3491        (1.0 - result_rgb[1]).clamp(0.0, 1.0),
3492        (1.0 - result_rgb[2]).clamp(0.0, 1.0),
3493        result_k,
3494    ]
3495}
3496
3497/// Render a transparency group into a pixmap.
3498/// Device-space axis-aligned bbox of a path, computed from its segment
3499/// endpoints and curve control points. Returned as (x0, y0, x1, y1) with
3500/// x0 ≤ x1, y0 ≤ y1. Returns `None` for an empty path.
3501fn ps_path_bbox(path: &PsPath) -> Option<(f64, f64, f64, f64)> {
3502    let mut it = path.segments.iter().filter_map(|seg| match *seg {
3503        PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => Some(vec![(x, y)]),
3504        PathSegment::CurveTo {
3505            x1,
3506            y1,
3507            x2,
3508            y2,
3509            x3,
3510            y3,
3511        } => Some(vec![(x1, y1), (x2, y2), (x3, y3)]),
3512        PathSegment::ClosePath => None,
3513    });
3514    let first = it.next()?.into_iter().next()?;
3515    let (mut x0, mut y0) = first;
3516    let (mut x1, mut y1) = first;
3517    for seg_points in std::iter::once(vec![first]).chain(it) {
3518        for (x, y) in seg_points {
3519            x0 = x0.min(x);
3520            y0 = y0.min(y);
3521            x1 = x1.max(x);
3522            y1 = y1.max(y);
3523        }
3524    }
3525    Some((x0, y0, x1, y1))
3526}
3527
3528/// True when rectangle `inner` fits inside `outer` with `tolerance` slack
3529/// (positive tolerance = inner may protrude by up to `tolerance` units).
3530fn bbox_contains(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64), tolerance: f64) -> bool {
3531    inner.0 >= outer.0 - tolerance
3532        && inner.1 >= outer.1 - tolerance
3533        && inner.2 <= outer.2 + tolerance
3534        && inner.3 <= outer.3 + tolerance
3535}
3536
3537/// Detect the GWG "reference-under-test" authoring pattern: a parent Fill
3538/// that will be fully covered by the first Fill of a following isolated
3539/// transparency group. When detected, the parent's Fill can be skipped —
3540/// its AA edges otherwise bleed into the dest under the group's partial-
3541/// alpha source during composite-back, producing a visible outline where
3542/// Acrobat shows none (see GWG 16.2 Opacity(0%) analysis in
3543/// `project_icc_profile_stability.md`).
3544///
3545/// Returns indices in `elements` that should be skipped. Safety conditions:
3546///   1. Parent fill is fully opaque, Normal blend.
3547///   2. Next paint (ignoring Clip/InitClip) is an isolated, alpha-1,
3548///      Normal-blend Group whose first paint is a Fill with matching
3549///      path (within tolerance) and the same opacity/blend conditions.
3550///   3. The group's declared bbox fully contains the parent path's bbox
3551///      — i.e. the form's own BBox clip won't carve the fill away.
3552///   4. Every Clip element between the parent fill and the group, and
3553///      every Clip between the group's start and its first fill, has a
3554///      bbox that also fully contains the parent path — so no additional
3555///      clip can cut the group's first fill to a subset of the parent's
3556///      extent.
3557///   5. PDF's isolated transparency semantics guarantee that once the
3558///      first fill establishes alpha=1 at the parent-path pixels, later
3559///      Normal-blend paints can only add colour there; alpha can't
3560///      decrease. So nothing in the group's tail can re-expose backdrop,
3561///      even without auditing those elements explicitly.
3562fn compute_obscured_fill_skips(elements: &DisplayList) -> Vec<usize> {
3563    let mut skips = Vec::new();
3564    let els = elements.elements();
3565    for i in 0..els.len() {
3566        let DisplayElement::Fill {
3567            path: parent_path,
3568            params: parent_params,
3569        } = &els[i]
3570        else {
3571            continue;
3572        };
3573        if (parent_params.alpha - 1.0).abs() > 1e-6 || parent_params.blend_mode != 0 {
3574            continue;
3575        }
3576        let Some(parent_bbox) = ps_path_bbox(parent_path) else {
3577            continue;
3578        };
3579        // Walk forward past Clip/InitClip between parent fill and the
3580        // group. Each such clip must contain the parent's extent; any
3581        // other element type ends the scan.
3582        let mut j = i + 1;
3583        let mut clips_ok = true;
3584        while j < els.len() {
3585            match &els[j] {
3586                DisplayElement::InitClip => {}
3587                DisplayElement::Clip {
3588                    path: clip_path, ..
3589                } => match ps_path_bbox(clip_path) {
3590                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3591                    _ => {
3592                        clips_ok = false;
3593                        break;
3594                    }
3595                },
3596                _ => break,
3597            }
3598            j += 1;
3599        }
3600        if !clips_ok {
3601            continue;
3602        }
3603        let Some(DisplayElement::Group {
3604            elements: group_elements,
3605            params: group_params,
3606        }) = els.get(j)
3607        else {
3608            continue;
3609        };
3610        if !group_params.isolated
3611            || (group_params.alpha - 1.0).abs() > 1e-6
3612            || group_params.blend_mode != 0
3613        {
3614            continue;
3615        }
3616        // The form's declared BBox acts as a clip inside the group; the
3617        // parent's fill must fit inside it or the group's output will be
3618        // carved away where we'd rely on coverage.
3619        let group_bbox = (
3620            group_params.bbox[0],
3621            group_params.bbox[1],
3622            group_params.bbox[2],
3623            group_params.bbox[3],
3624        );
3625        if !bbox_contains(group_bbox, parent_bbox, 0.5) {
3626            continue;
3627        }
3628        // Walk past Clip/InitClip inside the group to its first paint,
3629        // requiring each clip to contain the parent's extent.
3630        let inner_els = group_elements.elements();
3631        let mut k = 0;
3632        let mut inner_clips_ok = true;
3633        while k < inner_els.len() {
3634            match &inner_els[k] {
3635                DisplayElement::InitClip => {}
3636                DisplayElement::Clip {
3637                    path: clip_path, ..
3638                } => match ps_path_bbox(clip_path) {
3639                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3640                    _ => {
3641                        inner_clips_ok = false;
3642                        break;
3643                    }
3644                },
3645                _ => break,
3646            }
3647            k += 1;
3648        }
3649        if !inner_clips_ok {
3650            continue;
3651        }
3652        let Some(DisplayElement::Fill {
3653            path: group_path,
3654            params: group_fill_params,
3655        }) = inner_els.get(k)
3656        else {
3657            continue;
3658        };
3659        if (group_fill_params.alpha - 1.0).abs() > 1e-6 || group_fill_params.blend_mode != 0 {
3660            continue;
3661        }
3662        if paths_approximately_equal(parent_path, group_path, 0.5) {
3663            skips.push(i);
3664        }
3665    }
3666    skips
3667}
3668
3669/// True when two device-space paths have the same segment sequence and
3670/// matching endpoints within `tolerance` device pixels per coordinate.
3671/// Used by `compute_obscured_fill_skips` to recognise PDF-authored patterns
3672/// where the same logical X path is emitted twice with sub-unit rounding
3673/// differences (GWG test suite authoring style from InDesign CS6).
3674fn paths_approximately_equal(a: &PsPath, b: &PsPath, tolerance: f64) -> bool {
3675    if a.segments.len() != b.segments.len() {
3676        return false;
3677    }
3678    for (sa, sb) in a.segments.iter().zip(b.segments.iter()) {
3679        let close_pair = |(x1, y1): (f64, f64), (x2, y2): (f64, f64)| -> bool {
3680            (x1 - x2).abs() <= tolerance && (y1 - y2).abs() <= tolerance
3681        };
3682        match (sa, sb) {
3683            (PathSegment::MoveTo(x1, y1), PathSegment::MoveTo(x2, y2)) => {
3684                if !close_pair((*x1, *y1), (*x2, *y2)) {
3685                    return false;
3686                }
3687            }
3688            (PathSegment::LineTo(x1, y1), PathSegment::LineTo(x2, y2)) => {
3689                if !close_pair((*x1, *y1), (*x2, *y2)) {
3690                    return false;
3691                }
3692            }
3693            (
3694                PathSegment::CurveTo {
3695                    x1: ax1,
3696                    y1: ay1,
3697                    x2: ax2,
3698                    y2: ay2,
3699                    x3: ax3,
3700                    y3: ay3,
3701                },
3702                PathSegment::CurveTo {
3703                    x1: bx1,
3704                    y1: by1,
3705                    x2: bx2,
3706                    y2: by2,
3707                    x3: bx3,
3708                    y3: by3,
3709                },
3710            ) => {
3711                if !close_pair((*ax1, *ay1), (*bx1, *by1))
3712                    || !close_pair((*ax2, *ay2), (*bx2, *by2))
3713                    || !close_pair((*ax3, *ay3), (*bx3, *by3))
3714                {
3715                    return false;
3716                }
3717            }
3718            (PathSegment::ClosePath, PathSegment::ClosePath) => {}
3719            _ => return false,
3720        }
3721    }
3722    true
3723}
3724
3725///
3726/// Creates an offscreen pixmap, renders the group's child elements into it,
3727/// then composites back onto the parent with the group's blend mode and alpha.
3728fn render_group(
3729    pixmap: &mut Pixmap,
3730    band_state: &mut BandState,
3731    elements: &DisplayList,
3732    params: &stet_graphics::display_list::GroupParams,
3733    ctx: &RenderContext<'_>,
3734) {
3735    if params.knockout {
3736        render_knockout_group(pixmap, band_state, elements, params, ctx);
3737        return;
3738    }
3739
3740    let crop = compute_group_crop(&params.bbox, ctx);
3741
3742    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
3743        Some((cx, cy, cw, ch)) => (
3744            cw,
3745            ch,
3746            cx,
3747            cy,
3748            ctx.vp_x + cx as f32 / ctx.scale_x,
3749            ctx.vp_y + cy as f32 / ctx.scale_y,
3750        ),
3751        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
3752    };
3753
3754    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
3755        return;
3756    };
3757
3758    // Decide upfront whether the composite-back will run in CMYK. The CMYK
3759    // path needs the parent backdrop pre-loaded into the offscreen so that
3760    // per-element painting accumulates in the right starting state. The
3761    // sRGB contribution-extraction path renders against an empty offscreen
3762    // for non-Normal BMs to avoid anti-aliased clip artifacts at the BBox
3763    // edges (the diff-against-backdrop logic mishandles partially-blended
3764    // edge pixels otherwise).
3765    use stet_graphics::display_list::GroupColorSpace;
3766
3767    // Allocate a CMYK buffer for the group when:
3768    //   - it tracks overprint, OR
3769    //   - the parent already has one (CMYK context inheritance), OR
3770    //   - this group itself or one of its descendants declares an explicit
3771    //     `/CS DeviceCMYK`, meaning compositing within it needs CMYK math.
3772    let needs_group_cmyk = has_overprint_elements(elements)
3773        || band_state.cmyk_buffer.is_some()
3774        || params.color_space == GroupColorSpace::DeviceCMYK
3775        || has_cmyk_group(elements);
3776
3777    // Decide whether to run the per-pixel CMYK composite-back. The default
3778    // (gated) rule restricts it to the cases the prior rendering session
3779    // explicitly validated. The `STET_FORCE_CMYK_COMPOSITE_BACK=1` env var
3780    // bypasses both gates and switches to the principled rule that the rest
3781    // of this plan will adopt — useful for A/B-comparing the broader fix
3782    // before flipping the default in Step 9.
3783    let force_cmyk_compose =
3784        std::env::var_os("STET_FORCE_CMYK_COMPOSITE_BACK").as_deref() == Some("1".as_ref());
3785    // The knockout group's coverage pass disables CMYK composite-back so the
3786    // painter falls through to the simple sRGB draw_pixmap path. Without this,
3787    // a white-source painter (CMYK 0,0,0,0) would be skipped by the
3788    // composite-back's "source==backdrop" guard against the transparent
3789    // coverage backdrop, and pass 2 wouldn't capture the painter's coverage.
3790    //
3791    // The color pass widens the gate to all non-Normal blend modes so a
3792    // `/CS DeviceCMYK` knockout group's painters with separable blends like
3793    // Screen / ColorDodge / Overlay / SoftLight blend in CMYK math (matching
3794    // the spec) instead of in tiny-skia's sRGB blend.
3795    let plan_cmyk_compose = match ctx.knockout_painter_pass {
3796        KnockoutPainterPass::CoveragePass => false,
3797        KnockoutPainterPass::ColorPass => {
3798            !params.isolated
3799                && params.blend_mode != 0
3800                && needs_group_cmyk
3801                && band_state.cmyk_buffer.is_some()
3802                && group_content_is_native_cmyk(elements)
3803        }
3804        KnockoutPainterPass::None if force_cmyk_compose => {
3805            // Principled rule: non-isolated group with an inversion-sensitive
3806            // blend mode (Difference, Exclusion, Hue, Saturation, Color,
3807            // Luminosity) whose painters all supply native CMYK source colors.
3808            //
3809            // The blend-mode restriction is intentional: bm 10..=15 produce
3810            // visibly *wrong* results in sRGB (the GWG 16.0 transparency test
3811            // exists exactly to expose this), so CMYK math is unambiguously
3812            // correct there. The separable modes 1..=9 (Multiply, Screen, etc.)
3813            // are spec-defensible in either color space but look noticeably
3814            // different — most renderers blend them in sRGB, and PDFs authored
3815            // for that look "wrong" if we suddenly switch them to CMYK math.
3816            //
3817            // The painter-set restriction (no shadings, no non-CMYK content)
3818            // exists because the parallel CMYK buffer can only faithfully track
3819            // single-CMYK-value-per-pixel painters; gradients interpolate
3820            // differently in pixmap RGB vs buffer CMYK and the divergence makes
3821            // the composite-back read stale source values.
3822            !params.isolated
3823                && matches!(params.blend_mode, 10..=15)
3824                && needs_group_cmyk
3825                && band_state.cmyk_buffer.is_some()
3826                && group_content_is_native_cmyk(elements)
3827        }
3828        KnockoutPainterPass::None => {
3829            // Default rule: only the inversion-sensitive blend modes
3830            // (Difference, Exclusion, HSL non-separable) need CMYK math; the
3831            // separable modes 1..=9 are spec-defensible in either color space
3832            // and most sRGB-authored PDFs expect them to blend in sRGB.
3833            let inversion_sensitive = !params.isolated
3834                && matches!(params.blend_mode, 10..=15)
3835                && group_only_native_cmyk_fills(elements);
3836            // GWG 16.2 ("Transparency Basic Blend Modes — DeviceCMYK,
3837            // Isolated") nests non-isolated `/CS DeviceCMYK` painter sub-groups
3838            // inside an isolated `/CS DeviceCMYK` group, with the swatch's
3839            // blend mode applied at the inner Do. Per PDF spec §11.6.7 the
3840            // compositing for those inner groups must happen in DeviceCMYK,
3841            // not sRGB — otherwise their colored X-shape produces the wrong
3842            // color and fails to cover the painter-A black X. The explicit
3843            // `/CS DeviceCMYK` declaration plus the isolated parent are the
3844            // spec signal that the author wants CMYK-space compositing for
3845            // a fresh transparent backdrop. The `parent_group_isolated`
3846            // gate keeps the rule from firing for non-isolated parents like
3847            // 907 page 28's chart panels, where the existing sRGB
3848            // contribution-extraction path correctly preserves anti-aliased
3849            // gray strokes.
3850            //
3851            // GWG 16.1 ("Transparency Basic Blend Modes — ICCBasedRGB")
3852            // exercises the same DeviceCMYK page group but the parent is
3853            // *non-isolated*, so the `parent_group_isolated` gate refused
3854            // to fire and every separable blend swatch fell back to sRGB
3855            // blending (visible as the test's "X" markers). PDF/X
3856            // workflows already declare their target compositing space via
3857            // `/OutputIntents`, and the proofing chain in
3858            // `register_profile_with_n` flips `IccCache::proofing_enabled`
3859            // on once that's been honoured. Use that as the PDF/X-specific
3860            // signal for "blend in DeviceCMYK regardless of group
3861            // isolation"; non-proofing documents (907 p28 et al.) keep
3862            // the original `parent_group_isolated` requirement.
3863            let proofing_enabled = ctx.icc.is_some_and(|c| c.proofing_enabled());
3864            // Per PDF 1.7 §11.6.6, a transparency group with no `/CS` inherits
3865            // its color space from the enclosing group. When the parent has
3866            // already allocated a CMYK buffer (the only way `cmyk_buffer` is
3867            // `Some` on this band_state when we enter `render_group`), the
3868            // parent's effective compositing space is DeviceCMYK and an
3869            // `Inherited` child should join it. Without this, GWG 16.4 swatch
3870            // groups (no `/CS`) fell back to sRGB blending and the Multiply /
3871            // Color Burn blends produced visible X markers.
3872            let effective_cs_is_cmyk = params.color_space == GroupColorSpace::DeviceCMYK
3873                || (params.color_space == GroupColorSpace::Inherited
3874                    && band_state.cmyk_buffer.is_some());
3875            let cmyk_group_blend = !params.isolated
3876                && (ctx.parent_group_isolated || proofing_enabled)
3877                && params.blend_mode != 0
3878                && effective_cs_is_cmyk
3879                && needs_group_cmyk
3880                && band_state.cmyk_buffer.is_some()
3881                && group_content_is_native_cmyk(elements);
3882            inversion_sensitive || cmyk_group_blend
3883        }
3884    };
3885    // Non-isolated groups with non-Normal blend modes on the sRGB path
3886    // need a two-pass render: once against the backdrop (for correct
3887    // internal blending) and once against transparent (to extract the
3888    // group's shape/alpha for the proper source-contribution formula).
3889    let needs_alpha_extraction = !params.isolated
3890        && params.blend_mode != 0
3891        && !plan_cmyk_compose
3892        && !ctx.alpha_extraction_pass;
3893    let needs_backdrop_preload =
3894        !params.isolated && (params.blend_mode == 0 || plan_cmyk_compose || needs_alpha_extraction);
3895    let backdrop = if needs_backdrop_preload {
3896        let data = if crop.is_some() {
3897            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
3898        } else {
3899            pixmap.data().to_vec()
3900        };
3901        offscreen.data_mut().copy_from_slice(&data);
3902        Some(data)
3903    } else {
3904        None
3905    };
3906    let group_cmyk = if needs_group_cmyk {
3907        let buf_size = eff_w as usize * eff_h as usize * 4;
3908        let mut buf = vec![0.0f32; buf_size];
3909        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
3910            let parent_stride = ctx.out_w as usize * 4;
3911            let group_stride = eff_w as usize * 4;
3912            for gy in 0..eff_h as usize {
3913                let py = crop_y as usize + gy;
3914                if py < ctx.out_h as usize {
3915                    let p_start = py * parent_stride + crop_x as usize * 4;
3916                    let g_start = gy * group_stride;
3917                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
3918                    buf[g_start..g_start + copy_len]
3919                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
3920                }
3921            }
3922        }
3923        Some(buf)
3924    } else {
3925        None
3926    };
3927
3928    // Snapshot the pre-load CMYK so the composite-back can identify pixels
3929    // the group actually modified. Without a separate snapshot we'd have to
3930    // diff against the parent CMYK buffer, which would lose any in-place
3931    // updates to the parent across the group's lifetime.
3932    let backdrop_cmyk: Option<Vec<f32>> = if !params.isolated {
3933        group_cmyk.clone()
3934    } else {
3935        None
3936    };
3937
3938    let mut group_band = BandState {
3939        clip_region: None,
3940        spare_mask: None,
3941        clip_mask_cache: HashMap::new(),
3942        clip_mask_seen: HashSet::new(),
3943        mask_pool: Vec::new(),
3944        cmyk_buffer: group_cmyk,
3945        op_bg_snapshot: None,
3946        op_touched: None,
3947        spot_mask: None,
3948    };
3949
3950    let group_ctx = RenderContext {
3951        vp_x: eff_vp_x,
3952        vp_y: eff_vp_y,
3953        scale_x: ctx.scale_x,
3954        scale_y: ctx.scale_y,
3955        out_w: eff_w,
3956        out_h: eff_h,
3957        effective_dpi: ctx.effective_dpi,
3958        icc: ctx.icc,
3959        image_cache: None, // Group elements don't use parent image cache
3960        preprocessed: None,
3961        elem_idx: 0,
3962        no_aa: ctx.no_aa,
3963        opm_zero_transparent: ctx.opm_zero_transparent,
3964        knockout_painter_pass: ctx.knockout_painter_pass,
3965        // The children of this group see *this* group as their parent.
3966        parent_group_isolated: params.isolated,
3967        alpha_extraction_pass: ctx.alpha_extraction_pass,
3968        layer_set: ctx.layer_set,
3969    };
3970
3971    let skip_indices = compute_obscured_fill_skips(elements);
3972    for (idx, elem) in elements.elements().iter().enumerate() {
3973        if skip_indices.contains(&idx) {
3974            continue;
3975        }
3976        let elem_ctx = RenderContext {
3977            elem_idx: idx,
3978            ..group_ctx
3979        };
3980        render_element(&mut offscreen, &mut group_band, elem, &elem_ctx);
3981    }
3982
3983    // Second pass: render against transparent to extract the group's
3984    // shape/alpha.  Only needed for the sRGB two-pass composite-back
3985    // path (non-isolated, non-Normal blend, no CMYK compose).
3986    let alpha_offscreen = if needs_alpha_extraction {
3987        let mut iso = Pixmap::new(eff_w, eff_h);
3988        if let Some(ref mut iso_pm) = iso {
3989            let mut iso_band = BandState {
3990                clip_region: None,
3991                spare_mask: None,
3992                clip_mask_cache: HashMap::new(),
3993                clip_mask_seen: HashSet::new(),
3994                mask_pool: Vec::new(),
3995                cmyk_buffer: None,
3996                op_bg_snapshot: None,
3997                op_touched: None,
3998                spot_mask: None,
3999            };
4000            let iso_ctx = RenderContext {
4001                parent_group_isolated: true,
4002                alpha_extraction_pass: true,
4003                ..group_ctx
4004            };
4005            for (idx, elem) in elements.elements().iter().enumerate() {
4006                let elem_ctx = RenderContext {
4007                    elem_idx: idx,
4008                    ..iso_ctx
4009                };
4010                render_element(iso_pm, &mut iso_band, elem, &elem_ctx);
4011            }
4012        }
4013        iso
4014    } else {
4015        None
4016    };
4017
4018    let mut temp_mask = None;
4019    let mask_ref = match resolve_clip_mask(
4020        &band_state.clip_region,
4021        &mut temp_mask,
4022        ctx.out_w,
4023        ctx.out_h,
4024    ) {
4025        None => return, // empty clip → nothing visible
4026        Some(m) => m,
4027    };
4028
4029    // Coverage pass override: force opacity 1.0 + Normal blend so the
4030    // painter's shape reaches the coverage offscreen even when the
4031    // original alpha was 0 (Opacity 0% test) or the blend mode would
4032    // erase the source against the transparent coverage backdrop.
4033    let coverage_params;
4034    let effective_params: &stet_graphics::display_list::GroupParams =
4035        if ctx.knockout_painter_pass == KnockoutPainterPass::CoveragePass {
4036            coverage_params = stet_graphics::display_list::GroupParams {
4037                alpha: 1.0,
4038                blend_mode: 0,
4039                ..params.clone()
4040            };
4041            &coverage_params
4042        } else {
4043            params
4044        };
4045
4046    let mut cmyk_compose_done = false;
4047    if let Some(backdrop) = &backdrop {
4048        // Non-isolated group. For the inversion-sensitive blend modes
4049        // (Difference, Exclusion) and the HSL non-separable modes (Hue,
4050        // Saturation, Color, Luminosity), tiny-skia's sRGB blend math gives
4051        // visibly wrong results for the GWG 16.0 transparency test, where
4052        // the source colors are chosen so that, in CMYK, the blend produces
4053        // the backdrop color exactly. Run the composite-back per pixel in
4054        // CMYK for those modes when the inner content is exclusively
4055        // native-CMYK fills (so the inner CMYK buffer faithfully represents
4056        // the source). The other separable modes (Multiply / Lighten /
4057        // Darken / etc.) and non-CMYK content stay on the existing sRGB
4058        // contribution-extraction path because their CMYK pipeline currently
4059        // depends on `interpolate_cmyk_from_stops`, which derives CMYK from
4060        // sRGB via the lossy `(1−r,1−g,1−b,0)` inverse for shadings/images
4061        // and would shift their colors. Lifting that restriction requires
4062        // computing exact CMYK from each shading/image's source color space
4063        // (e.g. running the DeviceN tint transform), which is a larger
4064        // change than this fix attempts.
4065        let inner_cmyk = group_band.cmyk_buffer.as_deref();
4066        let pre_cmyk = backdrop_cmyk.as_deref();
4067        if plan_cmyk_compose && let (Some(inner), Some(pre)) = (inner_cmyk, pre_cmyk) {
4068            composite_non_isolated_cmyk(
4069                pixmap,
4070                band_state.cmyk_buffer.as_deref_mut(),
4071                &offscreen,
4072                inner,
4073                pre,
4074                backdrop,
4075                effective_params,
4076                mask_ref,
4077                crop_x,
4078                crop_y,
4079                ctx.icc,
4080            );
4081            cmyk_compose_done = true;
4082        } else if let Some(ref alpha_os) = alpha_offscreen {
4083            composite_non_isolated_extracted(
4084                pixmap,
4085                &offscreen,
4086                alpha_os,
4087                backdrop,
4088                effective_params,
4089                mask_ref,
4090                crop_x,
4091                crop_y,
4092            );
4093        } else {
4094            composite_non_isolated_group_cropped(
4095                pixmap,
4096                &offscreen,
4097                backdrop,
4098                effective_params,
4099                mask_ref,
4100                crop_x,
4101                crop_y,
4102            );
4103        }
4104    } else {
4105        let paint = stet_tiny_skia::PixmapPaint {
4106            opacity: effective_params.alpha as f32,
4107            blend_mode: u8_to_blend_mode(effective_params.blend_mode),
4108            quality: stet_tiny_skia::FilterQuality::Nearest,
4109        };
4110        pixmap.draw_pixmap(
4111            crop_x,
4112            crop_y,
4113            offscreen.as_ref(),
4114            &paint,
4115            Transform::identity(),
4116            mask_ref,
4117        );
4118    }
4119
4120    // Write group CMYK buffer back to parent. Skip when the CMYK composite-back
4121    // already wrote the blended values into the parent CMYK buffer — running
4122    // `copy_cmyk_buffer_to_parent` afterwards would overwrite those blended
4123    // values with the inner buffer's raw source colors, breaking subsequent
4124    // siblings that read the parent CMYK as their backdrop.
4125    if !cmyk_compose_done
4126        && let (Some(group_cmyk), Some(parent_cmyk)) =
4127            (&group_band.cmyk_buffer, &mut band_state.cmyk_buffer)
4128    {
4129        copy_cmyk_buffer_to_parent(
4130            parent_cmyk,
4131            group_cmyk,
4132            offscreen.data(),
4133            crop_x as usize,
4134            crop_y as usize,
4135            eff_w as usize,
4136            eff_h as usize,
4137            ctx.out_w as usize,
4138            ctx.out_h as usize,
4139        );
4140    }
4141}
4142
4143/// CMYK-aware composite-back for a non-isolated transparency group.
4144///
4145/// For each pixel in the group's region:
4146///   1. If the inner CMYK buffer matches the snapshot taken when the group
4147///      started, the group painted nothing there → leave the parent unchanged.
4148///   2. Otherwise apply the group blend mode in DeviceCMYK using the spec's
4149///      effective inversion formulas (`blend_cmyk_separable_channel` or
4150///      `blend_cmyk_nonseparable`), convert the result to sRGB through the
4151///      ICC system CMYK profile so it sits seamlessly next to the rest of the
4152///      page, and write the result to both the parent pixmap and (when
4153///      present) the parent CMYK buffer.
4154#[allow(clippy::too_many_arguments)]
4155fn composite_non_isolated_cmyk(
4156    target: &mut Pixmap,
4157    parent_cmyk: Option<&mut [f32]>,
4158    source: &Pixmap,
4159    source_cmyk: &[f32],
4160    backdrop_cmyk: &[f32],
4161    backdrop_pixels: &[u8],
4162    params: &stet_graphics::display_list::GroupParams,
4163    clip_mask: Option<&stet_tiny_skia::Mask>,
4164    crop_x: i32,
4165    crop_y: i32,
4166    icc: Option<&IccCache>,
4167) {
4168    let cw = source.width() as usize;
4169    let ch = source.height() as usize;
4170    let target_w = target.width() as usize;
4171    let target_h = target.height() as usize;
4172
4173    let opacity = params.alpha.clamp(0.0, 1.0);
4174    let blend_mode = params.blend_mode;
4175    let is_nonseparable = matches!(blend_mode, 12..=15);
4176
4177    let target_data = target.data_mut();
4178    let target_stride = target_w * 4;
4179    let group_stride = cw * 4;
4180
4181    let clip_data = clip_mask.map(|m| m.data());
4182
4183    for gy in 0..ch {
4184        let ty = crop_y + gy as i32;
4185        if ty < 0 || ty as usize >= target_h {
4186            continue;
4187        }
4188        let ty = ty as usize;
4189        let group_row = gy * group_stride;
4190        let target_row = ty * target_stride;
4191
4192        for gx in 0..cw {
4193            let tx = crop_x + gx as i32;
4194            if tx < 0 || tx as usize >= target_w {
4195                continue;
4196            }
4197            let tx = tx as usize;
4198            let gi = group_row + gx * 4;
4199            let ti = target_row + tx * 4;
4200
4201            // Did the group actually paint this pixel?
4202            let bc = backdrop_cmyk[gi] as f64;
4203            let bm = backdrop_cmyk[gi + 1] as f64;
4204            let by_ = backdrop_cmyk[gi + 2] as f64;
4205            let bk = backdrop_cmyk[gi + 3] as f64;
4206            let sc = source_cmyk[gi] as f64;
4207            let sm = source_cmyk[gi + 1] as f64;
4208            let sy_ = source_cmyk[gi + 2] as f64;
4209            let sk = source_cmyk[gi + 3] as f64;
4210            if (sc - bc).abs() < 1.0 / 255.0
4211                && (sm - bm).abs() < 1.0 / 255.0
4212                && (sy_ - by_).abs() < 1.0 / 255.0
4213                && (sk - bk).abs() < 1.0 / 255.0
4214            {
4215                continue;
4216            }
4217
4218            // Clip mask coverage in target coordinates.
4219            let cov = if let Some(cd) = clip_data {
4220                cd[ty * target_w + tx] as f64 / 255.0
4221            } else {
4222                1.0
4223            };
4224            if cov <= 0.0 {
4225                continue;
4226            }
4227
4228            // Transparent-backdrop fast path: when the backdrop pixmap's alpha
4229            // is 0 the parent group hasn't painted this pixel, so PDF spec
4230            // §11.4.6 says the blended result reduces to α_s · source — the
4231            // blend formula must NOT be applied. Without this check, formulas
4232            // like ColorBurn / ColorDodge / Lighten / Screen produce visibly
4233            // wrong colors (yellow instead of orange-yellow, white instead of
4234            // the source) because an all-zero CMYK backdrop is identical to
4235            // opaque white in CMYK terms. Using the pixmap alpha as the
4236            // sentinel correctly distinguishes "truly nothing painted"
4237            // (alpha 0) from "white painted" (alpha 1, CMYK 0,0,0,0).
4238            //
4239            // For this branch we composite the source pixmap directly via
4240            // SourceOver (rather than converting source CMYK→sRGB) so the
4241            // source's per-pixel alpha — including anti-aliased edges and
4242            // partially-transparent paint like 907 page 28's gray rules —
4243            // is preserved. The CMYK→sRGB direct path used the un-modulated
4244            // painter color and the group opacity, which forced antialiased
4245            // gray strokes to opaque black.
4246            let backdrop_alpha = backdrop_pixels[gi + 3];
4247            let backdrop_transparent = backdrop_alpha == 0;
4248
4249            let mix = cov * opacity;
4250            let dst_a = target_data[ti + 3] as f64 / 255.0;
4251
4252            if backdrop_transparent {
4253                // SourceOver of the source pixmap (already correctly rendered
4254                // for transparent-backdrop semantics) modulated by the group's
4255                // mix factor. To ensure inner-group AA edges don't leave
4256                // sliver gaps where the outer parent pixmap had previously
4257                // drawn a near-identical path (GWG 16.2 directly-drawn black
4258                // X covered by Painter B's slightly-offset colored X), we
4259                // promote any non-zero source alpha to the painter's full
4260                // unpremultiplied source CMYK converted to sRGB. This
4261                // produces fully-opaque coverage at edge pixels matching
4262                // what the inner painter would render at the path interior,
4263                // so the inner group can fully knock out the outer's AA
4264                // edge when composited back to its parent.
4265                let src_data = source.data();
4266                let src_a_pm = src_data[gi + 3] as f64 / 255.0;
4267                if src_a_pm <= 0.0 {
4268                    continue;
4269                }
4270                // Convert source CMYK directly to sRGB. The CMYK at this
4271                // pixel was written by the inner painter at its full
4272                // un-modulated value (the cmyk_buf doesn't track AA), so
4273                // this is the pure painter color regardless of AA cov.
4274                let (full_r, full_g, full_b) = icc
4275                    .and_then(|i| i.convert_cmyk_readonly(sc, sm, sy_, sk))
4276                    .unwrap_or_else(|| cmyk_to_rgb_plrm(sc, sm, sy_, sk));
4277                let alpha_s = mix;
4278                let inv_sa = 1.0 - alpha_s;
4279                let dst_r_pm = target_data[ti] as f64 / 255.0;
4280                let dst_g_pm = target_data[ti + 1] as f64 / 255.0;
4281                let dst_b_pm = target_data[ti + 2] as f64 / 255.0;
4282                let out_r = full_r * alpha_s + dst_r_pm * inv_sa;
4283                let out_g = full_g * alpha_s + dst_g_pm * inv_sa;
4284                let out_b = full_b * alpha_s + dst_b_pm * inv_sa;
4285                let out_a = alpha_s + dst_a * inv_sa;
4286                target_data[ti] = (out_r * 255.0).round().clamp(0.0, 255.0) as u8;
4287                target_data[ti + 1] = (out_g * 255.0).round().clamp(0.0, 255.0) as u8;
4288                target_data[ti + 2] = (out_b * 255.0).round().clamp(0.0, 255.0) as u8;
4289                target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4290                continue;
4291            }
4292
4293            // Apply the group's blend mode in CMYK.
4294            let (rc, rm, ry, rk) = if is_nonseparable {
4295                let r = blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4296                (r[0], r[1], r[2], r[3])
4297            } else {
4298                (
4299                    blend_cmyk_separable_channel(bc, sc, blend_mode),
4300                    blend_cmyk_separable_channel(bm, sm, blend_mode),
4301                    blend_cmyk_separable_channel(by_, sy_, blend_mode),
4302                    blend_cmyk_separable_channel(bk, sk, blend_mode),
4303                )
4304            };
4305
4306            let (new_r, new_g, new_b) = icc
4307                .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
4308                .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
4309
4310            // tiny-skia stores premultiplied sRGB. Apply the PDF
4311            // §11.4.6 result formula in straight-color form. We force the
4312            // source alpha to 1 (subject to clip + group opacity) at any
4313            // pixel where the source CMYK was written by the inner painter
4314            // — the cmyk_buf flags coverage at the path's full extent, even
4315            // at AA edges. Using full alpha here ensures the inner group
4316            // fully covers the outer parent's previously-drawn content
4317            // when both reference near-identical paths (GWG 16.2 directly-
4318            // drawn outer X path covered by Painter B's slightly-offset
4319            // colored X path). Without this, the formula's partial-cover
4320            // mix produces a 1-pixel sliver of darker color where the two
4321            // paths' rasterizations diverge sub-pixel-wise.
4322            let alpha_s = mix;
4323            let alpha_b = dst_a;
4324            let out_a = alpha_s + alpha_b * (1.0 - alpha_s);
4325            if out_a <= 0.0 {
4326                continue;
4327            }
4328            let (dst_r, dst_g, dst_b) = if alpha_b > 0.0 {
4329                let inv_a = 1.0 / alpha_b;
4330                (
4331                    (target_data[ti] as f64 / 255.0) * inv_a,
4332                    (target_data[ti + 1] as f64 / 255.0) * inv_a,
4333                    (target_data[ti + 2] as f64 / 255.0) * inv_a,
4334                )
4335            } else {
4336                (0.0, 0.0, 0.0)
4337            };
4338            // Spec §11.4.6 result computation:
4339            //   C_o = (α_s·(1−α_b)·C_s + α_s·α_b·B(C_b,C_s) + (1−α_s)·α_b·C_b) / α_o
4340            // Here we already have B(C_b,C_s) computed in CMYK and converted
4341            // to sRGB as (new_r, new_g, new_b). The "C_s" term — the source
4342            // color un-blended — uses the same value because the spec says
4343            // when α_b = 0 the formula reduces to source-as-is, which the
4344            // (1−α_b) coefficient already handles.
4345            let coef_b = alpha_s * alpha_b;
4346            let coef_s = alpha_s * (1.0 - alpha_b);
4347            let coef_d = (1.0 - alpha_s) * alpha_b;
4348            let out_r = (coef_s * new_r + coef_b * new_r + coef_d * dst_r) / out_a;
4349            let out_g = (coef_s * new_g + coef_b * new_g + coef_d * dst_g) / out_a;
4350            let out_b = (coef_s * new_b + coef_b * new_b + coef_d * dst_b) / out_a;
4351
4352            target_data[ti] = (out_r * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4353            target_data[ti + 1] = (out_g * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4354            target_data[ti + 2] = (out_b * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4355            target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4356        }
4357    }
4358
4359    // Write the blended CMYK back to the parent CMYK buffer so subsequent
4360    // sibling groups see consistent backdrop values. We re-walk the same
4361    // region — keeps the inner loop above tight (no double-borrow on the
4362    // parent buffer) and only touches pixels we actually modified.
4363    if let Some(parent_cmyk) = parent_cmyk {
4364        for gy in 0..ch {
4365            let ty = crop_y + gy as i32;
4366            if ty < 0 || ty as usize >= target_h {
4367                continue;
4368            }
4369            let ty = ty as usize;
4370            let group_row = gy * group_stride;
4371            let parent_row = ty * target_stride;
4372
4373            for gx in 0..cw {
4374                let tx = crop_x + gx as i32;
4375                if tx < 0 || tx as usize >= target_w {
4376                    continue;
4377                }
4378                let tx = tx as usize;
4379                let gi = group_row + gx * 4;
4380                let pi = parent_row + tx * 4;
4381
4382                let bc = backdrop_cmyk[gi] as f64;
4383                let bm = backdrop_cmyk[gi + 1] as f64;
4384                let by_ = backdrop_cmyk[gi + 2] as f64;
4385                let bk = backdrop_cmyk[gi + 3] as f64;
4386                let sc = source_cmyk[gi] as f64;
4387                let sm = source_cmyk[gi + 1] as f64;
4388                let sy_ = source_cmyk[gi + 2] as f64;
4389                let sk = source_cmyk[gi + 3] as f64;
4390                if (sc - bc).abs() < 1.0 / 255.0
4391                    && (sm - bm).abs() < 1.0 / 255.0
4392                    && (sy_ - by_).abs() < 1.0 / 255.0
4393                    && (sk - bk).abs() < 1.0 / 255.0
4394                {
4395                    continue;
4396                }
4397
4398                // Same transparent-backdrop fast path as above: use source
4399                // as-is. We read the original backdrop alpha from the saved
4400                // backdrop_pixels slice, NOT the live target — the live
4401                // target's alpha was already updated by the first loop's
4402                // composite-back writes.
4403                let backdrop_transparent = backdrop_pixels[gi + 3] == 0;
4404                let (rc, rm, ry, rk) = if backdrop_transparent {
4405                    (sc, sm, sy_, sk)
4406                } else if is_nonseparable {
4407                    let r =
4408                        blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4409                    (r[0], r[1], r[2], r[3])
4410                } else {
4411                    (
4412                        blend_cmyk_separable_channel(bc, sc, blend_mode),
4413                        blend_cmyk_separable_channel(bm, sm, blend_mode),
4414                        blend_cmyk_separable_channel(by_, sy_, blend_mode),
4415                        blend_cmyk_separable_channel(bk, sk, blend_mode),
4416                    )
4417                };
4418                parent_cmyk[pi] = rc as f32;
4419                parent_cmyk[pi + 1] = rm as f32;
4420                parent_cmyk[pi + 2] = ry as f32;
4421                parent_cmyk[pi + 3] = rk as f32;
4422            }
4423        }
4424    }
4425}
4426
4427/// Render a knockout transparency group into a pixmap.
4428///
4429/// In a knockout group, each element composites against the group's initial
4430/// backdrop (not the accumulated result of previous elements).
4431fn render_knockout_group(
4432    pixmap: &mut Pixmap,
4433    band_state: &mut BandState,
4434    elements: &DisplayList,
4435    params: &stet_graphics::display_list::GroupParams,
4436    ctx: &RenderContext<'_>,
4437) {
4438    let crop = compute_group_crop(&params.bbox, ctx);
4439
4440    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
4441        Some((cx, cy, cw, ch)) => (
4442            cw,
4443            ch,
4444            cx,
4445            cy,
4446            ctx.vp_x + cx as f32 / ctx.scale_x,
4447            ctx.vp_y + cy as f32 / ctx.scale_y,
4448        ),
4449        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
4450    };
4451
4452    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
4453        return;
4454    };
4455
4456    let initial_backdrop = if !params.isolated {
4457        if crop.is_some() {
4458            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
4459        } else {
4460            pixmap.data().to_vec()
4461        }
4462    } else {
4463        vec![0u8; (eff_w * eff_h * 4) as usize]
4464    };
4465
4466    let Some(mut accumulated) = Pixmap::new(eff_w, eff_h) else {
4467        return;
4468    };
4469    accumulated.data_mut().copy_from_slice(&initial_backdrop);
4470
4471    // Initial CMYK values for the knockout group
4472    let needs_cmyk = has_overprint_elements(elements) || band_state.cmyk_buffer.is_some();
4473    let initial_cmyk = if needs_cmyk {
4474        let buf_size = eff_w as usize * eff_h as usize * 4;
4475        let mut buf = vec![0.0f32; buf_size];
4476        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4477            let parent_stride = ctx.out_w as usize * 4;
4478            let group_stride = eff_w as usize * 4;
4479            for gy in 0..eff_h as usize {
4480                let py = crop_y as usize + gy;
4481                if py < ctx.out_h as usize {
4482                    let p_start = py * parent_stride + crop_x as usize * 4;
4483                    let g_start = gy * group_stride;
4484                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4485                    buf[g_start..g_start + copy_len]
4486                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
4487                }
4488            }
4489        }
4490        Some(buf)
4491    } else {
4492        None
4493    };
4494
4495    let mut accumulated_cmyk = initial_cmyk.clone();
4496
4497    // Disable anti-aliasing in knockout groups to prevent seam artifacts.
4498    // Each element composites independently against the backdrop, so adjacent
4499    // fills' AA edges don't mesh — both blend toward the backdrop color,
4500    // creating visible 1px white lines at shared boundaries.
4501    let group_ctx = RenderContext {
4502        vp_x: eff_vp_x,
4503        vp_y: eff_vp_y,
4504        scale_x: ctx.scale_x,
4505        scale_y: ctx.scale_y,
4506        out_w: eff_w,
4507        out_h: eff_h,
4508        effective_dpi: ctx.effective_dpi,
4509        icc: ctx.icc,
4510        image_cache: None,
4511        preprocessed: None,
4512        elem_idx: 0,
4513        no_aa: true,
4514        opm_zero_transparent: ctx.opm_zero_transparent,
4515        knockout_painter_pass: ctx.knockout_painter_pass,
4516        // Knockout groups composite each element against the initial backdrop;
4517        // children effectively see this group's "fresh" backdrop. Treat the
4518        // knockout group as isolated for the purposes of the inner CMYK rule.
4519        parent_group_isolated: true,
4520        alpha_extraction_pass: false,
4521        layer_set: ctx.layer_set,
4522    };
4523
4524    // Persistent band state for clip tracking — clips must accumulate across
4525    // elements in the knockout group (each paint element still composites
4526    // against the initial backdrop, but it must respect the current clip).
4527    let mut ko_band = BandState {
4528        clip_region: None,
4529        spare_mask: None,
4530        clip_mask_cache: HashMap::new(),
4531        clip_mask_seen: HashSet::new(),
4532        mask_pool: Vec::new(),
4533        cmyk_buffer: None,
4534        op_bg_snapshot: None,
4535        op_touched: None,
4536        spot_mask: None,
4537    };
4538
4539    // Coverage offscreen for two-pass painter rendering of nested transparency
4540    // groups. Reused (zeroed) across painters; allocated lazily on first need.
4541    let mut coverage_offscreen: Option<Pixmap> = None;
4542
4543    for elem in elements.elements() {
4544        match elem {
4545            // State-only elements: update persistent clip, no knockout compositing
4546            DisplayElement::Clip { .. } | DisplayElement::InitClip => {
4547                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4548            }
4549            // Group painters need two-pass rendering. Knockout semantics
4550            // require each painter to overwrite previous siblings within its
4551            // coverage area, even when the painter's blend mode happens to
4552            // produce a result that equals the initial backdrop (e.g.
4553            // Darken(red, white)=red, SoftLight(red, black)=red,
4554            // Multiply(red, magenta)=red — which is exactly what GWG 16.1
4555            // tests). The single-pass change-against-backdrop check used for
4556            // simpler painter types would miss those pixels, and earlier
4557            // siblings' contributions would bleed through.
4558            DisplayElement::Group { .. } => {
4559                // Pass 1: render painter against initial_backdrop to compute
4560                // the blended-color result (the painter's contribution).
4561                // Use ColorPass mode so any non-Normal blend mode goes through
4562                // the per-pixel CMYK composite-back — required for separable
4563                // blends like Screen / ColorDodge / Overlay / SoftLight whose
4564                // sRGB result drifts away from the CMYK-math result.
4565                let pass1_ctx = RenderContext {
4566                    knockout_painter_pass: KnockoutPainterPass::ColorPass,
4567                    ..group_ctx
4568                };
4569                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4570                ko_band.cmyk_buffer = initial_cmyk.clone();
4571                render_element(&mut offscreen, &mut ko_band, elem, &pass1_ctx);
4572                let pass1_cmyk = ko_band.cmyk_buffer.take();
4573
4574                // Pass 2: render painter into a fresh transparent offscreen so
4575                // the alpha channel captures the painter's coverage, which the
4576                // result-color comparison cannot recover when the blend mode
4577                // outputs the backdrop color exactly.
4578                let cov = match coverage_offscreen.as_mut() {
4579                    Some(p) => {
4580                        p.data_mut().fill(0);
4581                        p
4582                    }
4583                    None => {
4584                        let Some(p) = Pixmap::new(eff_w, eff_h) else {
4585                            // Out of memory for coverage buffer — fall back
4586                            // to the change-detection path so the painter
4587                            // still appears (just without proper knockout).
4588                            replace_changed_pixels(
4589                                accumulated.data_mut(),
4590                                offscreen.data(),
4591                                &initial_backdrop,
4592                            );
4593                            if let (Some(p1), Some(acc)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4594                                replace_changed_cmyk(acc, p1, offscreen.data(), &initial_backdrop);
4595                            }
4596                            continue;
4597                        };
4598                        coverage_offscreen = Some(p);
4599                        coverage_offscreen.as_mut().unwrap()
4600                    }
4601                };
4602                ko_band.cmyk_buffer = None;
4603                // Coverage pass: render through the simple sRGB path with
4604                // alpha forced to 1.0 and Normal blend so the painter's
4605                // shape reaches the coverage offscreen even for white-source
4606                // CMYK painters and zero-alpha painters (Opacity 0% test).
4607                let coverage_ctx = RenderContext {
4608                    knockout_painter_pass: KnockoutPainterPass::CoveragePass,
4609                    ..group_ctx
4610                };
4611                render_element(cov, &mut ko_band, elem, &coverage_ctx);
4612
4613                // Use the coverage offscreen's alpha as a knockout mask: the
4614                // painter's contribution from pass 1 source-overs onto
4615                // accumulated weighted by the coverage alpha.
4616                replace_with_coverage_mask(accumulated.data_mut(), offscreen.data(), cov.data());
4617
4618                if let (Some(p1_cmyk), Some(acc_cmyk)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4619                    replace_cmyk_with_coverage_mask(acc_cmyk, p1_cmyk, cov.data());
4620                }
4621                ko_band.cmyk_buffer = None;
4622            }
4623            // Other paint elements: single-pass with change-against-backdrop.
4624            // Direct path/image/shading paints always change pixels they cover,
4625            // so the simpler detection works and avoids the second-pass cost.
4626            _ => {
4627                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4628
4629                ko_band.cmyk_buffer = initial_cmyk.clone();
4630
4631                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4632
4633                if let (Some(elem_cmyk), Some(acc_cmyk)) =
4634                    (&ko_band.cmyk_buffer, &mut accumulated_cmyk)
4635                {
4636                    replace_changed_cmyk(acc_cmyk, elem_cmyk, offscreen.data(), &initial_backdrop);
4637                }
4638                ko_band.cmyk_buffer = None;
4639
4640                replace_changed_pixels(accumulated.data_mut(), offscreen.data(), &initial_backdrop);
4641            }
4642        }
4643    }
4644
4645    let mut temp_mask = None;
4646    let mask_ref = resolve_clip_mask(
4647        &band_state.clip_region,
4648        &mut temp_mask,
4649        ctx.out_w,
4650        ctx.out_h,
4651    );
4652    let mask_ref = match mask_ref {
4653        None => return,
4654        Some(m) => m,
4655    };
4656
4657    composite_non_isolated_group_cropped(
4658        pixmap,
4659        &accumulated,
4660        &initial_backdrop,
4661        params,
4662        mask_ref,
4663        crop_x,
4664        crop_y,
4665    );
4666
4667    if let (Some(acc_cmyk), Some(parent_cmyk)) = (&accumulated_cmyk, &mut band_state.cmyk_buffer) {
4668        copy_cmyk_buffer_to_parent(
4669            parent_cmyk,
4670            acc_cmyk,
4671            accumulated.data(),
4672            crop_x as usize,
4673            crop_y as usize,
4674            eff_w as usize,
4675            eff_h as usize,
4676            ctx.out_w as usize,
4677            ctx.out_h as usize,
4678        );
4679    }
4680}
4681/// Source-over `source` onto `target` weighted by `coverage`'s alpha channel.
4682/// Used for the two-pass knockout group rendering: `coverage` is rendered
4683/// into a transparent offscreen so its alpha records the painter's coverage
4684/// regardless of whether the painter's blend mode produced backdrop-equal
4685/// pixels in the color pass. Both `source` and `target` are assumed fully
4686/// opaque pixmaps (alpha=255 everywhere) since the knockout offscreens are
4687/// pre-loaded with the opaque initial backdrop.
4688fn replace_with_coverage_mask(target: &mut [u8], source: &[u8], coverage: &[u8]) {
4689    for i in (0..target.len()).step_by(4) {
4690        let cov_a = coverage[i + 3];
4691        if cov_a == 0 {
4692            continue;
4693        }
4694        if cov_a == 255 {
4695            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4696            continue;
4697        }
4698        let a = cov_a as u32;
4699        let inv = 255 - a;
4700        for c in 0..4 {
4701            let s = source[i + c] as u32;
4702            let t = target[i + c] as u32;
4703            target[i + c] = ((s * a + t * inv + 127) / 255) as u8;
4704        }
4705    }
4706}
4707
4708/// Source-over CMYK values from `source` onto `target` weighted by the
4709/// coverage offscreen's alpha channel. Companion to
4710/// `replace_with_coverage_mask` for the parallel CMYK buffer.
4711fn replace_cmyk_with_coverage_mask(target: &mut [f32], source: &[f32], coverage: &[u8]) {
4712    let pixel_count = target.len() / 4;
4713    for i in 0..pixel_count {
4714        let pi = i * 4;
4715        let cov_a = coverage[pi + 3];
4716        if cov_a == 0 {
4717            continue;
4718        }
4719        if cov_a == 255 {
4720            target[pi..pi + 4].copy_from_slice(&source[pi..pi + 4]);
4721            continue;
4722        }
4723        let a = cov_a as f32 / 255.0;
4724        let inv = 1.0 - a;
4725        for c in 0..4 {
4726            target[pi + c] = source[pi + c] * a + target[pi + c] * inv;
4727        }
4728    }
4729}
4730
4731/// Replace pixels in `target` with pixels from `source` wherever `source`
4732/// differs from `backdrop`. Used for knockout group per-element compositing
4733/// where each element replaces (not blends with) previous elements.
4734fn replace_changed_pixels(target: &mut [u8], source: &[u8], backdrop: &[u8]) {
4735    for i in (0..target.len()).step_by(4) {
4736        if source[i] != backdrop[i]
4737            || source[i + 1] != backdrop[i + 1]
4738            || source[i + 2] != backdrop[i + 2]
4739            || source[i + 3] != backdrop[i + 3]
4740        {
4741            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4742        }
4743    }
4744}
4745
4746/// Copy a group's CMYK buffer back to the parent's CMYK buffer after compositing.
4747/// Only copies values for pixels where the group offscreen has non-zero alpha,
4748/// indicating the group actually painted something at that position.
4749#[allow(clippy::too_many_arguments)]
4750fn copy_cmyk_buffer_to_parent(
4751    parent_cmyk: &mut [f32],
4752    group_cmyk: &[f32],
4753    group_pixels: &[u8],
4754    crop_x: usize,
4755    crop_y: usize,
4756    group_w: usize,
4757    group_h: usize,
4758    parent_w: usize,
4759    parent_h: usize,
4760) {
4761    let parent_stride = parent_w * 4;
4762    let group_stride = group_w * 4;
4763    for gy in 0..group_h {
4764        let py = crop_y + gy;
4765        if py >= parent_h {
4766            break;
4767        }
4768        for gx in 0..group_w {
4769            let px = crop_x + gx;
4770            if px >= parent_w {
4771                break;
4772            }
4773            // Only copy if the group pixel has non-zero alpha AND
4774            // the group's cmyk at that pixel is non-zero.
4775            // Zero cmyk means "not tracked by a CMYK fill in this group"
4776            // — writing it back would erase the parent's tracked values.
4777            let g_pixel_idx = (gy * group_w + gx) * 4;
4778            let g_cmyk_idx = gy * group_stride + gx * 4;
4779            if group_pixels[g_pixel_idx + 3] > 0
4780                && (group_cmyk[g_cmyk_idx] != 0.0
4781                    || group_cmyk[g_cmyk_idx + 1] != 0.0
4782                    || group_cmyk[g_cmyk_idx + 2] != 0.0
4783                    || group_cmyk[g_cmyk_idx + 3] != 0.0)
4784            {
4785                let p_cmyk_idx = py * parent_stride + px * 4;
4786                parent_cmyk[p_cmyk_idx..p_cmyk_idx + 4]
4787                    .copy_from_slice(&group_cmyk[g_cmyk_idx..g_cmyk_idx + 4]);
4788            }
4789        }
4790    }
4791}
4792
4793/// Copy CMYK values for pixels that changed in a knockout element.
4794/// Used alongside replace_changed_pixels to keep CMYK in sync with RGB.
4795fn replace_changed_cmyk(
4796    target_cmyk: &mut [f32],
4797    source_cmyk: &[f32],
4798    source_pixels: &[u8],
4799    backdrop_pixels: &[u8],
4800) {
4801    let pixel_count = target_cmyk.len() / 4;
4802    for i in 0..pixel_count {
4803        let pi = i * 4;
4804        if source_pixels[pi] != backdrop_pixels[pi]
4805            || source_pixels[pi + 1] != backdrop_pixels[pi + 1]
4806            || source_pixels[pi + 2] != backdrop_pixels[pi + 2]
4807            || source_pixels[pi + 3] != backdrop_pixels[pi + 3]
4808        {
4809            target_cmyk[pi..pi + 4].copy_from_slice(&source_cmyk[pi..pi + 4]);
4810        }
4811    }
4812}
4813
4814/// Render soft-masked content.
4815///
4816/// 1. Renders the mask display list to an offscreen pixmap.
4817/// 2. Extracts a grayscale mask (luminosity or alpha).
4818/// 3. Renders content into another offscreen pixmap.
4819/// 4. Multiplies content alpha by the mask values.
4820/// 5. Composites the masked content onto the parent.
4821#[allow(clippy::too_many_arguments)]
4822fn render_soft_masked(
4823    pixmap: &mut Pixmap,
4824    band_state: &mut BandState,
4825    mask_list: &DisplayList,
4826    content_list: &DisplayList,
4827    params: &stet_graphics::display_list::SoftMaskParams,
4828    mask_cache: &Arc<Mutex<Option<Option<stet_graphics::display_list::MaskRaster>>>>,
4829    ctx: &RenderContext<'_>,
4830) {
4831    // The SoftMask's display list elements are in absolute device space (page coords).
4832    // params.bbox is the SoftMasked element's compositing bounds, derived
4833    // from the form's /BBox transformed by the gs-time CTM. The mask raster
4834    // (built lazily by `rasterize_mask` and cached on the display-list
4835    // element) is anchored independently to the *actual* mask paint bounds,
4836    // which may differ from params.bbox when the form's internal `cm`
4837    // operators translated paint elements outside the form bbox.
4838    //
4839    // The cached-raster path can produce truncated output when the
4840    // SoftMasked is rendered inside an outer offscreen (a Group, an
4841    // outer SoftMasked, etc.) — the nested offscreen's coordinate
4842    // system clips the mask raster's right edge unexpectedly. Detect
4843    // "nested" via `ctx.vp_x != 0.0` (top-level banded rendering uses
4844    // vp_x = 0; nested rendering inherits the parent offscreen's vp).
4845    // For nested cases, fall back to the inline band-local mask
4846    // rendering that worked before Step 4 of cosmic-masking-bird.
4847    let use_inline_mask = ctx.vp_x != 0.0;
4848    let bbox = &params.bbox;
4849    let smask_px_x0 = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
4850    let smask_px_y0 = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
4851    let smask_px_x1 = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
4852    let smask_px_y1 = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
4853
4854    // Clip to parent output bounds
4855    let crop_x = smask_px_x0.max(0);
4856    let crop_y = smask_px_y0.max(0);
4857    let crop_x1 = smask_px_x1.min(ctx.out_w as i32);
4858    let crop_y1 = smask_px_y1.min(ctx.out_h as i32);
4859    if crop_x >= crop_x1 || crop_y >= crop_y1 {
4860        return;
4861    }
4862    let eff_w = (crop_x1 - crop_x) as u32;
4863    let eff_h = (crop_y1 - crop_y) as u32;
4864
4865    // Viewport for the content offscreen: derived from the SoftMask's bbox
4866    // position relative to the parent's viewport. The content offscreen
4867    // still uses params.bbox because params.bbox correctly bounds where
4868    // the content can paint.
4869    let eff_vp_x = ctx.vp_x + crop_x as f32 / ctx.scale_x;
4870    let eff_vp_y = ctx.vp_y + crop_y as f32 / ctx.scale_y;
4871
4872    let sub_ctx = RenderContext {
4873        vp_x: eff_vp_x,
4874        vp_y: eff_vp_y,
4875        scale_x: ctx.scale_x,
4876        scale_y: ctx.scale_y,
4877        out_w: eff_w,
4878        out_h: eff_h,
4879        effective_dpi: ctx.effective_dpi,
4880        icc: ctx.icc,
4881        image_cache: None,
4882        preprocessed: None,
4883        elem_idx: 0,
4884        no_aa: ctx.no_aa,
4885        opm_zero_transparent: ctx.opm_zero_transparent,
4886        knockout_painter_pass: ctx.knockout_painter_pass,
4887        parent_group_isolated: ctx.parent_group_isolated,
4888        // Soft masks render into their own independent offscreen and must
4889        // not inherit the alpha extraction pass — their groups need normal
4890        // backdrop preloading regardless of the outer extraction context.
4891        alpha_extraction_pass: false,
4892        layer_set: ctx.layer_set,
4893    };
4894
4895    // 1a. INLINE PATH: Mask form contains nested offscreens.
4896    // Render the mask form into a band-local offscreen sized to the
4897    // SoftMasked's bbox crop. This matches the pre-Step-4 behavior.
4898    let mut mask_values_inline: Vec<u8> = Vec::new();
4899    if use_inline_mask {
4900        let Some(mut mask_pixmap) = Pixmap::new(eff_w, eff_h) else {
4901            return;
4902        };
4903        let mut mask_band = BandState {
4904            clip_region: None,
4905            spare_mask: None,
4906            clip_mask_cache: HashMap::new(),
4907            clip_mask_seen: HashSet::new(),
4908            mask_pool: Vec::new(),
4909            cmyk_buffer: None,
4910            op_bg_snapshot: None,
4911            op_touched: None,
4912            spot_mask: None,
4913        };
4914        for (idx, elem) in mask_list.elements().iter().enumerate() {
4915            let elem_ctx = RenderContext {
4916                elem_idx: idx,
4917                ..sub_ctx
4918            };
4919            render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
4920        }
4921        if params.has_nested_mask_scope
4922            && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
4923        {
4924            let bc = params.backdrop_color.as_ref();
4925            let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4926            let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4927            let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4928            for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
4929                let a = chunk[3] as u16;
4930                if a == 255 {
4931                    continue;
4932                }
4933                let inv_a = 255 - a;
4934                chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
4935                chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
4936                chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
4937                chunk[3] = 255;
4938            }
4939        }
4940        mask_values_inline = vec![0u8; (eff_w * eff_h) as usize];
4941        extract_soft_mask_values(mask_pixmap.data(), &mut mask_values_inline, params);
4942    }
4943
4944    // 1b. CACHED RASTER PATH: simple masks (no nested offscreens).
4945    let raster_owned: Option<stet_graphics::display_list::MaskRaster> = if use_inline_mask {
4946        None
4947    } else {
4948        let mut guard = mask_cache.lock().unwrap();
4949        let needs_build = match guard.as_ref() {
4950            None => true,
4951            Some(None) => false, // memoized "no mask"
4952            Some(Some(r)) => {
4953                (r.scale_x - ctx.scale_x).abs() > 1e-4 || (r.scale_y - ctx.scale_y).abs() > 1e-4
4954            }
4955        };
4956        if needs_build {
4957            let built = rasterize_mask(
4958                mask_list,
4959                params,
4960                ctx.icc,
4961                ctx.no_aa,
4962                ctx.effective_dpi,
4963                ctx.scale_x,
4964                ctx.scale_y,
4965                ctx.layer_set,
4966            );
4967            *guard = Some(built);
4968        }
4969        guard.as_ref().and_then(|inner| inner.clone())
4970    };
4971
4972    // Default mask value for content pixels that fall outside the mask
4973    // raster (e.g. backdrop region for a Luminosity mask with non-black
4974    // /BC, or always 0 for Alpha masks).
4975    let fallback_mask = out_of_bounds_mask_value(params) as i32;
4976
4977    // 2. Render content into an offscreen, initialized with the parent's
4978    // backdrop so non-isolated groups with blend modes (e.g. Multiply) see
4979    // the correct background and produce the right composited result.
4980    let Some(mut content_pixmap) = Pixmap::new(eff_w, eff_h) else {
4981        return;
4982    };
4983    let backdrop = copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h);
4984    content_pixmap.data_mut().copy_from_slice(&backdrop);
4985
4986    let content_cmyk = if has_overprint_elements(content_list) || band_state.cmyk_buffer.is_some() {
4987        let buf_size = eff_w as usize * eff_h as usize * 4;
4988        let mut buf = vec![0.0f32; buf_size];
4989        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4990            let parent_stride = ctx.out_w as usize * 4;
4991            let group_stride = eff_w as usize * 4;
4992            for gy in 0..eff_h as usize {
4993                let py = crop_y as usize + gy;
4994                if py < ctx.out_h as usize {
4995                    let p_start = py * parent_stride + crop_x as usize * 4;
4996                    let g_start = gy * group_stride;
4997                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4998                    buf[g_start..g_start + copy_len]
4999                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
5000                }
5001            }
5002        }
5003        Some(buf)
5004    } else {
5005        None
5006    };
5007    // Snapshot the pre-content CMYK state so the mask blend can run in CMYK
5008    // space. Without this, the downstream sRGB blend interpolates between
5009    // CMYK backdrop and source after each has been ICC-converted separately,
5010    // which shifts the midtones away from the CMYK-interpolated result the
5011    // source was authored against (pink cast vs warm peach on GWG 16.10
5012    // inner-glow in PDFX-ready_Output-Test_X4.pdf).
5013    let backdrop_cmyk: Option<Vec<f32>> = content_cmyk.clone();
5014    let mut content_band = BandState {
5015        clip_region: None,
5016        spare_mask: None,
5017        clip_mask_cache: HashMap::new(),
5018        clip_mask_seen: HashSet::new(),
5019        mask_pool: Vec::new(),
5020        cmyk_buffer: content_cmyk,
5021        op_bg_snapshot: None,
5022        op_touched: None,
5023        spot_mask: None,
5024    };
5025    for (idx, elem) in content_list.elements().iter().enumerate() {
5026        let elem_ctx = RenderContext {
5027            elem_idx: idx,
5028            ..sub_ctx
5029        };
5030        render_element(&mut content_pixmap, &mut content_band, elem, &elem_ctx);
5031    }
5032
5033    // 3. Apply soft mask: compute per-pixel masked contribution and write
5034    // to parent. result[c] = parent[c] + m * (content_on_backdrop[c] - backdrop[c]) / 255
5035    //
5036    // Mask sampling: the mask raster is in page-pixel coordinates at the
5037    // current render scale, anchored at `(raster.origin_x, raster.origin_y)`.
5038    // The combine loop iterates over content pixel `(x, y)` band-local in
5039    // the content offscreen. To translate to a mask raster index:
5040    //
5041    //   page_x = vp_x_pixels + crop_x + x
5042    //   page_y = vp_y_pixels + crop_y + y
5043    //   mask_x = page_x - raster.origin_x
5044    //   mask_y = page_y - raster.origin_y
5045    //
5046    // where `vp_x_pixels = round(ctx.vp_x * ctx.scale_x)` is the page-pixel
5047    // offset of the band's top-left. For banded rendering this is exact
5048    // (vp = 0, scale = 1, so vp_x_pixels = 0). For viewport rendering with
5049    // a fractional `vp_x`, there is at most a 0.5-pixel sub-pixel offset
5050    // between the content render grid and the cached mask grid; this is
5051    // bounded and visually acceptable for nearest-neighbor sampling.
5052    let vp_x_pixels = (ctx.vp_x * ctx.scale_x).round() as i32;
5053    let vp_y_pixels = (ctx.vp_y * ctx.scale_y).round() as i32;
5054
5055    let mut temp_mask = None;
5056    let clip_ref = resolve_clip_mask(
5057        &band_state.clip_region,
5058        &mut temp_mask,
5059        ctx.out_w,
5060        ctx.out_h,
5061    );
5062    let clip_ref = match clip_ref {
5063        None => return,
5064        Some(m) => m,
5065    };
5066
5067    // Decide whether to interpolate the masked delta in CMYK (with ICC→sRGB
5068    // on the way out) instead of sRGB. The CMYK path matches Acrobat's
5069    // behaviour when the transparency group declares /CS DeviceCMYK and all
5070    // content is native CMYK — the blend color space is then CMYK, and
5071    // sRGB-space interpolation on ICC-converted endpoints loses the warm
5072    // midtone that M+Y mixing produces under a proper CMYK profile.
5073    //
5074    // Gate strictly: content_list must be a flat list of native-CMYK fills
5075    // or strokes with Normal blend and full opacity. Any nested Group,
5076    // SoftMasked, Image, or blend-mode-modulated paint means the parallel
5077    // cmyk_buffer can't be trusted to match the pixmap — running CMYK
5078    // interpolation against a mismatched CMYK snapshot produced wrong
5079    // colors on GWG 16.10 outer-glow C (Fm5 is a Screen-blend white rect
5080    // inside a Group; cmyk_buffer held raw white while pixmap held the
5081    // screen-blended light gray).
5082    let use_cmyk_blend = ctx.icc.is_some()
5083        && backdrop_cmyk.is_some()
5084        && content_band.cmyk_buffer.is_some()
5085        && content_list_is_simple_native_cmyk(content_list);
5086
5087    let content_data = content_pixmap.data();
5088    let parent_data = pixmap.data_mut();
5089    let parent_stride = ctx.out_w as usize * 4;
5090    let content_stride = eff_w as usize * 4;
5091
5092    for y in 0..eff_h as usize {
5093        let py = crop_y as usize + y;
5094        if py >= ctx.out_h as usize {
5095            break;
5096        }
5097        let ci_row = y * content_stride;
5098        let pi_row = py * parent_stride;
5099        let page_y = vp_y_pixels + crop_y + y as i32;
5100
5101        for x in 0..eff_w as usize {
5102            let px = crop_x as usize + x;
5103            if px >= ctx.out_w as usize {
5104                break;
5105            }
5106
5107            // Check clip mask (in parent coordinates)
5108            if let Some(clip) = clip_ref {
5109                if clip.data()[py * ctx.out_w as usize + px] == 0 {
5110                    continue;
5111                }
5112            }
5113
5114            // Sample the mask: inline-rendered values for masks with
5115            // nested offscreens, cached raster for simple masks.
5116            let m = if use_inline_mask {
5117                mask_values_inline[y * eff_w as usize + x] as i32
5118            } else if let Some(ref raster) = raster_owned {
5119                let page_x = vp_x_pixels + crop_x + x as i32;
5120                let mx = page_x - raster.origin_x;
5121                let my = page_y - raster.origin_y;
5122                if mx >= 0 && (mx as u32) < raster.width && my >= 0 && (my as u32) < raster.height {
5123                    raster.data[my as usize * raster.width as usize + mx as usize] as i32
5124                } else {
5125                    fallback_mask
5126                }
5127            } else {
5128                fallback_mask
5129            };
5130            if m == 0 {
5131                continue;
5132            }
5133
5134            let ci = ci_row + x * 4;
5135            let pi = pi_row + px * 4;
5136
5137            // Per-pixel gate: CMYK interpolation is only safe when both
5138            // endpoints are faithfully tracked. ICC-convert both cmyk
5139            // snapshots and compare with the sRGB endpoints; only take
5140            // the CMYK path if BOTH agree within tolerance. The backdrop
5141            // check catches image/RGB paints upstream (tile_clamp_bug.pdf
5142            // photo background) where cmyk_buffer is an approximate
5143            // reverse-transform. The content check catches cases where
5144            // non-CMYK paints inside content leave the cmyk_buffer stale
5145            // relative to the sRGB content pixmap.
5146            let ci_cmyk = (y * eff_w as usize + x) * 4;
5147            let cmyk_path_ok = use_cmyk_blend && {
5148                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5149                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5150                let icc_match = |cmyk: &[f32], rgb: &[u8]| -> bool {
5151                    let (r, g, b) = ctx
5152                        .icc
5153                        .and_then(|i| {
5154                            i.convert_cmyk_readonly(
5155                                cmyk[0] as f64,
5156                                cmyk[1] as f64,
5157                                cmyk[2] as f64,
5158                                cmyk[3] as f64,
5159                            )
5160                        })
5161                        .unwrap_or_else(|| {
5162                            cmyk_to_rgb_plrm(
5163                                cmyk[0] as f64,
5164                                cmyk[1] as f64,
5165                                cmyk[2] as f64,
5166                                cmyk[3] as f64,
5167                            )
5168                        });
5169                    let r = (r * 255.0).round() as i32;
5170                    let g = (g * 255.0).round() as i32;
5171                    let b = (b * 255.0).round() as i32;
5172                    (r - rgb[0] as i32).abs() <= 3
5173                        && (g - rgb[1] as i32).abs() <= 3
5174                        && (b - rgb[2] as i32).abs() <= 3
5175                };
5176                icc_match(bc_cmyk, &backdrop[ci..ci + 3])
5177                    && icc_match(cc_cmyk, &content_data[ci..ci + 3])
5178            };
5179
5180            if cmyk_path_ok {
5181                // CMYK-space mask blend: result_cmyk = backdrop + m*(content - backdrop)
5182                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5183                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5184                let mf = m as f64 / 255.0;
5185                let rc = bc_cmyk[0] as f64 + mf * (cc_cmyk[0] as f64 - bc_cmyk[0] as f64);
5186                let rm = bc_cmyk[1] as f64 + mf * (cc_cmyk[1] as f64 - bc_cmyk[1] as f64);
5187                let ry = bc_cmyk[2] as f64 + mf * (cc_cmyk[2] as f64 - bc_cmyk[2] as f64);
5188                let rk = bc_cmyk[3] as f64 + mf * (cc_cmyk[3] as f64 - bc_cmyk[3] as f64);
5189                let (fr, fg, fb) = ctx
5190                    .icc
5191                    .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
5192                    .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
5193                parent_data[pi] = (fr * 255.0).round().clamp(0.0, 255.0) as u8;
5194                parent_data[pi + 1] = (fg * 255.0).round().clamp(0.0, 255.0) as u8;
5195                parent_data[pi + 2] = (fb * 255.0).round().clamp(0.0, 255.0) as u8;
5196                // Alpha channel: keep sRGB delta blend.
5197                let content_a = content_data[ci + 3] as i32;
5198                let backdrop_a = backdrop[ci + 3] as i32;
5199                let delta = content_a - backdrop_a;
5200                if delta != 0 {
5201                    let masked_delta = if delta > 0 {
5202                        (delta * m + 128) / 255
5203                    } else {
5204                        (delta * m - 128) / 255
5205                    };
5206                    let result = (parent_data[pi + 3] as i32 + masked_delta).clamp(0, 255);
5207                    parent_data[pi + 3] = result as u8;
5208                }
5209                // The parent's cmyk_buffer is deliberately NOT written here.
5210                // Writing back mask-blended CMYK would overwrite backdrop
5211                // tracking that downstream CMYK consumers (outer groups,
5212                // subsequent masks) depend on and cause them to render
5213                // nearby pixels as pure CMYK channels (e.g. the outer-glow
5214                // C regression: adjacent gray pixels ICC-resolved to a
5215                // black K silhouette). The sRGB pixmap carries the mask-
5216                // blended color; parent_cmyk stays untouched.
5217            } else {
5218                for c in 0..4 {
5219                    let content_val = content_data[ci + c] as i32;
5220                    let backdrop_val = backdrop[ci + c] as i32;
5221                    let delta = content_val - backdrop_val;
5222                    if delta != 0 {
5223                        let masked_delta = if delta > 0 {
5224                            (delta * m + 128) / 255
5225                        } else {
5226                            (delta * m - 128) / 255
5227                        };
5228                        let result = (parent_data[pi + c] as i32 + masked_delta).clamp(0, 255);
5229                        parent_data[pi + c] = result as u8;
5230                    }
5231                }
5232            }
5233        }
5234    }
5235
5236    // Write content CMYK buffer back to parent. Skip when the CMYK blend
5237    // loop already updated band_state.cmyk_buffer with mask-blended values
5238    // — copying the unmodulated content CMYK here would overwrite them.
5239    if !use_cmyk_blend {
5240        if let (Some(content_cmyk), Some(parent_cmyk)) =
5241            (&content_band.cmyk_buffer, &mut band_state.cmyk_buffer)
5242        {
5243            copy_cmyk_buffer_to_parent(
5244                parent_cmyk,
5245                content_cmyk,
5246                content_pixmap.data(),
5247                crop_x as usize,
5248                crop_y as usize,
5249                eff_w as usize,
5250                eff_h as usize,
5251                ctx.out_w as usize,
5252                ctx.out_h as usize,
5253            );
5254        }
5255    }
5256}
5257/// Extract grayscale mask values from rendered RGBA pixels.
5258fn extract_soft_mask_values(
5259    rgba: &[u8],
5260    out: &mut [u8],
5261    params: &stet_graphics::display_list::SoftMaskParams,
5262) {
5263    use stet_graphics::display_list::SoftMaskSubtype;
5264    let pixel_count = out.len();
5265
5266    match params.subtype {
5267        SoftMaskSubtype::Alpha => {
5268            for i in 0..pixel_count {
5269                let a = rgba[i * 4 + 3]; // alpha channel
5270                out[i] = if params.transfer_invert { 255 - a } else { a };
5271            }
5272        }
5273        SoftMaskSubtype::Luminosity => {
5274            // Backdrop luminosity for transparent pixels
5275            let backdrop_lum = if let Some(bc) = &params.backdrop_color {
5276                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5277            } else {
5278                0.0 // black backdrop
5279            };
5280            let backdrop_byte = (backdrop_lum * 255.0 + 0.5) as u8;
5281
5282            #[allow(clippy::needless_range_loop)]
5283            for i in 0..pixel_count {
5284                let off = i * 4;
5285                let a = rgba[off + 3];
5286                let lum_byte = if a == 0 {
5287                    backdrop_byte
5288                } else if a < 255 {
5289                    // Composite premultiplied RGB onto backdrop before computing
5290                    // luminosity (PDF spec 11.6.5.3): premul_rgb + BC × (1 - α/255)
5291                    let af = a as f64;
5292                    let bd = backdrop_lum * 255.0;
5293                    let r = rgba[off] as f64 + bd * (255.0 - af) / 255.0;
5294                    let g = rgba[off + 1] as f64 + bd * (255.0 - af) / 255.0;
5295                    let b = rgba[off + 2] as f64 + bd * (255.0 - af) / 255.0;
5296                    let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
5297                    (lum + 0.5).clamp(0.0, 255.0) as u8
5298                } else {
5299                    // Fully opaque: premultiplied == straight RGB
5300                    let lum = 0.2126 * rgba[off] as f64
5301                        + 0.7152 * rgba[off + 1] as f64
5302                        + 0.0722 * rgba[off + 2] as f64;
5303                    (lum + 0.5).clamp(0.0, 255.0) as u8
5304                };
5305                // Apply transfer function inversion: {1 exch sub} → 255 - value
5306                out[i] = if params.transfer_invert {
5307                    255 - lum_byte
5308                } else {
5309                    lum_byte
5310                };
5311            }
5312        }
5313    }
5314}
5315
5316/// Compute the byte the mask sample loop should use for content pixels
5317/// that fall outside the rasterized mask raster.
5318///
5319/// For Luminosity masks, transparent pixels (no rendered mask paint)
5320/// composite onto the backdrop color, so the effective mask value is the
5321/// backdrop's luminosity. For Alpha masks, transparent = 0 = mask off.
5322/// Both subtypes apply the `/TR {1 exch sub}` transfer inversion.
5323fn out_of_bounds_mask_value(params: &stet_graphics::display_list::SoftMaskParams) -> u8 {
5324    use stet_graphics::display_list::SoftMaskSubtype;
5325    let raw = match params.subtype {
5326        SoftMaskSubtype::Alpha => 0u8,
5327        SoftMaskSubtype::Luminosity => {
5328            let lum = if let Some(bc) = &params.backdrop_color {
5329                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5330            } else {
5331                0.0
5332            };
5333            (lum * 255.0 + 0.5) as u8
5334        }
5335    };
5336    if params.transfer_invert {
5337        255 - raw
5338    } else {
5339        raw
5340    }
5341}
5342
5343/// Maximum mask raster area in pixels.  A malformed PDF that asks for a
5344/// gigantic mask form would otherwise OOM. 64 megapixels = 64 MB for
5345/// grayscale or 256 MB for RGBA — generous but bounded.  Using an area
5346/// limit instead of a per-dimension limit correctly handles narrow-but-tall
5347/// pages (e.g. infographics that exceed 8192 pixels in height while being
5348/// only ~1000 pixels wide).
5349const MAX_MASK_RASTER_PIXELS: u64 = 64 * 1024 * 1024;
5350
5351/// Rasterize a soft mask form's display list into a `MaskRaster`.
5352///
5353/// Walks the mask display list to compute its actual paint bounds (which
5354/// may differ from the SoftMasked element's `params.bbox` because the
5355/// form's internal `cm` operators may translate paint elements outside
5356/// the form's `/BBox`), allocates a pixmap that exactly covers those
5357/// bounds in device-space pixels, and renders the mask elements with the
5358/// viewport set to the bounds origin so each element rasterizes at
5359/// `(device_x - origin_x, device_y - origin_y)`.
5360///
5361/// Returns `None` when the mask paints nothing.
5362fn rasterize_mask(
5363    mask_list: &DisplayList,
5364    params: &stet_graphics::display_list::SoftMaskParams,
5365    icc: Option<&IccCache>,
5366    no_aa: bool,
5367    effective_dpi: f64,
5368    scale_x: f32,
5369    scale_y: f32,
5370    layer_set: &LayerSet,
5371) -> Option<stet_graphics::display_list::MaskRaster> {
5372    // 1. Find the actual paint bounds in device space, then cap them to
5373    // the parent gstate's clip path bbox if known. The cap is critical
5374    // for masks whose form contains an unbounded shading inside a
5375    // sentinel-sized internal clip — without it, the raster blows past
5376    // the size limit and produces no output. Pixels outside the parent
5377    // clip can never affect the final image, so the cap is safe.
5378    let mut bounds = compute_paint_bounds(mask_list, effective_dpi)?;
5379    if let Some(cap) = params.parent_clip_bbox {
5380        let cap_bbox = BBox2D {
5381            x_min: cap[0],
5382            y_min: cap[1],
5383            x_max: cap[2],
5384            y_max: cap[3],
5385        };
5386        bounds = intersect_bbox(&bounds, &cap_bbox)?;
5387    }
5388
5389    // 2. Snap to integer device pixels at the current render scale, with a
5390    // 1-pixel pad on each side to avoid antialiasing edge clipping.
5391    let px_x_min = (bounds.x_min as f32 * scale_x).floor() as i32 - 1;
5392    let px_y_min = (bounds.y_min as f32 * scale_y).floor() as i32 - 1;
5393    let px_x_max = (bounds.x_max as f32 * scale_x).ceil() as i32 + 1;
5394    let px_y_max = (bounds.y_max as f32 * scale_y).ceil() as i32 + 1;
5395    if px_x_min >= px_x_max || px_y_min >= px_y_max {
5396        return None;
5397    }
5398    let raster_w = (px_x_max - px_x_min) as u32;
5399    let raster_h = (px_y_max - px_y_min) as u32;
5400    if raster_w == 0 || raster_h == 0 {
5401        return None;
5402    }
5403    if (raster_w as u64) * (raster_h as u64) > MAX_MASK_RASTER_PIXELS {
5404        return None;
5405    }
5406
5407    // 3. Allocate the offscreen pixmap (transparent backdrop).
5408    let mut mask_pixmap = Pixmap::new(raster_w, raster_h)?;
5409
5410    // 4. Build a RenderContext that maps device pixel `(dx, dy)` to
5411    // raster pixel `(dx - px_x_min, dy - px_y_min)`. The viewport is in
5412    // device-space units (not pixels), so divide by scale.
5413    let sub_ctx = RenderContext {
5414        vp_x: px_x_min as f32 / scale_x,
5415        vp_y: px_y_min as f32 / scale_y,
5416        scale_x,
5417        scale_y,
5418        out_w: raster_w,
5419        out_h: raster_h,
5420        effective_dpi,
5421        icc,
5422        image_cache: None,
5423        preprocessed: None,
5424        elem_idx: 0,
5425        no_aa,
5426        opm_zero_transparent: false,
5427        knockout_painter_pass: KnockoutPainterPass::None,
5428        parent_group_isolated: false,
5429        alpha_extraction_pass: false,
5430        layer_set,
5431    };
5432
5433    // 5. Mask rendering doesn't participate in CMYK overprint compositing.
5434    let mut mask_band = BandState {
5435        clip_region: None,
5436        spare_mask: None,
5437        clip_mask_cache: HashMap::new(),
5438        clip_mask_seen: HashSet::new(),
5439        mask_pool: Vec::new(),
5440        cmyk_buffer: None,
5441        op_bg_snapshot: None,
5442        op_touched: None,
5443        spot_mask: None,
5444    };
5445
5446    // 6. Render every element of the mask display list into the offscreen.
5447    for (idx, elem) in mask_list.elements().iter().enumerate() {
5448        let elem_ctx = RenderContext {
5449            elem_idx: idx,
5450            ..sub_ctx
5451        };
5452        render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
5453    }
5454
5455    // 7. If the mask form contained nested gs-set SMask scopes, composite
5456    // the rendered mask onto the backdrop color before extracting
5457    // luminosity. Nested masks produce semi-transparent pixels where
5458    // alpha encodes the mask modulation; without compositing,
5459    // un-premultiplying would amplify the color and lose the modulation.
5460    // Only Luminosity: Alpha masks extract the alpha channel directly,
5461    // so forcing alpha=255 via compositing would destroy the mask info.
5462    if params.has_nested_mask_scope
5463        && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
5464    {
5465        let bc = params.backdrop_color.as_ref();
5466        let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5467        let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5468        let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5469        for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
5470            let a = chunk[3] as u16;
5471            if a == 255 {
5472                continue;
5473            }
5474            let inv_a = 255 - a;
5475            chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
5476            chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
5477            chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
5478            chunk[3] = 255;
5479        }
5480    }
5481
5482    // 8. Extract grayscale mask values into a flat single-channel buffer.
5483    let pixel_count = (raster_w * raster_h) as usize;
5484    let mut data = vec![0u8; pixel_count];
5485    extract_soft_mask_values(mask_pixmap.data(), &mut data, params);
5486
5487    Some(stet_graphics::display_list::MaskRaster {
5488        data,
5489        width: raster_w,
5490        height: raster_h,
5491        origin_x: px_x_min,
5492        origin_y: px_y_min,
5493        scale_x,
5494        scale_y,
5495    })
5496}
5497
5498/// Transform a display element's CTM through a matrix so that pattern-space
5499/// coordinates map to device space.  Recursively transforms children of
5500/// Group and SoftMasked elements, and adjusts their bboxes.
5501fn transform_element_ctm(elem: &DisplayElement, pm: &Matrix) -> DisplayElement {
5502    match elem {
5503        DisplayElement::Fill { path, params } => {
5504            let mut p = params.clone();
5505            p.ctm = pm.concat(&p.ctm);
5506            DisplayElement::Fill {
5507                path: path.clone(),
5508                params: p,
5509            }
5510        }
5511        DisplayElement::Stroke { path, params } => {
5512            let mut p = params.clone();
5513            p.ctm = pm.concat(&p.ctm);
5514            DisplayElement::Stroke {
5515                path: path.clone(),
5516                params: p,
5517            }
5518        }
5519        DisplayElement::Clip { path, params } => {
5520            let mut p = params.clone();
5521            p.ctm = pm.concat(&p.ctm);
5522            if let Some(ref mut sp) = p.stroke_params {
5523                sp.ctm = pm.concat(&sp.ctm);
5524            }
5525            DisplayElement::Clip {
5526                path: path.clone(),
5527                params: p,
5528            }
5529        }
5530        DisplayElement::Image {
5531            sample_data,
5532            params,
5533        } => {
5534            let mut p = params.clone();
5535            p.ctm = pm.concat(&p.ctm);
5536            DisplayElement::Image {
5537                sample_data: sample_data.clone(),
5538                params: p,
5539            }
5540        }
5541        DisplayElement::MeshShading { params } => {
5542            let mut p = params.clone();
5543            p.ctm = pm.concat(&p.ctm);
5544            DisplayElement::MeshShading { params: p }
5545        }
5546        DisplayElement::PatchShading { params } => {
5547            let mut p = params.clone();
5548            p.ctm = pm.concat(&p.ctm);
5549            DisplayElement::PatchShading { params: p }
5550        }
5551        DisplayElement::AxialShading { params } => {
5552            let mut p = params.clone();
5553            p.ctm = pm.concat(&p.ctm);
5554            DisplayElement::AxialShading { params: p }
5555        }
5556        DisplayElement::RadialShading { params } => {
5557            let mut p = params.clone();
5558            p.ctm = pm.concat(&p.ctm);
5559            DisplayElement::RadialShading { params: p }
5560        }
5561        DisplayElement::Group { elements, params } => {
5562            let mut t = DisplayList::new();
5563            for child in elements.elements() {
5564                t.push(transform_element_ctm(child, pm));
5565            }
5566            let mut p = params.clone();
5567            let corners = [
5568                pm.transform_point(p.bbox[0], p.bbox[1]),
5569                pm.transform_point(p.bbox[2], p.bbox[1]),
5570                pm.transform_point(p.bbox[0], p.bbox[3]),
5571                pm.transform_point(p.bbox[2], p.bbox[3]),
5572            ];
5573            p.bbox = [
5574                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5575                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5576                corners
5577                    .iter()
5578                    .map(|c| c.0)
5579                    .fold(f64::NEG_INFINITY, f64::max),
5580                corners
5581                    .iter()
5582                    .map(|c| c.1)
5583                    .fold(f64::NEG_INFINITY, f64::max),
5584            ];
5585            DisplayElement::Group {
5586                elements: t,
5587                params: p,
5588            }
5589        }
5590        DisplayElement::SoftMasked {
5591            mask,
5592            content,
5593            params,
5594            ..
5595        } => {
5596            let mut t_mask = DisplayList::new();
5597            for child in mask.elements() {
5598                t_mask.push(transform_element_ctm(child, pm));
5599            }
5600            let mut t_content = DisplayList::new();
5601            for child in content.elements() {
5602                t_content.push(transform_element_ctm(child, pm));
5603            }
5604            let mut p = params.clone();
5605            let corners = [
5606                pm.transform_point(p.bbox[0], p.bbox[1]),
5607                pm.transform_point(p.bbox[2], p.bbox[1]),
5608                pm.transform_point(p.bbox[0], p.bbox[3]),
5609                pm.transform_point(p.bbox[2], p.bbox[3]),
5610            ];
5611            p.bbox = [
5612                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5613                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5614                corners
5615                    .iter()
5616                    .map(|c| c.0)
5617                    .fold(f64::NEG_INFINITY, f64::max),
5618                corners
5619                    .iter()
5620                    .map(|c| c.1)
5621                    .fold(f64::NEG_INFINITY, f64::max),
5622            ];
5623            // parent_clip_bbox was captured in the original (pattern)
5624            // coordinate system. Transform it through pm to match the
5625            // device-space coords that mask/content elements were just
5626            // moved into; otherwise the renderer would intersect a
5627            // device-space mask bbox with a pattern-space clip and get
5628            // an empty raster.
5629            if let Some(pcb) = p.parent_clip_bbox {
5630                let pcb_corners = [
5631                    pm.transform_point(pcb[0], pcb[1]),
5632                    pm.transform_point(pcb[2], pcb[1]),
5633                    pm.transform_point(pcb[0], pcb[3]),
5634                    pm.transform_point(pcb[2], pcb[3]),
5635                ];
5636                p.parent_clip_bbox = Some([
5637                    pcb_corners
5638                        .iter()
5639                        .map(|c| c.0)
5640                        .fold(f64::INFINITY, f64::min),
5641                    pcb_corners
5642                        .iter()
5643                        .map(|c| c.1)
5644                        .fold(f64::INFINITY, f64::min),
5645                    pcb_corners
5646                        .iter()
5647                        .map(|c| c.0)
5648                        .fold(f64::NEG_INFINITY, f64::max),
5649                    pcb_corners
5650                        .iter()
5651                        .map(|c| c.1)
5652                        .fold(f64::NEG_INFINITY, f64::max),
5653                ]);
5654            }
5655            // The transformed element's coordinate system is different
5656            // from the original; the original cache (if any) is invalid.
5657            // Allocate a fresh cache cell.
5658            DisplayElement::SoftMasked {
5659                mask: t_mask,
5660                content: t_content,
5661                params: p,
5662                mask_cache: Arc::new(Mutex::new(None)),
5663            }
5664        }
5665        DisplayElement::PatternFill { params } => {
5666            let mut p = params.clone();
5667            p.pattern_matrix = pm.concat(&p.pattern_matrix);
5668            // Transform the fill path (device-space coordinates)
5669            p.path = transform_path_by_matrix(&p.path, pm);
5670            if let Some(ref mut sp) = p.stroke_params {
5671                sp.ctm = pm.concat(&sp.ctm);
5672            }
5673            DisplayElement::PatternFill { params: p }
5674        }
5675        DisplayElement::OcgGroup {
5676            elements,
5677            visibility,
5678        } => {
5679            let mut t = DisplayList::new();
5680            for child in elements.elements() {
5681                t.push(transform_element_ctm(child, pm));
5682            }
5683            DisplayElement::OcgGroup {
5684                elements: t,
5685                visibility: visibility.clone(),
5686            }
5687        }
5688        other => other.clone(),
5689    }
5690}
5691
5692/// Transform all points in a path through a matrix.
5693fn transform_path_by_matrix(path: &PsPath, m: &Matrix) -> PsPath {
5694    use stet_fonts::geometry::PathSegment;
5695    let mut out = PsPath::new();
5696    for seg in &path.segments {
5697        out.segments.push(match *seg {
5698            PathSegment::MoveTo(x, y) => {
5699                let (nx, ny) = m.transform_point(x, y);
5700                PathSegment::MoveTo(nx, ny)
5701            }
5702            PathSegment::LineTo(x, y) => {
5703                let (nx, ny) = m.transform_point(x, y);
5704                PathSegment::LineTo(nx, ny)
5705            }
5706            PathSegment::CurveTo {
5707                x1,
5708                y1,
5709                x2,
5710                y2,
5711                x3,
5712                y3,
5713            } => {
5714                let (nx1, ny1) = m.transform_point(x1, y1);
5715                let (nx2, ny2) = m.transform_point(x2, y2);
5716                let (nx3, ny3) = m.transform_point(x3, y3);
5717                PathSegment::CurveTo {
5718                    x1: nx1,
5719                    y1: ny1,
5720                    x2: nx2,
5721                    y2: ny2,
5722                    x3: nx3,
5723                    y3: ny3,
5724                }
5725            }
5726            PathSegment::ClosePath => PathSegment::ClosePath,
5727        });
5728    }
5729    out
5730}
5731
5732/// Render a tiled pattern fill.
5733/// Bilinear downscale of premultiplied RGBA image data.
5734///
5735/// Used to pre-scale pattern tile images when the device-space tile is smaller
5736/// than the image resolution, since tiny-skia's `draw_pixmap` doesn't handle
5737/// sub-1.0 scale transforms.
5738fn bilinear_prescale(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
5739    let mut dst = vec![0u8; (dw * dh * 4) as usize];
5740    for dy in 0..dh {
5741        let sy_f = (dy as f64 + 0.5) * sh as f64 / dh as f64 - 0.5;
5742        let sy0 = sy_f.floor().max(0.0) as u32;
5743        let sy1 = (sy0 + 1).min(sh - 1);
5744        let fy = (sy_f - sy0 as f64) as f32;
5745        let ify = 1.0 - fy;
5746        for dx in 0..dw {
5747            let sx_f = (dx as f64 + 0.5) * sw as f64 / dw as f64 - 0.5;
5748            let sx0 = sx_f.floor().max(0.0) as u32;
5749            let sx1 = (sx0 + 1).min(sw - 1);
5750            let fx = (sx_f - sx0 as f64) as f32;
5751            let ifx = 1.0 - fx;
5752
5753            let i00 = (sy0 * sw + sx0) as usize * 4;
5754            let i10 = (sy0 * sw + sx1) as usize * 4;
5755            let i01 = (sy1 * sw + sx0) as usize * 4;
5756            let i11 = (sy1 * sw + sx1) as usize * 4;
5757            let di = (dy * dw + dx) as usize * 4;
5758            for c in 0..4 {
5759                dst[di + c] = (src[i00 + c] as f32 * ifx * ify
5760                    + src[i10 + c] as f32 * fx * ify
5761                    + src[i01 + c] as f32 * ifx * fy
5762                    + src[i11 + c] as f32 * fx * fy)
5763                    .round() as u8;
5764            }
5765        }
5766    }
5767    dst
5768}
5769
5770fn render_pattern_fill(
5771    pixmap: &mut Pixmap,
5772    band_state: &mut BandState,
5773    params: &stet_graphics::device::PatternFillParams,
5774    ctx: &RenderContext<'_>,
5775) {
5776    let mut temp_mask = None;
5777    let Some(mask_ref) = resolve_clip_mask(
5778        &band_state.clip_region,
5779        &mut temp_mask,
5780        ctx.out_w,
5781        ctx.out_h,
5782    ) else {
5783        return;
5784    };
5785
5786    let pm = &params.pattern_matrix;
5787
5788    // Tile step vectors in device space (handles rotation/shear)
5789    let (step_ux, step_uy) = pm.transform_delta(params.xstep, 0.0);
5790    let (step_vx, step_vy) = pm.transform_delta(0.0, params.ystep);
5791
5792    let step_u_len = (step_ux * step_ux + step_uy * step_uy).sqrt();
5793    let step_v_len = (step_vx * step_vx + step_vy * step_vy).sqrt();
5794    if step_u_len < 0.01 || step_v_len < 0.01 {
5795        return;
5796    }
5797
5798    let origin_x = pm.tx;
5799    let origin_y = pm.ty;
5800
5801    // Viewport bounds in device space
5802    let dev_vp_x = ctx.vp_x as f64;
5803    let dev_vp_y = ctx.vp_y as f64;
5804    let dev_vp_w = ctx.out_w as f64 / ctx.scale_x as f64;
5805    let dev_vp_h = ctx.out_h as f64 / ctx.scale_y as f64;
5806
5807    let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
5808    for seg in &params.path.segments {
5809        let (x, y) = match seg {
5810            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => (*x, *y),
5811            PathSegment::CurveTo { x3, y3, .. } => (*x3, *y3),
5812            PathSegment::ClosePath => continue,
5813        };
5814        min_x = min_x.min(x);
5815        min_y = min_y.min(y);
5816        max_x = max_x.max(x);
5817        max_y = max_y.max(y);
5818    }
5819
5820    // For stroke patterns, the path extends beyond the centerline by half
5821    // the stroke width.  The path is in user space; transform the bbox
5822    // corners through the CTM to get device-space bounds.
5823    if let Some(ref sp) = params.stroke_params {
5824        // Transform user-space bbox corners through CTM to device space
5825        let ctm = &sp.ctm;
5826        let corners = [
5827            ctm.transform_point(min_x, min_y),
5828            ctm.transform_point(max_x, min_y),
5829            ctm.transform_point(min_x, max_y),
5830            ctm.transform_point(max_x, max_y),
5831        ];
5832        min_x = f64::MAX;
5833        min_y = f64::MAX;
5834        max_x = f64::MIN;
5835        max_y = f64::MIN;
5836        for (cx, cy) in &corners {
5837            min_x = min_x.min(*cx);
5838            min_y = min_y.min(*cy);
5839            max_x = max_x.max(*cx);
5840            max_y = max_y.max(*cy);
5841        }
5842        // Expand by half stroke width in device space
5843        let half_w = sp.line_width
5844            * 0.5
5845            * (ctm.a * ctm.a + ctm.b * ctm.b)
5846                .sqrt()
5847                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
5848        min_x -= half_w;
5849        min_y -= half_w;
5850        max_x += half_w;
5851        max_y += half_w;
5852    }
5853
5854    // Clamp to viewport bounds in device space
5855    min_x = min_x.max(dev_vp_x);
5856    min_y = min_y.max(dev_vp_y);
5857    max_x = max_x.min(dev_vp_x + dev_vp_w);
5858    max_y = max_y.min(dev_vp_y + dev_vp_h);
5859    if min_x >= max_x || min_y >= max_y {
5860        return;
5861    }
5862
5863    let det = step_ux * step_vy - step_uy * step_vx;
5864    if det.abs() < 1e-10 {
5865        return;
5866    }
5867    let inv_det = 1.0 / det;
5868
5869    let mut tu_min = f64::MAX;
5870    let mut tu_max = f64::MIN;
5871    let mut tv_min = f64::MAX;
5872    let mut tv_max = f64::MIN;
5873    for &(cx, cy) in &[
5874        (min_x, min_y),
5875        (max_x, min_y),
5876        (min_x, max_y),
5877        (max_x, max_y),
5878    ] {
5879        let dx = cx - origin_x;
5880        let dy = cy - origin_y;
5881        let tu = (dx * step_vy - dy * step_vx) * inv_det;
5882        let tv = (-dx * step_uy + dy * step_ux) * inv_det;
5883        tu_min = tu_min.min(tu);
5884        tu_max = tu_max.max(tu);
5885        tv_min = tv_min.min(tv);
5886        tv_max = tv_max.max(tv);
5887    }
5888
5889    let tile_x_start = tu_min.floor() as i32 - 1;
5890    let tile_x_end = tu_max.ceil() as i32 + 1;
5891    let tile_y_start = tv_min.floor() as i32 - 1;
5892    let tile_y_end = tv_max.ceil() as i32 + 1;
5893
5894    let tile_count = (tile_x_end - tile_x_start) as i64 * (tile_y_end - tile_y_start) as i64;
5895    if tile_count > 10000 {
5896        return;
5897    }
5898
5899    let Some(mut tile_buf) = Pixmap::new(ctx.out_w, ctx.out_h) else {
5900        return;
5901    };
5902
5903    let sx_f = ctx.scale_x as f64;
5904    let sy_f = ctx.scale_y as f64;
5905
5906    if params.device_space_tile {
5907        // Device-space tile path: tile elements have CTMs in device space
5908        // (pattern matrix baked in). Use the full render_element pipeline
5909        // which handles all element types (clips, soft masks, shadings,
5910        // groups). For each tile position, shift the viewport origin by the
5911        // tile offset in device space.
5912        for tv in tile_y_start..tile_y_end {
5913            for tu in tile_x_start..tile_x_end {
5914                let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
5915                let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
5916
5917                let tile_ctx = RenderContext {
5918                    vp_x: ctx.vp_x - offset_x as f32,
5919                    vp_y: ctx.vp_y - offset_y as f32,
5920                    scale_x: ctx.scale_x,
5921                    scale_y: ctx.scale_y,
5922                    out_w: ctx.out_w,
5923                    out_h: ctx.out_h,
5924                    effective_dpi: ctx.effective_dpi,
5925                    icc: ctx.icc,
5926                    image_cache: None,
5927                    preprocessed: None,
5928                    elem_idx: 0,
5929                    no_aa: ctx.no_aa,
5930                    opm_zero_transparent: params.overprint_mode == 1,
5931                    knockout_painter_pass: ctx.knockout_painter_pass,
5932                    parent_group_isolated: ctx.parent_group_isolated,
5933                    alpha_extraction_pass: ctx.alpha_extraction_pass,
5934                    layer_set: ctx.layer_set,
5935                };
5936
5937                let mut tile_band = BandState {
5938                    clip_region: None,
5939                    spare_mask: None,
5940                    clip_mask_cache: HashMap::new(),
5941                    clip_mask_seen: HashSet::new(),
5942                    mask_pool: Vec::new(),
5943                    cmyk_buffer: None,
5944                    op_bg_snapshot: None,
5945                    op_touched: None,
5946                    spot_mask: None,
5947                };
5948
5949                for (idx, elem) in params.tile.elements().iter().enumerate() {
5950                    let elem_ctx = RenderContext {
5951                        elem_idx: idx,
5952                        ..tile_ctx
5953                    };
5954                    render_element(&mut tile_buf, &mut tile_band, elem, &elem_ctx);
5955                }
5956            }
5957        }
5958    } else if params.tile.elements().iter().any(|e| {
5959        !matches!(
5960            e,
5961            DisplayElement::Fill { .. }
5962                | DisplayElement::Stroke { .. }
5963                | DisplayElement::Image { .. }
5964                | DisplayElement::Clip { .. }
5965                | DisplayElement::InitClip
5966        )
5967    }) {
5968        // Complex tile path: pre-render one tile into a small pixmap using
5969        // the full render_element pipeline (handles shadings, groups,
5970        // soft masks, etc.), then stamp copies at each tile position.
5971        let bbox = &params.bbox;
5972        let corners_dev = [
5973            pm.transform_point(bbox[0], bbox[1]),
5974            pm.transform_point(bbox[2], bbox[1]),
5975            pm.transform_point(bbox[0], bbox[3]),
5976            pm.transform_point(bbox[2], bbox[3]),
5977        ];
5978        let (mut td_x0, mut td_y0) = (f64::MAX, f64::MAX);
5979        let (mut td_x1, mut td_y1) = (f64::MIN, f64::MIN);
5980        for (x, y) in &corners_dev {
5981            td_x0 = td_x0.min(*x);
5982            td_y0 = td_y0.min(*y);
5983            td_x1 = td_x1.max(*x);
5984            td_y1 = td_y1.max(*y);
5985        }
5986        let tile_pw = ((td_x1 - td_x0) * sx_f).ceil().max(1.0) as u32;
5987        let tile_ph = ((td_y1 - td_y0) * sy_f).ceil().max(1.0) as u32;
5988        let tile_pw = tile_pw.min(8192);
5989        let tile_ph = tile_ph.min(8192);
5990
5991        if let Some(mut one_tile) = Pixmap::new(tile_pw, tile_ph) {
5992            let tile_render_ctx = RenderContext {
5993                vp_x: td_x0 as f32,
5994                vp_y: td_y0 as f32,
5995                scale_x: ctx.scale_x,
5996                scale_y: ctx.scale_y,
5997                out_w: tile_pw,
5998                out_h: tile_ph,
5999                effective_dpi: ctx.effective_dpi,
6000                icc: ctx.icc,
6001                image_cache: None,
6002                preprocessed: None,
6003                elem_idx: 0,
6004                no_aa: ctx.no_aa,
6005                opm_zero_transparent: params.overprint_mode == 1,
6006                knockout_painter_pass: ctx.knockout_painter_pass,
6007                parent_group_isolated: ctx.parent_group_isolated,
6008                alpha_extraction_pass: ctx.alpha_extraction_pass,
6009                layer_set: ctx.layer_set,
6010            };
6011            let mut tile_bs = BandState {
6012                clip_region: None,
6013                spare_mask: None,
6014                clip_mask_cache: HashMap::new(),
6015                clip_mask_seen: HashSet::new(),
6016                mask_pool: Vec::new(),
6017                cmyk_buffer: None,
6018                op_bg_snapshot: None,
6019                op_touched: None,
6020                spot_mask: None,
6021            };
6022            for (idx, elem) in params.tile.elements().iter().enumerate() {
6023                let transformed = transform_element_ctm(elem, pm);
6024                let elem_ctx = RenderContext {
6025                    elem_idx: idx,
6026                    ..tile_render_ctx
6027                };
6028                render_element(&mut one_tile, &mut tile_bs, &transformed, &elem_ctx);
6029            }
6030            // Stamp pre-rendered tile at each position
6031            for tv in tile_y_start..tile_y_end {
6032                for tu in tile_x_start..tile_x_end {
6033                    let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
6034                    let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
6035                    let px = ((td_x0 + offset_x - dev_vp_x) * sx_f) as i32;
6036                    let py = ((td_y0 + offset_y - dev_vp_y) * sy_f) as i32;
6037                    let paint = stet_tiny_skia::PixmapPaint {
6038                        opacity: 1.0,
6039                        blend_mode: BlendMode::SourceOver,
6040                        quality: stet_tiny_skia::FilterQuality::Nearest,
6041                    };
6042                    tile_buf.draw_pixmap(
6043                        px,
6044                        py,
6045                        one_tile.as_ref(),
6046                        &paint,
6047                        Transform::identity(),
6048                        None,
6049                    );
6050                }
6051            }
6052        }
6053    } else {
6054        // Simple tile path: tile elements have identity CTMs.
6055        // Manually apply the pattern matrix + tile offset for each element.
6056        // Only handles Fill, Stroke, Image, and Clip.
6057
6058        // Pre-process Image elements: convert to RGBA once and pre-scale if
6059        // the combined transform would require downscaling (scale < 1.0).
6060        // tiny-skia's draw_pixmap doesn't handle sub-1.0 scale transforms.
6061        struct PreprocessedImage {
6062            rgba: Vec<u8>,
6063            width: u32,
6064            height: u32,
6065            /// Transform from pixel coords to pattern space, possibly adjusted
6066            /// to account for pre-scaling.
6067            img_transform: Transform,
6068        }
6069        let tile_elements = params.tile.elements();
6070        let mut preprocessed: Vec<Option<PreprocessedImage>> =
6071            Vec::with_capacity(tile_elements.len());
6072        // Tile transform scale components (constant across all tiles)
6073        let tt_sx = (pm.a * sx_f) as f32;
6074        let tt_sy = (pm.d * sy_f) as f32;
6075        let tt_kx = (pm.c * sx_f) as f32;
6076        let tt_ky = (pm.b * sy_f) as f32;
6077        for elem in tile_elements {
6078            if let DisplayElement::Image {
6079                sample_data,
6080                params: ip,
6081            } = elem
6082            {
6083                let iw = ip.width;
6084                let ih = ip.height;
6085                if iw > 0 && ih > 0 {
6086                    let mut rgba =
6087                        samples_to_rgba(sample_data, ip, ctx.icc, ctx.opm_zero_transparent);
6088                    if ip.mask_color.is_some() {
6089                        apply_mask_color_rgba(&mut rgba, sample_data, ip);
6090                    }
6091                    let expected = (iw * ih * 4) as usize;
6092                    if rgba.len() >= expected {
6093                        if let Some(inv) = ip.image_matrix.invert() {
6094                            let combined_mat = ip.ctm.concat(&inv);
6095                            let t = to_transform(&combined_mat);
6096                            // Check effective scale: t maps image pixels → pattern space,
6097                            // tile_transform maps pattern space → device space.
6098                            let test = t.post_concat(Transform::from_row(
6099                                tt_sx, tt_ky, tt_kx, tt_sy, 0.0, 0.0,
6100                            ));
6101                            let eff_sx = (test.sx * test.sx + test.ky * test.ky).sqrt();
6102                            let eff_sy = (test.kx * test.kx + test.sy * test.sy).sqrt();
6103                            if eff_sx < 0.99 || eff_sy < 0.99 {
6104                                // Pre-scale image to avoid sub-1.0 draw_pixmap transform.
6105                                // Use floor so the scaled image is smaller than the
6106                                // device-space tile, ensuring the adjusted scale >= 1.0.
6107                                let tw = (iw as f32 * eff_sx).floor().max(1.0) as u32;
6108                                let th = (ih as f32 * eff_sy).floor().max(1.0) as u32;
6109                                let scaled = bilinear_prescale(&rgba, iw, ih, tw, th);
6110                                // Adjust transform: pre-multiply a scale that maps new
6111                                // pixel coords back to original pixel coords
6112                                let adj = Transform::from_scale(
6113                                    iw as f32 / tw as f32,
6114                                    ih as f32 / th as f32,
6115                                );
6116                                preprocessed.push(Some(PreprocessedImage {
6117                                    rgba: scaled,
6118                                    width: tw,
6119                                    height: th,
6120                                    img_transform: t.pre_concat(adj),
6121                                }));
6122                            } else {
6123                                preprocessed.push(Some(PreprocessedImage {
6124                                    rgba,
6125                                    width: iw,
6126                                    height: ih,
6127                                    img_transform: t,
6128                                }));
6129                            }
6130                        } else {
6131                            preprocessed.push(None);
6132                        }
6133                    } else {
6134                        preprocessed.push(None);
6135                    }
6136                } else {
6137                    preprocessed.push(None);
6138                }
6139                // Note: only Image elements push to preprocessed, so img_idx
6140                // in the tile loop correctly indexes this array.
6141            }
6142        }
6143
6144        for tv in tile_y_start..tile_y_end {
6145            for tu in tile_x_start..tile_x_end {
6146                let pat_offset_x = tu as f64 * params.xstep;
6147                let pat_offset_y = tv as f64 * params.ystep;
6148
6149                let tile_transform = Transform::from_row(
6150                    tt_sx,
6151                    tt_ky,
6152                    tt_kx,
6153                    tt_sy,
6154                    ((pm.a * pat_offset_x + pm.c * pat_offset_y + pm.tx - dev_vp_x) * sx_f) as f32,
6155                    ((pm.b * pat_offset_x + pm.d * pat_offset_y + pm.ty - dev_vp_y) * sy_f) as f32,
6156                );
6157
6158                // Clip tile elements to BBox (PDF spec 8.7.4.2)
6159                let bbox_clip = {
6160                    let bb = &params.bbox;
6161                    let mut bp = stet_tiny_skia::PathBuilder::new();
6162                    bp.move_to(bb[0] as f32, bb[1] as f32);
6163                    bp.line_to(bb[2] as f32, bb[1] as f32);
6164                    bp.line_to(bb[2] as f32, bb[3] as f32);
6165                    bp.line_to(bb[0] as f32, bb[3] as f32);
6166                    bp.close();
6167                    bp.finish().and_then(|sp| {
6168                        let mut m = Mask::new(ctx.out_w, ctx.out_h)?;
6169                        m.fill_path(
6170                            &sp,
6171                            stet_tiny_skia::FillRule::Winding,
6172                            false,
6173                            tile_transform,
6174                        );
6175                        Some(m)
6176                    })
6177                };
6178                let mut tile_clip: Option<Mask> = bbox_clip;
6179                let mut img_idx = 0usize;
6180                for elem in tile_elements {
6181                    let clip_ref = tile_clip.as_ref();
6182                    match elem {
6183                        DisplayElement::Clip { path, params: cp } => {
6184                            if let Some(sp) = build_skia_path(path) {
6185                                let t = to_transform(&cp.ctm);
6186                                let combined = t.post_concat(tile_transform);
6187                                let mut mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6188                                mask.fill_path(&sp, to_fill_rule(&cp.fill_rule), false, combined);
6189                                if let Some(prev) = tile_clip.take() {
6190                                    intersect_masks(&mut mask, &prev);
6191                                }
6192                                tile_clip = Some(mask);
6193                            }
6194                        }
6195                        DisplayElement::InitClip => {
6196                            tile_clip = None;
6197                        }
6198                        DisplayElement::Fill { path, params: fp } => {
6199                            if let Some(sp) = build_skia_path(path) {
6200                                let mut paint = if params.paint_type == 1 {
6201                                    to_paint(&fp.color)
6202                                } else {
6203                                    to_paint(
6204                                        params
6205                                            .underlying_color
6206                                            .as_ref()
6207                                            .unwrap_or(&DeviceColor::black()),
6208                                    )
6209                                };
6210                                paint.anti_alias = false;
6211                                let t = to_transform(&fp.ctm);
6212                                let combined = t.post_concat(tile_transform);
6213                                let fr = to_fill_rule(&fp.fill_rule);
6214                                tile_buf.fill_path(&sp, &paint, fr, combined, clip_ref);
6215                            }
6216                        }
6217                        DisplayElement::Stroke { path, params: sp } => {
6218                            if let Some(skp) = build_skia_path(path) {
6219                                // Compose element CTM with pattern matrix so
6220                                // hairline_min_width sees the real device scale,
6221                                // not the tile's identity CTM.
6222                                let effective_ctm = pm.concat(&sp.ctm);
6223                                let mut sp_adj = sp.clone();
6224                                sp_adj.ctm = effective_ctm;
6225                                let stroke = build_stroke(&sp_adj, ctx.effective_dpi);
6226                                let paint = if params.paint_type == 1 {
6227                                    to_paint(&sp.color)
6228                                } else {
6229                                    to_paint(
6230                                        params
6231                                            .underlying_color
6232                                            .as_ref()
6233                                            .unwrap_or(&DeviceColor::black()),
6234                                    )
6235                                };
6236                                let t = to_transform(&sp.ctm);
6237                                let combined = t.post_concat(tile_transform);
6238                                tile_buf.stroke_path(&skp, &paint, &stroke, combined, clip_ref);
6239                            }
6240                        }
6241                        DisplayElement::Image { .. } => {
6242                            if let Some(ref pi) = preprocessed[img_idx] {
6243                                let combined = pi.img_transform.post_concat(tile_transform);
6244                                if let Some(img_ref) = stet_tiny_skia::PixmapRef::from_bytes(
6245                                    &pi.rgba, pi.width, pi.height,
6246                                ) {
6247                                    let paint = stet_tiny_skia::PixmapPaint {
6248                                        opacity: 1.0,
6249                                        blend_mode: BlendMode::SourceOver,
6250                                        quality: stet_tiny_skia::FilterQuality::Nearest,
6251                                    };
6252                                    tile_buf.draw_pixmap(0, 0, img_ref, &paint, combined, clip_ref);
6253                                }
6254                            }
6255                            img_idx += 1;
6256                        }
6257                        _ => {}
6258                    }
6259                }
6260            }
6261        }
6262    }
6263
6264    // Composite tile_buf onto main pixmap through the fill/stroke path
6265    let Some(fill_skia_path) = build_skia_path(&params.path) else {
6266        return;
6267    };
6268    let fill_rule = to_fill_rule(&params.fill_rule);
6269    let mut fill_mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6270    let path_transform = viewport_transform(
6271        Transform::identity(),
6272        ctx.vp_x,
6273        ctx.vp_y,
6274        ctx.scale_x,
6275        ctx.scale_y,
6276    );
6277    if let Some(ref sp) = params.stroke_params {
6278        // Stroke pattern: expand the centerline path to a fill outline
6279        // using the stroke parameters (width, cap, join, miter, dash).
6280        // Apply dash pattern first (Path::stroke doesn't handle dashing).
6281        let stroke = build_stroke(sp, ctx.effective_dpi);
6282        let ctm_transform = to_transform(&sp.ctm);
6283        let combined = ctm_transform.post_concat(path_transform);
6284        let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&combined);
6285        let dashed;
6286        let stroke_path = if let Some(ref dash) = stroke.dash {
6287            dashed = fill_skia_path.dash(dash, res_scale);
6288            match dashed.as_ref() {
6289                Some(p) => p,
6290                None => &fill_skia_path,
6291            }
6292        } else {
6293            &fill_skia_path
6294        };
6295        if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6296            fill_mask.fill_path(
6297                &outline,
6298                stet_tiny_skia::FillRule::Winding,
6299                !ctx.no_aa,
6300                combined,
6301            );
6302        }
6303    } else {
6304        fill_mask.fill_path(&fill_skia_path, fill_rule, !ctx.no_aa, path_transform);
6305    }
6306
6307    if let Some(clip_mask) = mask_ref {
6308        intersect_masks(&mut fill_mask, clip_mask);
6309    }
6310
6311    let img_paint = stet_tiny_skia::PixmapPaint::default();
6312    pixmap.draw_pixmap(
6313        0,
6314        0,
6315        tile_buf.as_ref(),
6316        &img_paint,
6317        Transform::identity(),
6318        Some(&fill_mask),
6319    );
6320}
6321
6322/// Unified clip path handling for both band and viewport rendering.
6323///
6324/// For band rendering (scale=1.0), includes rect fast-path and Y-bbox early exit.
6325/// For viewport rendering (scale!=1.0), uses the general mask path.
6326fn clip_path_unified(
6327    band_state: &mut BandState,
6328    path: &PsPath,
6329    params: &ClipParams,
6330    ctx: &RenderContext<'_>,
6331) {
6332    let is_unit_scale = ctx.scale_x == 1.0 && ctx.scale_y == 1.0;
6333
6334    // Band-mode optimizations (scale=1.0): Y-bbox early exit and rect fast-path
6335    if is_unit_scale {
6336        let y_start = ctx.vp_y as u32;
6337        let x_start = ctx.vp_x as u32;
6338
6339        // Y-bbox early exit: if clip path doesn't overlap this band, set empty clip
6340        // (only valid when CTM is identity — path coords must be in device space).
6341        // Skip when stroke_params is present: the path is in user space and
6342        // needs the stroke CTM transform, so raw Y bounds are meaningless here.
6343        if x_start == 0
6344            && params.stroke_params.is_none()
6345            && params.ctm.a == 1.0
6346            && params.ctm.d == 1.0
6347            && params.ctm.tx == 0.0
6348            && params.ctm.ty == 0.0
6349            && let Some(bbox) = path_y_bbox(path)
6350            && (bbox.y_max <= y_start as f64 || bbox.y_min >= (y_start + ctx.out_h) as f64)
6351        {
6352            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
6353                band_state.recycle_mask(mask);
6354            }
6355            band_state.clip_region = Some(ClipRegion::Rect(ClipRect {
6356                x0: 0,
6357                y0: 0,
6358                x1: 0,
6359                y1: 0,
6360            }));
6361            return;
6362        }
6363
6364        // Rect fast-path (only when x_start==0 and CTM is identity —
6365        // detect_rect uses raw path coords which are only in device space
6366        // when the CTM is identity)
6367        let ctm_is_identity = params.ctm.a == 1.0
6368            && params.ctm.b == 0.0
6369            && params.ctm.c == 0.0
6370            && params.ctm.d == 1.0
6371            && params.ctm.tx == 0.0
6372            && params.ctm.ty == 0.0;
6373        if x_start == 0
6374            && ctm_is_identity
6375            && params.stroke_params.is_none()
6376            && let Some(dev_rect) = detect_rect(path, ctx.out_w, u32::MAX)
6377        {
6378            let new_rect = translate_clip_rect(&dev_rect, y_start, ctx.out_h);
6379            match band_state.clip_region.take() {
6380                None => {
6381                    band_state.clip_region = Some(ClipRegion::Rect(new_rect));
6382                }
6383                Some(ClipRegion::Rect(existing)) => {
6384                    band_state.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6385                }
6386                Some(ClipRegion::Mask(mut mask)) => {
6387                    intersect_mask_with_rect(&mut mask, &new_rect, ctx.out_w, ctx.out_h);
6388                    band_state.clip_region = Some(ClipRegion::Mask(mask));
6389                }
6390            }
6391            return;
6392        }
6393    }
6394
6395    // General path: non-rectangular clip with cache + mask reuse
6396    let fill_rule = to_fill_rule(&params.fill_rule);
6397    let path_hash = hash_clip_path(path, &params.fill_rule);
6398    let prev_region = band_state.clip_region.take();
6399
6400    let mut mask = band_state.take_mask(ctx.out_w, ctx.out_h);
6401
6402    let path_mask = if let Some(cached) = band_state.clip_mask_cache.get(&path_hash) {
6403        mask.data_mut().copy_from_slice(cached.data());
6404        mask
6405    } else {
6406        let Some(skia_path) = build_skia_path(path) else {
6407            band_state.recycle_mask(mask);
6408            band_state.clip_region = prev_region;
6409            return;
6410        };
6411        mask.data_mut().fill(0);
6412        if let Some(ref sp) = params.stroke_params {
6413            // Stroke-based clip: expand centerline to stroke outline.
6414            // Apply dash pattern first (Path::stroke doesn't handle dashing).
6415            let stroke = build_stroke(sp, ctx.effective_dpi);
6416            let transform = ctx.transform(&sp.ctm);
6417            let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&transform);
6418            let dashed;
6419            let stroke_path = if let Some(ref dash) = stroke.dash {
6420                dashed = skia_path.dash(dash, res_scale);
6421                match dashed.as_ref() {
6422                    Some(p) => p,
6423                    None => &skia_path,
6424                }
6425            } else {
6426                &skia_path
6427            };
6428            if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6429                mask.fill_path(
6430                    &outline,
6431                    stet_tiny_skia::FillRule::Winding,
6432                    false,
6433                    transform,
6434                );
6435            }
6436        } else {
6437            let transform = ctx.transform(&params.ctm);
6438            mask.fill_path(&skia_path, fill_rule, false, transform);
6439        }
6440        if !band_state.clip_mask_seen.insert(path_hash) {
6441            band_state.clip_mask_cache.insert(path_hash, mask.clone());
6442        }
6443        mask
6444    };
6445
6446    match prev_region {
6447        None => {
6448            band_state.clip_region = Some(ClipRegion::Mask(path_mask));
6449        }
6450        Some(ClipRegion::Rect(rect)) => {
6451            if rect.is_empty() {
6452                band_state.recycle_mask(path_mask);
6453                // Intersection with empty clip is still empty — preserve empty state.
6454                // Without this, clip_region stays None (= no clip = paint everything).
6455                band_state.clip_region = Some(ClipRegion::Rect(rect));
6456            } else {
6457                let mut mask = path_mask;
6458                intersect_mask_with_rect(&mut mask, &rect, ctx.out_w, ctx.out_h);
6459                band_state.clip_region = Some(ClipRegion::Mask(mask));
6460            }
6461        }
6462        Some(ClipRegion::Mask(mut existing)) => {
6463            intersect_masks(&mut existing, &path_mask);
6464            band_state.recycle_mask(path_mask);
6465            band_state.clip_region = Some(ClipRegion::Mask(existing));
6466        }
6467    }
6468}
6469// Only compiled with the `ps-device` feature. The trait lives in `stet-core`
6470// and hands the device the live interpreter `Context` at end of job, so
6471// implementing it links the PostScript VM. A consumer that only rasterizes a
6472// display list needs none of that — see the feature comment in Cargo.toml.
6473#[cfg(feature = "ps-device")]
6474impl OutputDevice for SkiaDevice {
6475    fn fill_path(&mut self, path: &PsPath, params: &FillParams) {
6476        self.ensure_full_pixmap();
6477        let Some(skia_path) = build_skia_path(path) else {
6478            return;
6479        };
6480        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6481        let mut temp_mask = None;
6482        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6483            return; // empty clip
6484        };
6485
6486        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6487        let transform = to_transform(&params.ctm);
6488        let fill_rule = to_fill_rule(&params.fill_rule);
6489
6490        self.pixmap
6491            .fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
6492    }
6493
6494    fn stroke_path(&mut self, path: &PsPath, params: &StrokeParams) {
6495        self.ensure_full_pixmap();
6496        let stroke = build_stroke(params, self.dpi);
6497        let adjusted;
6498        let draw_path =
6499            if params.stroke_adjust && stroke.width <= 2.0 && ctm_is_device_space(&params.ctm) {
6500                adjusted =
6501                    stroke_adjust_path_viewport(path, stroke.width as f64, 1.0, 1.0, 0.0, 0.0);
6502                &adjusted
6503            } else {
6504                path
6505            };
6506        let Some(skia_path) = build_skia_path(draw_path) else {
6507            return;
6508        };
6509        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6510        let transform = to_transform(&params.ctm);
6511
6512        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6513        let mut temp_mask = None;
6514        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6515            return; // empty clip
6516        };
6517
6518        self.pixmap
6519            .stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
6520    }
6521
6522    fn clip_path(&mut self, path: &PsPath, params: &ClipParams) {
6523        self.ensure_full_pixmap();
6524        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6525
6526        // Fast path: detect axis-aligned rectangle
6527        if let Some(new_rect) = detect_rect(path, w, h) {
6528            match self.clip_region.take() {
6529                None => {
6530                    self.clip_region = Some(ClipRegion::Rect(new_rect));
6531                }
6532                Some(ClipRegion::Rect(existing)) => {
6533                    // O(1) rect-rect intersection
6534                    self.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6535                }
6536                Some(ClipRegion::Mask(mut mask)) => {
6537                    // Zero mask pixels outside rect
6538                    intersect_mask_with_rect(&mut mask, &new_rect, w, h);
6539                    self.clip_region = Some(ClipRegion::Mask(mask));
6540                }
6541            }
6542            return;
6543        }
6544
6545        // Slow path: non-rectangular clip with mask caching + allocation reuse.
6546        let fill_rule = to_fill_rule(&params.fill_rule);
6547        let path_hash = hash_clip_path(path, &params.fill_rule);
6548        let prev_region = self.clip_region.take();
6549
6550        // Reuse a spare mask buffer if available (avoids alloc/dealloc per tile).
6551        macro_rules! take_spare {
6552            ($self:expr, $w:expr, $h:expr) => {
6553                $self
6554                    .spare_mask
6555                    .take()
6556                    .unwrap_or_else(|| Mask::new($w, $h).expect("Failed to create mask"))
6557            };
6558        }
6559
6560        // Try cache first; rasterize only on miss
6561        let path_mask = if let Some(cached) = self.clip_mask_cache.get(&path_hash) {
6562            // Cache hit: copy cached data into reused buffer (memcpy, no alloc)
6563            let mut mask = take_spare!(self, w, h);
6564            mask.data_mut().copy_from_slice(cached.data());
6565            mask
6566        } else {
6567            let Some(skia_path) = build_skia_path(path) else {
6568                self.clip_region = prev_region;
6569                return;
6570            };
6571            let transform = to_transform(&params.ctm);
6572            let mut mask = take_spare!(self, w, h);
6573            mask.data_mut().fill(0); // zero before rasterizing (spare may have old data)
6574            mask.fill_path(&skia_path, fill_rule, false, transform);
6575            // Cache on second sight: first time just record, second time store
6576            if !self.clip_mask_seen.insert(path_hash) {
6577                // Seen before — cache it (this clone only happens once per unique path)
6578                self.clip_mask_cache.insert(path_hash, mask.clone());
6579            }
6580            mask
6581        };
6582
6583        match prev_region {
6584            None => {
6585                self.clip_region = Some(ClipRegion::Mask(path_mask));
6586            }
6587            Some(ClipRegion::Rect(rect)) => {
6588                if rect.is_empty() {
6589                    self.spare_mask = Some(path_mask); // recycle
6590                } else {
6591                    let mut mask = path_mask;
6592                    intersect_mask_with_rect(&mut mask, &rect, w, h);
6593                    self.clip_region = Some(ClipRegion::Mask(mask));
6594                }
6595            }
6596            Some(ClipRegion::Mask(mut existing)) => {
6597                intersect_masks(&mut existing, &path_mask);
6598                self.spare_mask = Some(path_mask); // recycle the copy
6599                self.clip_region = Some(ClipRegion::Mask(existing));
6600            }
6601        }
6602    }
6603
6604    fn init_clip(&mut self) {
6605        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6606            self.spare_mask = Some(mask);
6607        }
6608        self.clip_region = None;
6609    }
6610
6611    fn erase_page(&mut self) {
6612        // Only fill the full pixmap when it's actually allocated (non-banded path).
6613        // During banding, self.pixmap is a 1×1 placeholder — filling it is harmless.
6614        self.pixmap.fill(Color::WHITE);
6615        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6616            self.spare_mask = Some(mask);
6617        }
6618        self.clip_region = None;
6619    }
6620
6621    fn show_page(&mut self, output_path: &str) -> Result<(), String> {
6622        let w = self.pixmap.width();
6623        let h = self.pixmap.height();
6624        // Composite onto white background before output
6625        composite_onto_white(self.pixmap.data_mut());
6626        let mut sink = self.sink_factory.create_sink(output_path)?;
6627        sink.begin_page(w, h)?;
6628        sink.write_rows(self.pixmap.data(), h)?;
6629        sink.end_page()
6630    }
6631
6632    fn draw_image(&mut self, sample_data: &[u8], params: &ImageParams) {
6633        self.ensure_full_pixmap();
6634        let w = params.width;
6635        let h = params.height;
6636        if w == 0 || h == 0 {
6637            return;
6638        }
6639        let mut rgba_data =
6640            samples_to_rgba(sample_data, params, self.render_icc_cache.as_ref(), false);
6641        if params.mask_color.is_some() {
6642            apply_mask_color_rgba(&mut rgba_data, sample_data, params);
6643        }
6644        let expected = (w * h * 4) as usize;
6645        if rgba_data.len() < expected {
6646            return;
6647        }
6648
6649        let Some(image_inv) = params.image_matrix.invert() else {
6650            return;
6651        };
6652        let combined = params.ctm.concat(&image_inv);
6653        let raw_transform = enforce_min_image_size(to_transform(&combined), w, h);
6654
6655        let prescaled = prescale_image(&rgba_data, w, h, raw_transform, params.interpolate);
6656        let (img_data, img_w, img_h, transform) = match &prescaled {
6657            Some((data, pw, ph, t)) => (data.as_slice(), *pw, *ph, *t),
6658            None => (rgba_data.as_slice(), w, h, raw_transform),
6659        };
6660
6661        let Some(img_pixmap) = stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h) else {
6662            return;
6663        };
6664
6665        let (pw, ph) = (self.pixmap.width(), self.pixmap.height());
6666        let mut temp_mask = None;
6667        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, pw, ph) else {
6668            return;
6669        };
6670
6671        let paint = stet_tiny_skia::PixmapPaint {
6672            quality: image_filter_quality(transform, params.interpolate),
6673            opacity: params.alpha as f32,
6674            blend_mode: u8_to_blend_mode(params.blend_mode),
6675        };
6676        self.pixmap
6677            .draw_pixmap(0, 0, img_pixmap, &paint, transform, mask_ref);
6678    }
6679
6680    fn paint_axial_shading(&mut self, params: &AxialShadingParams) {
6681        self.ensure_full_pixmap();
6682        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6683        let mut temp_mask = None;
6684        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6685            return;
6686        };
6687        render_axial_shading(
6688            &mut self.pixmap,
6689            params,
6690            0.0,
6691            0.0,
6692            1.0,
6693            1.0,
6694            mask_ref,
6695            self.no_aa,
6696            None,
6697            None,
6698        );
6699    }
6700
6701    fn paint_radial_shading(&mut self, params: &RadialShadingParams) {
6702        self.ensure_full_pixmap();
6703        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6704        let mut temp_mask = None;
6705        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6706            return;
6707        };
6708        render_radial_shading(
6709            &mut self.pixmap,
6710            params,
6711            0.0,
6712            0.0,
6713            1.0,
6714            1.0,
6715            mask_ref,
6716            self.no_aa,
6717            None,
6718            None,
6719        );
6720    }
6721
6722    fn paint_mesh_shading(&mut self, params: &MeshShadingParams) {
6723        self.ensure_full_pixmap();
6724        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6725        let mut temp_mask = None;
6726        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6727            return;
6728        };
6729        render_mesh_shading(
6730            &mut self.pixmap,
6731            params,
6732            0.0,
6733            0.0,
6734            1.0,
6735            1.0,
6736            mask_ref,
6737            None,
6738            None,
6739        );
6740    }
6741
6742    fn paint_patch_shading(&mut self, params: &PatchShadingParams) {
6743        self.ensure_full_pixmap();
6744        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6745        let mut temp_mask = None;
6746        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6747            return;
6748        };
6749        render_patch_shading(
6750            &mut self.pixmap,
6751            params,
6752            0.0,
6753            0.0,
6754            1.0,
6755            1.0,
6756            mask_ref,
6757            None,
6758            None,
6759        );
6760    }
6761
6762    fn paint_pattern_fill(&mut self, params: &stet_graphics::device::PatternFillParams) {
6763        self.ensure_full_pixmap();
6764        let w = self.pixmap.width();
6765        let h = self.pixmap.height();
6766        let mut band_state = BandState {
6767            clip_region: self.clip_region.take(),
6768            spare_mask: self.spare_mask.take(),
6769            clip_mask_cache: HashMap::new(),
6770            clip_mask_seen: HashSet::new(),
6771            mask_pool: Vec::new(),
6772            cmyk_buffer: None,
6773            op_bg_snapshot: None,
6774            op_touched: None,
6775            spot_mask: None,
6776        };
6777        {
6778            let ctx = RenderContext {
6779                vp_x: 0.0,
6780                vp_y: 0.0,
6781                scale_x: 1.0,
6782                scale_y: 1.0,
6783                out_w: w,
6784                out_h: h,
6785                effective_dpi: self.dpi,
6786                icc: None,
6787                image_cache: None,
6788                preprocessed: None,
6789                elem_idx: 0,
6790                no_aa: self.no_aa,
6791                opm_zero_transparent: false,
6792                knockout_painter_pass: KnockoutPainterPass::None,
6793                parent_group_isolated: false,
6794                alpha_extraction_pass: false,
6795                layer_set: &self.layer_set,
6796            };
6797            render_pattern_fill(&mut self.pixmap, &mut band_state, params, &ctx);
6798        }
6799        self.clip_region = band_state.clip_region.take();
6800        if let Some(mask) = band_state.spare_mask.take() {
6801            self.spare_mask = Some(mask);
6802        }
6803    }
6804
6805    fn page_size(&self) -> (u32, u32) {
6806        (self.page_w, self.page_h)
6807    }
6808
6809    fn replay_and_show(&mut self, list: DisplayList, output_path: &str) -> Result<(), String> {
6810        // Wait for any previous background render to complete
6811        self.join_pending()?;
6812
6813        let (page_w, page_h) = self.page_size();
6814
6815        // Audit mode: re-render through the viewport pipeline so visual tests
6816        // can catch viewport-only bugs against the same baselines. Same
6817        // `render_element`, same display list — differs only in how culling
6818        // and epochs are computed.
6819        if self.use_viewport_path {
6820            let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref(), false);
6821            let rgba = render_to_rgba_viewport(
6822                &list,
6823                page_w,
6824                page_h,
6825                self.dpi,
6826                Some(&icc_cache),
6827                self.no_aa,
6828            );
6829            let mut sink = self.sink_factory.create_sink(output_path)?;
6830            sink.begin_page(page_w, page_h)?;
6831            sink.write_rows(&rgba, page_h)?;
6832            sink.end_page()?;
6833            return Ok(());
6834        }
6835
6836        let band_h = select_band_height(page_w, page_h);
6837
6838        // Build ICC cache for this page's display list
6839        let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref(), false);
6840
6841        // If banding not worthwhile, render the full page as a single band.
6842        // This still uses render_element (same as banded path) so that Group
6843        // and SoftMasked elements get proper offscreen compositing.
6844        if band_h >= page_h {
6845            self.ensure_full_pixmap();
6846            let ctx = RenderContext {
6847                vp_x: 0.0,
6848                vp_y: 0.0,
6849                scale_x: 1.0,
6850                scale_y: 1.0,
6851                out_w: page_w,
6852                out_h: page_h,
6853                effective_dpi: self.dpi,
6854                icc: Some(&icc_cache),
6855                image_cache: None,
6856                preprocessed: None,
6857                elem_idx: 0,
6858                no_aa: self.no_aa,
6859                opm_zero_transparent: false,
6860                knockout_painter_pass: KnockoutPainterPass::None,
6861                parent_group_isolated: false,
6862                alpha_extraction_pass: false,
6863                layer_set: &self.layer_set,
6864            };
6865            let mut band_state = BandState {
6866                clip_region: None,
6867                spare_mask: None,
6868                clip_mask_cache: HashMap::new(),
6869                clip_mask_seen: HashSet::new(),
6870                mask_pool: Vec::new(),
6871                cmyk_buffer: None,
6872                op_bg_snapshot: None,
6873                op_touched: None,
6874                spot_mask: None,
6875            };
6876            for (idx, elem) in list.elements().iter().enumerate() {
6877                let elem_ctx = RenderContext {
6878                    elem_idx: idx,
6879                    ..ctx
6880                };
6881                render_element(&mut self.pixmap, &mut band_state, elem, &elem_ctx);
6882            }
6883            return self.show_page(output_path);
6884        }
6885
6886        // Banded path: shrink self.pixmap to free memory — we use a
6887        // band-sized pixmap instead. This avoids holding a multi-GB
6888        // full-page buffer during rendering.
6889        if self.pixmap.width() > 1 {
6890            self.pixmap = Pixmap::new(1, 1).expect("Failed to create placeholder pixmap");
6891        }
6892
6893        // Create the sink for this page before spawning background work
6894        let mut sink = self.sink_factory.create_sink(output_path)?;
6895        let dpi = self.dpi;
6896        let layer_set = self.layer_set.clone();
6897
6898        #[cfg(feature = "parallel")]
6899        {
6900            // Spawn banded rendering on rayon's thread pool, overlapping with
6901            // interpretation of the next page. Using rayon::spawn avoids OS thread
6902            // creation overhead and keeps work on the warmed-up pool.
6903            let no_aa = self.no_aa;
6904            let (tx, rx) = std::sync::mpsc::sync_channel(1);
6905            rayon::spawn(move || {
6906                let result = render_banded_to_sink(
6907                    page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, no_aa, &layer_set,
6908                );
6909                let _ = tx.send(result);
6910            });
6911            self.pending_render = Some(rx);
6912        }
6913        #[cfg(not(feature = "parallel"))]
6914        {
6915            render_banded_to_sink(
6916                page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, self.no_aa, &layer_set,
6917            )?;
6918        }
6919
6920        Ok(())
6921    }
6922
6923    fn finish(&mut self) -> Result<(), String> {
6924        self.join_pending()
6925    }
6926}
6927
6928#[cfg(feature = "ps-device")]
6929impl Drop for SkiaDevice {
6930    fn drop(&mut self) {
6931        // Safety net: ensure background render completes before device is destroyed.
6932        if let Some(rx) = self.pending_render.take() {
6933            let _ = rx.recv();
6934        }
6935    }
6936}
6937
6938#[cfg(feature = "ps-device")]
6939impl SkiaDevice {
6940    /// Wait for the pending background render to complete, if any.
6941    fn join_pending(&mut self) -> Result<(), String> {
6942        if let Some(rx) = self.pending_render.take() {
6943            match rx.recv() {
6944                Ok(result) => result?,
6945                Err(_) => return Err("Background render task failed".to_string()),
6946            }
6947        }
6948        Ok(())
6949    }
6950}
6951
6952/// Returns true if any descendant transparency group declares an explicit
6953/// `/CS DeviceCMYK`. The renderer uses this to decide whether to allocate a
6954/// parallel CMYK buffer for the band/page so that compositing inside CMYK
6955/// groups can read the exact backdrop CMYK rather than rounding-trip via sRGB.
6956fn has_cmyk_group(list: &DisplayList) -> bool {
6957    use stet_graphics::display_list::GroupColorSpace;
6958    for elem in list.elements() {
6959        match elem {
6960            DisplayElement::Group { elements, params } => {
6961                if params.color_space == GroupColorSpace::DeviceCMYK {
6962                    return true;
6963                }
6964                if has_cmyk_group(elements) {
6965                    return true;
6966                }
6967            }
6968            DisplayElement::SoftMasked { content, mask, .. } => {
6969                if has_cmyk_group(content) || has_cmyk_group(mask) {
6970                    return true;
6971                }
6972            }
6973            DisplayElement::OcgGroup { elements, .. } => {
6974                if has_cmyk_group(elements) {
6975                    return true;
6976                }
6977            }
6978            _ => {}
6979        }
6980    }
6981    false
6982}
6983
6984/// Returns true if every visible element in `elements` is a `Fill` whose
6985/// color carries `native_cmyk`. Clip and `InitClip` ops are skipped (they
6986/// don't paint). Returns `false` for any other shape (shadings, images,
6987/// patterns, nested groups, etc.) where the inner CMYK buffer would be
6988/// derived from sRGB via the lossy `interpolate_cmyk_from_stops` /
6989/// `(1-r,1-g,1-b,0)` inverse rather than tracked from the source CMYK.
6990fn group_only_native_cmyk_fills(elements: &DisplayList) -> bool {
6991    let mut found_paint = false;
6992    for elem in elements.elements() {
6993        match elem {
6994            DisplayElement::InitClip => continue,
6995            DisplayElement::Clip { .. } => continue,
6996            DisplayElement::Fill { params, .. } => {
6997                if params.color.native_cmyk.is_none() {
6998                    return false;
6999                }
7000                found_paint = true;
7001            }
7002            DisplayElement::Stroke { params, .. } => {
7003                // Strokes write a single CMYK value per painted pixel just
7004                // like fills, so the parallel CMYK buffer stays in sync with
7005                // the pixmap. Including strokes here is required by GWG 16.1
7006                // painters whose X path is both filled and stroked with the
7007                // same registration color.
7008                if params.color.native_cmyk.is_none() {
7009                    return false;
7010                }
7011                found_paint = true;
7012            }
7013            _ => return false,
7014        }
7015    }
7016    found_paint
7017}
7018
7019/// Stronger predicate: returns `true` when every paint operation in `elements`
7020/// supplies its color directly as CMYK with one CMYK value per painted pixel
7021/// — i.e. the parallel CMYK buffer is *guaranteed* to match the rendered
7022/// pixmap on a per-pixel basis. When this holds, the per-pixel CMYK
7023/// composite-back can run safely.
7024///
7025/// Importantly, this excludes **shadings** even when their declared color
7026/// space is DeviceCMYK. The pixmap rasterizer interpolates the per-stop
7027/// `.color` (RGB) linearly across the gradient via [`build_gradient_lut`],
7028/// while [`interpolate_cmyk_from_stops`] interpolates the per-stop CMYK
7029/// `raw_components` linearly. Because the system CMYK ICC profile is
7030/// non-linear, the two interpolation strategies produce different intermediate
7031/// colors at each gradient pixel — the buffer no longer represents what the
7032/// pixmap shows, and feeding that into the composite-back yields visibly
7033/// shifted colors. Until the per-pixel rasterizer is taught to interpolate
7034/// CMYK directly (or the buffer is filled by ICC-reversing the pixmap), keep
7035/// shadings on the existing sRGB compositing path.
7036///
7037/// Recurses into nested groups and soft masks. Returns `false` if the group
7038/// contains no paint operations at all (so the composite-back has no work).
7039fn group_content_is_native_cmyk(elements: &DisplayList) -> bool {
7040    let mut found_paint = false;
7041    for elem in elements.elements() {
7042        match elem {
7043            DisplayElement::InitClip => continue,
7044            DisplayElement::Clip { .. } => continue,
7045            DisplayElement::Text { .. } => continue,
7046            DisplayElement::ErasePage => continue,
7047            DisplayElement::Fill { params, .. } => {
7048                if params.color.native_cmyk.is_none() {
7049                    return false;
7050                }
7051                found_paint = true;
7052            }
7053            DisplayElement::Stroke { params, .. } => {
7054                if params.color.native_cmyk.is_none() {
7055                    return false;
7056                }
7057                found_paint = true;
7058            }
7059            DisplayElement::Image { params, .. } => {
7060                if !is_cmyk_color_space(&params.color_space) {
7061                    return false;
7062                }
7063                found_paint = true;
7064            }
7065            DisplayElement::AxialShading { .. }
7066            | DisplayElement::RadialShading { .. }
7067            | DisplayElement::MeshShading { .. }
7068            | DisplayElement::PatchShading { .. } => {
7069                // See doc comment above: shading interpolation strategies
7070                // diverge between pixmap and buffer.
7071                return false;
7072            }
7073            DisplayElement::PatternFill { .. } => {
7074                // Pattern tiles render through their own BandState with
7075                // `cmyk_buffer: None`, so the parallel CMYK buffer can't track
7076                // per-tile source CMYK. Treat patterns as non-CMYK content.
7077                return false;
7078            }
7079            DisplayElement::Group { elements: sub, .. } => {
7080                if !group_content_is_native_cmyk(sub) {
7081                    return false;
7082                }
7083                found_paint = true;
7084            }
7085            DisplayElement::SoftMasked { .. } => {
7086                // Soft masks apply a per-pixel alpha modulation that the
7087                // parallel CMYK buffer cannot represent: the buffer holds raw
7088                // source CMYK while the pixmap holds the soft-masked blend
7089                // (`backdrop * (1 − mask) + source * mask`). Running
7090                // `composite_non_isolated_cmyk` over a soft-masked region
7091                // would feed the unmodulated source CMYK into the blend
7092                // formula and produce the wrong result for any non-Normal
7093                // parent blend mode (5310.pdf phone highlight regression).
7094                // Fall back to the sRGB contribution-extraction path, which
7095                // handles soft masks correctly.
7096                return false;
7097            }
7098            DisplayElement::OcgGroup { elements: sub, .. } => {
7099                if !group_content_is_native_cmyk(sub) {
7100                    return false;
7101                }
7102                found_paint = true;
7103            }
7104            _ => return false,
7105        }
7106    }
7107    found_paint
7108}
7109
7110/// True when `list` is a flat sequence of native-CMYK Fill/Stroke paints
7111/// with Normal blend and full opacity — i.e. the cmyk_buffer's content
7112/// faithfully represents what the pixmap shows. Used by `render_soft_masked`
7113/// to decide whether to interpolate the mask blend in CMYK (ICC→sRGB).
7114/// Rejects Group/SoftMasked/Image/Shading/Pattern and any blend-mode-modulated
7115/// paint because those would diverge from the parallel CMYK snapshot.
7116fn content_list_is_simple_native_cmyk(list: &DisplayList) -> bool {
7117    let mut found_paint = false;
7118    for elem in list.elements() {
7119        match elem {
7120            DisplayElement::InitClip
7121            | DisplayElement::Clip { .. }
7122            | DisplayElement::Text { .. }
7123            | DisplayElement::ErasePage => continue,
7124            DisplayElement::Fill { params, .. } => {
7125                if params.color.native_cmyk.is_none() {
7126                    return false;
7127                }
7128                if params.blend_mode != 0 || params.alpha != 1.0 {
7129                    return false;
7130                }
7131                found_paint = true;
7132            }
7133            DisplayElement::Stroke { params, .. } => {
7134                if params.color.native_cmyk.is_none() {
7135                    return false;
7136                }
7137                if params.blend_mode != 0 || params.alpha != 1.0 {
7138                    return false;
7139                }
7140                found_paint = true;
7141            }
7142            // Recurse into a transparency Group only when the group itself is
7143            // Normal-blend / full-opacity AND its contents are themselves
7144            // simple native CMYK. This lets gradient-feather-style content
7145            // (a Group wrapping a single CMYK fill, GWG 16.11) qualify for
7146            // CMYK-domain mask blending while the prior outer-glow C
7147            // regression (a Group wrapping a Screen-blend white rect, GWG
7148            // 16.10) still gets rejected on the inner blend_mode check.
7149            DisplayElement::Group { params, elements } => {
7150                if params.blend_mode != 0 || params.alpha != 1.0 {
7151                    return false;
7152                }
7153                if !content_list_is_simple_native_cmyk(elements) {
7154                    return false;
7155                }
7156                // A Group whose contents are all clip/text without paint
7157                // adds no paint of its own; don't flip `found_paint` here —
7158                // the recursive call already counted any inner paints.
7159                if elements.elements().iter().any(|e| {
7160                    matches!(
7161                        e,
7162                        DisplayElement::Fill { .. } | DisplayElement::Stroke { .. }
7163                    )
7164                }) {
7165                    found_paint = true;
7166                }
7167            }
7168            _ => return false,
7169        }
7170    }
7171    found_paint
7172}
7173
7174/// Scan a display list for any overprint fill/stroke elements that need CMYK simulation.
7175fn has_overprint_elements(list: &DisplayList) -> bool {
7176    for elem in list.elements() {
7177        match elem {
7178            DisplayElement::Fill { params, .. } => {
7179                if params.overprint {
7180                    return true;
7181                }
7182            }
7183            DisplayElement::Stroke { params, .. } => {
7184                if params.overprint {
7185                    return true;
7186                }
7187            }
7188            DisplayElement::Image { params, .. } => {
7189                if params.overprint {
7190                    return true;
7191                }
7192            }
7193            DisplayElement::AxialShading { params } => {
7194                if params.overprint {
7195                    return true;
7196                }
7197            }
7198            DisplayElement::RadialShading { params } => {
7199                if params.overprint {
7200                    return true;
7201                }
7202            }
7203            DisplayElement::MeshShading { params } => {
7204                if params.overprint {
7205                    return true;
7206                }
7207            }
7208            DisplayElement::PatchShading { params } => {
7209                if params.overprint {
7210                    return true;
7211                }
7212            }
7213            DisplayElement::Group { elements, .. } => {
7214                if has_overprint_elements(elements) {
7215                    return true;
7216                }
7217            }
7218            DisplayElement::SoftMasked { content, mask, .. } => {
7219                if has_overprint_elements(content) || has_overprint_elements(mask) {
7220                    return true;
7221                }
7222            }
7223            DisplayElement::OcgGroup { elements, .. } => {
7224                if has_overprint_elements(elements) {
7225                    return true;
7226                }
7227            }
7228            _ => {}
7229        }
7230    }
7231    false
7232}
7233
7234/// Render an overprint fill: rasterize path to coverage mask, then composite
7235/// at the CMYK level, converting the result to RGB for the pixmap.
7236#[allow(clippy::too_many_arguments)]
7237fn render_overprint_fill(
7238    pixmap: &mut Pixmap,
7239    cmyk_buf: &mut [f32],
7240    op_bg: &mut [u8],
7241    op_touched: &mut [u8],
7242    spot_mask: &[u8],
7243    band_state: &mut BandState,
7244    path: &PsPath,
7245    params: &FillParams,
7246    vp_x: f32,
7247    vp_y: f32,
7248    scale_x: f32,
7249    scale_y: f32,
7250    out_w: u32,
7251    out_h: u32,
7252    icc: Option<&IccCache>,
7253    no_aa: bool,
7254) {
7255    let Some(skia_path) = build_skia_path(path) else {
7256        return;
7257    };
7258    let fill_rule = to_fill_rule(&params.fill_rule);
7259
7260    let mut coverage_mask = match Mask::new(out_w, out_h) {
7261        Some(m) => m,
7262        None => return,
7263    };
7264    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7265    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7266
7267    // Compute path bbox for constrained iteration
7268    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7269        path_device_bbox(&skia_path, transform, out_w, out_h);
7270
7271    // Intersect with clip mask
7272    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7273        None => None,
7274        Some(ClipRegion::Rect(r)) => {
7275            // Only zero coverage within the path bbox (not the full page)
7276            let data = coverage_mask.data_mut();
7277            let stride = out_w as usize;
7278            for y in bbox_y0..bbox_y1 {
7279                let row_start = y * stride;
7280                for x in bbox_x0..bbox_x1 {
7281                    let yu = y as u32;
7282                    let xu = x as u32;
7283                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7284                        data[row_start + x] = 0;
7285                    }
7286                }
7287            }
7288            None
7289        }
7290        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7291    };
7292
7293    // Custom spot paints (Separation/DeviceN whose named colorants don't include
7294    // any process channel) go to a separation plate, not CMYK. In the composite
7295    // preview we layer the spot's alt-CMYK onto the pixmap via multiplicative
7296    // ink stacking and leave the cmyk_buffer untouched — otherwise a later OPM 1
7297    // overprint would see the spot's alt-CMYK as "backdrop" and knock it out.
7298    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7299
7300    // Source CMYK preference: for paints with a process colorant in the mix
7301    // (Separation /Black, DeviceN [Black, …]), prefer `process_cmyk` — it
7302    // carries the named-colorant tint at full f64 precision (e.g. `(0, 0, 0,
7303    // 0.5)` for 50% /Black), matching what `update_cmyk_buffer_for_fill` writes
7304    // into the process buffer. Without this, the X paint reads native (e.g.
7305    // 0.502 from an 8-bit-quantized sampled Function) while the BG wrote
7306    // process (0.500), the per-pixel delta clears the 1e-4 no-op skip
7307    // threshold, and the X over-paints the spot backdrop with plain ICC-grey
7308    // (GWG 3.0 swatches c/i, "50% sep. black over spot").
7309    //
7310    // Custom spots (no process colorant) keep reading `native_cmyk` — that's
7311    // the spot's visual alt-CMYK representation, while `process_cmyk` is
7312    // `(0, 0, 0, 0)` for pure spots (the process buffer should not record
7313    // their tint). Falling back to native here keeps spot-coloured text
7314    // visible (1307.pdf "Business of the Meeting" in PANTONE 7427 C).
7315    let (src_c, src_m, src_y, src_k) = if !is_custom_spot && let Some(c) = params.color.process_cmyk
7316    {
7317        c
7318    } else if let Some(c) = params.color.native_cmyk {
7319        c
7320    } else {
7321        let r = params.color.r;
7322        let g = params.color.g;
7323        let b = params.color.b;
7324        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7325    };
7326
7327    let mut channels = params.painted_channels;
7328    // Non-CMYK fills (painted_channels=0, e.g. Separation spot colors, RGB, Gray)
7329    // replace all color at each pixel — update all CMYK channels to keep buffer in sync.
7330    if channels == 0 {
7331        channels = stet_graphics::device::CMYK_ALL;
7332    }
7333    // OPM 1 per-pixel zero filtering only applies to DeviceCMYK, not DeviceN/Separation
7334    if params.overprint_mode == 1
7335        && channels == stet_graphics::device::CMYK_ALL
7336        && params.is_device_cmyk
7337    {
7338        channels = 0;
7339        if src_c != 0.0 {
7340            channels |= stet_graphics::device::CMYK_C;
7341        }
7342        if src_m != 0.0 {
7343            channels |= stet_graphics::device::CMYK_M;
7344        }
7345        if src_y != 0.0 {
7346            channels |= stet_graphics::device::CMYK_Y;
7347        }
7348        if src_k != 0.0 {
7349            channels |= stet_graphics::device::CMYK_K;
7350        }
7351        // PDF 1.7 §7.6.4.5: OPM 1 with /op true preserves zero-source
7352        // components — leave `channels = 0` for an all-zero CMYK source only
7353        // when the gstate signals "strict overprint": /OPM and /op|/OP were
7354        // set together in the same ExtGState dict (as Adobe Illustrator
7355        // emits) OR /OP and /op were paired in one dict (legacy old-style
7356        // overprint, e.g. GWG 12.0 White Overprint where /GS6 sets both).
7357        // When the current /op was set standalone and OPM was merely
7358        // inherited (e.g. 2495.pdf page 5 page-icon, where /R20 has only
7359        // /op and OPM=1 came from /R11), fall back to legacy knockout so
7360        // a `0 0 0 0 k` paint still acts as a white knockout.
7361        if channels == 0 && !params.opm_paired {
7362            channels = stet_graphics::device::CMYK_ALL;
7363        }
7364    }
7365
7366    // Bulk tiny-skia fast path for the plain CMYK_ALL replace case. Skipped
7367    // only for K-only DeviceCMYK paints under OPM 0 (C=M=Y=0, any K) because
7368    // those match the Black plate of a DeviceN [Black, spot] backdrop and
7369    // need the per-pixel no-op-delta skip to preserve spot-derived colour —
7370    // the bulk fill_path here would otherwise wipe the spot. Other CMYK
7371    // overprints (teal, full-colour, etc.) stay on the fast path to avoid
7372    // AA drift vs the non-overprint rasteriser.
7373    let is_k_only_cmyk = params.is_device_cmyk
7374        && params.overprint_mode == 0
7375        && src_c == 0.0
7376        && src_m == 0.0
7377        && src_y == 0.0;
7378    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7379        let cov_data = coverage_mask.data();
7380        let stride = out_w as usize;
7381        for y in bbox_y0..bbox_y1 {
7382            for x in bbox_x0..bbox_x1 {
7383                let mi = y * stride + x;
7384                let mut cov = cov_data[mi] as f32 / 255.0;
7385                if let Some(clip) = clip_coverage {
7386                    cov *= clip[mi] as f32 / 255.0;
7387                }
7388                if cov > 0.0 {
7389                    let ci = mi * 4;
7390                    cmyk_buf[ci] = src_c as f32;
7391                    cmyk_buf[ci + 1] = src_m as f32;
7392                    cmyk_buf[ci + 2] = src_y as f32;
7393                    cmyk_buf[ci + 3] = src_k as f32;
7394                }
7395            }
7396        }
7397        let mut temp_mask = None;
7398        let Some(mask_ref) =
7399            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
7400        else {
7401            return;
7402        };
7403        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
7404        pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
7405        return;
7406    }
7407
7408    let cov_data = coverage_mask.data();
7409    let stride = out_w as usize;
7410    let px_data = pixmap.data_mut();
7411    let px_stride = out_w as usize * 4;
7412
7413    for y in bbox_y0..bbox_y1 {
7414        for x in bbox_x0..bbox_x1 {
7415            let mi = y * stride + x;
7416            let mut cov = cov_data[mi] as f32 / 255.0;
7417            if let Some(clip) = clip_coverage {
7418                cov *= clip[mi] as f32 / 255.0;
7419            }
7420            if cov <= 0.0 {
7421                continue;
7422            }
7423
7424            let ci = mi * 4;
7425            let pi = y * px_stride + x * 4;
7426            // Snapshot-based AA blending: on the first overprint touch of a
7427            // pixel that already has a backdrop (alpha > 0), capture the
7428            // pre-paint pixmap RGBA. Subsequent overprints at the same pixel
7429            // blend against the snapshot rather than the current pixmap, so
7430            // AA edges of stacked OPM-1 overprints do not leak colour from
7431            // earlier paints into later ones.
7432            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
7433                op_bg[pi] = px_data[pi];
7434                op_bg[pi + 1] = px_data[pi + 1];
7435                op_bg[pi + 2] = px_data[pi + 2];
7436                op_bg[pi + 3] = px_data[pi + 3];
7437                op_touched[mi] = 1;
7438            }
7439            let cur_c = cmyk_buf[ci] as f64;
7440            let cur_m = cmyk_buf[ci + 1] as f64;
7441            let cur_y = cmyk_buf[ci + 2] as f64;
7442            let cur_k = cmyk_buf[ci + 3] as f64;
7443            // Switch to multiplicative ink-stacking when the pixmap carries a
7444            // contribution not reflected in cmyk_buffer: either this paint is
7445            // itself a custom spot (painted_channels=0, non-CMYK) or the
7446            // process-ink state is empty while the pixmap shows colour *and*
7447            // is actually opaque — that signals a spot (or RGB) paint landed
7448            // here and the "replace" CMYK→RGB model would erase the
7449            // contribution for the channels being overwritten. Fully
7450            // transparent pixels are stored as premultiplied (0,0,0,0), so we
7451            // must require alpha>0 before trusting the RGB — otherwise fresh
7452            // paper (alpha=0) looks like "black backdrop" and multiplicative
7453            // darkening would paint the fill pure black.
7454            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
7455            let pixmap_has_colour = px_data[pi + 3] > 0
7456                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
7457            // Multiplicative ink-stacking only when the pixmap carries a real
7458            // backdrop: either this paint is a custom spot landing on an
7459            // already-coloured pixel, or the process-ink buffer is empty but
7460            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
7461            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
7462            // the fill to pure black, so those pixels fall through to the
7463            // replace path where the source RGB paints normally.
7464            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
7465
7466            // Promoted DeviceGray on a non-spot backdrop: fall back to a
7467            // plain knockout that replaces all four CMYK plates. The
7468            // `maybe_promote_gray_fill` path describes the paint as a
7469            // K-only subset so spot-backed swatches can preserve the spot
7470            // plate (GWG 3.0 "50% gray over spot"), but on a plain CMYK
7471            // backdrop that would preserve the old CMY values and turn the
7472            // cross into the bg colour (GWG 3.0 "50% gray over CMYK" e/k).
7473            // Expanding to CMYK_ALL here restores the regular-fill result
7474            // at those pixels.
7475            //
7476            // Gate on `params.painted_channels == CMYK_K` so this only fires
7477            // for genuinely-promoted DeviceGray. A `0 0 0 0.5 k` DeviceCMYK
7478            // paint filtered to CMYK_K by OPM 1 has `params.painted_channels
7479            // = CMYK_ALL`, and must stay K-subset so its CMY=0 values do
7480            // not wipe a CMYK backdrop (GWG 3.0 "50% K over CMYK" j/d).
7481            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
7482                && channels == stet_graphics::device::CMYK_K
7483                && params.is_device_cmyk
7484                && src_c == 0.0
7485                && src_m == 0.0
7486                && src_y == 0.0;
7487            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
7488                stet_graphics::device::CMYK_ALL
7489            } else {
7490                channels
7491            };
7492
7493            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
7494                src_c
7495            } else {
7496                cur_c
7497            };
7498            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
7499                src_m
7500            } else {
7501                cur_m
7502            };
7503            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
7504                src_y
7505            } else {
7506                cur_y
7507            };
7508            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
7509                src_k
7510            } else {
7511                cur_k
7512            };
7513
7514            // Custom spot paints live on a separation plate — skip the
7515            // cmyk_buffer write so a later OPM 1 overprint still sees the
7516            // original process-ink state as backdrop.
7517            if !is_custom_spot {
7518                cmyk_buf[ci] = new_c as f32;
7519                cmyk_buf[ci + 1] = new_m as f32;
7520                cmyk_buf[ci + 2] = new_y as f32;
7521                cmyk_buf[ci + 3] = new_k as f32;
7522            }
7523
7524            // No-op overprint: the paint's effective CMYK equals the existing
7525            // process state, so no plate actually changes. Skip the pixmap
7526            // write entirely — otherwise ICC(new_cmyk) paints a plain process
7527            // composite that erases any spot-derived colour already visible
7528            // at this pixel (GWG 3.0 "50% K over spot" swatches where the
7529            // backdrop's Black component and the cross's K value match).
7530            //
7531            // Only fire when a DeviceN/Separation paint with spot colorants
7532            // actually landed on this pixel (spot_mask[mi] != 0). On plain
7533            // CMYK backdrops, ICC(cmyk_buf) == pixmap_rgb already, and
7534            // skipping vs replacing produces the same result — but making
7535            // the skip unconditional subtly drifts AA edges because prior
7536            // stroke/fill precision accumulates (regressed GWG 1.0/1.1).
7537            let delta = (new_c - cur_c)
7538                .abs()
7539                .max((new_m - cur_m).abs())
7540                .max((new_y - cur_y).abs())
7541                .max((new_k - cur_k).abs());
7542            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
7543                continue;
7544            }
7545
7546            let (r, g, b) =
7547                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
7548                    // Promoted DeviceGray collapsing to a full replace — use the
7549                    // paint's RGB directly so the pixmap matches the colour a
7550                    // regular non-overprint gray fill would paint at the same
7551                    // pixel. Going through ICC(CMYK) here would produce a
7552                    // slightly different gray (e.g. 151 vs 127) and leave a
7553                    // darker outline where a subsequent non-promoted gray
7554                    // stroke overpaints on top of it.
7555                    //
7556                    // Checked before `use_multiplicative` because a white gray
7557                    // paint (`1 g`, native CMYK (0,0,0,0)) on a coloured RGB
7558                    // backdrop (e.g. the red `Reset Form` button in 682.pdf
7559                    // page 2) would otherwise hit the multiplicative branch
7560                    // with all-zero source CMYK, which leaves the backdrop
7561                    // unchanged — hiding the white label.
7562                    (params.color.r, params.color.g, params.color.b)
7563                } else if use_multiplicative {
7564                    // Multiplicative ink stacking: each painted channel attenuates
7565                    // the corresponding RGB component; preserved channels leave
7566                    // the pixmap's existing colour untouched. This keeps any spot
7567                    // contribution already in the pixmap visible under overprints
7568                    // whose zero-valued CMYK components should not erase it.
7569                    let bg_r = px_data[pi] as f64 / 255.0;
7570                    let bg_g = px_data[pi + 1] as f64 / 255.0;
7571                    let bg_b = px_data[pi + 2] as f64 / 255.0;
7572                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
7573                        1.0 - src_c
7574                    } else {
7575                        1.0
7576                    };
7577                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
7578                        1.0 - src_m
7579                    } else {
7580                        1.0
7581                    };
7582                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
7583                        1.0 - src_y
7584                    } else {
7585                        1.0
7586                    };
7587                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
7588                        1.0 - src_k
7589                    } else {
7590                        1.0
7591                    };
7592                    (
7593                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
7594                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
7595                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
7596                    )
7597                } else if let Some(icc_cache) = icc {
7598                    icc_cache
7599                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
7600                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
7601                } else {
7602                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
7603                };
7604
7605            let a = (cov * params.alpha as f32).min(1.0);
7606            // Blend backdrop: prefer the pre-overprint snapshot only when
7607            // this paint's colour is close to the snapshot — that signals
7608            // the paint effectively returns the pixel to its original
7609            // backdrop (e.g. the almost-white cross in GWG 4.1 cancelling
7610            // the red cross's M/Y contributions). In that case blending
7611            // against the snapshot keeps AA edges clean.
7612            //
7613            // When the paint introduces colour (e.g. a magenta stroke
7614            // following a magenta fill — both lay down ink that should
7615            // stack), fall through to the current pixmap so repeated
7616            // same-colour paints keep compounding at edges instead of
7617            // snapping back to bg.
7618            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
7619                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
7620                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
7621                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
7622                let dr = (op_bg[pi] as f32 - new_r).abs();
7623                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
7624                let db = (op_bg[pi + 2] as f32 - new_b).abs();
7625                if dr.max(dg).max(db) <= 4.0 {
7626                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
7627                } else {
7628                    (
7629                        px_data[pi],
7630                        px_data[pi + 1],
7631                        px_data[pi + 2],
7632                        px_data[pi + 3],
7633                    )
7634                }
7635            } else {
7636                (
7637                    px_data[pi],
7638                    px_data[pi + 1],
7639                    px_data[pi + 2],
7640                    px_data[pi + 3],
7641                )
7642            };
7643            let dst_a = bk_a as f32 / 255.0;
7644            let one_minus_a = 1.0 - a;
7645            let out_a = a + dst_a * one_minus_a;
7646            if out_a > 0.0 {
7647                // tiny-skia stores premultiplied RGBA. Use the standard
7648                // src-over formula in premul space: result_pre = src*a + dst_pre*(1-a).
7649                // The backdrop values are already premultiplied, so no
7650                // additional divide-by-out_a step is needed.
7651                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
7652                    .clamp(0.0, 255.0)
7653                    .round() as u8;
7654                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
7655                    .clamp(0.0, 255.0)
7656                    .round() as u8;
7657                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
7658                    .clamp(0.0, 255.0)
7659                    .round() as u8;
7660                px_data[pi + 3] = (out_a * 255.0).round() as u8;
7661            }
7662        }
7663    }
7664}
7665/// PLRM CMYK-to-RGB formula fallback.
7666fn cmyk_to_rgb_plrm(c: f64, m: f64, y: f64, k: f64) -> (f64, f64, f64) {
7667    (
7668        1.0 - (c + k).min(1.0),
7669        1.0 - (m + k).min(1.0),
7670        1.0 - (y + k).min(1.0),
7671    )
7672}
7673
7674/// Update the CMYK buffer for a non-overprint fill (to track backdrop for future overprints).
7675#[allow(clippy::too_many_arguments)]
7676/// Compute the device-space bounding box of a tiny-skia path after transform,
7677/// clamped to `(0, 0, w, h)`. Returns `(x0, y0, x1, y1)` as pixel indices.
7678fn path_device_bbox(
7679    skia_path: &stet_tiny_skia::Path,
7680    transform: Transform,
7681    w: u32,
7682    h: u32,
7683) -> (usize, usize, usize, usize) {
7684    let b = skia_path.bounds();
7685    let mut corners = [
7686        stet_tiny_skia::Point {
7687            x: b.left(),
7688            y: b.top(),
7689        },
7690        stet_tiny_skia::Point {
7691            x: b.right(),
7692            y: b.top(),
7693        },
7694        stet_tiny_skia::Point {
7695            x: b.right(),
7696            y: b.bottom(),
7697        },
7698        stet_tiny_skia::Point {
7699            x: b.left(),
7700            y: b.bottom(),
7701        },
7702    ];
7703    transform.map_points(&mut corners);
7704    let min_x = corners.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
7705    let min_y = corners.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
7706    let max_x = corners
7707        .iter()
7708        .map(|p| p.x)
7709        .fold(f32::NEG_INFINITY, f32::max);
7710    let max_y = corners
7711        .iter()
7712        .map(|p| p.y)
7713        .fold(f32::NEG_INFINITY, f32::max);
7714    // Floor/ceil + clamp to output dimensions (with 1px margin for AA)
7715    let x0 = (min_x.floor() as i32 - 1).max(0) as usize;
7716    let y0 = (min_y.floor() as i32 - 1).max(0) as usize;
7717    let x1 = (max_x.ceil() as i32 + 1).clamp(0, w as i32) as usize;
7718    let y1 = (max_y.ceil() as i32 + 1).clamp(0, h as i32) as usize;
7719    (x0, y0, x1, y1)
7720}
7721
7722fn update_cmyk_buffer_for_fill(
7723    cmyk_buf: &mut [f32],
7724    spot_mask: &mut [u8],
7725    path: &PsPath,
7726    params: &FillParams,
7727    vp_x: f32,
7728    vp_y: f32,
7729    scale_x: f32,
7730    scale_y: f32,
7731    out_w: u32,
7732    out_h: u32,
7733    clip_region: &Option<ClipRegion>,
7734    no_aa: bool,
7735    icc: Option<&IccCache>,
7736) {
7737    // Custom spot paints (Separation/DeviceN naming no process channel) go to
7738    // their own separation plate — the process CMYK buffer must be zeroed
7739    // under the paint (knockout) so a later overprint sees "no process ink"
7740    // and falls into the multiplicative-blend branch that preserves the
7741    // spot's visible contribution in the pixmap.
7742    //
7743    // The `process_cmyk.is_some()` guard distinguishes "Separation/DeviceN
7744    // custom spot" (where `process_cmyk` is `Some((0,0,0,0))` per
7745    // `separation_process_cmyk`) from "any other non-CMYK fill that
7746    // happens to satisfy `painted_channels == 0 && !is_device_cmyk`" —
7747    // notably DeviceRGB, DeviceGray, and ICCBased RGB. The latter need to
7748    // deposit their full process CMYK into the buffer (via `native_cmyk`
7749    // from the proofing chain or via the ICC reverse) so the
7750    // `cmyk_group_blend` composite-back in `composite_non_isolated_cmyk`
7751    // can blend them correctly. Without this guard, GWG 16.1's
7752    // ICCBased-RGB swatches landed `(0,0,0,0)` in the form's CMYK
7753    // buffer; every separable blend then composited the X mark against a
7754    // zero source CMYK, painting the X with the form's source pixmap
7755    // RGB unchanged and producing the test's "X visible" failure.
7756    let is_custom_spot = params.painted_channels == 0
7757        && !params.is_device_cmyk
7758        && params.color.process_cmyk.is_some();
7759
7760    // A DeviceN/Separation paint leaves "spot contribution" on the pixmap
7761    // when its full alt-CMYK (`native_cmyk`) differs from the process-only
7762    // tint (`process_cmyk`) — the extra RGB in the pixmap comes from a spot
7763    // plate that `cmyk_buf` cannot reflect. Pure DeviceCMYK paints have
7764    // `process_cmyk == None` (fall back to native), so no spot contribution.
7765    //
7766    // A "real" custom spot paint (`is_custom_spot && native_cmyk.is_some()`)
7767    // also deposits spot RGB that `cmyk_buf` loses (it's zeroed by the
7768    // custom-spot branch). Exclude DeviceRGB / DeviceGray / ICCBased-RGB
7769    // paints — those also satisfy `is_custom_spot = painted==0 &&
7770    // !is_device_cmyk` but carry no spot-plate contribution, and flagging
7771    // them would gate later OPM-1 cancel skips on a signal that doesn't
7772    // actually mean anything.
7773    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
7774        || matches!(
7775            (params.color.native_cmyk, params.color.process_cmyk),
7776            (Some(nat), Some(proc_))
7777                if (nat.0 - proc_.0).abs() > 1e-6
7778                    || (nat.1 - proc_.1).abs() > 1e-6
7779                    || (nat.2 - proc_.2).abs() > 1e-6
7780                    || (nat.3 - proc_.3).abs() > 1e-6
7781        );
7782
7783    // Source CMYK preference: process-only CMYK (from Separation/DeviceN paints
7784    // so spot-colorant tint contributions stay out of the process buffer) >
7785    // native CMYK (full alt-CMYK tint, fine for pure DeviceCMYK paints) > ICC
7786    // reverse (sRGB→CMYK via the system CMYK profile) > PLRM (1−r, 1−g, 1−b, 0)
7787    // fallback. The ICC reverse keeps non-CMYK fills (RGB/Gray/Lab/etc.)
7788    // representable as accurate CMYK in the parallel buffer so the
7789    // non-isolated CMYK composite-back can blend them correctly.
7790    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
7791        (0.0, 0.0, 0.0, 0.0)
7792    } else if let Some(c) = params.color.process_cmyk {
7793        c
7794    } else if let Some(c) = params.color.native_cmyk {
7795        c
7796    } else if let Some(cmyk) = icc.and_then(|i| {
7797        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
7798    }) {
7799        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
7800    } else {
7801        (
7802            (1.0 - params.color.r).clamp(0.0, 1.0),
7803            (1.0 - params.color.g).clamp(0.0, 1.0),
7804            (1.0 - params.color.b).clamp(0.0, 1.0),
7805            0.0,
7806        )
7807    };
7808    let Some(skia_path) = build_skia_path(path) else {
7809        return;
7810    };
7811
7812    let mut coverage_mask = match Mask::new(out_w, out_h) {
7813        Some(m) => m,
7814        None => return,
7815    };
7816    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7817    let fill_rule = to_fill_rule(&params.fill_rule);
7818    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7819
7820    let cov_data = coverage_mask.data();
7821    let clip_data: Option<&[u8]> = match clip_region {
7822        Some(ClipRegion::Mask(m)) => Some(m.data()),
7823        _ => None,
7824    };
7825
7826    // Constrain iteration to the path's device-space bounding box
7827    let (mut bx0, mut by0, mut bx1, mut by1) =
7828        path_device_bbox(&skia_path, transform, out_w, out_h);
7829    if let Some(ClipRegion::Rect(r)) = clip_region {
7830        bx0 = bx0.max(r.x0 as usize);
7831        by0 = by0.max(r.y0 as usize);
7832        bx1 = bx1.min(r.x1 as usize);
7833        by1 = by1.min(r.y1 as usize);
7834    }
7835
7836    let stride = out_w as usize;
7837    for y in by0..by1 {
7838        for x in bx0..bx1 {
7839            let mi = y * stride + x;
7840            let mut cov = cov_data[mi] as f32 / 255.0;
7841            if let Some(clip) = clip_data {
7842                cov *= clip[mi] as f32 / 255.0;
7843            }
7844            if cov > 0.0 {
7845                let ci = mi * 4;
7846                cmyk_buf[ci] = src_c as f32;
7847                cmyk_buf[ci + 1] = src_m as f32;
7848                cmyk_buf[ci + 2] = src_y as f32;
7849                cmyk_buf[ci + 3] = src_k as f32;
7850                if has_spot_contrib {
7851                    spot_mask[mi] = 1;
7852                }
7853            }
7854        }
7855    }
7856}
7857
7858/// Render an overprint stroke: convert the stroke outline to a fill path,
7859/// rasterize a coverage mask, then composite per-pixel in CMYK so the painted
7860/// channels of the stroke colour replace the matching backdrop channels and
7861/// the result lands in the pixmap as RGB. Mirrors `render_overprint_fill`.
7862#[allow(clippy::too_many_arguments)]
7863fn render_overprint_stroke(
7864    pixmap: &mut Pixmap,
7865    cmyk_buf: &mut [f32],
7866    op_bg: &mut [u8],
7867    op_touched: &mut [u8],
7868    spot_mask: &[u8],
7869    band_state: &mut BandState,
7870    skia_path: &stet_tiny_skia::Path,
7871    stroke: &Stroke,
7872    transform: Transform,
7873    params: &StrokeParams,
7874    out_w: u32,
7875    out_h: u32,
7876    icc: Option<&IccCache>,
7877    no_aa: bool,
7878) {
7879    // Convert stroke outline to fill path. Mirrors update_cmyk_buffer_for_stroke_overprint.
7880    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
7881        .sqrt()
7882        .max(1.0);
7883    let dashed_op;
7884    let stroke_src = if let Some(ref dash) = stroke.dash {
7885        dashed_op = skia_path.dash(dash, resolution_scale);
7886        match dashed_op.as_ref() {
7887            Some(p) => p,
7888            None => skia_path,
7889        }
7890    } else {
7891        skia_path
7892    };
7893    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
7894        return;
7895    };
7896    let Some(stroked) = stroked_user.transform(transform) else {
7897        return;
7898    };
7899
7900    let mut coverage_mask = match Mask::new(out_w, out_h) {
7901        Some(m) => m,
7902        None => return,
7903    };
7904    coverage_mask.fill_path(
7905        &stroked,
7906        SkiaFillRule::Winding,
7907        !no_aa,
7908        Transform::identity(),
7909    );
7910
7911    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7912        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
7913
7914    // Intersect with clip mask (same logic as render_overprint_fill).
7915    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7916        None => None,
7917        Some(ClipRegion::Rect(r)) => {
7918            let data = coverage_mask.data_mut();
7919            let stride = out_w as usize;
7920            for y in bbox_y0..bbox_y1 {
7921                let row_start = y * stride;
7922                for x in bbox_x0..bbox_x1 {
7923                    let yu = y as u32;
7924                    let xu = x as u32;
7925                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7926                        data[row_start + x] = 0;
7927                    }
7928                }
7929            }
7930            None
7931        }
7932        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7933    };
7934
7935    // See render_overprint_fill for the rationale: a custom spot stroke must
7936    // preserve the process CMYK buffer and blend multiplicatively in RGB so
7937    // later OPM 1 overprints don't knock out the spot's visible colour.
7938    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7939
7940    // Source CMYK preference: for paints with a process colorant in the mix,
7941    // prefer `process_cmyk` so the no-op-delta skip in the per-pixel loop sees
7942    // the same exact value the BG paint wrote into `cmyk_buf`. Custom spots
7943    // keep reading `native_cmyk` (the spot's visual alt-CMYK; process_cmyk is
7944    // (0,0,0,0) for pure spots). See `render_overprint_fill` for the full
7945    // rationale (GWG 3.0 swatches c/i, 1307.pdf spot text).
7946    let (src_c, src_m, src_y, src_k) = if !is_custom_spot && let Some(c) = params.color.process_cmyk
7947    {
7948        c
7949    } else if let Some(c) = params.color.native_cmyk {
7950        c
7951    } else {
7952        let r = params.color.r;
7953        let g = params.color.g;
7954        let b = params.color.b;
7955        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7956    };
7957
7958    let mut channels = params.painted_channels;
7959    if channels == 0 {
7960        channels = stet_graphics::device::CMYK_ALL;
7961    }
7962    if params.overprint_mode == 1
7963        && channels == stet_graphics::device::CMYK_ALL
7964        && params.is_device_cmyk
7965    {
7966        channels = 0;
7967        if src_c != 0.0 {
7968            channels |= stet_graphics::device::CMYK_C;
7969        }
7970        if src_m != 0.0 {
7971            channels |= stet_graphics::device::CMYK_M;
7972        }
7973        if src_y != 0.0 {
7974            channels |= stet_graphics::device::CMYK_Y;
7975        }
7976        if src_k != 0.0 {
7977            channels |= stet_graphics::device::CMYK_K;
7978        }
7979        // See render_overprint_fill: an all-zero CMYK source preserves the
7980        // backdrop only when /OPM and /op|/OP were set together (paired) in
7981        // the same ExtGState. Inherited-OPM cases fall back to legacy
7982        // knockout.
7983        if channels == 0 && !params.opm_paired {
7984            channels = stet_graphics::device::CMYK_ALL;
7985        }
7986    }
7987
7988    let is_k_only_cmyk = params.is_device_cmyk
7989        && params.overprint_mode == 0
7990        && src_c == 0.0
7991        && src_m == 0.0
7992        && src_y == 0.0;
7993    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7994        // Full-channel replacement: write source CMYK to buffer for covered
7995        // pixels and let tiny-skia stroke the pixmap with the source colour.
7996        // Only K-only DeviceCMYK OPM 0 paints are routed to the per-pixel
7997        // path (see render_overprint_fill).
7998        let cov_data = coverage_mask.data();
7999        let stride = out_w as usize;
8000        for y in bbox_y0..bbox_y1 {
8001            for x in bbox_x0..bbox_x1 {
8002                let mi = y * stride + x;
8003                let mut cov = cov_data[mi] as f32 / 255.0;
8004                if let Some(clip) = clip_coverage {
8005                    cov *= clip[mi] as f32 / 255.0;
8006                }
8007                if cov > 0.0 {
8008                    let ci = mi * 4;
8009                    cmyk_buf[ci] = src_c as f32;
8010                    cmyk_buf[ci + 1] = src_m as f32;
8011                    cmyk_buf[ci + 2] = src_y as f32;
8012                    cmyk_buf[ci + 3] = src_k as f32;
8013                }
8014            }
8015        }
8016        let mut temp_mask = None;
8017        let Some(mask_ref) =
8018            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
8019        else {
8020            return;
8021        };
8022        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
8023        pixmap.stroke_path(skia_path, &paint, stroke, transform, mask_ref);
8024        return;
8025    }
8026
8027    let cov_data = coverage_mask.data();
8028    let stride = out_w as usize;
8029    let px_data = pixmap.data_mut();
8030    let px_stride = out_w as usize * 4;
8031
8032    for y in bbox_y0..bbox_y1 {
8033        for x in bbox_x0..bbox_x1 {
8034            let mi = y * stride + x;
8035            let mut cov = cov_data[mi] as f32 / 255.0;
8036            if let Some(clip) = clip_coverage {
8037                cov *= clip[mi] as f32 / 255.0;
8038            }
8039            if cov <= 0.0 {
8040                continue;
8041            }
8042
8043            let ci = mi * 4;
8044            let pi = y * px_stride + x * 4;
8045            // Snapshot-based AA blending — see render_overprint_fill for the
8046            // rationale. Capture the pre-paint pixmap on first overprint touch
8047            // so stacked overprints at the same pixel blend against the
8048            // original backdrop rather than each other.
8049            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8050                op_bg[pi] = px_data[pi];
8051                op_bg[pi + 1] = px_data[pi + 1];
8052                op_bg[pi + 2] = px_data[pi + 2];
8053                op_bg[pi + 3] = px_data[pi + 3];
8054                op_touched[mi] = 1;
8055            }
8056            let cur_c = cmyk_buf[ci] as f64;
8057            let cur_m = cmyk_buf[ci + 1] as f64;
8058            let cur_y = cmyk_buf[ci + 2] as f64;
8059            let cur_k = cmyk_buf[ci + 3] as f64;
8060            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8061            let pixmap_has_colour = px_data[pi + 3] > 0
8062                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
8063            // Multiplicative ink-stacking only when the pixmap carries a real
8064            // backdrop: either this paint is a custom spot landing on an
8065            // already-coloured pixel, or the process-ink buffer is empty but
8066            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
8067            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
8068            // the fill to pure black, so those pixels fall through to the
8069            // replace path where the source RGB paints normally.
8070            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
8071
8072            // Promoted DeviceGray on non-spot backdrop: replace all channels
8073            // (see render_overprint_fill).
8074            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
8075                && channels == stet_graphics::device::CMYK_K
8076                && params.is_device_cmyk
8077                && src_c == 0.0
8078                && src_m == 0.0
8079                && src_y == 0.0;
8080            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
8081                stet_graphics::device::CMYK_ALL
8082            } else {
8083                channels
8084            };
8085
8086            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
8087                src_c
8088            } else {
8089                cur_c
8090            };
8091            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
8092                src_m
8093            } else {
8094                cur_m
8095            };
8096            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
8097                src_y
8098            } else {
8099                cur_y
8100            };
8101            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
8102                src_k
8103            } else {
8104                cur_k
8105            };
8106
8107            if !is_custom_spot {
8108                cmyk_buf[ci] = new_c as f32;
8109                cmyk_buf[ci + 1] = new_m as f32;
8110                cmyk_buf[ci + 2] = new_y as f32;
8111                cmyk_buf[ci + 3] = new_k as f32;
8112            }
8113
8114            // No-op overprint skip — see render_overprint_fill for rationale.
8115            let delta = (new_c - cur_c)
8116                .abs()
8117                .max((new_m - cur_m).abs())
8118                .max((new_y - cur_y).abs())
8119                .max((new_k - cur_k).abs());
8120            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
8121                continue;
8122            }
8123
8124            let (r, g, b) =
8125                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
8126                    // Promoted DeviceGray collapsing to a full replace — see
8127                    // render_overprint_fill for the rationale (must run before
8128                    // the multiplicative branch so a `1 g` / `1 G` white paint
8129                    // doesn't get folded into the backdrop via zero-source
8130                    // multiplication).
8131                    (params.color.r, params.color.g, params.color.b)
8132                } else if use_multiplicative {
8133                    let bg_r = px_data[pi] as f64 / 255.0;
8134                    let bg_g = px_data[pi + 1] as f64 / 255.0;
8135                    let bg_b = px_data[pi + 2] as f64 / 255.0;
8136                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
8137                        1.0 - src_c
8138                    } else {
8139                        1.0
8140                    };
8141                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
8142                        1.0 - src_m
8143                    } else {
8144                        1.0
8145                    };
8146                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
8147                        1.0 - src_y
8148                    } else {
8149                        1.0
8150                    };
8151                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
8152                        1.0 - src_k
8153                    } else {
8154                        1.0
8155                    };
8156                    (
8157                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
8158                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
8159                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
8160                    )
8161                } else if let Some(icc_cache) = icc {
8162                    icc_cache
8163                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8164                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8165                } else {
8166                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8167                };
8168
8169            let a = (cov * params.alpha as f32).min(1.0);
8170            // Blend backdrop: prefer snapshot only when this paint's colour
8171            // closely matches the snapshot — see render_overprint_fill for
8172            // the rationale (keeps aw-on-red-style cancel paints clean at
8173            // edges while preserving additive same-colour stacking).
8174            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
8175                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
8176                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
8177                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
8178                let dr = (op_bg[pi] as f32 - new_r).abs();
8179                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
8180                let db = (op_bg[pi + 2] as f32 - new_b).abs();
8181                if dr.max(dg).max(db) <= 4.0 {
8182                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
8183                } else {
8184                    (
8185                        px_data[pi],
8186                        px_data[pi + 1],
8187                        px_data[pi + 2],
8188                        px_data[pi + 3],
8189                    )
8190                }
8191            } else {
8192                (
8193                    px_data[pi],
8194                    px_data[pi + 1],
8195                    px_data[pi + 2],
8196                    px_data[pi + 3],
8197                )
8198            };
8199            let dst_a = bk_a as f32 / 255.0;
8200            let one_minus_a = 1.0 - a;
8201            let out_a = a + dst_a * one_minus_a;
8202            if out_a > 0.0 {
8203                // tiny-skia stores premultiplied RGBA (see render_overprint_fill).
8204                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
8205                    .clamp(0.0, 255.0)
8206                    .round() as u8;
8207                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
8208                    .clamp(0.0, 255.0)
8209                    .round() as u8;
8210                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
8211                    .clamp(0.0, 255.0)
8212                    .round() as u8;
8213                px_data[pi + 3] = (out_a * 255.0).round() as u8;
8214            }
8215        }
8216    }
8217}
8218
8219/// Update the CMYK buffer for a non-overprint stroke. Mirrors
8220/// [`update_cmyk_buffer_for_fill`] but rasterizes a stroked outline path
8221/// instead of a filled one. Source-CMYK selection follows the same
8222/// native_cmyk → ICC reverse → PLRM cascade.
8223#[allow(clippy::too_many_arguments)]
8224fn update_cmyk_buffer_for_stroke(
8225    cmyk_buf: &mut [f32],
8226    spot_mask: &mut [u8],
8227    path: &PsPath,
8228    params: &StrokeParams,
8229    stroke: &Stroke,
8230    transform: Transform,
8231    out_w: u32,
8232    out_h: u32,
8233    clip_region: &Option<ClipRegion>,
8234    no_aa: bool,
8235    icc: Option<&IccCache>,
8236) {
8237    // Custom spot strokes knockout the process CMYK plates — zero the buffer
8238    // under the stroke so later overprints fall into the multiplicative-blend
8239    // branch (see update_cmyk_buffer_for_fill, including the
8240    // `process_cmyk.is_some()` carve-out that keeps DeviceRGB / ICCBased-RGB
8241    // strokes off this branch so their proofing-chain CMYK reaches the
8242    // buffer).
8243    let is_custom_spot = params.painted_channels == 0
8244        && !params.is_device_cmyk
8245        && params.color.process_cmyk.is_some();
8246    // See update_cmyk_buffer_for_fill for rationale.
8247    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
8248        || matches!(
8249            (params.color.native_cmyk, params.color.process_cmyk),
8250            (Some(nat), Some(proc_))
8251                if (nat.0 - proc_.0).abs() > 1e-6
8252                    || (nat.1 - proc_.1).abs() > 1e-6
8253                    || (nat.2 - proc_.2).abs() > 1e-6
8254                    || (nat.3 - proc_.3).abs() > 1e-6
8255        );
8256
8257    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
8258        (0.0, 0.0, 0.0, 0.0)
8259    } else if let Some(c) = params.color.process_cmyk {
8260        c
8261    } else if let Some(c) = params.color.native_cmyk {
8262        c
8263    } else if let Some(cmyk) = icc.and_then(|i| {
8264        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
8265    }) {
8266        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
8267    } else {
8268        (
8269            (1.0 - params.color.r).clamp(0.0, 1.0),
8270            (1.0 - params.color.g).clamp(0.0, 1.0),
8271            (1.0 - params.color.b).clamp(0.0, 1.0),
8272            0.0,
8273        )
8274    };
8275
8276    let Some(skia_path) = build_skia_path(path) else {
8277        return;
8278    };
8279
8280    // Convert the stroke outline into a fill path so we can rasterize it via
8281    // Mask::fill_path. Mirrors the dance in the overprint stroke branch:
8282    // dash → stroke-to-outline (in user space) → device transform.
8283    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
8284        .sqrt()
8285        .max(1.0);
8286    let dashed_op;
8287    let stroke_src = if let Some(ref dash) = stroke.dash {
8288        dashed_op = skia_path.dash(dash, resolution_scale);
8289        match dashed_op.as_ref() {
8290            Some(p) => p,
8291            None => &skia_path,
8292        }
8293    } else {
8294        &skia_path
8295    };
8296    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
8297        return;
8298    };
8299    let Some(stroked) = stroked_user.transform(transform) else {
8300        return;
8301    };
8302
8303    let mut coverage_mask = match Mask::new(out_w, out_h) {
8304        Some(m) => m,
8305        None => return,
8306    };
8307    coverage_mask.fill_path(
8308        &stroked,
8309        SkiaFillRule::Winding,
8310        !no_aa,
8311        Transform::identity(),
8312    );
8313
8314    let cov_data = coverage_mask.data();
8315    let clip_data: Option<&[u8]> = match clip_region {
8316        Some(ClipRegion::Mask(m)) => Some(m.data()),
8317        _ => None,
8318    };
8319
8320    let (mut bx0, mut by0, mut bx1, mut by1) =
8321        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
8322    if let Some(ClipRegion::Rect(r)) = clip_region {
8323        bx0 = bx0.max(r.x0 as usize);
8324        by0 = by0.max(r.y0 as usize);
8325        bx1 = bx1.min(r.x1 as usize);
8326        by1 = by1.min(r.y1 as usize);
8327    }
8328
8329    let stride = out_w as usize;
8330    for y in by0..by1 {
8331        for x in bx0..bx1 {
8332            let mi = y * stride + x;
8333            let mut cov = cov_data[mi] as f32 / 255.0;
8334            if let Some(clip) = clip_data {
8335                cov *= clip[mi] as f32 / 255.0;
8336            }
8337            if cov > 0.0 {
8338                let ci = mi * 4;
8339                cmyk_buf[ci] = src_c as f32;
8340                cmyk_buf[ci + 1] = src_m as f32;
8341                cmyk_buf[ci + 2] = src_y as f32;
8342                cmyk_buf[ci + 3] = src_k as f32;
8343                if has_spot_contrib {
8344                    spot_mask[mi] = 1;
8345                }
8346            }
8347        }
8348    }
8349}
8350
8351/// Render an overprint image with viewport params.
8352#[allow(clippy::too_many_arguments)]
8353fn render_overprint_image(
8354    pixmap: &mut Pixmap,
8355    cmyk_buf: &mut [f32],
8356    op_bg: &mut [u8],
8357    op_touched: &mut [u8],
8358    band_state: &mut BandState,
8359    sample_data: &[u8],
8360    params: &ImageParams,
8361    vp_x: f32,
8362    vp_y: f32,
8363    scale_x: f32,
8364    scale_y: f32,
8365    out_w: u32,
8366    out_h: u32,
8367    icc: Option<&IccCache>,
8368) {
8369    let iw = params.width as usize;
8370    let ih = params.height as usize;
8371    let Some(image_inv) = params.image_matrix.invert() else {
8372        return;
8373    };
8374    let combined = params.ctm.concat(&image_inv);
8375    let Some(inv_combined) = combined.invert() else {
8376        return;
8377    };
8378
8379    let px_data = pixmap.data_mut();
8380    let stride = out_w as usize;
8381    let inv_sx = 1.0 / scale_x as f64;
8382    let inv_sy = 1.0 / scale_y as f64;
8383
8384    let clip_data: Option<&[u8]> = match &band_state.clip_region {
8385        Some(ClipRegion::Mask(m)) => Some(m.data()),
8386        _ => None,
8387    };
8388    let clip_rect = match &band_state.clip_region {
8389        Some(ClipRegion::Rect(r)) => Some(*r),
8390        _ => None,
8391    };
8392
8393    let mask_info = if let ImageColorSpace::Mask {
8394        color, polarity, ..
8395    } = &params.color_space
8396    {
8397        let (src_c, src_m, src_y, src_k) = color.native_cmyk.unwrap_or_else(|| {
8398            let r = color.r;
8399            let g = color.g;
8400            let b = color.b;
8401            (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
8402        });
8403        Some((src_c, src_m, src_y, src_k, *polarity, iw.div_ceil(8)))
8404    } else {
8405        None
8406    };
8407
8408    for by in 0..out_h as usize {
8409        for bx in 0..out_w as usize {
8410            if let Some(ref r) = clip_rect
8411                && ((by as u32) < r.y0
8412                    || (by as u32) >= r.y1
8413                    || (bx as u32) < r.x0
8414                    || (bx as u32) >= r.x1)
8415            {
8416                continue;
8417            }
8418            if let Some(clip) = clip_data {
8419                let ci_clip = by * stride + bx;
8420                if clip[ci_clip] == 0 {
8421                    let bh = out_h as usize;
8422                    let has_neighbor = (bx > 0 && clip[ci_clip - 1] != 0)
8423                        || (bx + 1 < stride && clip[ci_clip + 1] != 0)
8424                        || (by > 0 && clip[ci_clip - stride] != 0)
8425                        || (by + 1 < bh && clip[ci_clip + stride] != 0)
8426                        || (bx > 0 && by > 0 && clip[ci_clip - stride - 1] != 0)
8427                        || (bx + 1 < stride && by > 0 && clip[ci_clip - stride + 1] != 0)
8428                        || (bx > 0 && by + 1 < bh && clip[ci_clip + stride - 1] != 0)
8429                        || (bx + 1 < stride && by + 1 < bh && clip[ci_clip + stride + 1] != 0);
8430                    if !has_neighbor {
8431                        continue;
8432                    }
8433                }
8434            }
8435
8436            // Map output pixel to device space, then to image space
8437            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8438            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8439            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8440            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8441
8442            let col = ix.floor() as i64;
8443            let row = iy.floor() as i64;
8444            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8445                continue;
8446            }
8447            let col = col as usize;
8448            let row = row as usize;
8449
8450            let (src_c, src_m, src_y, src_k) =
8451                if let Some((mc, mm, my, mk, polarity, bytes_per_row)) = mask_info {
8452                    let byte_idx = row * bytes_per_row + col / 8;
8453                    let bit_offset = 7 - (col % 8);
8454                    let bit = if byte_idx < sample_data.len() {
8455                        (sample_data[byte_idx] >> bit_offset) & 1
8456                    } else {
8457                        0
8458                    };
8459                    let paint = if polarity { bit == 1 } else { bit == 0 };
8460                    if !paint {
8461                        continue;
8462                    }
8463                    (mc, mm, my, mk)
8464                } else if let Some(cmyk) =
8465                    sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8466                {
8467                    cmyk
8468                } else {
8469                    continue;
8470                };
8471
8472            let mi = by * stride + bx;
8473            let ci = mi * 4;
8474            let pi = mi * 4;
8475
8476            // Spot-tint images (Separation / DeviceN with CMYK alt and at
8477            // least one non-process colorant): per PDF spec 11.7.4.5 the
8478            // image affects only the device colorants identified by its color
8479            // space.  In composite preview that means:
8480            //   * Where the CMYK buffer is empty (fresh paper or a custom
8481            //     spot painted earlier whose alt-CMYK we never tracked),
8482            //     paint the pixel directly from the image's tint output —
8483            //     the spot's full alt-CMYK contribution shows up, and a
8484            //     same-spot underlying paint (e.g. a /GWG-Green X under an
8485            //     image whose GWG-Green is zero) is knocked out because
8486            //     ICC(0,0,0,0) is white.
8487            //   * Where the CMYK buffer carries prior CMYK (a `1 0 1 0.5 k`
8488            //     ✓ underneath), REPLACE only the NAMED PROCESS plates with
8489            //     the image's tint output and PRESERVE the rest, then
8490            //     recompose the pixmap.  A duotone DeviceN [Black, Green]
8491            //     image's "no ink" pixel knocks the ✓'s K=0.5 down to 0 —
8492            //     lightening it to (C=1, M=0, Y=1, K=0) — while leaving its
8493            //     C=1, Y=1 untouched.
8494            if image_cs_has_spot_tint_transform(&params.color_space) {
8495                let cur_c = cmyk_buf[ci] as f64;
8496                let cur_m = cmyk_buf[ci + 1] as f64;
8497                let cur_y = cmyk_buf[ci + 2] as f64;
8498                let cur_k = cmyk_buf[ci + 3] as f64;
8499                let cur_is_zero = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8500                let named = params.painted_channels;
8501                // OPM=1 zero-source preservation: when the image's tint
8502                // output for a named plate is zero, the underlying value is
8503                // preserved instead of replaced.  Without this, a duotone
8504                // DeviceN [Black, GWG-Green] image's "no ink" pixel
8505                // overwrote the K=0.5 of an underlying CMYK ✓ with 0,
8506                // rendering the checkmark too light versus Adobe Acrobat.
8507                let opm1 = params.overprint_mode == 1;
8508                let (new_c, new_m, new_y, new_k) = if cur_is_zero {
8509                    (src_c, src_m, src_y, src_k)
8510                } else {
8511                    let nc =
8512                        if named & stet_graphics::device::CMYK_C != 0 && !(opm1 && src_c == 0.0) {
8513                            src_c
8514                        } else {
8515                            cur_c
8516                        };
8517                    let nm =
8518                        if named & stet_graphics::device::CMYK_M != 0 && !(opm1 && src_m == 0.0) {
8519                            src_m
8520                        } else {
8521                            cur_m
8522                        };
8523                    let ny =
8524                        if named & stet_graphics::device::CMYK_Y != 0 && !(opm1 && src_y == 0.0) {
8525                            src_y
8526                        } else {
8527                            cur_y
8528                        };
8529                    let nk =
8530                        if named & stet_graphics::device::CMYK_K != 0 && !(opm1 && src_k == 0.0) {
8531                            src_k
8532                        } else {
8533                            cur_k
8534                        };
8535                    (nc, nm, ny, nk)
8536                };
8537                cmyk_buf[ci] = new_c as f32;
8538                cmyk_buf[ci + 1] = new_m as f32;
8539                cmyk_buf[ci + 2] = new_y as f32;
8540                cmyk_buf[ci + 3] = new_k as f32;
8541                // When the alt space is non-CMYK (e.g., DeviceN with Lab alt),
8542                // src_* came from named-colorant extraction and only describes
8543                // the named process plates — spot contributions are missing.
8544                // For fresh-paper pixels (cur_is_zero), reconstruct the visual
8545                // via the tint transform's alt → RGB output instead so the
8546                // spot's true colour shows through. Composite cells (cur not
8547                // zero) still go through CMYK → RGB on the plate-replaced
8548                // values so process plates from the underlay are honoured.
8549                let alt_is_non_cmyk = image_cs_alt_is_non_cmyk(&params.color_space);
8550                let (r, g, b) = if cur_is_zero
8551                    && alt_is_non_cmyk
8552                    && let Some(rgb) =
8553                        sample_pixel_visual_rgb(sample_data, &params.color_space, iw, row, col)
8554                {
8555                    rgb
8556                } else if let Some(icc_cache) = icc {
8557                    icc_cache
8558                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8559                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8560                } else {
8561                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8562                };
8563                if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8564                    op_bg[pi] = px_data[pi];
8565                    op_bg[pi + 1] = px_data[pi + 1];
8566                    op_bg[pi + 2] = px_data[pi + 2];
8567                    op_bg[pi + 3] = px_data[pi + 3];
8568                    op_touched[mi] = 1;
8569                }
8570                px_data[pi] = (r * 255.0).round() as u8;
8571                px_data[pi + 1] = (g * 255.0).round() as u8;
8572                px_data[pi + 2] = (b * 255.0).round() as u8;
8573                px_data[pi + 3] = 255;
8574                continue;
8575            }
8576
8577            let mut channels = params.painted_channels;
8578            // Non-CMYK images (painted_channels=0, e.g. Separation/DeviceN spot colors)
8579            // replace all CMYK channels with the tinted equivalent.
8580            if channels == 0 {
8581                channels = stet_graphics::device::CMYK_ALL;
8582            }
8583            let is_direct_cmyk = matches!(
8584                &params.color_space,
8585                ImageColorSpace::DeviceCMYK
8586                    | ImageColorSpace::ICCBased { n: 4, .. }
8587                    | ImageColorSpace::Mask { .. }
8588            );
8589            // Custom spot image: process plates stay untouched and the per-pixel
8590            // sampled CMYK is the spot's alt-CMYK, which we layer multiplicatively
8591            // onto the pixmap. For image masks, the spot identity lives on the
8592            // fill color (recognise them via painted_channels=0 paired with a
8593            // native-CMYK fill color from the alt-space conversion). Indexed
8594            // images inherit the base space, so an Indexed /DeviceCMYK palette
8595            // is NOT a custom spot even when painted_channels=0. Plain DeviceCMYK
8596            // / ICCBased(4) images keep is_custom_spot=false so standard OPM 1
8597            // behaviour still applies.
8598            let is_custom_spot = params.painted_channels == 0
8599                && !is_cmyk_color_space(&params.color_space)
8600                && match &params.color_space {
8601                    ImageColorSpace::Mask { color, .. } => color.native_cmyk.is_some(),
8602                    _ => true,
8603                };
8604            if params.overprint_mode == 1
8605                && channels == stet_graphics::device::CMYK_ALL
8606                && is_direct_cmyk
8607            {
8608                channels = 0;
8609                if src_c != 0.0 {
8610                    channels |= stet_graphics::device::CMYK_C;
8611                }
8612                if src_m != 0.0 {
8613                    channels |= stet_graphics::device::CMYK_M;
8614                }
8615                if src_y != 0.0 {
8616                    channels |= stet_graphics::device::CMYK_Y;
8617                }
8618                if src_k != 0.0 {
8619                    channels |= stet_graphics::device::CMYK_K;
8620                }
8621            }
8622
8623            let cur_c = cmyk_buf[ci] as f64;
8624            let cur_m = cmyk_buf[ci + 1] as f64;
8625            let cur_y = cmyk_buf[ci + 2] as f64;
8626            let cur_k = cmyk_buf[ci + 3] as f64;
8627            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8628            let pixmap_has_colour = px_data[pi + 3] > 0
8629                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
8630            // Multiplicative ink-stacking only when the pixmap carries a real
8631            // backdrop: either this paint is a custom spot landing on an
8632            // already-coloured pixel, or the process-ink buffer is empty but
8633            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
8634            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
8635            // the fill to pure black, so those pixels fall through to the
8636            // replace path where the source RGB paints normally.
8637            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
8638
8639            let new_c = if channels & stet_graphics::device::CMYK_C != 0 {
8640                src_c
8641            } else {
8642                cur_c
8643            };
8644            let new_m = if channels & stet_graphics::device::CMYK_M != 0 {
8645                src_m
8646            } else {
8647                cur_m
8648            };
8649            let new_y = if channels & stet_graphics::device::CMYK_Y != 0 {
8650                src_y
8651            } else {
8652                cur_y
8653            };
8654            let new_k = if channels & stet_graphics::device::CMYK_K != 0 {
8655                src_k
8656            } else {
8657                cur_k
8658            };
8659
8660            if !is_custom_spot {
8661                cmyk_buf[ci] = new_c as f32;
8662                cmyk_buf[ci + 1] = new_m as f32;
8663                cmyk_buf[ci + 2] = new_y as f32;
8664                cmyk_buf[ci + 3] = new_k as f32;
8665            }
8666
8667            let (r, g, b) = if use_multiplicative {
8668                let bg_r = px_data[pi] as f64 / 255.0;
8669                let bg_g = px_data[pi + 1] as f64 / 255.0;
8670                let bg_b = px_data[pi + 2] as f64 / 255.0;
8671                let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
8672                    1.0 - src_c
8673                } else {
8674                    1.0
8675                };
8676                let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
8677                    1.0 - src_m
8678                } else {
8679                    1.0
8680                };
8681                let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
8682                    1.0 - src_y
8683                } else {
8684                    1.0
8685                };
8686                let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
8687                    1.0 - src_k
8688                } else {
8689                    1.0
8690                };
8691                (
8692                    (bg_r * over_r * k_fac).clamp(0.0, 1.0),
8693                    (bg_g * over_g * k_fac).clamp(0.0, 1.0),
8694                    (bg_b * over_b * k_fac).clamp(0.0, 1.0),
8695                )
8696            } else if let Some(icc_cache) = icc {
8697                icc_cache
8698                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8699                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8700            } else {
8701                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8702            };
8703
8704            // Snapshot the pre-paint pixmap so a later overprint fill/stroke
8705            // at this pixel can blend against it (see render_overprint_fill).
8706            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8707                op_bg[pi] = px_data[pi];
8708                op_bg[pi + 1] = px_data[pi + 1];
8709                op_bg[pi + 2] = px_data[pi + 2];
8710                op_bg[pi + 3] = px_data[pi + 3];
8711                op_touched[mi] = 1;
8712            }
8713
8714            px_data[pi] = (r * 255.0).round() as u8;
8715            px_data[pi + 1] = (g * 255.0).round() as u8;
8716            px_data[pi + 2] = (b * 255.0).round() as u8;
8717            px_data[pi + 3] = 255;
8718        }
8719    }
8720}
8721
8722/// Update CMYK buffer for a non-overprint image.
8723///
8724/// For native-CMYK image color spaces (DeviceCMYK / ICCBased(4) / Separation
8725/// or DeviceN with CMYK alt), the source CMYK is sampled directly via
8726/// `sample_pixel_cmyk`. For non-CMYK source spaces (RGB/Gray/Lab/etc.), the
8727/// already-composited pixmap pixel is read and reverse-converted to CMYK via
8728/// the system CMYK ICC profile, falling back to the PLRM formula. This keeps
8729/// the parallel CMYK buffer faithful for any image painter inside a
8730/// CMYK-tracked context.
8731#[allow(clippy::too_many_arguments)]
8732fn update_cmyk_buffer_for_image(
8733    cmyk_buf: &mut [f32],
8734    sample_data: &[u8],
8735    pixmap_rgba: &[u8],
8736    params: &ImageParams,
8737    vp_x: f32,
8738    vp_y: f32,
8739    scale_x: f32,
8740    scale_y: f32,
8741    out_w: u32,
8742    out_h: u32,
8743    clip_region: &Option<ClipRegion>,
8744    icc: Option<&IccCache>,
8745) {
8746    let iw = params.width as usize;
8747    let ih = params.height as usize;
8748    let Some(image_inv) = params.image_matrix.invert() else {
8749        return;
8750    };
8751    let combined = params.ctm.concat(&image_inv);
8752    let Some(inv_combined) = combined.invert() else {
8753        return;
8754    };
8755    let stride = out_w as usize;
8756    let inv_sx = 1.0 / scale_x as f64;
8757    let inv_sy = 1.0 / scale_y as f64;
8758
8759    let mask_info = if let ImageColorSpace::Mask {
8760        color, polarity, ..
8761    } = &params.color_space
8762    {
8763        let Some((c, m, y, k)) = color.native_cmyk else {
8764            return;
8765        };
8766        Some((
8767            c as f32,
8768            m as f32,
8769            y as f32,
8770            k as f32,
8771            *polarity,
8772            iw.div_ceil(8),
8773        ))
8774    } else {
8775        None
8776    };
8777
8778    let clip_data: Option<&[u8]> = match clip_region {
8779        Some(ClipRegion::Mask(m)) => Some(m.data()),
8780        _ => None,
8781    };
8782    let clip_rect = match clip_region {
8783        Some(ClipRegion::Rect(r)) => Some(*r),
8784        _ => None,
8785    };
8786
8787    for by in 0..out_h as usize {
8788        for bx in 0..out_w as usize {
8789            if let Some(ref r) = clip_rect
8790                && ((by as u32) < r.y0
8791                    || (by as u32) >= r.y1
8792                    || (bx as u32) < r.x0
8793                    || (bx as u32) >= r.x1)
8794            {
8795                continue;
8796            }
8797            if let Some(clip) = clip_data
8798                && clip[by * stride + bx] == 0
8799            {
8800                continue;
8801            }
8802
8803            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8804            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8805            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8806            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8807
8808            let col = ix.floor() as i64;
8809            let row = iy.floor() as i64;
8810            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8811                continue;
8812            }
8813            let col = col as usize;
8814            let row = row as usize;
8815
8816            let ci = (by * stride + bx) * 4;
8817            if let Some((sc, sm, sy, sk, polarity, bytes_per_row)) = mask_info {
8818                let byte_idx = row * bytes_per_row + col / 8;
8819                let bit_offset = 7 - (col % 8);
8820                let bit = if byte_idx < sample_data.len() {
8821                    (sample_data[byte_idx] >> bit_offset) & 1
8822                } else {
8823                    0
8824                };
8825                let paint = if polarity { bit == 1 } else { bit == 0 };
8826                if paint {
8827                    cmyk_buf[ci] = sc;
8828                    cmyk_buf[ci + 1] = sm;
8829                    cmyk_buf[ci + 2] = sy;
8830                    cmyk_buf[ci + 3] = sk;
8831                }
8832            } else if let Some((sc, sm, sy, sk)) =
8833                sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8834            {
8835                cmyk_buf[ci] = sc as f32;
8836                cmyk_buf[ci + 1] = sm as f32;
8837                cmyk_buf[ci + 2] = sy as f32;
8838                cmyk_buf[ci + 3] = sk as f32;
8839            } else if ci + 3 < pixmap_rgba.len() && pixmap_rgba[ci + 3] > 0 {
8840                // Non-CMYK source space: reverse-convert the composited pixmap
8841                // pixel to CMYK via the system profile. Falls back to PLRM
8842                // (1 − r, 1 − g, 1 − b, 0) when no ICC reverse is available.
8843                let r = pixmap_rgba[ci] as f64 / 255.0;
8844                let g = pixmap_rgba[ci + 1] as f64 / 255.0;
8845                let b = pixmap_rgba[ci + 2] as f64 / 255.0;
8846                let cmyk =
8847                    if let Some(c) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
8848                        c
8849                    } else {
8850                        [
8851                            (1.0 - r).clamp(0.0, 1.0),
8852                            (1.0 - g).clamp(0.0, 1.0),
8853                            (1.0 - b).clamp(0.0, 1.0),
8854                            0.0,
8855                        ]
8856                    };
8857                cmyk_buf[ci] = cmyk[0] as f32;
8858                cmyk_buf[ci + 1] = cmyk[1] as f32;
8859                cmyk_buf[ci + 2] = cmyk[2] as f32;
8860                cmyk_buf[ci + 3] = cmyk[3] as f32;
8861            }
8862        }
8863    }
8864}
8865/// Check if an image color space can be rendered through the overprint path.
8866/// Image masks always work (they use the fill color's native CMYK).
8867/// Other color spaces must be CMYK-resolvable via `sample_pixel_cmyk`.
8868fn image_supports_overprint(cs: &ImageColorSpace) -> bool {
8869    use stet_graphics::device::cmyk_channel_for_name;
8870    match cs {
8871        ImageColorSpace::Mask { .. } => true,
8872        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => true,
8873        ImageColorSpace::Separation {
8874            alt_space, name, ..
8875        } => {
8876            matches!(
8877                alt_space.as_ref(),
8878                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8879            ) || cmyk_channel_for_name(name) != 0
8880        }
8881        ImageColorSpace::DeviceN {
8882            alt_space, names, ..
8883        } => {
8884            matches!(
8885                alt_space.as_ref(),
8886                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8887            ) || names.iter().any(|n| cmyk_channel_for_name(n) != 0)
8888        }
8889        ImageColorSpace::Indexed { base, .. } => image_supports_overprint(base),
8890        _ => false,
8891    }
8892}
8893
8894/// Check if an image color space is CMYK-based (DeviceCMYK, ICCBased 4-component, or Indexed over CMYK).
8895fn is_cmyk_color_space(cs: &ImageColorSpace) -> bool {
8896    match cs {
8897        ImageColorSpace::DeviceCMYK => true,
8898        ImageColorSpace::ICCBased { n: 4, .. } => true,
8899        ImageColorSpace::Indexed { base, .. } => is_cmyk_color_space(base),
8900        _ => false,
8901    }
8902}
8903
8904/// True when an image's color space is a Separation/DeviceN with at least
8905/// one non-process spot colorant. These images represent paint that affects
8906/// a virtual spot plate; the per-pixel CMYK produced by the tint transform
8907/// (when alt is CMYK) — or extracted directly from named process colorants
8908/// (when alt is non-CMYK) — must blend with the tracked CMYK buffer per
8909/// OPM=1: named process plates are replaced and unnamed plates are preserved.
8910fn image_cs_has_spot_tint_transform(cs: &ImageColorSpace) -> bool {
8911    use stet_graphics::device::cmyk_channel_for_name;
8912    let is_cmyk_alt = |alt: &ImageColorSpace| {
8913        matches!(
8914            alt,
8915            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8916        )
8917    };
8918    match cs {
8919        ImageColorSpace::Separation {
8920            name, alt_space, ..
8921        } => cmyk_channel_for_name(name) == 0 && is_cmyk_alt(alt_space.as_ref()),
8922        ImageColorSpace::DeviceN {
8923            names, alt_space, ..
8924        } => {
8925            let has_spot = names.iter().any(|n| cmyk_channel_for_name(n) == 0);
8926            let has_process = names.iter().any(|n| cmyk_channel_for_name(n) != 0);
8927            has_spot && (is_cmyk_alt(alt_space.as_ref()) || has_process)
8928        }
8929        ImageColorSpace::Indexed { base, .. } => image_cs_has_spot_tint_transform(base),
8930        _ => false,
8931    }
8932}
8933
8934/// True when the image's tint transform alt is non-CMYK (Lab/RGB/Gray/etc.).
8935/// In that case the per-pixel CMYK from `sample_pixel_cmyk` only carries the
8936/// named process colorants extracted directly — it doesn't capture spot
8937/// colorant contributions, so visual painting (when the buffer is fresh)
8938/// must come from `sample_pixel_visual_rgb` instead of CMYK→RGB conversion.
8939fn image_cs_alt_is_non_cmyk(cs: &ImageColorSpace) -> bool {
8940    let is_cmyk_alt = |alt: &ImageColorSpace| {
8941        matches!(
8942            alt,
8943            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8944        )
8945    };
8946    match cs {
8947        ImageColorSpace::Separation { alt_space, .. }
8948        | ImageColorSpace::DeviceN { alt_space, .. } => !is_cmyk_alt(alt_space.as_ref()),
8949        ImageColorSpace::Indexed { base, .. } => image_cs_alt_is_non_cmyk(base),
8950        _ => false,
8951    }
8952}
8953
8954/// Sample a pixel's visual RGB (0..1) via the tint-transform → alt-space →
8955/// RGB chain. Used by the spot-tint overprint path when the image's alt is
8956/// non-CMYK; for those images the named-colorant CMYK extraction loses the
8957/// spot contribution, but the tint table still produces the correct visual.
8958fn sample_pixel_visual_rgb(
8959    sample_data: &[u8],
8960    cs: &ImageColorSpace,
8961    iw: usize,
8962    row: usize,
8963    col: usize,
8964) -> Option<(f64, f64, f64)> {
8965    let to_f64 = |(r, g, b): (u8, u8, u8)| (r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0);
8966    match cs {
8967        ImageColorSpace::Separation {
8968            alt_space,
8969            tint_table,
8970            ..
8971        } => {
8972            let si = row * iw + col;
8973            if si >= sample_data.len() {
8974                return None;
8975            }
8976            let tint = sample_data[si] as f32 / 255.0;
8977            let no = tint_table.num_outputs as usize;
8978            let mut comps = vec![0.0f32; no];
8979            tint_table.lookup_1d(tint, &mut comps);
8980            Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
8981        }
8982        ImageColorSpace::DeviceN {
8983            alt_space,
8984            tint_table,
8985            ..
8986        } => {
8987            let ni = tint_table.num_inputs as usize;
8988            let si = (row * iw + col) * ni;
8989            if si + ni > sample_data.len() {
8990                return None;
8991            }
8992            let mut inputs = vec![0.0f32; ni];
8993            for (c, inp) in inputs.iter_mut().enumerate() {
8994                *inp = sample_data[si + c] as f32 / 255.0;
8995            }
8996            let no = tint_table.num_outputs as usize;
8997            let mut comps = vec![0.0f32; no];
8998            tint_table.lookup_nd(&inputs, &mut comps);
8999            Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
9000        }
9001        ImageColorSpace::Indexed {
9002            base,
9003            hival,
9004            lookup,
9005        } => {
9006            let pi = row * iw + col;
9007            if pi >= sample_data.len() {
9008                return None;
9009            }
9010            let idx = (sample_data[pi] as usize).min(*hival as usize);
9011            let base_ncomp = base.num_components() as usize;
9012            let li = idx * base_ncomp;
9013            if li + base_ncomp > lookup.len() {
9014                return None;
9015            }
9016            match base.as_ref() {
9017                ImageColorSpace::Separation {
9018                    alt_space,
9019                    tint_table,
9020                    ..
9021                } => {
9022                    let tint = lookup[li] as f32 / 255.0;
9023                    let no = tint_table.num_outputs as usize;
9024                    let mut comps = vec![0.0f32; no];
9025                    tint_table.lookup_1d(tint, &mut comps);
9026                    Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
9027                }
9028                ImageColorSpace::DeviceN {
9029                    alt_space,
9030                    tint_table,
9031                    ..
9032                } => {
9033                    let ni = tint_table.num_inputs as usize;
9034                    let mut inputs = vec![0.0f32; ni];
9035                    for (c, inp) in inputs.iter_mut().enumerate() {
9036                        if c < base_ncomp {
9037                            *inp = lookup[li + c] as f32 / 255.0;
9038                        }
9039                    }
9040                    let no = tint_table.num_outputs as usize;
9041                    let mut comps = vec![0.0f32; no];
9042                    tint_table.lookup_nd(&inputs, &mut comps);
9043                    Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
9044                }
9045                _ => None,
9046            }
9047        }
9048        _ => None,
9049    }
9050}
9051
9052/// Extract CMYK values from DeviceN colorant inputs by mapping each named
9053/// process colorant directly to its CMYK channel. Spot colorants and `/None`
9054/// don't contribute. Used when the DeviceN's alt is non-CMYK so the tint
9055/// transform can't produce CMYK; the named-colorant inputs are themselves the
9056/// per-pixel ink amounts for the named process plates.
9057fn devicen_named_cmyk(names: &[Vec<u8>], inputs: &[u8]) -> (f64, f64, f64, f64) {
9058    use stet_graphics::device::{CMYK_C, CMYK_K, CMYK_M, CMYK_Y, cmyk_channel_for_name};
9059    let mut c = 0.0;
9060    let mut m = 0.0;
9061    let mut y = 0.0;
9062    let mut k = 0.0;
9063    for (i, name) in names.iter().enumerate() {
9064        let bit = cmyk_channel_for_name(name);
9065        if bit == 0 {
9066            continue;
9067        }
9068        let v = inputs.get(i).copied().unwrap_or(0) as f64 / 255.0;
9069        if bit & CMYK_C != 0 {
9070            c = v;
9071        }
9072        if bit & CMYK_M != 0 {
9073            m = v;
9074        }
9075        if bit & CMYK_Y != 0 {
9076            y = v;
9077        }
9078        if bit & CMYK_K != 0 {
9079            k = v;
9080        }
9081    }
9082    (c, m, y, k)
9083}
9084
9085/// Sample a single pixel's CMYK values from image data, handling DeviceCMYK,
9086/// ICCBased(4), Separation/DeviceN (CMYK alt via tint table, or non-CMYK alt
9087/// via named-colorant extraction), and Indexed color spaces. Returns None for
9088/// non-CMYK images.
9089fn sample_pixel_cmyk(
9090    sample_data: &[u8],
9091    cs: &ImageColorSpace,
9092    iw: usize,
9093    row: usize,
9094    col: usize,
9095) -> Option<(f64, f64, f64, f64)> {
9096    use stet_graphics::device::cmyk_channel_for_name;
9097    let is_cmyk_alt = |alt: &ImageColorSpace| {
9098        matches!(
9099            alt,
9100            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
9101        )
9102    };
9103    match cs {
9104        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => {
9105            let si = (row * iw + col) * 4;
9106            if si + 3 < sample_data.len() {
9107                Some((
9108                    sample_data[si] as f64 / 255.0,
9109                    sample_data[si + 1] as f64 / 255.0,
9110                    sample_data[si + 2] as f64 / 255.0,
9111                    sample_data[si + 3] as f64 / 255.0,
9112                ))
9113            } else {
9114                None
9115            }
9116        }
9117        ImageColorSpace::Separation {
9118            alt_space,
9119            tint_table,
9120            name,
9121        } => {
9122            let si = row * iw + col;
9123            if si >= sample_data.len() {
9124                return None;
9125            }
9126            let tint = sample_data[si] as f32 / 255.0;
9127            if is_cmyk_alt(alt_space.as_ref()) {
9128                let mut alt = [0.0f32; 4];
9129                tint_table.lookup_1d(tint, &mut alt);
9130                return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
9131            }
9132            // Non-CMYK alt: only a named process colorant is recoverable.
9133            let bit = cmyk_channel_for_name(name);
9134            if bit == 0 {
9135                return None;
9136            }
9137            let names = vec![name.clone()];
9138            let inputs = [(tint * 255.0).round() as u8];
9139            Some(devicen_named_cmyk(&names, &inputs))
9140        }
9141        ImageColorSpace::DeviceN {
9142            alt_space,
9143            tint_table,
9144            names,
9145        } => {
9146            let ni = tint_table.num_inputs as usize;
9147            let si = (row * iw + col) * ni;
9148            if si + ni > sample_data.len() {
9149                return None;
9150            }
9151            if is_cmyk_alt(alt_space.as_ref()) {
9152                let mut inputs = vec![0.0f32; ni];
9153                for (c, inp) in inputs.iter_mut().enumerate() {
9154                    *inp = sample_data[si + c] as f32 / 255.0;
9155                }
9156                let mut alt = [0.0f32; 4];
9157                tint_table.lookup_nd(&inputs, &mut alt);
9158                return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
9159            }
9160            // Non-CMYK alt: extract from named process colorants directly.
9161            if !names.iter().any(|n| cmyk_channel_for_name(n) != 0) {
9162                return None;
9163            }
9164            Some(devicen_named_cmyk(names, &sample_data[si..si + ni]))
9165        }
9166        ImageColorSpace::Indexed {
9167            base,
9168            hival,
9169            lookup,
9170        } => {
9171            let pi = row * iw + col;
9172            if pi >= sample_data.len() {
9173                return None;
9174            }
9175            let idx = sample_data[pi] as usize;
9176            let idx = idx.min(*hival as usize);
9177            let base_ncomp = base.num_components() as usize;
9178            let li = idx * base_ncomp;
9179            // For direct CMYK base (4 components): read CMYK from lookup table
9180            if is_cmyk_color_space(base) && base_ncomp == 4 && li + 3 < lookup.len() {
9181                return Some((
9182                    lookup[li] as f64 / 255.0,
9183                    lookup[li + 1] as f64 / 255.0,
9184                    lookup[li + 2] as f64 / 255.0,
9185                    lookup[li + 3] as f64 / 255.0,
9186                ));
9187            }
9188            // For Separation/DeviceN base: extract base components from lookup, then tint
9189            if li + base_ncomp <= lookup.len() {
9190                match base.as_ref() {
9191                    ImageColorSpace::Separation {
9192                        alt_space,
9193                        tint_table,
9194                        name,
9195                    } => {
9196                        let tint = lookup[li] as f32 / 255.0;
9197                        if is_cmyk_alt(alt_space.as_ref()) {
9198                            let mut alt = [0.0f32; 4];
9199                            tint_table.lookup_1d(tint, &mut alt);
9200                            return Some((
9201                                alt[0] as f64,
9202                                alt[1] as f64,
9203                                alt[2] as f64,
9204                                alt[3] as f64,
9205                            ));
9206                        }
9207                        // Non-CMYK alt: only named process colorants extractable.
9208                        let bit = cmyk_channel_for_name(name);
9209                        if bit == 0 {
9210                            return None;
9211                        }
9212                        let names = vec![name.clone()];
9213                        let inputs = [(tint * 255.0).round() as u8];
9214                        return Some(devicen_named_cmyk(&names, &inputs));
9215                    }
9216                    ImageColorSpace::DeviceN {
9217                        alt_space,
9218                        tint_table,
9219                        names,
9220                    } => {
9221                        let ni = tint_table.num_inputs as usize;
9222                        if is_cmyk_alt(alt_space.as_ref()) {
9223                            let mut inputs = vec![0.0f32; ni];
9224                            for (c, inp) in inputs.iter_mut().enumerate() {
9225                                if c < base_ncomp {
9226                                    *inp = lookup[li + c] as f32 / 255.0;
9227                                }
9228                            }
9229                            let mut alt = [0.0f32; 4];
9230                            tint_table.lookup_nd(&inputs, &mut alt);
9231                            return Some((
9232                                alt[0] as f64,
9233                                alt[1] as f64,
9234                                alt[2] as f64,
9235                                alt[3] as f64,
9236                            ));
9237                        }
9238                        // Non-CMYK alt: extract from named process colorants directly.
9239                        if !names.iter().any(|n| cmyk_channel_for_name(n) != 0) {
9240                            return None;
9241                        }
9242                        let take = ni.min(base_ncomp);
9243                        return Some(devicen_named_cmyk(names, &lookup[li..li + take]));
9244                    }
9245                    _ => {}
9246                }
9247            }
9248            None
9249        }
9250        _ => None,
9251    }
9252}
9253/// Banded rendering as a free function — runs on a background thread.
9254///
9255/// Renders the display list in horizontal bands and streams the output
9256/// to a `PageSink`. This function is self-contained: it creates its own
9257/// band pixmaps, clip state, and streams rows to the sink.
9258#[allow(clippy::too_many_arguments)]
9259fn render_banded_to_sink(
9260    page_w: u32,
9261    page_h: u32,
9262    band_h: u32,
9263    dpi: f64,
9264    list: &DisplayList,
9265    sink: &mut dyn stet_graphics::device::PageSink,
9266    icc_cache: &IccCache,
9267    no_aa: bool,
9268    layer_set: &LayerSet,
9269) -> Result<(), String> {
9270    // Precompute Y bounding boxes for culling
9271    let bboxes = precompute_bboxes(list, dpi);
9272
9273    // Build clip epochs — groups of elements between InitClip boundaries.
9274    // Epochs whose paint elements don't overlap a band can be skipped entirely,
9275    // avoiding both the per-element iteration AND clip mask rasterization.
9276    let epochs = build_clip_epochs(list, &bboxes);
9277
9278    // Pre-populate clip_mask_seen so repeated clip paths get cached from first band
9279    let clip_seen = precompute_clip_seen(list);
9280
9281    // Allocate a CMYK buffer at the page level when CMYK math is needed:
9282    // overprint simulation, an explicit DeviceCMYK page-level transparency
9283    // group (PDF spec §11.6.7), or any descendant group that declares its own
9284    // DeviceCMYK transparency CS.
9285    use stet_graphics::display_list::GroupColorSpace;
9286    let needs_cmyk_buffer = has_overprint_elements(list)
9287        || list.page_group_color_space() == GroupColorSpace::DeviceCMYK
9288        || has_cmyk_group(list);
9289
9290    // Pre-convert and prescale images once (instead of per-band)
9291    let preprocessed_images = preprocess_images_for_bands(list, Some(icc_cache));
9292
9293    // Extra rows rendered above and below each band to provide anti-aliasing
9294    // context at band seams. Without this, tiny-skia clips geometry at the
9295    // pixmap edge, producing visible discontinuities in thin diagonal strokes.
9296    const BAND_OVERLAP: u32 = 6;
9297
9298    let render_h = band_h + 2 * BAND_OVERLAP;
9299
9300    // Initialize the sink for this page
9301    sink.begin_page(page_w, page_h)?;
9302
9303    let num_bands = page_h.div_ceil(band_h);
9304    let elements = list.elements();
9305    let row_bytes = page_w as usize * 4;
9306    let icc_ref = Some(icc_cache);
9307
9308    // Closure that renders a single band and returns its RGBA pixels.
9309    let render_band = |band_idx: u32| -> Vec<u8> {
9310        let y_start = band_idx * band_h;
9311        let actual_h = (page_h - y_start).min(band_h);
9312
9313        let render_y_start = y_start.saturating_sub(BAND_OVERLAP);
9314        let render_y_end_f = ((y_start + actual_h + BAND_OVERLAP).min(page_h)) as f64;
9315        let band_offset = y_start - render_y_start;
9316
9317        let mut band_pixmap = Pixmap::new(page_w, render_h).expect("Failed to create band pixmap");
9318        // Start transparent — white background composited after content rendering
9319        band_pixmap.as_mut().data_mut().fill(0x00);
9320
9321        let cmyk_buf = if needs_cmyk_buffer {
9322            // CMYK buffer for the render region (including overlap)
9323            Some(vec![0.0f32; page_w as usize * render_h as usize * 4])
9324        } else {
9325            None
9326        };
9327
9328        let mut band_state = BandState {
9329            clip_region: None,
9330            spare_mask: None,
9331            clip_mask_cache: HashMap::new(),
9332            clip_mask_seen: clip_seen.clone(),
9333            mask_pool: Vec::new(),
9334            cmyk_buffer: cmyk_buf,
9335            op_bg_snapshot: None,
9336            op_touched: None,
9337            spot_mask: None,
9338        };
9339
9340        // Epoch-based replay
9341        for epoch in &epochs {
9342            if !epoch.has_erase_page {
9343                match epoch.paint_bbox {
9344                    Some(ref pb)
9345                        if pb.y_max <= render_y_start as f64 || pb.y_min >= render_y_end_f =>
9346                    {
9347                        continue;
9348                    }
9349                    None => continue,
9350                    _ => {}
9351                }
9352            }
9353
9354            for i in epoch.start_idx..epoch.end_idx {
9355                // OcgGroups containing Clip/InitClip must always be
9356                // processed so their clip-state changes apply for every
9357                // band — per-element Y culling would strand clip mutations
9358                // inside a group whose paint content doesn't touch the
9359                // current band.
9360                let force_process = matches!(
9361                    &elements[i],
9362                    DisplayElement::OcgGroup { elements: inner, .. }
9363                        if contains_clip_op(inner)
9364                );
9365                if !force_process
9366                    && let Some(ref bbox) = bboxes[i]
9367                    && (bbox.y_max <= render_y_start as f64 || bbox.y_min >= render_y_end_f)
9368                {
9369                    continue;
9370                }
9371                let ctx = RenderContext {
9372                    vp_x: 0.0,
9373                    vp_y: render_y_start as f32,
9374                    scale_x: 1.0,
9375                    scale_y: 1.0,
9376                    out_w: page_w,
9377                    out_h: render_h,
9378                    effective_dpi: dpi,
9379                    icc: icc_ref,
9380                    image_cache: None,
9381                    preprocessed: Some(&preprocessed_images),
9382                    elem_idx: i,
9383                    no_aa,
9384                    opm_zero_transparent: false,
9385                    knockout_painter_pass: KnockoutPainterPass::None,
9386                    parent_group_isolated: false,
9387                    alpha_extraction_pass: false,
9388                    layer_set,
9389                };
9390                render_element(&mut band_pixmap, &mut band_state, &elements[i], &ctx);
9391            }
9392        }
9393
9394        // Composite content onto white background (premultiplied alpha)
9395        composite_onto_white(band_pixmap.data_mut());
9396
9397        // Extract only the actual band rows (skip overlap)
9398        let start_byte = band_offset as usize * row_bytes;
9399        let total_bytes = actual_h as usize * row_bytes;
9400        band_pixmap.data()[start_byte..start_byte + total_bytes].to_vec()
9401    };
9402
9403    // Render bands in parallel (when available), write to sink in order.
9404    #[cfg(feature = "parallel")]
9405    {
9406        // Process in chunks of `chunk_size` bands to limit peak memory
9407        // (each rendered band is ~band_h * page_w * 4 bytes).
9408        // Cap at 8 threads — sequential sink writing bottleneck means
9409        // additional cores yield no speedup (benchmarked: 8→7.8s plateau).
9410        let chunk_size = rayon::current_num_threads().max(1);
9411
9412        for chunk_start in (0..num_bands).step_by(chunk_size) {
9413            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
9414
9415            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
9416                .into_par_iter()
9417                .map(&render_band)
9418                .collect();
9419
9420            for (i, band_data) in rendered.iter().enumerate() {
9421                let band_idx = chunk_start + i as u32;
9422                let y_start = band_idx * band_h;
9423                let actual_h = (page_h - y_start).min(band_h);
9424                sink.write_rows(band_data, actual_h)?;
9425            }
9426        }
9427    }
9428    #[cfg(not(feature = "parallel"))]
9429    {
9430        // Sequential single-threaded rendering
9431        for band_idx in 0..num_bands {
9432            let band_data = render_band(band_idx);
9433            let y_start = band_idx * band_h;
9434            let actual_h = (page_h - y_start).min(band_h);
9435            sink.write_rows(&band_data, actual_h)?;
9436        }
9437    }
9438
9439    sink.end_page()
9440}
9441
9442/// 2D bounding box in device pixels.
9443#[derive(Clone, Copy)]
9444struct BBox2D {
9445    x_min: f64,
9446    y_min: f64,
9447    x_max: f64,
9448    y_max: f64,
9449}
9450
9451/// Compute full 2D bounding boxes for display list elements (for viewport culling).
9452fn precompute_full_bboxes(list: &DisplayList, dpi: f64) -> Vec<Option<BBox2D>> {
9453    list.elements()
9454        .iter()
9455        .map(|elem| match elem {
9456            DisplayElement::Fill { path, params } => fill_device_full_bbox(path, &params.ctm),
9457            DisplayElement::Stroke { path, params } => {
9458                path_full_bbox(path).map(|mut bbox| {
9459                    // Use effective line width: actual width or hairline minimum
9460                    let effective_lw = params.line_width.max(hairline_min_width(&params.ctm, dpi));
9461                    let expand = effective_lw * params.miter_limit * 0.5;
9462                    let m = &params.ctm;
9463                    let is_identity = m.a == 1.0
9464                        && m.b == 0.0
9465                        && m.c == 0.0
9466                        && m.d == 1.0
9467                        && m.tx == 0.0
9468                        && m.ty == 0.0;
9469                    if is_identity {
9470                        bbox.x_min -= expand;
9471                        bbox.x_max += expand;
9472                        bbox.y_min -= expand;
9473                        bbox.y_max += expand;
9474                    } else {
9475                        // Path is in user space — expand for stroke, then
9476                        // transform bbox corners through CTM to device space.
9477                        let col_x_len = (m.a * m.a + m.b * m.b).sqrt().max(1.0);
9478                        let col_y_len = (m.c * m.c + m.d * m.d).sqrt().max(1.0);
9479                        let expand_x = effective_lw * col_x_len * params.miter_limit * 0.5;
9480                        let expand_y = effective_lw * col_y_len * params.miter_limit * 0.5;
9481                        bbox.x_min -= expand_x;
9482                        bbox.x_max += expand_x;
9483                        bbox.y_min -= expand_y;
9484                        bbox.y_max += expand_y;
9485                        // Transform all 4 corners to device space
9486                        let corners = [
9487                            (
9488                                m.a * bbox.x_min + m.c * bbox.y_min + m.tx,
9489                                m.b * bbox.x_min + m.d * bbox.y_min + m.ty,
9490                            ),
9491                            (
9492                                m.a * bbox.x_max + m.c * bbox.y_min + m.tx,
9493                                m.b * bbox.x_max + m.d * bbox.y_min + m.ty,
9494                            ),
9495                            (
9496                                m.a * bbox.x_min + m.c * bbox.y_max + m.tx,
9497                                m.b * bbox.x_min + m.d * bbox.y_max + m.ty,
9498                            ),
9499                            (
9500                                m.a * bbox.x_max + m.c * bbox.y_max + m.tx,
9501                                m.b * bbox.x_max + m.d * bbox.y_max + m.ty,
9502                            ),
9503                        ];
9504                        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9505                        bbox.x_max = corners
9506                            .iter()
9507                            .map(|c| c.0)
9508                            .fold(f64::NEG_INFINITY, f64::max);
9509                        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9510                        bbox.y_max = corners
9511                            .iter()
9512                            .map(|c| c.1)
9513                            .fold(f64::NEG_INFINITY, f64::max);
9514                    }
9515                    bbox
9516                })
9517            }
9518            DisplayElement::Image { params, .. } => image_full_bbox(params),
9519            DisplayElement::AxialShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9520            DisplayElement::RadialShading { params } => {
9521                shading_full_bbox(&params.bbox, &params.ctm)
9522            }
9523            DisplayElement::MeshShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9524            DisplayElement::PatchShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9525            DisplayElement::PatternFill { params } => pattern_fill_full_bbox(params),
9526            DisplayElement::Group { params, .. } => Some(BBox2D {
9527                x_min: params.bbox[0],
9528                y_min: params.bbox[1],
9529                x_max: params.bbox[2],
9530                y_max: params.bbox[3],
9531            }),
9532            DisplayElement::SoftMasked { params, .. } => Some(BBox2D {
9533                x_min: params.bbox[0],
9534                y_min: params.bbox[1],
9535                x_max: params.bbox[2],
9536                y_max: params.bbox[3],
9537            }),
9538            DisplayElement::OcgGroup {
9539                elements,
9540                visibility,
9541            } => {
9542                // Hidden groups without clip ops contribute nothing. Hidden
9543                // + has clip ops is force-processed at the render-loop layer
9544                // (see the viewport render_region_prepared loop) so we still
9545                // return the paint bounds here for correct epoch bbox.
9546                if !visibility.default_visible() && !contains_clip_op(elements) {
9547                    return None;
9548                }
9549                let child_bboxes = precompute_full_bboxes(elements, dpi);
9550                let mut x_min = f64::INFINITY;
9551                let mut y_min = f64::INFINITY;
9552                let mut x_max = f64::NEG_INFINITY;
9553                let mut y_max = f64::NEG_INFINITY;
9554                for cb in child_bboxes.into_iter().flatten() {
9555                    x_min = x_min.min(cb.x_min);
9556                    y_min = y_min.min(cb.y_min);
9557                    x_max = x_max.max(cb.x_max);
9558                    y_max = y_max.max(cb.y_max);
9559                }
9560                if x_min <= x_max && y_min <= y_max {
9561                    Some(BBox2D {
9562                        x_min,
9563                        y_min,
9564                        x_max,
9565                        y_max,
9566                    })
9567                } else {
9568                    None
9569                }
9570            }
9571            _ => None, // Clip, InitClip, ErasePage: always process
9572        })
9573        .collect()
9574}
9575
9576/// Compute the device-space bounding box of a Clip element's path.
9577///
9578/// Clip paths emitted by the PDF reader use `ctm = identity`, so the path
9579/// segments are already in device space. For Clips that come from other
9580/// sources (PostScript, the pattern transform path), the `ctm` field may
9581/// be non-identity and the path is in user space — transform the path's
9582/// bbox corners through the CTM in that case. Stroke-clips are expanded
9583/// by half the line width.
9584fn clip_path_bbox(path: &PsPath, params: &ClipParams) -> Option<BBox2D> {
9585    let mut bbox = path_full_bbox(path)?;
9586    let ctm = &params.ctm;
9587    let is_identity = ctm.a == 1.0
9588        && ctm.b == 0.0
9589        && ctm.c == 0.0
9590        && ctm.d == 1.0
9591        && ctm.tx == 0.0
9592        && ctm.ty == 0.0;
9593    if !is_identity {
9594        let corners = [
9595            ctm.transform_point(bbox.x_min, bbox.y_min),
9596            ctm.transform_point(bbox.x_max, bbox.y_min),
9597            ctm.transform_point(bbox.x_min, bbox.y_max),
9598            ctm.transform_point(bbox.x_max, bbox.y_max),
9599        ];
9600        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9601        bbox.x_max = corners
9602            .iter()
9603            .map(|c| c.0)
9604            .fold(f64::NEG_INFINITY, f64::max);
9605        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9606        bbox.y_max = corners
9607            .iter()
9608            .map(|c| c.1)
9609            .fold(f64::NEG_INFINITY, f64::max);
9610    }
9611    if let Some(sp) = &params.stroke_params {
9612        let scale = (ctm.a * ctm.a + ctm.b * ctm.b)
9613            .sqrt()
9614            .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt())
9615            .max(1.0);
9616        let expand = sp.line_width * 0.5 * scale;
9617        bbox.x_min -= expand;
9618        bbox.x_max += expand;
9619        bbox.y_min -= expand;
9620        bbox.y_max += expand;
9621    }
9622    Some(bbox)
9623}
9624
9625/// Intersect two bboxes; returns `None` if they don't overlap.
9626fn intersect_bbox(a: &BBox2D, b: &BBox2D) -> Option<BBox2D> {
9627    let x_min = a.x_min.max(b.x_min);
9628    let y_min = a.y_min.max(b.y_min);
9629    let x_max = a.x_max.min(b.x_max);
9630    let y_max = a.y_max.min(b.y_max);
9631    if x_min < x_max && y_min < y_max {
9632        Some(BBox2D {
9633            x_min,
9634            y_min,
9635            x_max,
9636            y_max,
9637        })
9638    } else {
9639        None
9640    }
9641}
9642
9643/// Compute the union of all paint elements' device-space bounds in
9644/// `list`, with awareness of the active clip stack.
9645///
9646/// Used by the soft-mask rasterization path: a SoftMasked element's
9647/// `params.bbox` is derived from the form's `/BBox` transformed by the
9648/// gs-time CTM, but the form's internal `cm` operators may translate
9649/// individual paint elements outside that bbox. The mask raster needs to
9650/// be sized against the actual paint bounds, not the form bbox.
9651///
9652/// **Why clip-awareness matters**: a mask form may contain a shading
9653/// without an explicit `/BBox`, in which case `precompute_full_bboxes`
9654/// returns a sentinel "infinite" bbox (`shading_full_bbox` falls back to
9655/// `0..1e9`) so band rendering doesn't cull it. If `compute_paint_bounds`
9656/// just unioned that, the result would exceed the mask raster size cap
9657/// and `rasterize_mask` would return `None`, making the entire SoftMasked
9658/// element invisible. Tracking the active clip stack lets us bound those
9659/// shadings to their effective paint area.
9660///
9661/// Returns `None` when the list contains no paintable elements or when
9662/// no element survives clip culling.
9663fn compute_paint_bounds(list: &DisplayList, _dpi: f64) -> Option<BBox2D> {
9664    // Active clip stack: each entry is the intersection so far. The
9665    // current clip is `clip_stack.last()`; an empty stack means
9666    // "unbounded" (no clip established yet, or just after InitClip).
9667    let mut clip_stack: Vec<BBox2D> = Vec::new();
9668    let mut union: Option<BBox2D> = None;
9669
9670    let push_paint = |union: &mut Option<BBox2D>, clip_stack: &[BBox2D], bbox: BBox2D| {
9671        // Intersect against the active clip if any. If the clip is
9672        // tighter than the bbox, the visible region is the intersection;
9673        // if the bbox is fully clipped away, skip it.
9674        let visible = match clip_stack.last() {
9675            Some(clip) => match intersect_bbox(clip, &bbox) {
9676                Some(b) => b,
9677                None => return,
9678            },
9679            None => bbox,
9680        };
9681        *union = Some(match union.take() {
9682            None => visible,
9683            Some(u) => BBox2D {
9684                x_min: u.x_min.min(visible.x_min),
9685                y_min: u.y_min.min(visible.y_min),
9686                x_max: u.x_max.max(visible.x_max),
9687                y_max: u.y_max.max(visible.y_max),
9688            },
9689        });
9690    };
9691
9692    for elem in list.elements() {
9693        match elem {
9694            DisplayElement::Clip { path, params } => {
9695                if let Some(cb) = clip_path_bbox(path, params) {
9696                    let new_top = match clip_stack.last() {
9697                        Some(prev) => match intersect_bbox(prev, &cb) {
9698                            Some(b) => b,
9699                            // Clip cleared the visible region; push an
9700                            // empty bbox so subsequent paints are
9701                            // clipped away.
9702                            None => BBox2D {
9703                                x_min: 0.0,
9704                                y_min: 0.0,
9705                                x_max: 0.0,
9706                                y_max: 0.0,
9707                            },
9708                        },
9709                        None => cb,
9710                    };
9711                    clip_stack.push(new_top);
9712                }
9713            }
9714            DisplayElement::InitClip | DisplayElement::ErasePage => {
9715                clip_stack.clear();
9716            }
9717            DisplayElement::Fill { path, .. } => {
9718                if let Some(b) = path_full_bbox(path) {
9719                    push_paint(&mut union, &clip_stack, b);
9720                }
9721            }
9722            DisplayElement::Stroke { path, params } => {
9723                if let Some(mut b) = path_full_bbox(path) {
9724                    let expand = params.line_width * params.miter_limit * 0.5;
9725                    b.x_min -= expand;
9726                    b.x_max += expand;
9727                    b.y_min -= expand;
9728                    b.y_max += expand;
9729                    push_paint(&mut union, &clip_stack, b);
9730                }
9731            }
9732            DisplayElement::Image { params, .. } => {
9733                if let Some(b) = image_full_bbox(params) {
9734                    push_paint(&mut union, &clip_stack, b);
9735                }
9736            }
9737            DisplayElement::AxialShading { params } => {
9738                let b = match &params.bbox {
9739                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9740                    None => clip_stack.last().copied(),
9741                };
9742                if let Some(b) = b {
9743                    push_paint(&mut union, &clip_stack, b);
9744                }
9745            }
9746            DisplayElement::RadialShading { params } => {
9747                let b = match &params.bbox {
9748                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9749                    None => clip_stack.last().copied(),
9750                };
9751                if let Some(b) = b {
9752                    push_paint(&mut union, &clip_stack, b);
9753                }
9754            }
9755            DisplayElement::MeshShading { params } => {
9756                let b = match &params.bbox {
9757                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9758                    None => clip_stack.last().copied(),
9759                };
9760                if let Some(b) = b {
9761                    push_paint(&mut union, &clip_stack, b);
9762                }
9763            }
9764            DisplayElement::PatchShading { params } => {
9765                let b = match &params.bbox {
9766                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9767                    None => clip_stack.last().copied(),
9768                };
9769                if let Some(b) = b {
9770                    push_paint(&mut union, &clip_stack, b);
9771                }
9772            }
9773            DisplayElement::PatternFill { params } => {
9774                if let Some(b) = pattern_fill_full_bbox(params) {
9775                    push_paint(&mut union, &clip_stack, b);
9776                }
9777            }
9778            DisplayElement::Group { params, .. } => {
9779                push_paint(
9780                    &mut union,
9781                    &clip_stack,
9782                    BBox2D {
9783                        x_min: params.bbox[0],
9784                        y_min: params.bbox[1],
9785                        x_max: params.bbox[2],
9786                        y_max: params.bbox[3],
9787                    },
9788                );
9789            }
9790            DisplayElement::SoftMasked { params, .. } => {
9791                push_paint(
9792                    &mut union,
9793                    &clip_stack,
9794                    BBox2D {
9795                        x_min: params.bbox[0],
9796                        y_min: params.bbox[1],
9797                        x_max: params.bbox[2],
9798                        y_max: params.bbox[3],
9799                    },
9800                );
9801            }
9802            DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
9803            DisplayElement::OcgGroup { .. } => {
9804                // OCG groups have no inherent bbox; their children's bounds
9805                // are unknown without recursion. Conservative: skip here —
9806                // if the mask form contains OCG layers, the parent bbox cap
9807                // provides a sufficient upper bound.
9808            }
9809            _ => {}
9810        }
9811    }
9812    union
9813}
9814
9815/// Compute full 2D bounds from path segments.
9816/// Compute device-space 2D bounds for a Fill element, accounting for CTM.
9817/// Paths may be stored in device space (identity CTM) or user space
9818/// (non-identity CTM, e.g. synthesized annotation appearances).
9819fn fill_device_full_bbox(path: &PsPath, ctm: &Matrix) -> Option<BBox2D> {
9820    let bbox = path_full_bbox(path)?;
9821    let is_identity = ctm.a == 1.0
9822        && ctm.b == 0.0
9823        && ctm.c == 0.0
9824        && ctm.d == 1.0
9825        && ctm.tx == 0.0
9826        && ctm.ty == 0.0;
9827    if is_identity {
9828        return Some(bbox);
9829    }
9830    let corners = [
9831        (bbox.x_min, bbox.y_min),
9832        (bbox.x_max, bbox.y_min),
9833        (bbox.x_min, bbox.y_max),
9834        (bbox.x_max, bbox.y_max),
9835    ];
9836    let mut x_min = f64::INFINITY;
9837    let mut x_max = f64::NEG_INFINITY;
9838    let mut y_min = f64::INFINITY;
9839    let mut y_max = f64::NEG_INFINITY;
9840    for (x, y) in &corners {
9841        let dx = ctm.a * x + ctm.c * y + ctm.tx;
9842        let dy = ctm.b * x + ctm.d * y + ctm.ty;
9843        x_min = x_min.min(dx);
9844        x_max = x_max.max(dx);
9845        y_min = y_min.min(dy);
9846        y_max = y_max.max(dy);
9847    }
9848    Some(BBox2D {
9849        x_min,
9850        y_min,
9851        x_max,
9852        y_max,
9853    })
9854}
9855
9856fn path_full_bbox(path: &PsPath) -> Option<BBox2D> {
9857    let mut x_min = f64::INFINITY;
9858    let mut x_max = f64::NEG_INFINITY;
9859    let mut y_min = f64::INFINITY;
9860    let mut y_max = f64::NEG_INFINITY;
9861    for seg in &path.segments {
9862        match seg {
9863            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => {
9864                x_min = x_min.min(*x);
9865                x_max = x_max.max(*x);
9866                y_min = y_min.min(*y);
9867                y_max = y_max.max(*y);
9868            }
9869            PathSegment::CurveTo {
9870                x1,
9871                y1,
9872                x2,
9873                y2,
9874                x3,
9875                y3,
9876            } => {
9877                x_min = x_min.min(*x1).min(*x2).min(*x3);
9878                x_max = x_max.max(*x1).max(*x2).max(*x3);
9879                y_min = y_min.min(*y1).min(*y2).min(*y3);
9880                y_max = y_max.max(*y1).max(*y2).max(*y3);
9881            }
9882            PathSegment::ClosePath => {}
9883        }
9884    }
9885    if x_min <= x_max {
9886        Some(BBox2D {
9887            x_min,
9888            y_min,
9889            x_max,
9890            y_max,
9891        })
9892    } else {
9893        None
9894    }
9895}
9896
9897/// Compute full 2D bounds for a PatternFill element.
9898/// For stroke patterns, the path is in user space and must be transformed
9899/// through the CTM to get device-space bounds, then expanded by half
9900/// the stroke width.
9901fn pattern_fill_full_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<BBox2D> {
9902    if let Some(ref sp) = params.stroke_params {
9903        let bbox = path_full_bbox(&params.path)?;
9904        let ctm = &sp.ctm;
9905        let corners = [
9906            ctm.transform_point(bbox.x_min, bbox.y_min),
9907            ctm.transform_point(bbox.x_max, bbox.y_min),
9908            ctm.transform_point(bbox.x_min, bbox.y_max),
9909            ctm.transform_point(bbox.x_max, bbox.y_max),
9910        ];
9911        let mut dev_bbox = BBox2D {
9912            x_min: f64::INFINITY,
9913            y_min: f64::INFINITY,
9914            x_max: f64::NEG_INFINITY,
9915            y_max: f64::NEG_INFINITY,
9916        };
9917        for (x, y) in &corners {
9918            dev_bbox.x_min = dev_bbox.x_min.min(*x);
9919            dev_bbox.y_min = dev_bbox.y_min.min(*y);
9920            dev_bbox.x_max = dev_bbox.x_max.max(*x);
9921            dev_bbox.y_max = dev_bbox.y_max.max(*y);
9922        }
9923        let half_w = sp.line_width
9924            * 0.5
9925            * (ctm.a * ctm.a + ctm.b * ctm.b)
9926                .sqrt()
9927                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
9928        dev_bbox.x_min -= half_w;
9929        dev_bbox.y_min -= half_w;
9930        dev_bbox.x_max += half_w;
9931        dev_bbox.y_max += half_w;
9932        Some(dev_bbox)
9933    } else {
9934        path_full_bbox(&params.path)
9935    }
9936}
9937
9938/// Compute Y-axis bounds for a PatternFill element (banded rendering).
9939fn pattern_fill_y_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<YBBox> {
9940    let bbox = pattern_fill_full_bbox(params)?;
9941    Some(YBBox {
9942        y_min: bbox.y_min,
9943        y_max: bbox.y_max,
9944    })
9945}
9946
9947/// Compute full 2D bounds for an image from its transform.
9948fn image_full_bbox(params: &ImageParams) -> Option<BBox2D> {
9949    let m = &params.ctm;
9950    let im = &params.image_matrix;
9951    let im_inv = im.invert()?;
9952    let combined = m.concat(&im_inv);
9953    // Image occupies [0, width] × [0, height] in image space
9954    let w = params.width as f64;
9955    let h = params.height as f64;
9956    let corners = [
9957        combined.transform_point(0.0, 0.0),
9958        combined.transform_point(w, 0.0),
9959        combined.transform_point(0.0, h),
9960        combined.transform_point(w, h),
9961    ];
9962    let mut x_min = f64::INFINITY;
9963    let mut x_max = f64::NEG_INFINITY;
9964    let mut y_min = f64::INFINITY;
9965    let mut y_max = f64::NEG_INFINITY;
9966    for (x, y) in &corners {
9967        x_min = x_min.min(*x);
9968        x_max = x_max.max(*x);
9969        y_min = y_min.min(*y);
9970        y_max = y_max.max(*y);
9971    }
9972    Some(BBox2D {
9973        x_min,
9974        y_min,
9975        x_max,
9976        y_max,
9977    })
9978}
9979
9980/// Compute full 2D bounds for a shading element from its BBox.
9981fn shading_full_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<BBox2D> {
9982    if let Some(bbox) = bbox {
9983        let corners = [
9984            ctm.transform_point(bbox[0], bbox[1]),
9985            ctm.transform_point(bbox[2], bbox[1]),
9986            ctm.transform_point(bbox[0], bbox[3]),
9987            ctm.transform_point(bbox[2], bbox[3]),
9988        ];
9989        let mut x_min = f64::INFINITY;
9990        let mut x_max = f64::NEG_INFINITY;
9991        let mut y_min = f64::INFINITY;
9992        let mut y_max = f64::NEG_INFINITY;
9993        for (x, y) in &corners {
9994            x_min = x_min.min(*x);
9995            x_max = x_max.max(*x);
9996            y_min = y_min.min(*y);
9997            y_max = y_max.max(*y);
9998        }
9999        Some(BBox2D {
10000            x_min,
10001            y_min,
10002            x_max,
10003            y_max,
10004        })
10005    } else {
10006        Some(BBox2D {
10007            x_min: 0.0,
10008            y_min: 0.0,
10009            x_max: 1e9,
10010            y_max: 1e9,
10011        })
10012    }
10013}
10014
10015/// Build 2D clip epochs for viewport culling.
10016fn build_viewport_epochs(list: &DisplayList, bboxes: &[Option<BBox2D>]) -> Vec<ViewportEpoch> {
10017    let elements = list.elements();
10018    let mut epochs = Vec::new();
10019    let mut epoch_start = 0;
10020    let mut x_min = f64::INFINITY;
10021    let mut x_max = f64::NEG_INFINITY;
10022    let mut y_min = f64::INFINITY;
10023    let mut y_max = f64::NEG_INFINITY;
10024    let mut has_erase = false;
10025
10026    for (i, element) in elements.iter().enumerate() {
10027        if matches!(element, DisplayElement::InitClip) && i > epoch_start {
10028            epochs.push(ViewportEpoch {
10029                start_idx: epoch_start,
10030                end_idx: i,
10031                paint_bbox: if x_min <= x_max {
10032                    Some(BBox2D {
10033                        x_min,
10034                        y_min,
10035                        x_max,
10036                        y_max,
10037                    })
10038                } else {
10039                    None
10040                },
10041                has_erase_page: has_erase,
10042            });
10043            epoch_start = i;
10044            x_min = f64::INFINITY;
10045            x_max = f64::NEG_INFINITY;
10046            y_min = f64::INFINITY;
10047            y_max = f64::NEG_INFINITY;
10048            has_erase = false;
10049        }
10050        if matches!(element, DisplayElement::ErasePage) {
10051            has_erase = true;
10052        }
10053        if let Some(ref bbox) = bboxes[i] {
10054            x_min = x_min.min(bbox.x_min);
10055            x_max = x_max.max(bbox.x_max);
10056            y_min = y_min.min(bbox.y_min);
10057            y_max = y_max.max(bbox.y_max);
10058        }
10059    }
10060    if epoch_start < elements.len() {
10061        epochs.push(ViewportEpoch {
10062            start_idx: epoch_start,
10063            end_idx: elements.len(),
10064            paint_bbox: if x_min <= x_max {
10065                Some(BBox2D {
10066                    x_min,
10067                    y_min,
10068                    x_max,
10069                    y_max,
10070                })
10071            } else {
10072                None
10073            },
10074            has_erase_page: has_erase,
10075        });
10076    }
10077    epochs
10078}
10079
10080/// Clip epoch with full 2D bounding box for viewport culling.
10081struct ViewportEpoch {
10082    start_idx: usize,
10083    end_idx: usize,
10084    paint_bbox: Option<BBox2D>,
10085    has_erase_page: bool,
10086}
10087
10088/// Pre-computed metadata for fast viewport rendering.
10089///
10090/// Compute once per display list via [`prepare_display_list()`],
10091/// reuse across all [`render_region_prepared()`] calls. This avoids
10092/// three expensive traversals (bboxes, epochs, clip_seen) on every pan.
10093pub struct PreparedDisplayList {
10094    bboxes: Vec<Option<BBox2D>>,
10095    epochs: Vec<ViewportEpoch>,
10096    clip_seen: HashSet<u64>,
10097}
10098
10099/// Precompute display list metadata for fast viewport rendering.
10100///
10101/// Uses a conservative DPI (72.0) for hairline expansion in bounding boxes,
10102/// producing safe overestimates that work at any zoom level without recomputation.
10103pub fn prepare_display_list(list: &DisplayList) -> PreparedDisplayList {
10104    let bboxes = precompute_full_bboxes(list, 72.0);
10105    let epochs = build_viewport_epochs(list, &bboxes);
10106    let clip_seen = precompute_clip_seen(list);
10107    PreparedDisplayList {
10108        bboxes,
10109        epochs,
10110        clip_seen,
10111    }
10112}
10113
10114/// Pre-converted and prescaled image for banded rendering.
10115///
10116/// Built once per page before the band loop so that expensive RGBA conversion
10117/// and box-filter prescaling run once instead of once-per-band.
10118struct PreprocessedImage {
10119    /// RGBA pixel data (prescaled if applicable).
10120    data: Vec<u8>,
10121    /// Dimensions after prescaling.
10122    width: u32,
10123    height: u32,
10124    /// Scale/rotation part of the adjusted transform.
10125    /// Per-band rendering reconstructs the full transform by combining these
10126    /// with the band-specific translation (tx, ty).
10127    adj_sx: f32,
10128    adj_ky: f32,
10129    adj_kx: f32,
10130    adj_sy: f32,
10131    /// Filter quality for draw_pixmap.
10132    quality: stet_tiny_skia::FilterQuality,
10133}
10134
10135/// Pre-converted RGBA image data cache, indexed by display list element index.
10136///
10137/// Built once per page after display list capture. Reused across all viewport
10138/// renders so that ICC color conversion (especially CMYK→sRGB) is not repeated
10139/// on every pan/zoom.
10140pub struct ImageCache {
10141    /// RGBA data per element index. `None` for non-image elements.
10142    entries: Vec<Option<Vec<u8>>>,
10143}
10144
10145impl ImageCache {
10146    /// Build cache by pre-converting all images in the display list.
10147    pub fn build(list: &DisplayList, icc: Option<&IccCache>) -> Self {
10148        let entries = list
10149            .elements()
10150            .iter()
10151            .map(|elem| {
10152                if let DisplayElement::Image {
10153                    sample_data,
10154                    params,
10155                } = elem
10156                {
10157                    if params.width == 0 || params.height == 0 {
10158                        return None;
10159                    }
10160                    let mut rgba = samples_to_rgba(sample_data, params, icc, false);
10161                    if params.mask_color.is_some() {
10162                        apply_mask_color_rgba(&mut rgba, sample_data, params);
10163                    }
10164                    Some(rgba)
10165                } else {
10166                    None
10167                }
10168            })
10169            .collect();
10170        Self { entries }
10171    }
10172
10173    /// Get pre-converted RGBA for the element at the given index.
10174    pub fn get(&self, index: usize) -> Option<&[u8]> {
10175        self.entries.get(index).and_then(|e| e.as_deref())
10176    }
10177}
10178
10179/// Build preprocessed image cache for banded rendering.
10180///
10181/// For each Image element, converts to RGBA and prescales once.
10182/// Banded rendering then only needs `draw_pixmap` per band.
10183fn preprocess_images_for_bands(
10184    list: &DisplayList,
10185    icc: Option<&IccCache>,
10186) -> Vec<Option<PreprocessedImage>> {
10187    list.elements()
10188        .iter()
10189        .map(|elem| {
10190            let DisplayElement::Image {
10191                sample_data,
10192                params,
10193            } = elem
10194            else {
10195                return None;
10196            };
10197            let iw = params.width;
10198            let ih = params.height;
10199            if iw == 0 || ih == 0 {
10200                return None;
10201            }
10202            // Skip overprint images — they use a separate rendering path
10203            if params.overprint {
10204                return None;
10205            }
10206
10207            // Convert to RGBA
10208            let mut rgba = samples_to_rgba(sample_data, params, icc, false);
10209            if params.mask_color.is_some() {
10210                apply_mask_color_rgba(&mut rgba, sample_data, params);
10211            }
10212
10213            // Compute the device-space transform (vp_y=0, scale=1.0)
10214            let image_inv = params.image_matrix.invert()?;
10215            let combined = params.ctm.concat(&image_inv);
10216            let base_transform = enforce_min_image_size(to_transform(&combined), iw, ih);
10217
10218            // Prescale
10219            let (data, width, height, adj_t) =
10220                match prescale_image(&rgba, iw, ih, base_transform, params.interpolate) {
10221                    Some((d, w, h, t)) => {
10222                        drop(rgba); // free the full-size RGBA
10223                        (d, w, h, t)
10224                    }
10225                    None => (rgba, iw, ih, base_transform),
10226                };
10227
10228            let quality = image_filter_quality(adj_t, params.interpolate);
10229
10230            Some(PreprocessedImage {
10231                data,
10232                width,
10233                height,
10234                adj_sx: adj_t.sx,
10235                adj_ky: adj_t.ky,
10236                adj_kx: adj_t.kx,
10237                adj_sy: adj_t.sy,
10238                quality,
10239            })
10240        })
10241        .collect()
10242}
10243
10244/// Render a rectangular viewport region using precomputed metadata.
10245///
10246/// Like [`render_region()`] but skips the three precomputation passes,
10247/// using the [`PreparedDisplayList`] instead. Significantly faster for
10248/// repeated renders of the same display list (e.g., panning at a fixed zoom).
10249#[allow(clippy::too_many_arguments)]
10250pub fn render_region_prepared(
10251    list: &DisplayList,
10252    prepared: &PreparedDisplayList,
10253    vp_x: f64,
10254    vp_y: f64,
10255    vp_w: f64,
10256    vp_h: f64,
10257    pixel_w: u32,
10258    pixel_h: u32,
10259    dpi: f64,
10260    icc: Option<&IccCache>,
10261    image_cache: Option<&ImageCache>,
10262    no_aa: bool,
10263) -> Vec<u8> {
10264    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
10265        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10266    }
10267
10268    let layer_set = LayerSet::new();
10269    let scale_x = pixel_w as f64 / vp_w;
10270    let scale_y = pixel_h as f64 / vp_h;
10271    let effective_dpi = dpi * scale_x;
10272
10273    // Allocate a pixmap with the same OVERLAP padding as the banded page
10274    // renderer. This is essential for matching the banded baseline: the page
10275    // pipeline always allocates `band_h + 2*BAND_OVERLAP` rows, even for a
10276    // single-band render. tiny-skia's `Mask::fill_path` chooses between
10277    // edge-clipped and unclipped rasterization based on whether the path
10278    // bounds fit within the mask, and the two paths produce subtly different
10279    // winding counts at some pixels. Without the OVERLAP padding here, the
10280    // viewport pipeline rasterizes clip paths into a tighter mask than the
10281    // banded pipeline does, producing 39 (and other counts) of edge-pixel
10282    // divergences on samples like 1915_1.pdf.
10283    const OVERLAP: u32 = 6;
10284    let render_h = pixel_h + 2 * OVERLAP;
10285    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
10286    // Start transparent — white background composited after content rendering
10287    pixmap.fill(Color::TRANSPARENT);
10288
10289    let cmyk_buf = if has_overprint_elements(list)
10290        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10291        || has_cmyk_group(list)
10292    {
10293        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10294    } else {
10295        None
10296    };
10297
10298    let mut state = BandState {
10299        clip_region: None,
10300        spare_mask: None,
10301        clip_mask_cache: HashMap::new(),
10302        clip_mask_seen: prepared.clip_seen.clone(),
10303        mask_pool: Vec::new(),
10304        cmyk_buffer: cmyk_buf,
10305        op_bg_snapshot: None,
10306        op_touched: None,
10307        spot_mask: None,
10308    };
10309
10310    let elements = list.elements();
10311    let vp_x_f = vp_x as f32;
10312    let vp_y_f = vp_y as f32;
10313    let sx = scale_x as f32;
10314    let sy = scale_y as f32;
10315    let vp_x_max = vp_x + vp_w;
10316    let vp_y_max = vp_y + vp_h;
10317
10318    for epoch in &prepared.epochs {
10319        if !epoch.has_erase_page {
10320            match epoch.paint_bbox {
10321                Some(ref pb)
10322                    if pb.x_max <= vp_x
10323                        || pb.x_min >= vp_x_max
10324                        || pb.y_max <= vp_y
10325                        || pb.y_min >= vp_y_max =>
10326                {
10327                    continue;
10328                }
10329                None => continue,
10330                _ => {}
10331            }
10332        }
10333
10334        #[allow(clippy::needless_range_loop)]
10335        for i in epoch.start_idx..epoch.end_idx {
10336            // OcgGroups with Clip/InitClip must always be processed — see
10337            // the banded renderer for the rationale.
10338            let force_process = matches!(
10339                &elements[i],
10340                DisplayElement::OcgGroup { elements: inner, .. }
10341                    if contains_clip_op(inner)
10342            );
10343            if !force_process
10344                && let Some(ref bbox) = prepared.bboxes[i]
10345                && (bbox.x_max <= vp_x
10346                    || bbox.x_min >= vp_x_max
10347                    || bbox.y_max <= vp_y
10348                    || bbox.y_min >= vp_y_max)
10349            {
10350                continue;
10351            }
10352            let ctx = RenderContext {
10353                vp_x: vp_x_f,
10354                vp_y: vp_y_f,
10355                scale_x: sx,
10356                scale_y: sy,
10357                out_w: pixel_w,
10358                out_h: render_h,
10359                effective_dpi,
10360                icc,
10361                image_cache,
10362                preprocessed: None,
10363                elem_idx: i,
10364                no_aa,
10365                opm_zero_transparent: false,
10366                knockout_painter_pass: KnockoutPainterPass::None,
10367                parent_group_isolated: false,
10368                alpha_extraction_pass: false,
10369                layer_set: &layer_set,
10370            };
10371            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10372        }
10373    }
10374
10375    // Composite onto white background
10376    composite_onto_white(pixmap.data_mut());
10377    // Extract only the requested pixel_h rows (skip the OVERLAP padding at the bottom).
10378    let row_bytes = pixel_w as usize * 4;
10379    let end = pixel_h as usize * row_bytes;
10380    pixmap.data()[..end].to_vec()
10381}
10382
10383/// Compute the number of bands and band height for viewport banding.
10384///
10385/// Returns `(num_bands, band_height)` using the same L2-cache-budget logic
10386/// as the full-page banded renderer.
10387pub fn viewport_band_count(pixel_w: u32, pixel_h: u32) -> (u32, u32) {
10388    let band_h = select_band_height(pixel_w, pixel_h);
10389    let num_bands = if band_h >= pixel_h {
10390        1
10391    } else {
10392        pixel_h.div_ceil(band_h)
10393    };
10394    (num_bands, band_h)
10395}
10396
10397/// Render a single horizontal band of a viewport region.
10398///
10399/// This is the per-band counterpart to [`render_region_prepared()`]. The caller
10400/// loops over `band_idx` in `0..num_bands`, collecting RGBA strips that tile
10401/// vertically to form the full viewport image.
10402///
10403/// Returns RGBA pixel data for `actual_h` rows (may be less than `band_h` for
10404/// the last band).
10405#[allow(clippy::too_many_arguments)]
10406pub fn render_region_single_band(
10407    list: &DisplayList,
10408    prepared: &PreparedDisplayList,
10409    vp_x: f64,
10410    vp_y: f64,
10411    vp_w: f64,
10412    vp_h: f64,
10413    pixel_w: u32,
10414    pixel_h: u32,
10415    band_idx: u32,
10416    band_h: u32,
10417    num_bands: u32,
10418    dpi: f64,
10419    icc: Option<&IccCache>,
10420    image_cache: Option<&ImageCache>,
10421    no_aa: bool,
10422) -> Vec<u8> {
10423    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
10424        let actual_h = if band_idx < num_bands - 1 {
10425            band_h
10426        } else {
10427            pixel_h - band_idx * band_h
10428        };
10429        return vec![0xFF; pixel_w as usize * actual_h as usize * 4];
10430    }
10431
10432    let layer_set = LayerSet::new();
10433    let scale_x = pixel_w as f64 / vp_w;
10434    let scale_y = pixel_h as f64 / vp_h;
10435    let effective_dpi = dpi * scale_x;
10436
10437    // Output Y range for this band
10438    let out_y_start = band_idx * band_h;
10439    let actual_h = if band_idx < num_bands - 1 {
10440        band_h
10441    } else {
10442        pixel_h - out_y_start
10443    };
10444
10445    // Add overlap above/below for anti-aliasing at seams.
10446    //
10447    // The pixmap is always `band_h + 2*OVERLAP` rows — matching the page
10448    // renderer (`render_banded_to_sink`) — even at the bottom band, where
10449    // content rendering stops at `pixel_h`. Without this, the bottom band's
10450    // pixmap is shorter than the page renderer's, and tiny-skia's
10451    // `Mask::fill_path` rasterizes clip paths into a tighter mask, producing
10452    // edge-pixel divergences from the banded baseline (39 pixels on
10453    // 1915_1.pdf, etc.). The extra rows below `pixel_h` are unused for output
10454    // but ensure mask-size-independent rasterization.
10455    const OVERLAP: u32 = 6;
10456    let render_y_start = out_y_start.saturating_sub(OVERLAP);
10457    let render_y_end = (out_y_start + actual_h + OVERLAP).min(pixel_h);
10458    let render_h = band_h + 2 * OVERLAP;
10459    let overlap_top = out_y_start - render_y_start;
10460
10461    // Source-space Y range for culling
10462    let src_y_min = vp_y + render_y_start as f64 / scale_y;
10463    let src_y_max = vp_y + render_y_end as f64 / scale_y;
10464
10465    // Adjusted viewport offset for this band's pixmap
10466    let band_vp_y = vp_y + render_y_start as f64 / scale_y;
10467
10468    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create band pixmap");
10469    pixmap.fill(Color::TRANSPARENT);
10470
10471    let cmyk_buf = if has_overprint_elements(list)
10472        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10473        || has_cmyk_group(list)
10474    {
10475        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10476    } else {
10477        None
10478    };
10479
10480    let mut state = BandState {
10481        clip_region: None,
10482        spare_mask: None,
10483        clip_mask_cache: HashMap::new(),
10484        clip_mask_seen: prepared.clip_seen.clone(),
10485        mask_pool: Vec::new(),
10486        cmyk_buffer: cmyk_buf,
10487        op_bg_snapshot: None,
10488        op_touched: None,
10489        spot_mask: None,
10490    };
10491
10492    let elements = list.elements();
10493    let vp_x_f = vp_x as f32;
10494    let band_vp_y_f = band_vp_y as f32;
10495    let sx = scale_x as f32;
10496    let sy = scale_y as f32;
10497    let vp_x_max = vp_x + vp_w;
10498
10499    for epoch in &prepared.epochs {
10500        if !epoch.has_erase_page {
10501            match epoch.paint_bbox {
10502                Some(ref pb)
10503                    if pb.x_max <= vp_x
10504                        || pb.x_min >= vp_x_max
10505                        || pb.y_max <= src_y_min
10506                        || pb.y_min >= src_y_max =>
10507                {
10508                    continue;
10509                }
10510                None => continue,
10511                _ => {}
10512            }
10513        }
10514
10515        #[allow(clippy::needless_range_loop)]
10516        for i in epoch.start_idx..epoch.end_idx {
10517            // OcgGroups containing Clip/InitClip must always be processed
10518            // regardless of this band's bbox — see the full-page banded
10519            // renderer for the rationale.
10520            let force_process = matches!(
10521                &elements[i],
10522                DisplayElement::OcgGroup { elements: inner, .. }
10523                    if contains_clip_op(inner)
10524            );
10525            if !force_process
10526                && let Some(ref bbox) = prepared.bboxes[i]
10527                && (bbox.x_max <= vp_x
10528                    || bbox.x_min >= vp_x_max
10529                    || bbox.y_max <= src_y_min
10530                    || bbox.y_min >= src_y_max)
10531            {
10532                continue;
10533            }
10534            let ctx = RenderContext {
10535                vp_x: vp_x_f,
10536                vp_y: band_vp_y_f,
10537                scale_x: sx,
10538                scale_y: sy,
10539                out_w: pixel_w,
10540                out_h: render_h,
10541                effective_dpi,
10542                icc,
10543                image_cache,
10544                preprocessed: None,
10545                elem_idx: i,
10546                no_aa,
10547                opm_zero_transparent: false,
10548                knockout_painter_pass: KnockoutPainterPass::None,
10549                parent_group_isolated: false,
10550                alpha_extraction_pass: false,
10551                layer_set: &layer_set,
10552            };
10553            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10554        }
10555    }
10556
10557    // Composite onto white background
10558    composite_onto_white(pixmap.data_mut());
10559
10560    // Extract only the non-overlap rows
10561    let row_bytes = pixel_w as usize * 4;
10562    let start = overlap_top as usize * row_bytes;
10563    let end = start + actual_h as usize * row_bytes;
10564    pixmap.data()[start..end].to_vec()
10565}
10566
10567/// Render a viewport region using parallel banded rendering via rayon.
10568///
10569/// This is the WASM counterpart to the parallel path in `render_banded_to_sink`.
10570/// All bands are rendered in parallel using `par_iter`, then assembled into the
10571/// final RGBA buffer in order.
10572///
10573/// Requires the `parallel` feature (rayon). Falls back to sequential rendering
10574/// if `parallel` is not enabled.
10575#[allow(clippy::too_many_arguments)]
10576pub fn render_region_prepared_parallel(
10577    list: &DisplayList,
10578    prepared: &PreparedDisplayList,
10579    vp_x: f64,
10580    vp_y: f64,
10581    vp_w: f64,
10582    vp_h: f64,
10583    pixel_w: u32,
10584    pixel_h: u32,
10585    dpi: f64,
10586    icc: Option<&IccCache>,
10587    image_cache: Option<&ImageCache>,
10588    no_aa: bool,
10589) -> Vec<u8> {
10590    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10591
10592    if num_bands <= 1 {
10593        // Single band — no parallelism needed
10594        return render_region_prepared(
10595            list,
10596            prepared,
10597            vp_x,
10598            vp_y,
10599            vp_w,
10600            vp_h,
10601            pixel_w,
10602            pixel_h,
10603            dpi,
10604            icc,
10605            image_cache,
10606            no_aa,
10607        );
10608    }
10609
10610    let render_band = |band_idx: u32| -> Vec<u8> {
10611        render_region_single_band(
10612            list,
10613            prepared,
10614            vp_x,
10615            vp_y,
10616            vp_w,
10617            vp_h,
10618            pixel_w,
10619            pixel_h,
10620            band_idx,
10621            band_h,
10622            num_bands,
10623            dpi,
10624            icc,
10625            image_cache,
10626            no_aa,
10627        )
10628    };
10629
10630    let row_bytes = pixel_w as usize * 4;
10631    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10632
10633    #[cfg(feature = "parallel")]
10634    {
10635        let chunk_size = rayon::current_num_threads().max(1);
10636
10637        for chunk_start in (0..num_bands).step_by(chunk_size) {
10638            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10639
10640            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10641                .into_par_iter()
10642                .map(&render_band)
10643                .collect();
10644
10645            for (i, band_data) in rendered.iter().enumerate() {
10646                let band_idx = chunk_start + i as u32;
10647                let y_start = (band_idx * band_h) as usize;
10648                let dest_start = y_start * row_bytes;
10649                let len = band_data.len();
10650                result[dest_start..dest_start + len].copy_from_slice(band_data);
10651            }
10652        }
10653    }
10654    #[cfg(not(feature = "parallel"))]
10655    {
10656        for band_idx in 0..num_bands {
10657            let band_data = render_band(band_idx);
10658            let y_start = (band_idx * band_h) as usize;
10659            let dest_start = y_start * row_bytes;
10660            let len = band_data.len();
10661            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10662        }
10663    }
10664
10665    result
10666}
10667
10668/// Like [`render_region_prepared_parallel()`] but with an atomic progress counter.
10669///
10670/// The counter is incremented after each chunk of bands completes. The total
10671/// number of bands is returned alongside the counter via [`viewport_band_count()`].
10672#[allow(clippy::too_many_arguments)]
10673pub fn render_region_prepared_parallel_with_progress(
10674    list: &DisplayList,
10675    prepared: &PreparedDisplayList,
10676    vp_x: f64,
10677    vp_y: f64,
10678    vp_w: f64,
10679    vp_h: f64,
10680    pixel_w: u32,
10681    pixel_h: u32,
10682    dpi: f64,
10683    icc: Option<&IccCache>,
10684    image_cache: Option<&ImageCache>,
10685    no_aa: bool,
10686    progress: &std::sync::atomic::AtomicU32,
10687) -> Vec<u8> {
10688    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10689
10690    if num_bands <= 1 {
10691        let result = render_region_prepared(
10692            list,
10693            prepared,
10694            vp_x,
10695            vp_y,
10696            vp_w,
10697            vp_h,
10698            pixel_w,
10699            pixel_h,
10700            dpi,
10701            icc,
10702            image_cache,
10703            no_aa,
10704        );
10705        progress.store(1, std::sync::atomic::Ordering::Relaxed);
10706        return result;
10707    }
10708
10709    let render_band = |band_idx: u32| -> Vec<u8> {
10710        render_region_single_band(
10711            list,
10712            prepared,
10713            vp_x,
10714            vp_y,
10715            vp_w,
10716            vp_h,
10717            pixel_w,
10718            pixel_h,
10719            band_idx,
10720            band_h,
10721            num_bands,
10722            dpi,
10723            icc,
10724            image_cache,
10725            no_aa,
10726        )
10727    };
10728
10729    let row_bytes = pixel_w as usize * 4;
10730    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10731
10732    #[cfg(feature = "parallel")]
10733    {
10734        let chunk_size = rayon::current_num_threads().max(1);
10735
10736        for chunk_start in (0..num_bands).step_by(chunk_size) {
10737            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10738
10739            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10740                .into_par_iter()
10741                .map(&render_band)
10742                .collect();
10743
10744            for (i, band_data) in rendered.iter().enumerate() {
10745                let band_idx = chunk_start + i as u32;
10746                let y_start = (band_idx * band_h) as usize;
10747                let dest_start = y_start * row_bytes;
10748                let len = band_data.len();
10749                result[dest_start..dest_start + len].copy_from_slice(band_data);
10750            }
10751            progress.store(chunk_end, std::sync::atomic::Ordering::Relaxed);
10752        }
10753    }
10754    #[cfg(not(feature = "parallel"))]
10755    {
10756        for band_idx in 0..num_bands {
10757            let band_data = render_band(band_idx);
10758            let y_start = (band_idx * band_h) as usize;
10759            let dest_start = y_start * row_bytes;
10760            let len = band_data.len();
10761            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10762            progress.store(band_idx + 1, std::sync::atomic::Ordering::Relaxed);
10763        }
10764    }
10765
10766    result
10767}
10768
10769/// Like [`render_region_prepared_parallel()`] but checks a cancellation flag
10770/// between band chunks. Returns `None` if cancelled.
10771#[allow(clippy::too_many_arguments)]
10772pub fn render_region_prepared_parallel_cancellable(
10773    list: &DisplayList,
10774    prepared: &PreparedDisplayList,
10775    vp_x: f64,
10776    vp_y: f64,
10777    vp_w: f64,
10778    vp_h: f64,
10779    pixel_w: u32,
10780    pixel_h: u32,
10781    dpi: f64,
10782    icc: Option<&IccCache>,
10783    image_cache: Option<&ImageCache>,
10784    no_aa: bool,
10785    cancelled: &std::sync::atomic::AtomicBool,
10786) -> Option<Vec<u8>> {
10787    if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10788        return None;
10789    }
10790
10791    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10792
10793    if num_bands <= 1 {
10794        return Some(render_region_prepared(
10795            list,
10796            prepared,
10797            vp_x,
10798            vp_y,
10799            vp_w,
10800            vp_h,
10801            pixel_w,
10802            pixel_h,
10803            dpi,
10804            icc,
10805            image_cache,
10806            no_aa,
10807        ));
10808    }
10809
10810    let render_band = |band_idx: u32| -> Vec<u8> {
10811        render_region_single_band(
10812            list,
10813            prepared,
10814            vp_x,
10815            vp_y,
10816            vp_w,
10817            vp_h,
10818            pixel_w,
10819            pixel_h,
10820            band_idx,
10821            band_h,
10822            num_bands,
10823            dpi,
10824            icc,
10825            image_cache,
10826            no_aa,
10827        )
10828    };
10829
10830    let row_bytes = pixel_w as usize * 4;
10831    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10832
10833    #[cfg(feature = "parallel")]
10834    {
10835        let chunk_size = rayon::current_num_threads().max(1);
10836
10837        for chunk_start in (0..num_bands).step_by(chunk_size) {
10838            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10839                return None;
10840            }
10841            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10842
10843            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10844                .into_par_iter()
10845                .map(&render_band)
10846                .collect();
10847
10848            for (i, band_data) in rendered.iter().enumerate() {
10849                let band_idx = chunk_start + i as u32;
10850                let y_start = (band_idx * band_h) as usize;
10851                let dest_start = y_start * row_bytes;
10852                let len = band_data.len();
10853                result[dest_start..dest_start + len].copy_from_slice(band_data);
10854            }
10855        }
10856    }
10857    #[cfg(not(feature = "parallel"))]
10858    {
10859        for band_idx in 0..num_bands {
10860            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10861                return None;
10862            }
10863            let band_data = render_band(band_idx);
10864            let y_start = (band_idx * band_h) as usize;
10865            let dest_start = y_start * row_bytes;
10866            let len = band_data.len();
10867            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10868        }
10869    }
10870
10871    Some(result)
10872}
10873
10874/// Render a full-page display list to RGBA pixels using the banded parallel renderer.
10875///
10876/// This is the preferred way to render a complete page — it uses rayon parallelism
10877/// (when the `parallel` feature is enabled) and L2-cache-friendly band sizing.
10878/// For sub-region / zoomed viewport rendering, use `render_region` instead.
10879///
10880/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`, composited onto white.
10881pub fn render_to_rgba(
10882    list: &DisplayList,
10883    pixel_w: u32,
10884    pixel_h: u32,
10885    dpi: f64,
10886    icc: Option<&IccCache>,
10887    no_aa: bool,
10888) -> Vec<u8> {
10889    render_to_rgba_with_layers(list, pixel_w, pixel_h, dpi, icc, no_aa, &LayerSet::new())
10890}
10891
10892/// Like [`render_to_rgba`] but consults the supplied [`LayerSet`] when
10893/// evaluating each `OcgGroup`'s visibility.
10894///
10895/// Pass `&LayerSet::new()` (or use [`render_to_rgba`]) to fall back to
10896/// each OCG's `default_visible` baked from the document's default
10897/// configuration.
10898#[allow(clippy::too_many_arguments)]
10899pub fn render_to_rgba_with_layers(
10900    list: &DisplayList,
10901    pixel_w: u32,
10902    pixel_h: u32,
10903    dpi: f64,
10904    icc: Option<&IccCache>,
10905    no_aa: bool,
10906    layer_set: &LayerSet,
10907) -> Vec<u8> {
10908    if pixel_w == 0 || pixel_h == 0 {
10909        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10910    }
10911
10912    let mut icc_cache = match icc {
10913        Some(c) => c.clone(),
10914        None => IccCache::new(),
10915    };
10916    // Register any ICC profiles from shadings in the display list
10917    // (the caller's cache only has image profiles)
10918    register_shading_icc_profiles(list, &mut icc_cache);
10919
10920    let mut sink = MemorySink {
10921        data: Vec::new(),
10922        width: 0,
10923    };
10924
10925    let band_h = select_band_height(pixel_w, pixel_h);
10926    if let Err(e) = render_banded_to_sink(
10927        pixel_w, pixel_h, band_h, dpi, list, &mut sink, &icc_cache, no_aa, layer_set,
10928    ) {
10929        eprintln!("render_to_rgba: banded render failed: {e}");
10930        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10931    }
10932
10933    sink.data
10934}
10935
10936/// Render a display list to RGBA using the **viewport** code path, with
10937/// the viewport set to the full page at 1:1 scale.
10938///
10939/// This exists to audit the viewport pipeline (`render_region_prepared_*`)
10940/// against the same baselines the banded PNG path uses. The two paths share
10941/// `render_element` and the same display list, so their output should be
10942/// pixel-identical on a correctly implemented display list. Differences
10943/// indicate a bug in one of the two culling / epoch / bbox pipelines.
10944///
10945/// The CLI exposes this as `--device viewport-png`; the visual test runner
10946/// uses it to double-cover each sample without maintaining a second
10947/// baseline.
10948pub fn render_to_rgba_viewport(
10949    list: &DisplayList,
10950    pixel_w: u32,
10951    pixel_h: u32,
10952    dpi: f64,
10953    icc: Option<&IccCache>,
10954    no_aa: bool,
10955) -> Vec<u8> {
10956    if pixel_w == 0 || pixel_h == 0 {
10957        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10958    }
10959
10960    let mut icc_cache = match icc {
10961        Some(c) => c.clone(),
10962        None => IccCache::new(),
10963    };
10964    register_shading_icc_profiles(list, &mut icc_cache);
10965
10966    let prepared = prepare_display_list(list);
10967    render_region_prepared_parallel(
10968        list,
10969        &prepared,
10970        0.0,
10971        0.0,
10972        pixel_w as f64,
10973        pixel_h as f64,
10974        pixel_w,
10975        pixel_h,
10976        dpi,
10977        Some(&icc_cache),
10978        None,
10979        no_aa,
10980    )
10981}
10982
10983/// Debug helper: format both bbox precomputations side-by-side.
10984///
10985/// Returns one line per element describing its Y-only bbox (used by the
10986/// banded page pipeline) and its 2D bbox (used by the viewport pipeline).
10987/// Elements that disagree on presence, or whose 2D bbox's Y extent differs
10988/// from the Y-only bbox, are marked with `DIFF`.
10989fn debug_bbox_lines(list: &DisplayList, dpi: f64, depth: usize, out: &mut Vec<String>) {
10990    let y_bboxes = precompute_bboxes(list, dpi);
10991    let full_bboxes = precompute_full_bboxes(list, dpi);
10992    let elements = list.elements();
10993    let indent = "  ".repeat(depth);
10994    for (i, elem) in elements.iter().enumerate() {
10995        let kind = match elem {
10996            DisplayElement::Fill { .. } => "Fill",
10997            DisplayElement::Stroke { .. } => "Stroke",
10998            DisplayElement::Image { .. } => "Image",
10999            DisplayElement::AxialShading { .. } => "AxialShading",
11000            DisplayElement::RadialShading { .. } => "RadialShading",
11001            DisplayElement::MeshShading { .. } => "MeshShading",
11002            DisplayElement::PatchShading { .. } => "PatchShading",
11003            DisplayElement::PatternFill { .. } => "PatternFill",
11004            DisplayElement::Group { .. } => "Group",
11005            DisplayElement::SoftMasked { .. } => "SoftMasked",
11006            DisplayElement::OcgGroup { .. } => "OcgGroup",
11007            DisplayElement::Clip { .. } => "Clip",
11008            DisplayElement::InitClip => "InitClip",
11009            DisplayElement::ErasePage => "ErasePage",
11010            DisplayElement::Text { .. } => "Text",
11011            _ => "Unknown",
11012        };
11013        let yb = &y_bboxes[i];
11014        let fb = &full_bboxes[i];
11015        let mut diff = false;
11016        if yb.is_some() != fb.is_some() {
11017            diff = true;
11018        }
11019        if let (Some(yb), Some(fb)) = (yb, fb)
11020            && ((yb.y_min - fb.y_min).abs() > 1e-9 || (yb.y_max - fb.y_max).abs() > 1e-9)
11021        {
11022            diff = true;
11023        }
11024        let yb_s = match yb {
11025            Some(b) => format!("Y[{:8.3}..{:8.3}]", b.y_min, b.y_max),
11026            None => "Y[None]".to_string(),
11027        };
11028        let fb_s = match fb {
11029            Some(b) => format!(
11030                "2D[x {:8.3}..{:8.3} y {:8.3}..{:8.3}]",
11031                b.x_min, b.x_max, b.y_min, b.y_max
11032            ),
11033            None => "2D[None]".to_string(),
11034        };
11035        out.push(format!(
11036            "{}{:4} {:15} {:30} {:55} {}",
11037            indent,
11038            i,
11039            kind,
11040            yb_s,
11041            fb_s,
11042            if diff { "DIFF" } else { "" }
11043        ));
11044        if let DisplayElement::Stroke { path, params } = elem {
11045            let rp = path_full_bbox(path);
11046            let m = &params.ctm;
11047            out.push(format!(
11048                "{}        ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] lw={:.4} miter={:.4} raw={}",
11049                indent,
11050                m.a,
11051                m.b,
11052                m.c,
11053                m.d,
11054                m.tx,
11055                m.ty,
11056                params.line_width,
11057                params.miter_limit,
11058                match rp {
11059                    Some(b) => format!(
11060                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11061                        b.x_min, b.x_max, b.y_min, b.y_max
11062                    ),
11063                    None => "None".to_string(),
11064                }
11065            ));
11066        }
11067        if let DisplayElement::Clip { path, params } = elem {
11068            let rp = path_full_bbox(path);
11069            let m = &params.ctm;
11070            out.push(format!(
11071                "{}        clip ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] rule={:?} raw={}",
11072                indent,
11073                m.a,
11074                m.b,
11075                m.c,
11076                m.d,
11077                m.tx,
11078                m.ty,
11079                params.fill_rule,
11080                match rp {
11081                    Some(b) => format!(
11082                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11083                        b.x_min, b.x_max, b.y_min, b.y_max
11084                    ),
11085                    None => "None".to_string(),
11086                }
11087            ));
11088        }
11089        if let DisplayElement::PatchShading { params } = elem {
11090            out.push(format!(
11091                "{}        patch ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] bbox={:?} patches={}",
11092                indent,
11093                params.ctm.a,
11094                params.ctm.b,
11095                params.ctm.c,
11096                params.ctm.d,
11097                params.ctm.tx,
11098                params.ctm.ty,
11099                params.bbox,
11100                params.patches.len()
11101            ));
11102            if !params.patches.is_empty() {
11103                let patch = &params.patches[0];
11104                // Compute device-space bbox of patch points
11105                let mut x_min = f64::INFINITY;
11106                let mut y_min = f64::INFINITY;
11107                let mut x_max = f64::NEG_INFINITY;
11108                let mut y_max = f64::NEG_INFINITY;
11109                for &(px, py) in &patch.points {
11110                    let (dx, dy) = params.ctm.transform_point(px, py);
11111                    x_min = x_min.min(dx);
11112                    y_min = y_min.min(dy);
11113                    x_max = x_max.max(dx);
11114                    y_max = y_max.max(dy);
11115                }
11116                out.push(format!(
11117                    "{}        patch[0] pts={} dev x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11118                    indent,
11119                    patch.points.len(),
11120                    x_min,
11121                    x_max,
11122                    y_min,
11123                    y_max
11124                ));
11125            }
11126        }
11127        if let DisplayElement::Group {
11128            elements: inner,
11129            params,
11130        } = elem
11131        {
11132            out.push(format!(
11133                "{}        group bbox={:?} iso={} ko={} alpha={} bm={} cs={:?}",
11134                indent,
11135                params.bbox,
11136                params.isolated,
11137                params.knockout,
11138                params.alpha,
11139                params.blend_mode,
11140                params.color_space
11141            ));
11142            debug_bbox_lines(inner, dpi, depth + 1, out);
11143        }
11144        if let DisplayElement::SoftMasked {
11145            content, params, ..
11146        } = elem
11147        {
11148            out.push(format!(
11149                "{}        softmasked bbox={:?}",
11150                indent, params.bbox
11151            ));
11152            debug_bbox_lines(content, dpi, depth + 1, out);
11153        }
11154        if let DisplayElement::OcgGroup {
11155            elements: inner,
11156            visibility,
11157        } = elem
11158        {
11159            out.push(format!(
11160                "{}        ocg default_visible={}",
11161                indent,
11162                visibility.default_visible()
11163            ));
11164            debug_bbox_lines(inner, dpi, depth + 1, out);
11165        }
11166    }
11167}
11168
11169pub fn debug_bbox_comparison(list: &DisplayList, dpi: f64) -> Vec<String> {
11170    let mut out = Vec::new();
11171    debug_bbox_lines(list, dpi, 0, &mut out);
11172    out
11173}
11174
11175/// In-memory page sink that collects RGBA rows into a Vec.
11176struct MemorySink {
11177    data: Vec<u8>,
11178    width: u32,
11179}
11180
11181impl stet_graphics::device::PageSink for MemorySink {
11182    fn begin_page(&mut self, width: u32, height: u32) -> Result<(), String> {
11183        self.width = width;
11184        self.data.reserve(width as usize * height as usize * 4);
11185        Ok(())
11186    }
11187
11188    fn write_rows(&mut self, rgba_rows: &[u8], _num_rows: u32) -> Result<(), String> {
11189        self.data.extend_from_slice(rgba_rows);
11190        Ok(())
11191    }
11192
11193    fn end_page(&mut self) -> Result<(), String> {
11194        Ok(())
11195    }
11196}
11197
11198/// Render a rectangular viewport region of a display list to RGBA pixels.
11199///
11200/// - `list`: The display list to render (in device-space coordinates at the reference DPI)
11201/// - `vp_x, vp_y, vp_w, vp_h`: Viewport rectangle in device-space pixels
11202/// - `pixel_w, pixel_h`: Output pixel dimensions
11203/// - `dpi`: Reference DPI (for hairline width decisions)
11204///
11205/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`.
11206#[allow(clippy::too_many_arguments)]
11207pub fn render_region(
11208    list: &DisplayList,
11209    vp_x: f64,
11210    vp_y: f64,
11211    vp_w: f64,
11212    vp_h: f64,
11213    pixel_w: u32,
11214    pixel_h: u32,
11215    dpi: f64,
11216    icc: Option<&IccCache>,
11217    image_cache: Option<&ImageCache>,
11218    no_aa: bool,
11219) -> Vec<u8> {
11220    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
11221        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
11222    }
11223
11224    let layer_set = LayerSet::new();
11225    let scale_x = pixel_w as f64 / vp_w;
11226    let scale_y = pixel_h as f64 / vp_h;
11227    // Effective DPI for hairline decisions — reference DPI scaled by zoom
11228    let effective_dpi = dpi * scale_x;
11229
11230    let bboxes = precompute_full_bboxes(list, effective_dpi);
11231    let epochs = build_viewport_epochs(list, &bboxes);
11232    let clip_seen = precompute_clip_seen(list);
11233
11234    // OVERLAP padding to match `render_banded_to_sink`. See the comment in
11235    // `render_region_prepared` for why this is required for tiny-skia
11236    // mask-rasterization parity with the page renderer.
11237    const OVERLAP: u32 = 6;
11238    let render_h = pixel_h + 2 * OVERLAP;
11239
11240    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
11241    pixmap.fill(Color::TRANSPARENT);
11242
11243    let cmyk_buf = if has_overprint_elements(list)
11244        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
11245        || has_cmyk_group(list)
11246    {
11247        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
11248    } else {
11249        None
11250    };
11251
11252    let mut state = BandState {
11253        clip_region: None,
11254        spare_mask: None,
11255        clip_mask_cache: HashMap::new(),
11256        clip_mask_seen: clip_seen,
11257        mask_pool: Vec::new(),
11258        cmyk_buffer: cmyk_buf,
11259        op_bg_snapshot: None,
11260        op_touched: None,
11261        spot_mask: None,
11262    };
11263
11264    let elements = list.elements();
11265    let vp_x_f = vp_x as f32;
11266    let vp_y_f = vp_y as f32;
11267    let sx = scale_x as f32;
11268    let sy = scale_y as f32;
11269    let vp_x_max = vp_x + vp_w;
11270    let vp_y_max = vp_y + vp_h;
11271
11272    for epoch in &epochs {
11273        // Epoch-level culling
11274        if !epoch.has_erase_page {
11275            match epoch.paint_bbox {
11276                Some(ref pb)
11277                    if pb.x_max <= vp_x
11278                        || pb.x_min >= vp_x_max
11279                        || pb.y_max <= vp_y
11280                        || pb.y_min >= vp_y_max =>
11281                {
11282                    continue;
11283                }
11284                None => continue,
11285                _ => {}
11286            }
11287        }
11288
11289        for i in epoch.start_idx..epoch.end_idx {
11290            // OcgGroups with Clip/InitClip must always be processed — see
11291            // render_region_prepared for the rationale.
11292            let force_process = matches!(
11293                &elements[i],
11294                DisplayElement::OcgGroup { elements: inner, .. }
11295                    if contains_clip_op(inner)
11296            );
11297            // Element-level culling
11298            if !force_process
11299                && let Some(ref bbox) = bboxes[i]
11300                && (bbox.x_max <= vp_x
11301                    || bbox.x_min >= vp_x_max
11302                    || bbox.y_max <= vp_y
11303                    || bbox.y_min >= vp_y_max)
11304            {
11305                continue;
11306            }
11307            let ctx = RenderContext {
11308                vp_x: vp_x_f,
11309                vp_y: vp_y_f,
11310                scale_x: sx,
11311                scale_y: sy,
11312                out_w: pixel_w,
11313                out_h: render_h,
11314                effective_dpi,
11315                icc,
11316                image_cache,
11317                preprocessed: None,
11318                elem_idx: i,
11319                no_aa,
11320                opm_zero_transparent: false,
11321                knockout_painter_pass: KnockoutPainterPass::None,
11322                parent_group_isolated: false,
11323                alpha_extraction_pass: false,
11324                layer_set: &layer_set,
11325            };
11326            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
11327        }
11328    }
11329
11330    composite_onto_white(pixmap.data_mut());
11331    // Extract only the requested pixel_h rows (skip OVERLAP padding).
11332    let row_bytes = pixel_w as usize * 4;
11333    let end = pixel_h as usize * row_bytes;
11334    pixmap.data()[..end].to_vec()
11335}
11336/// Copy a rectangular region from parent pixmap into a smaller crop pixmap.
11337fn copy_backdrop_crop(
11338    parent: &Pixmap,
11339    crop_x: i32,
11340    crop_y: i32,
11341    crop_w: u32,
11342    crop_h: u32,
11343) -> Vec<u8> {
11344    let pw = parent.width() as usize;
11345    let src = parent.data();
11346    let cw = crop_w as usize;
11347    let ch = crop_h as usize;
11348    let cx = crop_x as usize;
11349    let cy = crop_y as usize;
11350    let mut backdrop = vec![0u8; cw * ch * 4];
11351    for row in 0..ch {
11352        let src_off = ((cy + row) * pw + cx) * 4;
11353        let dst_off = row * cw * 4;
11354        backdrop[dst_off..dst_off + cw * 4].copy_from_slice(&src[src_off..src_off + cw * 4]);
11355    }
11356    backdrop
11357}
11358// ---- Shading rendering ----
11359
11360/// Sutherland-Hodgman polygon clipping against a half-plane.
11361/// Keeps the side where `nx*(x-px) + ny*(y-py) >= 0`.
11362fn clip_polygon_halfplane(
11363    poly: &[(f32, f32)],
11364    nx: f32,
11365    ny: f32,
11366    px: f32,
11367    py: f32,
11368) -> Vec<(f32, f32)> {
11369    if poly.is_empty() {
11370        return vec![];
11371    }
11372    let dot = |x: f32, y: f32| nx * (x - px) + ny * (y - py);
11373    let mut out = Vec::with_capacity(poly.len() + 1);
11374    let n = poly.len();
11375    for i in 0..n {
11376        let (ax, ay) = poly[i];
11377        let (bx, by) = poly[(i + 1) % n];
11378        let da = dot(ax, ay);
11379        let db = dot(bx, by);
11380        if da >= 0.0 {
11381            out.push((ax, ay));
11382        }
11383        if (da >= 0.0) != (db >= 0.0) {
11384            // Edge crosses the clipping line — compute intersection
11385            let t = da / (da - db);
11386            out.push((ax + t * (bx - ax), ay + t * (by - ay)));
11387        }
11388    }
11389    out
11390}
11391
11392/// Render an axial (linear) gradient shading.
11393#[allow(clippy::too_many_arguments)]
11394fn render_axial_shading(
11395    pixmap: &mut Pixmap,
11396    params: &AxialShadingParams,
11397    vp_x: f32,
11398    vp_y: f32,
11399    scale_x: f32,
11400    scale_y: f32,
11401    clip_mask: Option<&Mask>,
11402    no_aa: bool,
11403    cmyk_buf: Option<&mut [f32]>,
11404    icc: Option<&IccCache>,
11405) {
11406    let pw = pixmap.width();
11407    let ph = pixmap.height();
11408    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
11409        return;
11410    }
11411
11412    let (mut rx_min, mut ry_min, mut rx_max, mut ry_max) = if let Some(bbox) = &params.bbox {
11413        let corners = [
11414            params.ctm.transform_point(bbox[0], bbox[1]),
11415            params.ctm.transform_point(bbox[2], bbox[1]),
11416            params.ctm.transform_point(bbox[0], bbox[3]),
11417            params.ctm.transform_point(bbox[2], bbox[3]),
11418        ];
11419        let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
11420        let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
11421        let x_max = corners
11422            .iter()
11423            .map(|c| c.0)
11424            .fold(f64::NEG_INFINITY, f64::max);
11425        let y_max = corners
11426            .iter()
11427            .map(|c| c.1)
11428            .fold(f64::NEG_INFINITY, f64::max);
11429        (
11430            ((x_min as f32 - vp_x) * scale_x).max(0.0),
11431            ((y_min as f32 - vp_y) * scale_y).max(0.0),
11432            ((x_max as f32 - vp_x) * scale_x).min(pw as f32),
11433            ((y_max as f32 - vp_y) * scale_y).min(ph as f32),
11434        )
11435    } else {
11436        (0.0, 0.0, pw as f32, ph as f32)
11437    };
11438
11439    if rx_max <= rx_min || ry_max <= ry_min {
11440        return;
11441    }
11442
11443    // Transform endpoints to device space for perpendicular clipping
11444    let (dx0, dy0) = params.ctm.transform_point(params.x0, params.y0);
11445    let (dx1, dy1) = params.ctm.transform_point(params.x1, params.y1);
11446
11447    // When extend is false on a side, clip the fill area along a line
11448    // perpendicular to the gradient axis through that endpoint. For diagonal
11449    // gradients this produces a diagonal cutoff (not axis-aligned).
11450    let needs_perpendicular_clip = (!params.extend_start || !params.extend_end) && {
11451        let axis_x = dx1 - dx0;
11452        let axis_y = dy1 - dy0;
11453        axis_x.abs() > 1e-6 && axis_y.abs() > 1e-6
11454    };
11455
11456    // Detect rotated BBox: if CTM has rotation components (b or c non-zero),
11457    // the BBox is not axis-aligned in device space and needs proper polygon clipping.
11458    let bbox_is_rotated =
11459        params.bbox.is_some() && (params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10);
11460
11461    if needs_perpendicular_clip {
11462        // Diagonal gradient with non-extended side — fall back to tiny-skia
11463        // for Sutherland-Hodgman polygon clipping.
11464        let stops = build_gradient_stops(&params.color_stops);
11465        if stops.is_empty() {
11466            return;
11467        }
11468        let start = stet_tiny_skia::Point::from_xy(params.x0 as f32, params.y0 as f32);
11469        let end = stet_tiny_skia::Point::from_xy(params.x1 as f32, params.y1 as f32);
11470        let gradient_transform =
11471            viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
11472        let Some(gradient) = stet_tiny_skia::LinearGradient::new(
11473            start,
11474            end,
11475            stops,
11476            stet_tiny_skia::SpreadMode::Pad,
11477            gradient_transform,
11478        ) else {
11479            return;
11480        };
11481        let paint = Paint {
11482            shader: gradient,
11483            anti_alias: !no_aa,
11484            ..Paint::default()
11485        };
11486
11487        // Use rotated BBox polygon when CTM has rotation, otherwise axis-aligned rect
11488        let mut poly: Vec<(f32, f32)> = if bbox_is_rotated {
11489            let bbox = params.bbox.as_ref().unwrap();
11490            let corners = [
11491                params.ctm.transform_point(bbox[0], bbox[1]),
11492                params.ctm.transform_point(bbox[2], bbox[1]),
11493                params.ctm.transform_point(bbox[2], bbox[3]),
11494                params.ctm.transform_point(bbox[0], bbox[3]),
11495            ];
11496            corners
11497                .iter()
11498                .map(|(x, y)| ((*x as f32 - vp_x) * scale_x, (*y as f32 - vp_y) * scale_y))
11499                .collect()
11500        } else {
11501            vec![
11502                (rx_min, ry_min),
11503                (rx_max, ry_min),
11504                (rx_max, ry_max),
11505                (rx_min, ry_max),
11506            ]
11507        };
11508        let ax = (dx1 - dx0) as f32 * scale_x;
11509        let ay = (dy1 - dy0) as f32 * scale_y;
11510        if !params.extend_start {
11511            let px = (dx0 as f32 - vp_x) * scale_x;
11512            let py = (dy0 as f32 - vp_y) * scale_y;
11513            poly = clip_polygon_halfplane(&poly, ax, ay, px, py);
11514        }
11515        if !params.extend_end {
11516            let px = (dx1 as f32 - vp_x) * scale_x;
11517            let py = (dy1 as f32 - vp_y) * scale_y;
11518            poly = clip_polygon_halfplane(&poly, -ax, -ay, px, py);
11519        }
11520        if poly.len() >= 3 {
11521            let mut pb = PathBuilder::new();
11522            pb.move_to(poly[0].0, poly[0].1);
11523            for &(x, y) in &poly[1..] {
11524                pb.line_to(x, y);
11525            }
11526            pb.close();
11527            if let Some(path) = pb.finish() {
11528                pixmap.fill_path(
11529                    &path,
11530                    &paint,
11531                    SkiaFillRule::Winding,
11532                    Transform::identity(),
11533                    clip_mask,
11534                );
11535            }
11536        }
11537    } else {
11538        // Common case: axis-aligned or both sides extended — direct rasterization.
11539        // Clip fill rect to gradient extent when sides aren't extended.
11540        if !params.extend_start || !params.extend_end {
11541            let axis_x = dx1 - dx0;
11542            let axis_y = dy1 - dy0;
11543            let gx0 = (dx0 as f32 - vp_x) * scale_x;
11544            let gy0 = (dy0 as f32 - vp_y) * scale_y;
11545            let gx1 = (dx1 as f32 - vp_x) * scale_x;
11546            let gy1 = (dy1 as f32 - vp_y) * scale_y;
11547
11548            if axis_x.abs() >= axis_y.abs() {
11549                if !params.extend_start {
11550                    if axis_x >= 0.0 {
11551                        rx_min = rx_min.max(gx0);
11552                    } else {
11553                        rx_max = rx_max.min(gx0);
11554                    }
11555                }
11556                if !params.extend_end {
11557                    if axis_x >= 0.0 {
11558                        rx_max = rx_max.min(gx1);
11559                    } else {
11560                        rx_min = rx_min.max(gx1);
11561                    }
11562                }
11563            } else {
11564                if !params.extend_start {
11565                    if axis_y >= 0.0 {
11566                        ry_min = ry_min.max(gy0);
11567                    } else {
11568                        ry_max = ry_max.min(gy0);
11569                    }
11570                }
11571                if !params.extend_end {
11572                    if axis_y >= 0.0 {
11573                        ry_max = ry_max.min(gy1);
11574                    } else {
11575                        ry_min = ry_min.max(gy1);
11576                    }
11577                }
11578            }
11579            if rx_max <= rx_min || ry_max <= ry_min {
11580                return;
11581            }
11582        }
11583
11584        // Compute gradient axis in shading space.
11585        let ax = params.x1 - params.x0;
11586        let ay = params.y1 - params.y0;
11587        let axis_sq = ax * ax + ay * ay;
11588        if axis_sq < 1e-20 {
11589            return;
11590        }
11591
11592        // Size the LUT to the gradient's pixel span so each entry covers ≤1 pixel.
11593        // This ensures nearest-neighbor lookup produces pixel-perfect sharp edges
11594        // at stitching function discontinuities without banding in smooth gradients.
11595        let pixel_dx = (dx1 - dx0) * scale_x as f64;
11596        let pixel_dy = (dy1 - dy0) * scale_y as f64;
11597        let pixel_axis_len = (pixel_dx * pixel_dx + pixel_dy * pixel_dy).sqrt();
11598        let lut_size = (pixel_axis_len as usize)
11599            .max(params.color_stops.len())
11600            .max(256)
11601            .min(16384);
11602        let lut = build_gradient_lut(&params.color_stops, lut_size);
11603
11604        let Some(inv) = params.ctm.invert() else {
11605            return;
11606        };
11607        let inv_sx = 1.0 / scale_x as f64;
11608        let inv_sy = 1.0 / scale_y as f64;
11609        let dev_origin_x = vp_x as f64;
11610        let dev_origin_y = vp_y as f64;
11611
11612        // Shading-space coords as linear function of pixel coords:
11613        //   sx = sx_base + dsx_dx * px + dsx_dy * py
11614        //   sy = sy_base + dsy_dx * px + dsy_dy * py
11615        let sx_base = inv.a * dev_origin_x + inv.c * dev_origin_y + inv.tx;
11616        let sy_base = inv.b * dev_origin_x + inv.d * dev_origin_y + inv.ty;
11617        let dsx_dx = inv.a * inv_sx;
11618        let dsx_dy = inv.c * inv_sy;
11619        let dsy_dx = inv.b * inv_sx;
11620        let dsy_dy = inv.d * inv_sy;
11621
11622        // t = dot(P_shading - P0, axis) / dot(axis, axis)
11623        let inv_axis_sq = 1.0 / axis_sq;
11624        let t_origin = ((sx_base - params.x0) * ax + (sy_base - params.y0) * ay) * inv_axis_sq;
11625        let dt_dx = (dsx_dx * ax + dsy_dx * ay) * inv_axis_sq;
11626        let dt_dy = (dsx_dy * ax + dsy_dy * ay) * inv_axis_sq;
11627
11628        // Per-pixel rotated BBox clipping: reuse inverse CTM to map each pixel
11629        // back to shading space and check against the original BBox.
11630        let bbox_pixel_clip = if bbox_is_rotated {
11631            let bbox = params.bbox.as_ref().unwrap();
11632            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11633            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11634            Some((
11635                dsx_dx, dsx_dy, sx_base, dsy_dx, dsy_dy, sy_base, bx0, by0, bx1, by1,
11636            ))
11637        } else {
11638            None
11639        };
11640
11641        let ix_min = rx_min.floor() as u32;
11642        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11643        let iy_min = ry_min.floor() as u32;
11644        let iy_max = ry_max.ceil().min(ph as f32) as u32;
11645
11646        let stride = pw as usize * 4;
11647        let data = pixmap.data_mut();
11648        let mask_data = clip_mask.map(|m| m.data());
11649        let alpha = (params.alpha.clamp(0.0, 1.0) * 255.0 + 0.5) as u16;
11650
11651        for py in iy_min..iy_max {
11652            let t_row = t_origin + dt_dy * py as f64;
11653            let row_offset = py as usize * stride;
11654
11655            // Precompute row-base values for rotated BBox check
11656            let (ux_row, uy_row) =
11657                if let Some((_, dux_dy, ux_base, _, duy_dy, uy_base, ..)) = &bbox_pixel_clip {
11658                    (ux_base + dux_dy * py as f64, uy_base + duy_dy * py as f64)
11659                } else {
11660                    (0.0, 0.0)
11661                };
11662
11663            for px in ix_min..ix_max {
11664                // Check clip mask
11665                if let Some(md) = mask_data {
11666                    if md[py as usize * pw as usize + px as usize] == 0 {
11667                        continue;
11668                    }
11669                }
11670
11671                // Per-pixel rotated BBox clip
11672                if let Some((dux_dx, _, _, duy_dx, _, _, bx0, by0, bx1, by1)) = &bbox_pixel_clip {
11673                    let ux = ux_row + dux_dx * px as f64;
11674                    let uy = uy_row + duy_dx * px as f64;
11675                    if ux < *bx0 || ux > *bx1 || uy < *by0 || uy > *by1 {
11676                        continue;
11677                    }
11678                }
11679
11680                let t = t_row + dt_dx * px as f64;
11681                let t_clamped = t.clamp(0.0, 1.0);
11682                let idx = (t_clamped * (lut_size - 1) as f64 + 0.5) as usize;
11683                let [r, g, b, _] = lut[idx.min(lut_size - 1)];
11684
11685                let offset = row_offset + px as usize * 4;
11686                if alpha >= 255 {
11687                    data[offset] = r;
11688                    data[offset + 1] = g;
11689                    data[offset + 2] = b;
11690                    data[offset + 3] = 255;
11691                } else {
11692                    // Alpha blend: premultiply and composite over existing pixel
11693                    let a = alpha as u16;
11694                    let inv_a = 255 - a;
11695                    data[offset] = ((r as u16 * a + data[offset] as u16 * inv_a + 127) / 255) as u8;
11696                    data[offset + 1] =
11697                        ((g as u16 * a + data[offset + 1] as u16 * inv_a + 127) / 255) as u8;
11698                    data[offset + 2] =
11699                        ((b as u16 * a + data[offset + 2] as u16 * inv_a + 127) / 255) as u8;
11700                    data[offset + 3] = ((a + data[offset + 3] as u16 * inv_a / 255).min(255)) as u8;
11701                }
11702            }
11703        }
11704    }
11705
11706    // Update CMYK tracking buffer for axial shading
11707    if let Some(buf) = cmyk_buf {
11708        let pw = pixmap.width();
11709        let inv_sx = 1.0 / scale_x as f64;
11710        let inv_sy = 1.0 / scale_y as f64;
11711        let axis_x = params.x1 - params.x0;
11712        let axis_y = params.y1 - params.y0;
11713        let axis_len_sq = axis_x * axis_x + axis_y * axis_y;
11714        let Some(inv_ctm) = params.ctm.invert() else {
11715            return;
11716        };
11717
11718        let iy_min = ry_min.floor() as u32;
11719        let iy_max = ry_max.ceil().min(pixmap.height() as f32) as u32;
11720        let ix_min = rx_min.floor() as u32;
11721        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11722
11723        for py in iy_min..iy_max {
11724            let dev_y = py as f64 * inv_sy + vp_y as f64;
11725            for px in ix_min..ix_max {
11726                let dev_x = px as f64 * inv_sx + vp_x as f64;
11727                let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11728                let t = if axis_len_sq > 1e-10 {
11729                    ((ux - params.x0) * axis_x + (uy - params.y0) * axis_y) / axis_len_sq
11730                } else {
11731                    0.0
11732                };
11733                if t < 0.0 && !params.extend_start {
11734                    continue;
11735                }
11736                if t > 1.0 && !params.extend_end {
11737                    continue;
11738                }
11739                let clamped = t.clamp(0.0, 1.0);
11740
11741                if let Some(mask) = clip_mask {
11742                    let mi = py as usize * pw as usize + px as usize;
11743                    if mask.data()[mi] == 0 {
11744                        continue;
11745                    }
11746                }
11747
11748                let color = interpolate_color_stops(&params.color_stops, clamped);
11749                let cmyk = interpolate_cmyk_from_stops(
11750                    &params.color_stops,
11751                    &params.color_space,
11752                    clamped,
11753                    &color,
11754                    icc,
11755                );
11756                let ci = (py as usize * pw as usize + px as usize) * 4;
11757                if ci + 3 < buf.len() {
11758                    if params.spot_tint_blend && params.overprint {
11759                        // Per PDF spec 11.7.4.5 a Separation/DeviceN gradient
11760                        // only affects the device colorants identified by its
11761                        // color space: plates for NAMED PROCESS colorants are
11762                        // REPLACED with the gradient's CMYK value at this
11763                        // pixel, plates not tied to a named process colorant
11764                        // are PRESERVED.  The LUT-painted pixmap already
11765                        // carries the spot's full ICC-converted color, so:
11766                        //
11767                        // Gated on `overprint` because the LUT pass for
11768                        // non-overprint shadings carries the author-intended
11769                        // blend mode (e.g. 2265.pdf draws each circle wedge
11770                        // twice — Normal then Multiply — and the multiplied
11771                        // pixmap is the wedge's final color).  Recomposing
11772                        // here would overwrite the multiply-darkened result
11773                        // with a single ICC sample of the source CMYK.
11774                        //   * Where the CMYK buffer is empty (fresh paper),
11775                        //     leave the pixmap alone — re-running CMYK→RGB
11776                        //     here would round-trip through the system
11777                        //     profile and produce a perceptibly different
11778                        //     gradient curve (the snowman shading regression
11779                        //     guarded against in the original recompose
11780                        //     branch).  Just record the named-process
11781                        //     contribution to the buffer for later overprint
11782                        //     tracking.
11783                        //   * Where the CMYK buffer has prior values (a
11784                        //     CMYK fill underneath, e.g. a `1 0 1 0.5 k`
11785                        //     checkmark under the strip), the LUT-paint had
11786                        //     wiped that underlying paint from the pixmap.
11787                        //     Recompose the pixmap from the merged CMYK
11788                        //     (REPLACE named, preserve non-named) to restore
11789                        //     the checkmark with the gradient's named-plate
11790                        //     contribution layered on top.
11791                        let cur_c = buf[ci] as f64;
11792                        let cur_m = buf[ci + 1] as f64;
11793                        let cur_y = buf[ci + 2] as f64;
11794                        let cur_k = buf[ci + 3] as f64;
11795                        let cur_is_zero =
11796                            cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
11797                        let named = params.painted_channels;
11798                        if cur_is_zero {
11799                            if named & stet_graphics::device::CMYK_C != 0 {
11800                                buf[ci] = cmyk.0 as f32;
11801                            }
11802                            if named & stet_graphics::device::CMYK_M != 0 {
11803                                buf[ci + 1] = cmyk.1 as f32;
11804                            }
11805                            if named & stet_graphics::device::CMYK_Y != 0 {
11806                                buf[ci + 2] = cmyk.2 as f32;
11807                            }
11808                            if named & stet_graphics::device::CMYK_K != 0 {
11809                                buf[ci + 3] = cmyk.3 as f32;
11810                            }
11811                        } else {
11812                            let new_c = if named & stet_graphics::device::CMYK_C != 0 {
11813                                cmyk.0
11814                            } else {
11815                                cur_c
11816                            };
11817                            let new_m = if named & stet_graphics::device::CMYK_M != 0 {
11818                                cmyk.1
11819                            } else {
11820                                cur_m
11821                            };
11822                            let new_y = if named & stet_graphics::device::CMYK_Y != 0 {
11823                                cmyk.2
11824                            } else {
11825                                cur_y
11826                            };
11827                            let new_k = if named & stet_graphics::device::CMYK_K != 0 {
11828                                cmyk.3
11829                            } else {
11830                                cur_k
11831                            };
11832                            buf[ci] = new_c as f32;
11833                            buf[ci + 1] = new_m as f32;
11834                            buf[ci + 2] = new_y as f32;
11835                            buf[ci + 3] = new_k as f32;
11836                            let (rv, gv, bv) = if let Some(icc_cache) = icc {
11837                                icc_cache
11838                                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
11839                                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
11840                            } else {
11841                                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
11842                            };
11843                            let stride = pixmap.data().len() / pixmap.height() as usize;
11844                            let offset = py as usize * stride + px as usize * 4;
11845                            let data = pixmap.data_mut();
11846                            data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11847                            data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11848                            data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11849                        }
11850                    } else if params.overprint
11851                        && params.painted_channels != stet_graphics::device::CMYK_ALL
11852                    {
11853                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11854                            buf[ci] = cmyk.0 as f32;
11855                        }
11856                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11857                            buf[ci + 1] = cmyk.1 as f32;
11858                        }
11859                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11860                            buf[ci + 2] = cmyk.2 as f32;
11861                        }
11862                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
11863                            buf[ci + 3] = cmyk.3 as f32;
11864                        }
11865                        // Recomposite RGB from merged CMYK via ICC
11866                        let c = buf[ci] as f64;
11867                        let m = buf[ci + 1] as f64;
11868                        let y = buf[ci + 2] as f64;
11869                        let k = buf[ci + 3] as f64;
11870                        let (rv, gv, bv) = if let Some(icc_cache) = icc {
11871                            icc_cache
11872                                .convert_cmyk_readonly(c, m, y, k)
11873                                .unwrap_or_else(|| cmyk_to_rgb_plrm(c, m, y, k))
11874                        } else {
11875                            cmyk_to_rgb_plrm(c, m, y, k)
11876                        };
11877                        let stride = pixmap.data().len() / pixmap.height() as usize;
11878                        let offset = py as usize * stride + px as usize * 4;
11879                        let data = pixmap.data_mut();
11880                        data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11881                        data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11882                        data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11883                    } else {
11884                        // Non-overprint axial shading: write the source CMYK
11885                        // to the buffer for any consumer that needs it (e.g.
11886                        // overprint sibling tracking) but leave the pixmap
11887                        // alone — `build_gradient_lut` already painted the
11888                        // pixel with linearly-interpolated source RGB, and
11889                        // round-tripping CMYK→RGB through the ICC profile
11890                        // produces a different gradient curve (linear in
11891                        // CMYK rather than linear in RGB) that diverges
11892                        // visibly from the LUT result. The CMYK buffer is
11893                        // only consumed by `composite_non_isolated_cmyk`,
11894                        // which excludes shading-containing groups via
11895                        // `group_content_is_native_cmyk`, so the
11896                        // buffer/pixmap mismatch never reaches a consumer
11897                        // that would notice. Reintroducing the round-trip
11898                        // here was the 3000_9 / 3000_10 snowman shading
11899                        // regression in the silly-weaving-bird plan.
11900                        buf[ci] = cmyk.0 as f32;
11901                        buf[ci + 1] = cmyk.1 as f32;
11902                        buf[ci + 2] = cmyk.2 as f32;
11903                        buf[ci + 3] = cmyk.3 as f32;
11904                    }
11905                }
11906            }
11907        }
11908    }
11909}
11910
11911/// Render a radial gradient shading.
11912#[allow(clippy::too_many_arguments)]
11913fn render_radial_shading(
11914    pixmap: &mut Pixmap,
11915    params: &RadialShadingParams,
11916    vp_x: f32,
11917    vp_y: f32,
11918    scale_x: f32,
11919    scale_y: f32,
11920    clip_mask: Option<&Mask>,
11921    _no_aa: bool,
11922    mut cmyk_buf: Option<&mut [f32]>,
11923    icc: Option<&IccCache>,
11924) {
11925    let pw = pixmap.width();
11926    let ph = pixmap.height();
11927    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
11928        return;
11929    }
11930
11931    let Some(inv_ctm) = params.ctm.invert() else {
11932        return;
11933    };
11934
11935    let (px_min, py_min, px_max, py_max) = if let Some(bbox) = &params.bbox {
11936        let corners = [
11937            params.ctm.transform_point(bbox[0], bbox[1]),
11938            params.ctm.transform_point(bbox[2], bbox[1]),
11939            params.ctm.transform_point(bbox[0], bbox[3]),
11940            params.ctm.transform_point(bbox[2], bbox[3]),
11941        ];
11942        let x_min = corners
11943            .iter()
11944            .map(|c| c.0 as f32)
11945            .fold(f32::INFINITY, f32::min);
11946        let y_min = corners
11947            .iter()
11948            .map(|c| c.1 as f32)
11949            .fold(f32::INFINITY, f32::min);
11950        let x_max = corners
11951            .iter()
11952            .map(|c| c.0 as f32)
11953            .fold(f32::NEG_INFINITY, f32::max);
11954        let y_max = corners
11955            .iter()
11956            .map(|c| c.1 as f32)
11957            .fold(f32::NEG_INFINITY, f32::max);
11958        (
11959            ((x_min - vp_x) * scale_x).max(0.0) as u32,
11960            ((y_min - vp_y) * scale_y).max(0.0) as u32,
11961            (((x_max - vp_x) * scale_x).ceil() as u32).min(pw),
11962            (((y_max - vp_y) * scale_y).ceil() as u32).min(ph),
11963        )
11964    } else {
11965        (0, 0, pw, ph)
11966    };
11967
11968    let inv_sx = 1.0 / scale_x as f64;
11969    let inv_sy = 1.0 / scale_y as f64;
11970
11971    // Rotated BBox: check per-pixel user-space containment
11972    let rotated_bbox = if let Some(bbox) = &params.bbox {
11973        if params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10 {
11974            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11975            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11976            Some((bx0, by0, bx1, by1))
11977        } else {
11978            None
11979        }
11980    } else {
11981        None
11982    };
11983
11984    let data = pixmap.data_mut();
11985    let stride = pw as usize * 4;
11986
11987    for py in py_min..py_max {
11988        let dev_y = py as f64 * inv_sy + vp_y as f64;
11989        for px in px_min..px_max {
11990            let dev_x = px as f64 * inv_sx + vp_x as f64;
11991            let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11992
11993            // Per-pixel rotated BBox clip
11994            if let Some((bx0, by0, bx1, by1)) = rotated_bbox {
11995                if ux < bx0 || ux > bx1 || uy < by0 || uy > by1 {
11996                    continue;
11997                }
11998            }
11999
12000            let t = solve_radial_t(
12001                ux,
12002                uy,
12003                params.x0,
12004                params.y0,
12005                params.r0,
12006                params.x1,
12007                params.y1,
12008                params.r1,
12009                params.extend_start,
12010                params.extend_end,
12011            );
12012            if let Some(t) = t {
12013                let clamped = t.clamp(0.0, 1.0);
12014                let color = interpolate_color_stops(&params.color_stops, clamped);
12015
12016                let clipped = clip_mask
12017                    .is_some_and(|mask| mask.data()[py as usize * pw as usize + px as usize] == 0);
12018
12019                if clipped {
12020                    continue;
12021                }
12022
12023                // Decide whether this pixel should use the multiplicative
12024                // ink-stacking blend to preserve a spot backdrop. We mirror
12025                // the rule in `render_overprint_fill`: overprint + subset
12026                // painted channels + buffer effectively empty at this pixel
12027                // means the pixmap carries a non-CMYK contribution (or the
12028                // pixel is fresh), so per-channel ink-stacking gives the
12029                // correct result whether the backdrop was spot-painted or
12030                // plain.
12031                let cmyk = interpolate_cmyk_from_stops(
12032                    &params.color_stops,
12033                    &params.color_space,
12034                    clamped,
12035                    &color,
12036                    icc,
12037                );
12038                let ci = (py as usize * pw as usize + px as usize) * 4;
12039                let buffer_clean = if let Some(ref buf) = cmyk_buf {
12040                    if ci + 3 < buf.len() {
12041                        buf[ci] == 0.0
12042                            && buf[ci + 1] == 0.0
12043                            && buf[ci + 2] == 0.0
12044                            && buf[ci + 3] == 0.0
12045                    } else {
12046                        false
12047                    }
12048                } else {
12049                    false
12050                };
12051                let offset_for_check = py as usize * stride + px as usize * 4;
12052                let pixmap_has_colour = data[offset_for_check + 3] > 0
12053                    && (data[offset_for_check] < 250
12054                        || data[offset_for_check + 1] < 250
12055                        || data[offset_for_check + 2] < 250);
12056                let use_multiplicative = params.overprint
12057                    && params.painted_channels != stet_graphics::device::CMYK_ALL
12058                    && buffer_clean
12059                    && pixmap_has_colour;
12060
12061                // Write CMYK buffer at non-clipped pixels
12062                if let Some(ref mut buf) = cmyk_buf
12063                    && ci + 3 < buf.len()
12064                {
12065                    if params.overprint
12066                        && params.painted_channels != stet_graphics::device::CMYK_ALL
12067                    {
12068                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12069                            buf[ci] = cmyk.0 as f32;
12070                        }
12071                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12072                            buf[ci + 1] = cmyk.1 as f32;
12073                        }
12074                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12075                            buf[ci + 2] = cmyk.2 as f32;
12076                        }
12077                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12078                            buf[ci + 3] = cmyk.3 as f32;
12079                        }
12080                    } else {
12081                        buf[ci] = cmyk.0 as f32;
12082                        buf[ci + 1] = cmyk.1 as f32;
12083                        buf[ci + 2] = cmyk.2 as f32;
12084                        buf[ci + 3] = cmyk.3 as f32;
12085                    }
12086                }
12087
12088                let offset = py as usize * stride + px as usize * 4;
12089                if use_multiplicative {
12090                    // Ink-stack the per-stop CMYK onto the pixmap RGB. Only
12091                    // channels named by painted_channels contribute; others
12092                    // leave the pixmap untouched, so a spot-painted backdrop
12093                    // survives with just the named inks darkening it.
12094                    let bg_r = data[offset] as f64 / 255.0;
12095                    let bg_g = data[offset + 1] as f64 / 255.0;
12096                    let bg_b = data[offset + 2] as f64 / 255.0;
12097                    let over_r = if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12098                        1.0 - cmyk.0
12099                    } else {
12100                        1.0
12101                    };
12102                    let over_g = if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12103                        1.0 - cmyk.1
12104                    } else {
12105                        1.0
12106                    };
12107                    let over_b = if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12108                        1.0 - cmyk.2
12109                    } else {
12110                        1.0
12111                    };
12112                    let k_fac = if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12113                        1.0 - cmyk.3
12114                    } else {
12115                        1.0
12116                    };
12117                    data[offset] = ((bg_r * over_r * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12118                    data[offset + 1] =
12119                        ((bg_g * over_g * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12120                    data[offset + 2] =
12121                        ((bg_b * over_b * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12122                    data[offset + 3] = 255;
12123                } else {
12124                    data[offset] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
12125                    data[offset + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
12126                    data[offset + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
12127                    data[offset + 3] = 255;
12128
12129                    // Recomposite RGB from the CMYK buffer via ICC only for
12130                    // overprint DeviceCMYK shadings on a CMYK-only backdrop,
12131                    // where the per-channel merge in the buffer means the
12132                    // displayed pixel must reflect the merged CMYK rather
12133                    // than the source's RGB. For non-overprint shadings the
12134                    // LUT-rendered pixmap (above) is already correct, and
12135                    // round-tripping CMYK→RGB through the ICC profile
12136                    // produces a different gradient curve (linear in CMYK
12137                    // rather than linear in RGB) — that drift was the
12138                    // 3000_9 / 3000_10 snowman shading regression. The CMYK
12139                    // buffer is only consumed by `composite_non_isolated_cmyk`,
12140                    // which excludes shading-containing groups via
12141                    // `group_content_is_native_cmyk`, so the buffer/pixmap
12142                    // mismatch never reaches a consumer that would notice.
12143                    if params.overprint
12144                        && params.painted_channels != stet_graphics::device::CMYK_ALL
12145                        && matches!(
12146                            params.color_space,
12147                            ShadingColorSpace::DeviceCMYK
12148                                | ShadingColorSpace::Separation { .. }
12149                                | ShadingColorSpace::DeviceN { .. }
12150                        )
12151                        && let Some(ref mut buf) = cmyk_buf
12152                        && ci + 3 < buf.len()
12153                        && let Some(icc_cache) = icc
12154                    {
12155                        let c = buf[ci] as f64;
12156                        let m = buf[ci + 1] as f64;
12157                        let y = buf[ci + 2] as f64;
12158                        let k = buf[ci + 3] as f64;
12159                        if let Some((r, g, b)) = icc_cache.convert_cmyk_readonly(c, m, y, k) {
12160                            data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
12161                            data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
12162                            data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
12163                        }
12164                    }
12165                }
12166            }
12167        }
12168    }
12169}
12170/// Solve for the parameter t of a two-circle radial gradient at point (px, py).
12171///
12172/// Returns the largest root of the circle equation that falls within the valid
12173/// domain and has R(t) >= 0. The valid domain is [0,1], extended by extend flags.
12174#[allow(clippy::too_many_arguments)]
12175fn solve_radial_t(
12176    px: f64,
12177    py: f64,
12178    x0: f64,
12179    y0: f64,
12180    r0: f64,
12181    x1: f64,
12182    y1: f64,
12183    r1: f64,
12184    extend_start: bool,
12185    extend_end: bool,
12186) -> Option<f64> {
12187    // Parametric: C(t) = (1-t)*C0 + t*C1, R(t) = (1-t)*r0 + t*r1
12188    // Solve: (px - Cx(t))^2 + (py - Cy(t))^2 = R(t)^2
12189    let cdx = x1 - x0;
12190    let cdy = y1 - y0;
12191    let dr = r1 - r0;
12192
12193    let a = cdx * cdx + cdy * cdy - dr * dr;
12194    let dpx = px - x0;
12195    let dpy = py - y0;
12196    let b = -2.0 * (dpx * cdx + dpy * cdy + r0 * dr);
12197    let c = dpx * dpx + dpy * dpy - r0 * r0;
12198
12199    // Helper: check if a root is in the valid domain
12200    let in_domain = |t: f64| -> bool {
12201        (0.0..=1.0).contains(&t) || (t < 0.0 && extend_start) || (t > 1.0 && extend_end)
12202    };
12203
12204    if a.abs() < 1e-10 {
12205        // Linear case
12206        if b.abs() < 1e-10 {
12207            return None;
12208        }
12209        let t = -c / b;
12210        let radius = r0 + t * dr;
12211        if radius >= 0.0 && in_domain(t) {
12212            return Some(t);
12213        }
12214        return None;
12215    }
12216
12217    let discriminant = b * b - 4.0 * a * c;
12218    if discriminant < 0.0 {
12219        return None;
12220    }
12221    let sqrt_d = discriminant.sqrt();
12222    let t1 = (-b + sqrt_d) / (2.0 * a);
12223    let t2 = (-b - sqrt_d) / (2.0 * a);
12224
12225    // Pick the largest root that is in the valid domain and has R(t) >= 0
12226    let mut best: Option<f64> = None;
12227    for t in [t1, t2] {
12228        let radius = r0 + t * dr;
12229        if radius >= 0.0 && in_domain(t) {
12230            best = Some(match best {
12231                Some(prev) => prev.max(t),
12232                None => t,
12233            });
12234        }
12235    }
12236    best
12237}
12238
12239/// Render a Gouraud-shaded triangle mesh.
12240#[allow(clippy::too_many_arguments)]
12241fn render_mesh_shading(
12242    pixmap: &mut Pixmap,
12243    params: &MeshShadingParams,
12244    vp_x: f32,
12245    vp_y: f32,
12246    scale_x: f32,
12247    scale_y: f32,
12248    clip_mask: Option<&Mask>,
12249    mut cmyk_buf: Option<&mut [f32]>,
12250    icc: Option<&IccCache>,
12251) {
12252    let pw = pixmap.width() as usize;
12253    let ph = pixmap.height() as usize;
12254    if pw == 0 || ph == 0 {
12255        return;
12256    }
12257    let data = pixmap.data_mut();
12258    let stride = pw * 4;
12259
12260    let lut = params.color_lut.as_deref();
12261
12262    for tri in &params.triangles {
12263        let (dx0, dy0) = params.ctm.transform_point(tri.v0.x, tri.v0.y);
12264        let (dx1, dy1) = params.ctm.transform_point(tri.v1.x, tri.v1.y);
12265        let (dx2, dy2) = params.ctm.transform_point(tri.v2.x, tri.v2.y);
12266
12267        let x0 = (dx0 as f32 - vp_x) * scale_x;
12268        let y0 = (dy0 as f32 - vp_y) * scale_y;
12269        let x1 = (dx1 as f32 - vp_x) * scale_x;
12270        let y1 = (dy1 as f32 - vp_y) * scale_y;
12271        let x2 = (dx2 as f32 - vp_x) * scale_x;
12272        let y2 = (dy2 as f32 - vp_y) * scale_y;
12273
12274        let min_x = (x0.min(x1).min(x2).floor().max(0.0)) as usize;
12275        let max_x = (x0.max(x1).max(x2).ceil() as usize).min(pw);
12276        let min_y = (y0.min(y1).min(y2).floor().max(0.0)) as usize;
12277        let max_y = (y0.max(y1).max(y2).ceil() as usize).min(ph);
12278
12279        if min_x >= max_x || min_y >= max_y {
12280            continue;
12281        }
12282
12283        let x0 = x0 as f64;
12284        let y0 = y0 as f64;
12285        let x1 = x1 as f64;
12286        let y1 = y1 as f64;
12287        let x2 = x2 as f64;
12288        let y2 = y2 as f64;
12289        // Swap vertices 1 and 2 when the triangle has reversed winding
12290        // (from a CTM with negative determinant, e.g. X- or Y-flip).
12291        // This ensures barycentric coordinates stay positive for interior
12292        // points regardless of the CTM orientation.
12293        let denom = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2);
12294        if denom.abs() < 1e-10 {
12295            continue;
12296        }
12297        let (x1, y1, x2, y2) = if denom < 0.0 {
12298            (x2, y2, x1, y1)
12299        } else {
12300            (x1, y1, x2, y2)
12301        };
12302        let (v1_ref, v2_ref) = if denom < 0.0 {
12303            (&tri.v2, &tri.v1)
12304        } else {
12305            (&tri.v1, &tri.v2)
12306        };
12307        let denom = denom.abs();
12308        let inv_denom = 1.0 / denom;
12309
12310        for py in min_y..max_y {
12311            for px in min_x..max_x {
12312                let pxf = px as f64 + 0.5;
12313                let pyf = py as f64 + 0.5;
12314
12315                let w0 = ((y1 - y2) * (pxf - x2) + (x2 - x1) * (pyf - y2)) * inv_denom;
12316                let w1 = ((y2 - y0) * (pxf - x2) + (x0 - x2) * (pyf - y2)) * inv_denom;
12317                let w2 = 1.0 - w0 - w1;
12318
12319                if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
12320                    continue;
12321                }
12322
12323                let clipped = clip_mask.is_some_and(|mask| mask.data()[py * pw + px] == 0);
12324
12325                let w0c = w0.max(0.0);
12326                let w1c = w1.max(0.0);
12327                let w2c = w2.max(0.0);
12328                let wsum = w0c + w1c + w2c;
12329                let w0n = w0c / wsum;
12330                let w1n = w1c / wsum;
12331                let w2n = w2c / wsum;
12332
12333                // Per-pixel color: either LUT lookup (for function-based meshes)
12334                // or direct Gouraud interpolation of vertex DeviceColors.
12335                let (r, g, b) = if let Some(lut) = lut {
12336                    // Interpolate raw function input values per-pixel
12337                    let raw = w0n * tri.v0.raw_components[0]
12338                        + w1n * v1_ref.raw_components[0]
12339                        + w2n * v2_ref.raw_components[0];
12340                    let raw = raw.clamp(0.0, 1.0);
12341                    // Linear interpolation in the LUT
12342                    let fi = raw * (lut.len() - 1) as f64;
12343                    let i0 = (fi as usize).min(lut.len().saturating_sub(2));
12344                    let frac = fi - i0 as f64;
12345                    let c0 = &lut[i0];
12346                    let c1 = &lut[i0 + 1];
12347                    (
12348                        c0.r + frac * (c1.r - c0.r),
12349                        c0.g + frac * (c1.g - c0.g),
12350                        c0.b + frac * (c1.b - c0.b),
12351                    )
12352                } else {
12353                    (
12354                        w0n * tri.v0.color.r + w1n * v1_ref.color.r + w2n * v2_ref.color.r,
12355                        w0n * tri.v0.color.g + w1n * v1_ref.color.g + w2n * v2_ref.color.g,
12356                        w0n * tri.v0.color.b + w1n * v1_ref.color.b + w2n * v2_ref.color.b,
12357                    )
12358                };
12359
12360                // Write CMYK buffer
12361                if let Some(ref mut buf) = cmyk_buf {
12362                    let ci = (py * pw + px) * 4;
12363                    if ci + 3 < buf.len() {
12364                        let cmyk = interpolate_cmyk_from_vertices(
12365                            &tri.v0,
12366                            v1_ref,
12367                            v2_ref,
12368                            w0n,
12369                            w1n,
12370                            w2n,
12371                            &params.color_space,
12372                            r,
12373                            g,
12374                            b,
12375                            icc,
12376                        );
12377                        if params.overprint
12378                            && params.painted_channels != stet_graphics::device::CMYK_ALL
12379                        {
12380                            if !clipped {
12381                                if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12382                                    buf[ci] = cmyk.0 as f32;
12383                                }
12384                                if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12385                                    buf[ci + 1] = cmyk.1 as f32;
12386                                }
12387                                if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12388                                    buf[ci + 2] = cmyk.2 as f32;
12389                                }
12390                                if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12391                                    buf[ci + 3] = cmyk.3 as f32;
12392                                }
12393                            }
12394                        } else {
12395                            buf[ci] = cmyk.0 as f32;
12396                            buf[ci + 1] = cmyk.1 as f32;
12397                            buf[ci + 2] = cmyk.2 as f32;
12398                            buf[ci + 3] = cmyk.3 as f32;
12399                        }
12400                    }
12401                }
12402
12403                if clipped {
12404                    continue;
12405                }
12406
12407                let offset = py * stride + px * 4;
12408                data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
12409                data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
12410                data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
12411                data[offset + 3] = 255;
12412            }
12413        }
12414    }
12415}
12416
12417/// The pixel rectangle a shading is being painted into, in the form needed to
12418/// decide whether a piece of geometry can reach it.
12419///
12420/// [`render_mesh_shading()`] maps a device-space point to pixel space as
12421/// `(d as f32 - vp) * scale` and skips any triangle whose pixel bounding box
12422/// misses `[0, w) x [0, h)`. [`ShadingCull::rejects()`] applies that same test
12423/// to a bounding box using the same arithmetic, so anything it rejects is
12424/// something `render_mesh_shading` would also have rejected — rejecting it
12425/// earlier avoids building the triangles rather than changing what is painted.
12426#[derive(Clone, Copy)]
12427struct ShadingCull {
12428    ctm: Matrix,
12429    vp_x: f32,
12430    vp_y: f32,
12431    scale_x: f32,
12432    scale_y: f32,
12433    w: f32,
12434    h: f32,
12435}
12436
12437impl ShadingCull {
12438    /// `None` when the target is empty or the scale is non-positive, in which
12439    /// case the comparisons in `rejects` would not be order-preserving.
12440    fn new(
12441        ctm: Matrix,
12442        vp_x: f32,
12443        vp_y: f32,
12444        scale_x: f32,
12445        scale_y: f32,
12446        w: u32,
12447        h: u32,
12448    ) -> Option<Self> {
12449        (scale_x > 0.0 && scale_y > 0.0 && w > 0 && h > 0).then_some(Self {
12450            ctm,
12451            vp_x,
12452            vp_y,
12453            scale_x,
12454            scale_y,
12455            w: w as f32,
12456            h: h as f32,
12457        })
12458    }
12459
12460    /// True when a device-space bounding box cannot cover any pixel of the
12461    /// target. Conversion to `f32` is monotonic and the scale is positive, so
12462    /// the projected bounds still bracket those of every point inside the box.
12463    fn rejects(&self, x_min: f64, y_min: f64, x_max: f64, y_max: f64) -> bool {
12464        let px_min = (x_min as f32 - self.vp_x) * self.scale_x;
12465        let px_max = (x_max as f32 - self.vp_x) * self.scale_x;
12466        let py_min = (y_min as f32 - self.vp_y) * self.scale_y;
12467        let py_max = (y_max as f32 - self.vp_y) * self.scale_y;
12468        px_max <= 0.0 || px_min >= self.w || py_max <= 0.0 || py_min >= self.h
12469    }
12470
12471    /// True when a triangle with these three device-space vertices cannot
12472    /// cover any pixel of the target.
12473    fn rejects_triangle(&self, p0: (f64, f64), p1: (f64, f64), p2: (f64, f64)) -> bool {
12474        self.rejects(
12475            p0.0.min(p1.0).min(p2.0),
12476            p0.1.min(p1.1).min(p2.1),
12477            p0.0.max(p1.0).max(p2.0),
12478            p0.1.max(p1.1).max(p2.1),
12479        )
12480    }
12481}
12482
12483/// One axis of the Coons-to-tensor conversion. See [`coons_tensor_net()`].
12484///
12485/// `c0`/`c2` are the u-direction Bezier coefficients of the two curves running
12486/// along u, `d0`/`d1` the v-direction coefficients of the two running along v,
12487/// and `corners` is `[p00, p10, p01, p11]`. The result is indexed
12488/// `[j * 4 + i]`, `i` stepping along u and `j` along v.
12489fn coons_tensor_axis(
12490    c0: [f64; 4],
12491    c2: [f64; 4],
12492    d0: [f64; 4],
12493    d1: [f64; 4],
12494    corners: [f64; 4],
12495) -> [f64; 16] {
12496    // Cubic Bernstein coefficients of the linear factors (1 - t) and t, which
12497    // is what degree-elevating the Coons blend weights to bicubic produces.
12498    const A: [f64; 4] = [1.0, 2.0 / 3.0, 1.0 / 3.0, 0.0];
12499    const B: [f64; 4] = [0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0];
12500    let [p00, p10, p01, p11] = corners;
12501    let mut out = [0.0; 16];
12502    for j in 0..4 {
12503        for i in 0..4 {
12504            let bilinear =
12505                A[i] * A[j] * p00 + B[i] * A[j] * p10 + A[i] * B[j] * p01 + B[i] * B[j] * p11;
12506            out[j * 4 + i] = A[j] * c0[i] + B[j] * c2[i] + A[i] * d0[j] + B[i] * d1[j] - bilinear;
12507        }
12508    }
12509    out
12510}
12511
12512/// The 16 tensor-product Bernstein coefficients of a Type 6 Coons patch.
12513///
12514/// A Coons patch is `S(u,v) = c(u,v) + d(u,v) - B(u,v)`, and the `- B` term
12515/// carries negative weight, so the surface is **not** confined to the convex
12516/// hull of the 12 boundary control points — it can bulge outside them. Written
12517/// in the bicubic Bernstein basis, though, the weights are non-negative and
12518/// sum to one, so the surface does lie in the hull of these 16 coefficients.
12519///
12520/// The index and direction conventions match [`eval_coons_patch()`], and
12521/// `coons_tensor_net_matches_coons_evaluation` checks the two agree.
12522///
12523/// Panics if `pts` holds fewer than 12 points.
12524fn coons_tensor_net(pts: &[(f64, f64)]) -> [(f64, f64); 16] {
12525    let c0 = [pts[0], pts[1], pts[2], pts[3]];
12526    // Side 2 runs u: 1 -> 0, so reverse it to share the u parameter with c0.
12527    let c2 = [pts[9], pts[8], pts[7], pts[6]];
12528    let d0 = [pts[0], pts[11], pts[10], pts[9]];
12529    let d1 = [pts[3], pts[4], pts[5], pts[6]];
12530    let corners = [pts[0], pts[3], pts[9], pts[6]];
12531
12532    let xs = coons_tensor_axis(
12533        c0.map(|p| p.0),
12534        c2.map(|p| p.0),
12535        d0.map(|p| p.0),
12536        d1.map(|p| p.0),
12537        corners.map(|p| p.0),
12538    );
12539    let ys = coons_tensor_axis(
12540        c0.map(|p| p.1),
12541        c2.map(|p| p.1),
12542        d0.map(|p| p.1),
12543        d1.map(|p| p.1),
12544        corners.map(|p| p.1),
12545    );
12546    std::array::from_fn(|k| (xs[k], ys[k]))
12547}
12548
12549/// Device-space bounding box of a patch's control net, guaranteed to contain
12550/// every point of the patch surface.
12551///
12552/// A Type 7 tensor patch is a bicubic Bernstein surface, so it lies in the
12553/// convex hull of its 16 control points. A Type 6 Coons patch is converted to
12554/// its equivalent tensor net first — see [`coons_tensor_net()`] for why its
12555/// own 12 points are not a bound. The `>= 16` split matches the one
12556/// [`subdivide_patch_to_triangles()`] uses to pick an evaluator.
12557fn patch_hull_bbox(
12558    patch: &stet_graphics::device::ShadingPatch,
12559    ctm: &Matrix,
12560) -> Option<(f64, f64, f64, f64)> {
12561    if patch.points.len() < 12 {
12562        return None;
12563    }
12564    let tensor_net;
12565    let net: &[(f64, f64)] = if patch.points.len() >= 16 {
12566        &patch.points[..16]
12567    } else {
12568        tensor_net = coons_tensor_net(&patch.points);
12569        &tensor_net
12570    };
12571    let mut x_min = f64::INFINITY;
12572    let mut y_min = f64::INFINITY;
12573    let mut x_max = f64::NEG_INFINITY;
12574    let mut y_max = f64::NEG_INFINITY;
12575    for &(px, py) in net {
12576        let (dx, dy) = ctm.transform_point(px, py);
12577        x_min = x_min.min(dx);
12578        y_min = y_min.min(dy);
12579        x_max = x_max.max(dx);
12580        y_max = y_max.max(dy);
12581    }
12582    x_min.is_finite().then_some((x_min, y_min, x_max, y_max))
12583}
12584
12585/// Render a Coons/tensor-product patch mesh by subdividing into triangles.
12586#[allow(clippy::too_many_arguments)]
12587fn render_patch_shading(
12588    pixmap: &mut Pixmap,
12589    params: &PatchShadingParams,
12590    vp_x: f32,
12591    vp_y: f32,
12592    scale_x: f32,
12593    scale_y: f32,
12594    clip_mask: Option<&Mask>,
12595    cmyk_buf: Option<&mut [f32]>,
12596    icc: Option<&IccCache>,
12597) {
12598    let mut triangles = Vec::new();
12599    let scale = scale_x.max(scale_y) as f64;
12600    // Every band renders the whole display list, so without this the patches
12601    // of a page-spanning shading are triangulated once per band, and with
12602    // bands running concurrently that cost scales with the thread count.
12603    let cull = ShadingCull::new(
12604        params.ctm,
12605        vp_x,
12606        vp_y,
12607        scale_x,
12608        scale_y,
12609        pixmap.width(),
12610        pixmap.height(),
12611    );
12612    for patch in &params.patches {
12613        if patch.points.len() >= 12 {
12614            // Compute device-space extent to choose subdivision level
12615            let mut x_min = f64::INFINITY;
12616            let mut y_min = f64::INFINITY;
12617            let mut x_max = f64::NEG_INFINITY;
12618            let mut y_max = f64::NEG_INFINITY;
12619            for &(px, py) in &patch.points {
12620                let (dx, dy) = params.ctm.transform_point(px, py);
12621                x_min = x_min.min(dx);
12622                y_min = y_min.min(dy);
12623                x_max = x_max.max(dx);
12624                y_max = y_max.max(dy);
12625            }
12626            let extent = (x_max - x_min).max(y_max - y_min).abs() * scale;
12627            // Target ~2 device pixels per boundary segment
12628            let n = (extent / 2.0).ceil().clamp(8.0, 64.0) as usize;
12629            // Skip patches that cannot reach the target pixmap. The bbox above
12630            // is over the raw control points and is only a subdivision-level
12631            // heuristic; culling needs a bound that provably contains the
12632            // surface, which is what `patch_hull_bbox` returns.
12633            if let Some(cull) = cull.as_ref()
12634                && let Some((hx_min, hy_min, hx_max, hy_max)) = patch_hull_bbox(patch, &params.ctm)
12635                && cull.rejects(hx_min, hy_min, hx_max, hy_max)
12636            {
12637                continue;
12638            }
12639            // Extract ICC profile hash for per-grid-point color conversion
12640            let icc_profile_hash = match &params.color_space {
12641                stet_graphics::device::ShadingColorSpace::ICCBased { profile_hash, .. } => {
12642                    Some(profile_hash)
12643                }
12644                _ => None,
12645            };
12646            subdivide_patch_to_triangles(
12647                patch,
12648                &mut triangles,
12649                n,
12650                icc_profile_hash,
12651                icc,
12652                cull.as_ref(),
12653            );
12654        }
12655    }
12656    if !triangles.is_empty() {
12657        let mesh_params = MeshShadingParams {
12658            triangles,
12659            ctm: params.ctm,
12660            bbox: params.bbox,
12661            color_space: params.color_space.clone(),
12662            overprint: params.overprint,
12663            overprint_mode: params.overprint_mode,
12664            painted_channels: params.painted_channels,
12665            color_lut: params.color_lut.clone(),
12666            alpha: params.alpha,
12667            blend_mode: params.blend_mode,
12668            alpha_is_shape: params.alpha_is_shape,
12669        };
12670        render_mesh_shading(
12671            pixmap,
12672            &mesh_params,
12673            vp_x,
12674            vp_y,
12675            scale_x,
12676            scale_y,
12677            clip_mask,
12678            cmyk_buf,
12679            icc,
12680        );
12681    }
12682}
12683/// Subdivide a Coons/tensor patch into triangles via grid subdivision.
12684/// Evaluates the patch at NxN points and triangulates the resulting grid.
12685/// When an ICC profile hash and cache are provided, interpolates colors in the
12686/// source ICC color space and converts per-grid-point for accurate rendering.
12687fn subdivide_patch_to_triangles(
12688    patch: &stet_graphics::device::ShadingPatch,
12689    triangles: &mut Vec<stet_graphics::device::ShadingTriangle>,
12690    n: usize,
12691    icc_profile_hash: Option<&stet_graphics::icc::ProfileHash>,
12692    icc_cache: Option<&IccCache>,
12693    cull: Option<&ShadingCull>,
12694) {
12695    // Evaluate patch at grid points.
12696    // Use tensor-product evaluation when 16 control points are available (Type 7),
12697    // otherwise fall back to Coons blending (Type 6, 12 points).
12698    let mut grid: Vec<(f64, f64, DeviceColor, Vec<f64>)> = Vec::with_capacity((n + 1) * (n + 1));
12699    // Device-space companions to `grid`, populated only when culling, so a
12700    // triangle can be tested without re-running the CTM per vertex.
12701    let mut device: Vec<(f64, f64)> = Vec::new();
12702    if cull.is_some() {
12703        device.reserve((n + 1) * (n + 1));
12704    }
12705    let use_tensor = patch.points.len() >= 16;
12706    let has_raw = !patch.raw_colors[0].is_empty();
12707    // Use per-grid-point ICC conversion when profile info is available
12708    let use_icc_interp = has_raw && icc_profile_hash.is_some() && icc_cache.is_some();
12709
12710    for row in 0..=n {
12711        let v = row as f64 / n as f64;
12712        for col in 0..=n {
12713            let u = col as f64 / n as f64;
12714            let (x, y) = if use_tensor {
12715                eval_tensor_patch(patch, u, v)
12716            } else {
12717                eval_coons_patch(patch, u, v)
12718            };
12719            if let Some(cull) = cull {
12720                device.push(cull.ctm.transform_point(x, y));
12721            }
12722            let raw = if has_raw {
12723                bilinear_raw(&patch.raw_colors, u, v)
12724            } else {
12725                vec![]
12726            };
12727            // When ICC profile is available, convert the interpolated raw
12728            // components at each grid point for accurate color rendering.
12729            // This interpolates in the source color space (e.g. ProPhoto RGB)
12730            // and converts per-grid-point, rather than interpolating pre-converted
12731            // sRGB values from only the 4 corners.
12732            let color = if use_icc_interp {
12733                if let Some((r, g, b)) = icc_cache
12734                    .unwrap()
12735                    .convert_color_readonly(icc_profile_hash.unwrap(), &raw)
12736                {
12737                    DeviceColor::from_rgb(r, g, b)
12738                } else {
12739                    bilinear_color(&patch.colors, u, v)
12740                }
12741            } else {
12742                bilinear_color(&patch.colors, u, v)
12743            };
12744            grid.push((x, y, color, raw));
12745        }
12746    }
12747
12748    // Triangulate grid
12749    let cols = n + 1;
12750    for row in 0..n {
12751        for col in 0..n {
12752            let i00 = row * cols + col;
12753            let i10 = i00 + 1;
12754            let i01 = i00 + cols;
12755            let i11 = i01 + 1;
12756
12757            // Drop triangles that cannot cover a pixel of the target before
12758            // paying for a `ShadingTriangle` — three vertices, each cloning a
12759            // colour and a component vector. `render_mesh_shading` performs
12760            // the identical rejection, so what survives is unchanged.
12761            let (keep_lower, keep_upper) = match cull {
12762                Some(cull) => (
12763                    !cull.rejects_triangle(device[i00], device[i10], device[i01]),
12764                    !cull.rejects_triangle(device[i10], device[i11], device[i01]),
12765                ),
12766                None => (true, true),
12767            };
12768            if !keep_lower && !keep_upper {
12769                continue;
12770            }
12771
12772            let (x00, y00, c00, r00) = &grid[i00];
12773            let (x10, y10, c10, r10) = &grid[i10];
12774            let (x01, y01, c01, r01) = &grid[i01];
12775            let (x11, y11, c11, r11) = &grid[i11];
12776
12777            use stet_graphics::device::ShadingVertex;
12778            if keep_lower {
12779                triangles.push(stet_graphics::device::ShadingTriangle {
12780                    v0: ShadingVertex {
12781                        x: *x00,
12782                        y: *y00,
12783                        color: c00.clone(),
12784                        raw_components: r00.clone(),
12785                    },
12786                    v1: ShadingVertex {
12787                        x: *x10,
12788                        y: *y10,
12789                        color: c10.clone(),
12790                        raw_components: r10.clone(),
12791                    },
12792                    v2: ShadingVertex {
12793                        x: *x01,
12794                        y: *y01,
12795                        color: c01.clone(),
12796                        raw_components: r01.clone(),
12797                    },
12798                });
12799            }
12800            if keep_upper {
12801                triangles.push(stet_graphics::device::ShadingTriangle {
12802                    v0: ShadingVertex {
12803                        x: *x10,
12804                        y: *y10,
12805                        color: c10.clone(),
12806                        raw_components: r10.clone(),
12807                    },
12808                    v1: ShadingVertex {
12809                        x: *x11,
12810                        y: *y11,
12811                        color: c11.clone(),
12812                        raw_components: r11.clone(),
12813                    },
12814                    v2: ShadingVertex {
12815                        x: *x01,
12816                        y: *y01,
12817                        color: c01.clone(),
12818                        raw_components: r01.clone(),
12819                    },
12820                });
12821            }
12822        }
12823    }
12824}
12825
12826/// Evaluate a Coons patch at parameter (u, v).
12827/// The 12 control points define 4 cubic Bezier boundary curves.
12828fn eval_coons_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12829    let pts = &patch.points;
12830    if pts.len() < 12 {
12831        return (0.0, 0.0);
12832    }
12833
12834    // Side 0 (bottom): pts[0..4], u goes 0→1
12835    // Side 1 (right): pts[3..7], v goes 0→1
12836    // Side 2 (top): pts[6..10], u goes 1→0 (reversed)
12837    // Side 3 (left): pts[9..12] + pts[0], v goes 1→0 (reversed)
12838    let c0 = eval_cubic_bezier(pts[0], pts[1], pts[2], pts[3], u);
12839    let c2 = eval_cubic_bezier(pts[6], pts[7], pts[8], pts[9], 1.0 - u);
12840    let d0 = eval_cubic_bezier(pts[0], pts[11], pts[10], pts[9], v);
12841    let d1 = eval_cubic_bezier(pts[3], pts[4], pts[5], pts[6], v);
12842
12843    // Bilinear blending of corners
12844    let p00 = pts[0];
12845    let p10 = pts[3];
12846    let p01 = pts[9];
12847    let p11 = pts[6];
12848    let bx = (1.0 - u) * (1.0 - v) * p00.0
12849        + u * (1.0 - v) * p10.0
12850        + (1.0 - u) * v * p01.0
12851        + u * v * p11.0;
12852    let by = (1.0 - u) * (1.0 - v) * p00.1
12853        + u * (1.0 - v) * p10.1
12854        + (1.0 - u) * v * p01.1
12855        + u * v * p11.1;
12856
12857    // Coons blending: S(u,v) = c(u,v) + d(u,v) - B(u,v)
12858    let x = (1.0 - v) * c0.0 + v * c2.0 + (1.0 - u) * d0.0 + u * d1.0 - bx;
12859    let y = (1.0 - v) * c0.1 + v * c2.1 + (1.0 - u) * d0.1 + u * d1.1 - by;
12860
12861    (x, y)
12862}
12863
12864/// Evaluate a Type 7 tensor-product patch at parameter (u, v).
12865///
12866/// Uses 16 control points arranged in a 4×4 grid, evaluated as a bicubic
12867/// Bernstein surface: S(u,v) = ΣΣ B_i(u) * B_j(v) * P_ij
12868///
12869/// PDF spec (ISO 32000, Table 85) data ordering for flag=0:
12870///   p₁₁ p₁₂ p₁₃ p₁₄  p₂₁ p₂₂ p₂₃ p₂₄  p₃₁ p₃₂ p₃₃ p₃₄  p₄₁ p₄₂ p₄₃ p₄₄
12871///
12872/// In the grid (Figure 86), column index = u direction, row index = v direction:
12873///   grid[v=0][u] = p₁₁, p₂₁, p₃₁, p₄₁  = pts[0], pts[4], pts[8],  pts[12]
12874///   grid[v=⅓][u] = p₁₂, p₂₂, p₃₂, p₄₂  = pts[1], pts[5], pts[9],  pts[13]
12875///   grid[v=⅔][u] = p₁₃, p₂₃, p₃₃, p₄₃  = pts[2], pts[6], pts[10], pts[14]
12876///   grid[v=1][u] = p₁₄, p₂₄, p₃₄, p₄₄  = pts[3], pts[7], pts[11], pts[15]
12877fn eval_tensor_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12878    let pts = &patch.points;
12879
12880    // Map data indices to 4×4 grid [row][col].
12881    // pts[0..12] are boundary points around the perimeter (same as Type 6).
12882    // pts[12..16] are the 4 interior control points.
12883    let grid: [[usize; 4]; 4] = [[0, 1, 2, 3], [11, 12, 13, 4], [10, 15, 14, 5], [9, 8, 7, 6]];
12884
12885    // Cubic Bernstein basis values
12886    let su = 1.0 - u;
12887    let bu = [su * su * su, 3.0 * su * su * u, 3.0 * su * u * u, u * u * u];
12888    let sv = 1.0 - v;
12889    let bv = [sv * sv * sv, 3.0 * sv * sv * v, 3.0 * sv * v * v, v * v * v];
12890
12891    let mut x = 0.0;
12892    let mut y = 0.0;
12893    for j in 0..4 {
12894        for i in 0..4 {
12895            let w = bu[i] * bv[j];
12896            let p = pts[grid[j][i]];
12897            x += w * p.0;
12898            y += w * p.1;
12899        }
12900    }
12901    (x, y)
12902}
12903
12904/// Evaluate a cubic Bezier curve at parameter t.
12905fn eval_cubic_bezier(
12906    p0: (f64, f64),
12907    p1: (f64, f64),
12908    p2: (f64, f64),
12909    p3: (f64, f64),
12910    t: f64,
12911) -> (f64, f64) {
12912    let s = 1.0 - t;
12913    let s2 = s * s;
12914    let t2 = t * t;
12915    let b0 = s2 * s;
12916    let b1 = 3.0 * s2 * t;
12917    let b2 = 3.0 * s * t2;
12918    let b3 = t2 * t;
12919    (
12920        b0 * p0.0 + b1 * p1.0 + b2 * p2.0 + b3 * p3.0,
12921        b0 * p0.1 + b1 * p1.1 + b2 * p2.1 + b3 * p3.1,
12922    )
12923}
12924
12925/// Bilinear color interpolation across patch corners.
12926fn bilinear_color(colors: &[DeviceColor; 4], u: f64, v: f64) -> DeviceColor {
12927    let r = (1.0 - u) * (1.0 - v) * colors[0].r
12928        + u * (1.0 - v) * colors[1].r
12929        + (1.0 - u) * v * colors[3].r
12930        + u * v * colors[2].r;
12931    let g = (1.0 - u) * (1.0 - v) * colors[0].g
12932        + u * (1.0 - v) * colors[1].g
12933        + (1.0 - u) * v * colors[3].g
12934        + u * v * colors[2].g;
12935    let b = (1.0 - u) * (1.0 - v) * colors[0].b
12936        + u * (1.0 - v) * colors[1].b
12937        + (1.0 - u) * v * colors[3].b
12938        + u * v * colors[2].b;
12939    DeviceColor::from_rgb(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
12940}
12941
12942/// Bilinear interpolation of raw color components across patch corners.
12943fn bilinear_raw(raw_colors: &[Vec<f64>; 4], u: f64, v: f64) -> Vec<f64> {
12944    let n = raw_colors[0].len();
12945    let mut result = vec![0.0; n];
12946    for i in 0..n {
12947        result[i] = (1.0 - u) * (1.0 - v) * raw_colors[0][i]
12948            + u * (1.0 - v) * raw_colors[1][i]
12949            + (1.0 - u) * v * raw_colors[3][i]
12950            + u * v * raw_colors[2][i];
12951    }
12952    result
12953}
12954
12955/// Pre-rasterize color stops into a 256-entry RGBA lookup table.
12956///
12957/// Each entry is linearly interpolated from the color stops. Used by the
12958/// direct-rasterization axial shading path to replace per-pixel stop search
12959/// with a single array lookup.
12960fn build_gradient_lut(stops: &[stet_graphics::device::ColorStop], size: usize) -> Vec<[u8; 4]> {
12961    let size = size.max(2);
12962    let mut lut = vec![[0u8; 4]; size];
12963    if stops.is_empty() {
12964        return lut;
12965    }
12966    let mut si = 0usize; // current stop index
12967    let last = (size - 1) as f64;
12968    for i in 0..size {
12969        let t = i as f64 / last;
12970        // Advance stop index
12971        while si + 1 < stops.len() && stops[si + 1].position < t {
12972            si += 1;
12973        }
12974        let (r, g, b) = if si + 1 >= stops.len() {
12975            let c = &stops[stops.len() - 1].color;
12976            (c.r, c.g, c.b)
12977        } else if t <= stops[si].position {
12978            let c = &stops[si].color;
12979            (c.r, c.g, c.b)
12980        } else {
12981            let t0 = stops[si].position;
12982            let t1 = stops[si + 1].position;
12983            let frac = if (t1 - t0).abs() < 1e-10 {
12984                0.0
12985            } else {
12986                (t - t0) / (t1 - t0)
12987            };
12988            let c0 = &stops[si].color;
12989            let c1 = &stops[si + 1].color;
12990            (
12991                c0.r + frac * (c1.r - c0.r),
12992                c0.g + frac * (c1.g - c0.g),
12993                c0.b + frac * (c1.b - c0.b),
12994            )
12995        };
12996        lut[i] = [
12997            (r * 255.0).round().clamp(0.0, 255.0) as u8,
12998            (g * 255.0).round().clamp(0.0, 255.0) as u8,
12999            (b * 255.0).round().clamp(0.0, 255.0) as u8,
13000            255,
13001        ];
13002    }
13003    lut
13004}
13005
13006/// Build tiny-skia gradient stops from color stops.
13007fn build_gradient_stops(
13008    stops: &[stet_graphics::device::ColorStop],
13009) -> Vec<stet_tiny_skia::GradientStop> {
13010    let mut result = Vec::with_capacity(stops.len());
13011    for stop in stops {
13012        let r = (stop.color.r * 255.0).round().clamp(0.0, 255.0) as u8;
13013        let g = (stop.color.g * 255.0).round().clamp(0.0, 255.0) as u8;
13014        let b = (stop.color.b * 255.0).round().clamp(0.0, 255.0) as u8;
13015        result.push(stet_tiny_skia::GradientStop::new(
13016            stop.position as f32,
13017            Color::from_rgba8(r, g, b, 255),
13018        ));
13019    }
13020    result
13021}
13022
13023/// Interpolate between color stops at a given position (0.0..=1.0).
13024fn interpolate_color_stops(
13025    stops: &[stet_graphics::device::ColorStop],
13026    position: f64,
13027) -> DeviceColor {
13028    if stops.is_empty() {
13029        return DeviceColor::from_gray(0.0);
13030    }
13031    if stops.len() == 1 || position <= stops[0].position {
13032        return stops[0].color.clone();
13033    }
13034    if position >= stops.last().unwrap().position {
13035        return stops.last().unwrap().color.clone();
13036    }
13037
13038    // Find the two stops bracketing this position
13039    for i in 1..stops.len() {
13040        if position <= stops[i].position {
13041            let t0 = stops[i - 1].position;
13042            let t1 = stops[i].position;
13043            let frac = if (t1 - t0).abs() < 1e-10 {
13044                0.0
13045            } else {
13046                (position - t0) / (t1 - t0)
13047            };
13048            let c0 = &stops[i - 1].color;
13049            let c1 = &stops[i].color;
13050            return DeviceColor::from_rgb(
13051                (c0.r + frac * (c1.r - c0.r)).clamp(0.0, 1.0),
13052                (c0.g + frac * (c1.g - c0.g)).clamp(0.0, 1.0),
13053                (c0.b + frac * (c1.b - c0.b)).clamp(0.0, 1.0),
13054            );
13055        }
13056    }
13057
13058    stops.last().unwrap().color.clone()
13059}
13060
13061/// Derive CMYK values from color stops at parameter t.
13062///
13063/// For DeviceCMYK shading color spaces the per-stop `raw_components` carry the
13064/// authoritative 4-channel CMYK values (already tint-transformed for
13065/// Separation/DeviceN with a CMYK alt) — those are interpolated directly.
13066///
13067/// For non-CMYK source color spaces (DeviceRGB, DeviceGray, CalRGB, CalGray,
13068/// ICCBased non-4) the interpolated sRGB color is round-tripped to CMYK via
13069/// the system CMYK ICC profile so the parallel CMYK buffer holds an accurate
13070/// representation. Falls back to PLRM `(1−r, 1−g, 1−b, 0)` when no system
13071/// profile is registered (e.g. `--no-icc`).
13072fn interpolate_cmyk_from_stops(
13073    stops: &[stet_graphics::device::ColorStop],
13074    cs: &ShadingColorSpace,
13075    t: f64,
13076    color: &DeviceColor,
13077    icc: Option<&IccCache>,
13078) -> (f64, f64, f64, f64) {
13079    let rgb_to_cmyk = |c: &DeviceColor| -> (f64, f64, f64, f64) {
13080        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(c.r, c.g, c.b)) {
13081            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
13082        } else {
13083            (
13084                (1.0 - c.r).clamp(0.0, 1.0),
13085                (1.0 - c.g).clamp(0.0, 1.0),
13086                (1.0 - c.b).clamp(0.0, 1.0),
13087                0.0,
13088            )
13089        }
13090    };
13091
13092    match cs {
13093        // Separation/DeviceN with a CMYK alternate carry tint-transformed CMYK
13094        // in `raw_components`, the same shape as DeviceCMYK — handle them on
13095        // the same path so spot-shading round-trips render identically.
13096        ShadingColorSpace::DeviceCMYK
13097        | ShadingColorSpace::Separation { .. }
13098        | ShadingColorSpace::DeviceN { .. } => {
13099            // Interpolate raw CMYK components from stops
13100            if stops.len() == 1 {
13101                let rc = &stops[0].raw_components;
13102                if rc.len() >= 4 {
13103                    return (rc[0], rc[1], rc[2], rc[3]);
13104                }
13105            }
13106            // Find surrounding stops and interpolate
13107            let mut lo = &stops[0];
13108            let mut hi = stops.last().unwrap();
13109            for i in 0..stops.len() - 1 {
13110                if stops[i + 1].position >= t {
13111                    lo = &stops[i];
13112                    hi = &stops[i + 1];
13113                    break;
13114                }
13115            }
13116            let span = hi.position - lo.position;
13117            let frac = if span > 1e-10 {
13118                (t - lo.position) / span
13119            } else {
13120                0.0
13121            };
13122            let frac = frac.clamp(0.0, 1.0);
13123            if lo.raw_components.len() >= 4 && hi.raw_components.len() >= 4 {
13124                (
13125                    lo.raw_components[0] + frac * (hi.raw_components[0] - lo.raw_components[0]),
13126                    lo.raw_components[1] + frac * (hi.raw_components[1] - lo.raw_components[1]),
13127                    lo.raw_components[2] + frac * (hi.raw_components[2] - lo.raw_components[2]),
13128                    lo.raw_components[3] + frac * (hi.raw_components[3] - lo.raw_components[3]),
13129                )
13130            } else {
13131                rgb_to_cmyk(color)
13132            }
13133        }
13134        _ => rgb_to_cmyk(color),
13135    }
13136}
13137
13138/// Derive CMYK values from triangle mesh vertices using barycentric weights.
13139///
13140/// Mirrors [`interpolate_cmyk_from_stops`]: DeviceCMYK source spaces use the
13141/// per-vertex `raw_components`, non-CMYK spaces ICC-reverse the interpolated
13142/// sRGB color, and PLRM is the last-resort fallback.
13143#[allow(clippy::too_many_arguments)]
13144fn interpolate_cmyk_from_vertices(
13145    v0: &ShadingVertex,
13146    v1: &ShadingVertex,
13147    v2: &ShadingVertex,
13148    w0: f64,
13149    w1: f64,
13150    w2: f64,
13151    cs: &ShadingColorSpace,
13152    r: f64,
13153    g: f64,
13154    b: f64,
13155    icc: Option<&IccCache>,
13156) -> (f64, f64, f64, f64) {
13157    let rgb_to_cmyk = |r: f64, g: f64, b: f64| -> (f64, f64, f64, f64) {
13158        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
13159            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
13160        } else {
13161            (
13162                (1.0 - r).clamp(0.0, 1.0),
13163                (1.0 - g).clamp(0.0, 1.0),
13164                (1.0 - b).clamp(0.0, 1.0),
13165                0.0,
13166            )
13167        }
13168    };
13169
13170    match cs {
13171        ShadingColorSpace::DeviceCMYK
13172        | ShadingColorSpace::Separation { .. }
13173        | ShadingColorSpace::DeviceN { .. } => {
13174            if v0.raw_components.len() >= 4
13175                && v1.raw_components.len() >= 4
13176                && v2.raw_components.len() >= 4
13177            {
13178                (
13179                    w0 * v0.raw_components[0]
13180                        + w1 * v1.raw_components[0]
13181                        + w2 * v2.raw_components[0],
13182                    w0 * v0.raw_components[1]
13183                        + w1 * v1.raw_components[1]
13184                        + w2 * v2.raw_components[1],
13185                    w0 * v0.raw_components[2]
13186                        + w1 * v1.raw_components[2]
13187                        + w2 * v2.raw_components[2],
13188                    w0 * v0.raw_components[3]
13189                        + w1 * v1.raw_components[3]
13190                        + w2 * v2.raw_components[3],
13191                )
13192            } else {
13193                rgb_to_cmyk(r, g, b)
13194            }
13195        }
13196        _ => rgb_to_cmyk(r, g, b),
13197    }
13198}
13199
13200#[cfg(test)]
13201mod tests {
13202    use super::*;
13203    #[cfg(feature = "ps-device")]
13204    use stet_graphics::color::DashPattern;
13205    use stet_graphics::device::{BgUcrState, HalftoneState, TransferState};
13206
13207    /// A deterministic pseudo-random 12-point Coons patch.
13208    ///
13209    /// The twelve control points are drawn independently over `[-1, 1]` rather
13210    /// than being tied to the edges of a quad. Patches built by perturbing a
13211    /// square stay inside their own control points, which would leave
13212    /// `patch_hull_bbox_contains_the_coons_surface` passing for the wrong
13213    /// reason; unconstrained points reach the cases that actually escape.
13214    fn sample_coons_patch(seed: u64) -> stet_graphics::device::ShadingPatch {
13215        let mut state = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
13216        let mut next = || {
13217            state = state
13218                .wrapping_mul(6364136223846793005)
13219                .wrapping_add(1442695040888963407);
13220            ((state >> 33) as f64 / (1u64 << 31) as f64) * 2.0 - 1.0
13221        };
13222        stet_graphics::device::ShadingPatch {
13223            points: (0..12).map(|_| (next(), next())).collect(),
13224            colors: std::array::from_fn(|_| DeviceColor::from_rgb(0.5, 0.5, 0.5)),
13225            raw_colors: std::array::from_fn(|_| Vec::new()),
13226        }
13227    }
13228
13229    /// Evaluate a 4x4 tensor net laid out `[j * 4 + i]` at `(u, v)`.
13230    fn eval_tensor_net(net: &[(f64, f64); 16], u: f64, v: f64) -> (f64, f64) {
13231        let su = 1.0 - u;
13232        let bu = [su * su * su, 3.0 * su * su * u, 3.0 * su * u * u, u * u * u];
13233        let sv = 1.0 - v;
13234        let bv = [sv * sv * sv, 3.0 * sv * sv * v, 3.0 * sv * v * v, v * v * v];
13235        let mut x = 0.0;
13236        let mut y = 0.0;
13237        for j in 0..4 {
13238            for i in 0..4 {
13239                let w = bu[i] * bv[j];
13240                x += w * net[j * 4 + i].0;
13241                y += w * net[j * 4 + i].1;
13242            }
13243        }
13244        (x, y)
13245    }
13246
13247    /// The conversion is only a valid bound if it describes the same surface.
13248    #[test]
13249    fn coons_tensor_net_matches_coons_evaluation() {
13250        for seed in 0..32 {
13251            let patch = sample_coons_patch(seed);
13252            let net = coons_tensor_net(&patch.points);
13253            for iu in 0..=8 {
13254                for iv in 0..=8 {
13255                    let (u, v) = (iu as f64 / 8.0, iv as f64 / 8.0);
13256                    let (ex, ey) = eval_coons_patch(&patch, u, v);
13257                    let (tx, ty) = eval_tensor_net(&net, u, v);
13258                    assert!(
13259                        (ex - tx).abs() < 1e-9 && (ey - ty).abs() < 1e-9,
13260                        "seed {seed} at ({u}, {v}): coons ({ex}, {ey}) != tensor ({tx}, {ty})"
13261                    );
13262                }
13263            }
13264        }
13265    }
13266
13267    /// A worked counterexample to the tempting shortcut of culling on the
13268    /// bounding box of a Coons patch's own twelve control points.
13269    ///
13270    /// Here the control points span x in [-0.9, 0.9], yet the surface reaches
13271    /// x = 1.44 — outside by 30% of the box's own width. Culling on that box
13272    /// would drop a patch with pixels to paint, which is why
13273    /// [`patch_hull_bbox()`] converts to the tensor net first.
13274    #[test]
13275    fn coons_surface_can_escape_its_boundary_control_points() {
13276        let patch = stet_graphics::device::ShadingPatch {
13277            points: vec![
13278                (-0.5, 0.7),
13279                (0.88, 0.05),
13280                (0.86, 0.28),
13281                (-0.9, -0.49),
13282                (0.9, 0.78),
13283                (0.01, 0.22),
13284                (-0.67, -0.6),
13285                (0.83, 0.68),
13286                (0.87, -0.7),
13287                (-0.53, -0.88),
13288                (0.8, 0.76),
13289                (0.74, 0.84),
13290            ],
13291            colors: std::array::from_fn(|_| DeviceColor::from_rgb(0.5, 0.5, 0.5)),
13292            raw_colors: std::array::from_fn(|_| Vec::new()),
13293        };
13294        let control_x_max = patch
13295            .points
13296            .iter()
13297            .fold(f64::NEG_INFINITY, |m, p| m.max(p.0));
13298        let (surface_x, _) = eval_coons_patch(&patch, 0.475, 0.45);
13299        assert!(
13300            surface_x > control_x_max + 0.5,
13301            "surface x {surface_x} should escape control-point max {control_x_max}"
13302        );
13303
13304        // The tensor net, and so the hull bound, does contain it.
13305        let (_, _, hull_x_max, _) = patch_hull_bbox(&patch, &Matrix::identity()).unwrap();
13306        assert!(hull_x_max >= surface_x);
13307    }
13308
13309    /// The whole point of the hull bound: no surface point may fall outside it,
13310    /// or culling would drop a patch that had pixels to paint.
13311    #[test]
13312    fn patch_hull_bbox_contains_the_coons_surface() {
13313        let ctm = Matrix {
13314            a: 90.0,
13315            b: 12.0,
13316            c: -7.0,
13317            d: -80.0,
13318            tx: 15.0,
13319            ty: 400.0,
13320        };
13321        for seed in 0..64 {
13322            let patch = sample_coons_patch(seed);
13323            let (x_min, y_min, x_max, y_max) = patch_hull_bbox(&patch, &ctm).unwrap();
13324            for iu in 0..=16 {
13325                for iv in 0..=16 {
13326                    let (u, v) = (iu as f64 / 16.0, iv as f64 / 16.0);
13327                    let (x, y) = eval_coons_patch(&patch, u, v);
13328                    let (dx, dy) = ctm.transform_point(x, y);
13329                    assert!(
13330                        dx >= x_min - 1e-9
13331                            && dx <= x_max + 1e-9
13332                            && dy >= y_min - 1e-9
13333                            && dy <= y_max + 1e-9,
13334                        "seed {seed} at ({u}, {v}): ({dx}, {dy}) outside \
13335                         ({x_min}, {y_min})-({x_max}, {y_max})"
13336                    );
13337                }
13338            }
13339        }
13340    }
13341
13342    /// Culling must not change which triangles get painted, only which get
13343    /// built: what survives has to match what an unculled run would have had
13344    /// `render_mesh_shading` accept, triangle for triangle.
13345    #[test]
13346    fn triangle_culling_keeps_exactly_the_paintable_triangles() {
13347        let patch = sample_coons_patch(7);
13348        let ctm = Matrix {
13349            a: 100.0,
13350            b: 0.0,
13351            c: 0.0,
13352            d: 100.0,
13353            tx: 20.0,
13354            ty: 30.0,
13355        };
13356        let n = 16;
13357
13358        let mut all = Vec::new();
13359        subdivide_patch_to_triangles(&patch, &mut all, n, None, None, None);
13360        assert_eq!(all.len(), 2 * n * n);
13361
13362        // A target the patch sits entirely inside keeps every triangle. The
13363        // patch reaches negative device coordinates, so the target has to
13364        // start there too.
13365        let wide = ShadingCull::new(ctm, -500.0, -500.0, 1.0, 1.0, 4000, 4000).unwrap();
13366        let mut kept = Vec::new();
13367        subdivide_patch_to_triangles(&patch, &mut kept, n, None, None, Some(&wide));
13368        assert_eq!(kept.len(), all.len());
13369
13370        // A target far below the patch keeps nothing.
13371        let elsewhere = ShadingCull::new(ctm, 0.0, 3000.0, 1.0, 1.0, 200, 140).unwrap();
13372        let mut none = Vec::new();
13373        subdivide_patch_to_triangles(&patch, &mut none, n, None, None, Some(&elsewhere));
13374        assert!(none.is_empty());
13375
13376        // A band-sized target keeps precisely the triangles that reach it.
13377        let band = ShadingCull::new(ctm, -200.0, 0.0, 1.0, 1.0, 600, 40).unwrap();
13378        let mut banded = Vec::new();
13379        subdivide_patch_to_triangles(&patch, &mut banded, n, None, None, Some(&band));
13380        let expected = all
13381            .iter()
13382            .filter(|tri| {
13383                !band.rejects_triangle(
13384                    ctm.transform_point(tri.v0.x, tri.v0.y),
13385                    ctm.transform_point(tri.v1.x, tri.v1.y),
13386                    ctm.transform_point(tri.v2.x, tri.v2.y),
13387                )
13388            })
13389            .count();
13390        assert_eq!(banded.len(), expected);
13391        assert!(
13392            !banded.is_empty() && banded.len() < all.len(),
13393            "band should keep some but not all of {} triangles, kept {}",
13394            all.len(),
13395            banded.len()
13396        );
13397    }
13398
13399    #[cfg(feature = "ps-device")]
13400    #[test]
13401    fn test_create_device() {
13402        let dev = SkiaDevice::new(100, 100);
13403        assert_eq!(dev.page_size(), (100, 100));
13404    }
13405
13406    #[cfg(feature = "ps-device")]
13407    #[test]
13408    fn test_fill_rect() {
13409        let mut dev = SkiaDevice::new(100, 100);
13410        let mut path = PsPath::new();
13411        path.segments.push(PathSegment::MoveTo(10.0, 10.0));
13412        path.segments.push(PathSegment::LineTo(90.0, 10.0));
13413        path.segments.push(PathSegment::LineTo(90.0, 90.0));
13414        path.segments.push(PathSegment::LineTo(10.0, 90.0));
13415        path.segments.push(PathSegment::ClosePath);
13416
13417        let params = FillParams {
13418            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
13419            fill_rule: FillRule::NonZeroWinding,
13420            ctm: Matrix::identity(),
13421            is_text_glyph: false,
13422            overprint: false,
13423            overprint_mode: 0,
13424            opm_paired: false,
13425            painted_channels: 0,
13426            is_device_cmyk: false,
13427            spot_color: None,
13428            icc_color: None,
13429            rendering_intent: 0,
13430            transfer: TransferState::default(),
13431            halftone: HalftoneState::default(),
13432            bg_ucr: BgUcrState::default(),
13433            alpha: 1.0,
13434            blend_mode: 0,
13435            alpha_is_shape: false,
13436        };
13437        dev.fill_path(&path, &params);
13438
13439        // Check that pixel at center is red
13440        let pixel = dev.pixmap().pixel(50, 50).unwrap();
13441        assert_eq!(pixel.red(), 255);
13442        assert_eq!(pixel.green(), 0);
13443        assert_eq!(pixel.blue(), 0);
13444    }
13445
13446    #[cfg(feature = "ps-device")]
13447    #[test]
13448    fn test_stroke_line() {
13449        let mut dev = SkiaDevice::new(100, 100);
13450        let mut path = PsPath::new();
13451        path.segments.push(PathSegment::MoveTo(10.0, 50.0));
13452        path.segments.push(PathSegment::LineTo(90.0, 50.0));
13453
13454        let params = StrokeParams {
13455            color: DeviceColor::from_rgb(0.0, 0.0, 1.0),
13456            line_width: 4.0,
13457            line_cap: LineCap::Butt,
13458            line_join: LineJoin::Miter,
13459            miter_limit: 10.0,
13460            dash_pattern: DashPattern::solid(),
13461            ctm: Matrix::identity(),
13462            stroke_adjust: false,
13463            is_text_glyph: false,
13464            overprint: false,
13465            overprint_mode: 0,
13466            opm_paired: false,
13467            painted_channels: 0,
13468            is_device_cmyk: false,
13469            spot_color: None,
13470            icc_color: None,
13471            rendering_intent: 0,
13472            transfer: TransferState::default(),
13473            halftone: HalftoneState::default(),
13474            bg_ucr: BgUcrState::default(),
13475            alpha: 1.0,
13476            blend_mode: 0,
13477            alpha_is_shape: false,
13478        };
13479        dev.stroke_path(&path, &params);
13480
13481        // Check that pixel on the line is blue
13482        let pixel = dev.pixmap().pixel(50, 50).unwrap();
13483        assert_eq!(pixel.blue(), 255);
13484    }
13485
13486    #[cfg(feature = "ps-device")]
13487    #[test]
13488    fn test_clip() {
13489        let mut dev = SkiaDevice::new(100, 100);
13490
13491        // Set clip to left half
13492        let mut clip_path = PsPath::new();
13493        clip_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13494        clip_path.segments.push(PathSegment::LineTo(50.0, 0.0));
13495        clip_path.segments.push(PathSegment::LineTo(50.0, 100.0));
13496        clip_path.segments.push(PathSegment::LineTo(0.0, 100.0));
13497        clip_path.segments.push(PathSegment::ClosePath);
13498
13499        let clip_params = ClipParams {
13500            fill_rule: FillRule::NonZeroWinding,
13501            ctm: Matrix::identity(),
13502            stroke_params: None,
13503        };
13504        dev.clip_path(&clip_path, &clip_params);
13505
13506        // Fill entire page with red
13507        let mut fill_path = PsPath::new();
13508        fill_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13509        fill_path.segments.push(PathSegment::LineTo(100.0, 0.0));
13510        fill_path.segments.push(PathSegment::LineTo(100.0, 100.0));
13511        fill_path.segments.push(PathSegment::LineTo(0.0, 100.0));
13512        fill_path.segments.push(PathSegment::ClosePath);
13513
13514        let fill_params = FillParams {
13515            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
13516            fill_rule: FillRule::NonZeroWinding,
13517            ctm: Matrix::identity(),
13518            is_text_glyph: false,
13519            overprint: false,
13520            overprint_mode: 0,
13521            opm_paired: false,
13522            painted_channels: 0,
13523            is_device_cmyk: false,
13524            spot_color: None,
13525            icc_color: None,
13526            rendering_intent: 0,
13527            transfer: TransferState::default(),
13528            halftone: HalftoneState::default(),
13529            bg_ucr: BgUcrState::default(),
13530            alpha: 1.0,
13531            blend_mode: 0,
13532            alpha_is_shape: false,
13533        };
13534        dev.fill_path(&fill_path, &fill_params);
13535
13536        // Left half should be red
13537        let left_pixel = dev.pixmap().pixel(25, 50).unwrap();
13538        assert_eq!(left_pixel.red(), 255);
13539
13540        // Right half should still be white
13541        let right_pixel = dev.pixmap().pixel(75, 50).unwrap();
13542        assert_eq!(right_pixel.red(), 255);
13543        assert_eq!(right_pixel.green(), 255); // white
13544    }
13545
13546    #[cfg(feature = "ps-device")]
13547    #[test]
13548    fn test_erase_page() {
13549        let mut dev = SkiaDevice::new(100, 100);
13550        // Fill with red
13551        let mut path = PsPath::new();
13552        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13553        path.segments.push(PathSegment::LineTo(100.0, 0.0));
13554        path.segments.push(PathSegment::LineTo(100.0, 100.0));
13555        path.segments.push(PathSegment::LineTo(0.0, 100.0));
13556        path.segments.push(PathSegment::ClosePath);
13557        let params = FillParams {
13558            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
13559            fill_rule: FillRule::NonZeroWinding,
13560            ctm: Matrix::identity(),
13561            is_text_glyph: false,
13562            overprint: false,
13563            overprint_mode: 0,
13564            opm_paired: false,
13565            painted_channels: 0,
13566            is_device_cmyk: false,
13567            spot_color: None,
13568            icc_color: None,
13569            rendering_intent: 0,
13570            transfer: TransferState::default(),
13571            halftone: HalftoneState::default(),
13572            bg_ucr: BgUcrState::default(),
13573            alpha: 1.0,
13574            blend_mode: 0,
13575            alpha_is_shape: false,
13576        };
13577        dev.fill_path(&path, &params);
13578
13579        dev.erase_page();
13580
13581        // Should be white again
13582        let pixel = dev.pixmap().pixel(50, 50).unwrap();
13583        assert_eq!(pixel.red(), 255);
13584        assert_eq!(pixel.green(), 255);
13585        assert_eq!(pixel.blue(), 255);
13586    }
13587
13588    #[cfg(feature = "ps-device")]
13589    #[test]
13590    fn test_show_page() {
13591        let mut dev = SkiaDevice::new(10, 10);
13592        let path = std::env::temp_dir().join("stet_test_output.png");
13593        let path_str = path.to_string_lossy();
13594        let result = dev.show_page(&path_str);
13595        assert!(result.is_ok());
13596        assert!(path.exists());
13597        std::fs::remove_file(&path).ok();
13598    }
13599
13600    #[cfg(feature = "ps-device")]
13601    #[test]
13602    fn test_transform() {
13603        let mut dev = SkiaDevice::new(200, 200);
13604        // Draw at origin with a translate transform
13605        let mut path = PsPath::new();
13606        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13607        path.segments.push(PathSegment::LineTo(10.0, 0.0));
13608        path.segments.push(PathSegment::LineTo(10.0, 10.0));
13609        path.segments.push(PathSegment::LineTo(0.0, 10.0));
13610        path.segments.push(PathSegment::ClosePath);
13611
13612        let params = FillParams {
13613            color: DeviceColor::from_rgb(0.0, 1.0, 0.0),
13614            fill_rule: FillRule::NonZeroWinding,
13615            ctm: Matrix::translate(100.0, 100.0),
13616            is_text_glyph: false,
13617            overprint: false,
13618            overprint_mode: 0,
13619            opm_paired: false,
13620            painted_channels: 0,
13621            is_device_cmyk: false,
13622            spot_color: None,
13623            icc_color: None,
13624            rendering_intent: 0,
13625            transfer: TransferState::default(),
13626            halftone: HalftoneState::default(),
13627            bg_ucr: BgUcrState::default(),
13628            alpha: 1.0,
13629            blend_mode: 0,
13630            alpha_is_shape: false,
13631        };
13632        dev.fill_path(&path, &params);
13633
13634        // Pixel at translated location should be green
13635        let pixel = dev.pixmap().pixel(105, 105).unwrap();
13636        assert_eq!(pixel.green(), 255);
13637        assert_eq!(pixel.red(), 0);
13638    }
13639
13640    fn make_test_fill_at(x: f64, y: f64, w: f64, h: f64) -> DisplayElement {
13641        let mut path = PsPath::new();
13642        path.segments.push(PathSegment::MoveTo(x, y));
13643        path.segments.push(PathSegment::LineTo(x + w, y));
13644        path.segments.push(PathSegment::LineTo(x + w, y + h));
13645        path.segments.push(PathSegment::LineTo(x, y + h));
13646        path.segments.push(PathSegment::ClosePath);
13647        DisplayElement::Fill {
13648            path,
13649            params: FillParams {
13650                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
13651                fill_rule: FillRule::NonZeroWinding,
13652                ctm: Matrix::identity(),
13653                is_text_glyph: false,
13654                overprint: false,
13655                overprint_mode: 0,
13656                opm_paired: false,
13657                painted_channels: 0,
13658                is_device_cmyk: false,
13659                spot_color: None,
13660                icc_color: None,
13661                rendering_intent: 0,
13662                transfer: TransferState::default(),
13663                halftone: HalftoneState::default(),
13664                bg_ucr: BgUcrState::default(),
13665                alpha: 1.0,
13666                blend_mode: 0,
13667                alpha_is_shape: false,
13668            },
13669        }
13670    }
13671
13672    #[test]
13673    fn test_compute_paint_bounds_two_fills() {
13674        let mut list = DisplayList::new();
13675        list.push(make_test_fill_at(10.0, 20.0, 30.0, 40.0)); // [10..40, 20..60]
13676        list.push(make_test_fill_at(100.0, 50.0, 50.0, 25.0)); // [100..150, 50..75]
13677
13678        let bounds = compute_paint_bounds(&list, 72.0).expect("expected union bounds");
13679        assert!(
13680            (bounds.x_min - 10.0).abs() < 1e-9,
13681            "x_min was {}",
13682            bounds.x_min
13683        );
13684        assert!(
13685            (bounds.y_min - 20.0).abs() < 1e-9,
13686            "y_min was {}",
13687            bounds.y_min
13688        );
13689        assert!(
13690            (bounds.x_max - 150.0).abs() < 1e-9,
13691            "x_max was {}",
13692            bounds.x_max
13693        );
13694        assert!(
13695            (bounds.y_max - 75.0).abs() < 1e-9,
13696            "y_max was {}",
13697            bounds.y_max
13698        );
13699    }
13700
13701    #[test]
13702    fn test_compute_paint_bounds_empty_list() {
13703        let list = DisplayList::new();
13704        assert!(compute_paint_bounds(&list, 72.0).is_none());
13705    }
13706
13707    #[test]
13708    fn test_compute_paint_bounds_only_clip_returns_none() {
13709        let mut list = DisplayList::new();
13710        list.push(DisplayElement::InitClip);
13711        // Clip / InitClip / ErasePage are skipped (return None from
13712        // precompute_full_bboxes), so a list of only clip ops yields no bounds.
13713        assert!(compute_paint_bounds(&list, 72.0).is_none());
13714    }
13715
13716    #[test]
13717    fn test_rasterize_mask_anchors_to_paint_bounds() {
13718        use stet_graphics::display_list::{SoftMaskParams, SoftMaskSubtype};
13719
13720        // A 50×40 white fill at page coords (200, 300)..(250, 340).
13721        // Mask paint bounds in device units: x [200..250], y [300..340].
13722        let mut mask = DisplayList::new();
13723        let mut path = PsPath::new();
13724        path.segments.push(PathSegment::MoveTo(200.0, 300.0));
13725        path.segments.push(PathSegment::LineTo(250.0, 300.0));
13726        path.segments.push(PathSegment::LineTo(250.0, 340.0));
13727        path.segments.push(PathSegment::LineTo(200.0, 340.0));
13728        path.segments.push(PathSegment::ClosePath);
13729        mask.push(DisplayElement::Fill {
13730            path,
13731            params: FillParams {
13732                color: DeviceColor::from_rgb(1.0, 1.0, 1.0),
13733                fill_rule: FillRule::NonZeroWinding,
13734                ctm: Matrix::identity(),
13735                is_text_glyph: false,
13736                overprint: false,
13737                overprint_mode: 0,
13738                opm_paired: false,
13739                painted_channels: 0,
13740                is_device_cmyk: false,
13741                spot_color: None,
13742                icc_color: None,
13743                rendering_intent: 0,
13744                transfer: TransferState::default(),
13745                halftone: HalftoneState::default(),
13746                bg_ucr: BgUcrState::default(),
13747                alpha: 1.0,
13748                blend_mode: 0,
13749                alpha_is_shape: false,
13750            },
13751        });
13752
13753        let params = SoftMaskParams {
13754            subtype: SoftMaskSubtype::Luminosity,
13755            // Form bbox; intentionally tighter than paint bounds — the
13756            // raster should follow paint bounds, not this.
13757            bbox: [0.0, 0.0, 100.0, 100.0],
13758            backdrop_color: None, // black backdrop → out-of-bounds value = 0
13759            transfer_invert: false,
13760            has_nested_mask_scope: false,
13761            parent_clip_bbox: None,
13762        };
13763
13764        let raster = rasterize_mask(
13765            &mask,
13766            &params,
13767            None,
13768            false,
13769            72.0,
13770            1.0,
13771            1.0,
13772            &LayerSet::new(),
13773        )
13774        .expect("expected raster");
13775
13776        // Origin must be at (or just before) the paint bounds, with the
13777        // 1-pixel AA pad.
13778        assert_eq!(raster.origin_x, 199);
13779        assert_eq!(raster.origin_y, 299);
13780        // Width / height = paint bounds + 2 pixels of pad (1 each side).
13781        assert_eq!(raster.width, 52);
13782        assert_eq!(raster.height, 42);
13783        assert_eq!(raster.scale_x, 1.0);
13784        assert_eq!(raster.scale_y, 1.0);
13785
13786        // The raster should be non-zero somewhere inside the painted region.
13787        // Sample the center of the painted area: page (225, 320) → mask
13788        // index (225 - 199, 320 - 299) = (26, 21).
13789        let mx = 225 - raster.origin_x;
13790        let my = 320 - raster.origin_y;
13791        assert!(mx >= 0 && (mx as u32) < raster.width);
13792        assert!(my >= 0 && (my as u32) < raster.height);
13793        let center_value = raster.data[(my as usize) * raster.width as usize + mx as usize];
13794        assert_eq!(
13795            center_value, 255,
13796            "center of painted mask should be opaque white (lum=255)"
13797        );
13798
13799        // A point outside the paint bounds (page (300, 320)) maps to mask
13800        // index (101, 21) which is outside the raster width — sampling
13801        // there should fall back to out_of_bounds_mask_value(params) = 0.
13802        let mx_out = 300 - raster.origin_x;
13803        let in_bounds = mx_out >= 0 && (mx_out as u32) < raster.width;
13804        assert!(!in_bounds, "page x=300 should be outside the mask raster");
13805        assert_eq!(
13806            out_of_bounds_mask_value(&params),
13807            0,
13808            "black backdrop → out-of-bounds = 0"
13809        );
13810    }
13811
13812    #[test]
13813    fn test_band_local_to_mask_formula() {
13814        // Verify the band-local → page-pixel → mask-index arithmetic for
13815        // several band offsets. This is the highest-risk part of Step 4
13816        // because it bridges three coordinate systems:
13817        //
13818        //   band-local pixel (x, y)
13819        //     + (crop_x, crop_y)            → soft-mask offset within band
13820        //     + (vp_x_pixels, vp_y_pixels)  → page-pixel position
13821        //     - (origin_x, origin_y)        → mask raster index
13822
13823        // Mask raster anchored at page-pixel (200, 300).
13824        let raster_origin_x = 200i32;
13825        let raster_origin_y = 300i32;
13826
13827        // Helper that runs the formula from render_soft_masked.
13828        let sample = |vp_x_dev: f32,
13829                      vp_y_dev: f32,
13830                      scale: f32,
13831                      crop_x: i32,
13832                      crop_y: i32,
13833                      x: i32,
13834                      y: i32|
13835         -> (i32, i32) {
13836            let vp_x_pixels = (vp_x_dev * scale).round() as i32;
13837            let vp_y_pixels = (vp_y_dev * scale).round() as i32;
13838            let page_x = vp_x_pixels + crop_x + x;
13839            let page_y = vp_y_pixels + crop_y + y;
13840            let mx = page_x - raster_origin_x;
13841            let my = page_y - raster_origin_y;
13842            (mx, my)
13843        };
13844
13845        // Case 1: band starts at page Y=0 (top band of page).
13846        // vp_y=0, scale=1. The soft-mask top-left page (220, 310) must
13847        // map to mask index (20, 10).
13848        // crop_x = floor((220 - 0) * 1) = 220, crop_y = floor((310 - 0) * 1) = 310
13849        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 0, 0);
13850        assert_eq!((mx, my), (20, 10), "top band: smask top-left");
13851
13852        // 5 pixels into the smask region (band-local): page (225, 315)
13853        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 5, 5);
13854        assert_eq!((mx, my), (25, 15), "top band: 5px into smask");
13855
13856        // Case 2: band starts at page Y=400. The smask region [310..340]
13857        // doesn't intersect this band — covered by the early-return path.
13858        // But test a band that DOES intersect the smask, e.g. starting at
13859        // Y=305. Then page-Y 310 is band-local Y=5.
13860        // vp_y_pixels = round(305 * 1) = 305
13861        // crop_y = floor((310 - 305) * 1) = 5  (band-local)
13862        // For content y=0 (band-local), page_y = 305 + 5 + 0 = 310 ✓
13863        let (mx, my) = sample(0.0, 305.0, 1.0, 220, 5, 0, 0);
13864        assert_eq!((mx, my), (20, 10), "mid band: smask top-left");
13865
13866        // Case 3: viewport rendering at scale 2. vp_x=100.0, vp_y=150.0,
13867        // scale=2. Page pixel offset = (200, 300). The smask region
13868        // [220..270] in device units = [440..540] in page-pixels at scale 2.
13869        // But the mask raster was built at scale 1, so this is a
13870        // SCALE-MISMATCH case — the cache would invalidate and rebuild.
13871        // We're not testing the rebuild, just that the formula computes
13872        // the right page-pixel coords:
13873        //   vp_x_pixels = round(100 * 2) = 200
13874        //   smask in band: page (440..540), band-local (240..340)
13875        //   crop_x = max(0, floor((220 - 100) * 2)) = 240
13876        //   For x=0 (band-local), page_x = 200 + 240 + 0 = 440 ✓
13877        let vp_x_pixels = (100.0_f32 * 2.0).round() as i32;
13878        let crop_x = ((220.0_f32 - 100.0) * 2.0).floor() as i32;
13879        let page_x_for_x_zero = vp_x_pixels + crop_x;
13880        assert_eq!(page_x_for_x_zero, 440, "viewport scale-2: page-x at x=0");
13881    }
13882
13883    // --- obscured-fill skip (§ GWG reference-under-test pattern) ---
13884
13885    fn x_path() -> PsPath {
13886        let mut p = PsPath::new();
13887        p.segments.push(PathSegment::MoveTo(10.0, 10.0));
13888        p.segments.push(PathSegment::LineTo(20.0, 20.0));
13889        p.segments.push(PathSegment::LineTo(30.0, 10.0));
13890        p.segments.push(PathSegment::LineTo(20.0, 0.0));
13891        p.segments.push(PathSegment::ClosePath);
13892        p
13893    }
13894
13895    fn x_path_perturbed() -> PsPath {
13896        // Same shape, sub-unit rounding — stand-in for GWG's 0.001-unit
13897        // coordinate drift between duplicated path emissions.
13898        let mut p = PsPath::new();
13899        p.segments.push(PathSegment::MoveTo(10.001, 10.0));
13900        p.segments.push(PathSegment::LineTo(20.0, 19.999));
13901        p.segments.push(PathSegment::LineTo(30.002, 10.001));
13902        p.segments.push(PathSegment::LineTo(19.999, 0.0));
13903        p.segments.push(PathSegment::ClosePath);
13904        p
13905    }
13906
13907    fn fill(path: PsPath, alpha: f64, blend: u8) -> DisplayElement {
13908        DisplayElement::Fill {
13909            path,
13910            params: FillParams {
13911                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
13912                fill_rule: FillRule::NonZeroWinding,
13913                ctm: Matrix::identity(),
13914                is_text_glyph: false,
13915                overprint: false,
13916                overprint_mode: 0,
13917                opm_paired: false,
13918                painted_channels: 0,
13919                is_device_cmyk: false,
13920                spot_color: None,
13921                icc_color: None,
13922                rendering_intent: 0,
13923                transfer: TransferState::default(),
13924                halftone: HalftoneState::default(),
13925                bg_ucr: BgUcrState::default(),
13926                alpha,
13927                blend_mode: blend,
13928                alpha_is_shape: false,
13929            },
13930        }
13931    }
13932
13933    fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> PsPath {
13934        let mut p = PsPath::new();
13935        p.segments.push(PathSegment::MoveTo(x0, y0));
13936        p.segments.push(PathSegment::LineTo(x1, y0));
13937        p.segments.push(PathSegment::LineTo(x1, y1));
13938        p.segments.push(PathSegment::LineTo(x0, y1));
13939        p.segments.push(PathSegment::ClosePath);
13940        p
13941    }
13942
13943    fn clip_elem(path: PsPath) -> DisplayElement {
13944        DisplayElement::Clip {
13945            path,
13946            params: ClipParams {
13947                fill_rule: FillRule::NonZeroWinding,
13948                ctm: Matrix::identity(),
13949                stroke_params: None,
13950            },
13951        }
13952    }
13953
13954    fn group_elem(
13955        inner: Vec<DisplayElement>,
13956        bbox: [f64; 4],
13957        isolated: bool,
13958        alpha: f64,
13959        blend: u8,
13960    ) -> DisplayElement {
13961        let mut dl = DisplayList::new();
13962        for e in inner {
13963            dl.push(e);
13964        }
13965        DisplayElement::Group {
13966            elements: dl,
13967            params: stet_graphics::display_list::GroupParams {
13968                bbox,
13969                isolated,
13970                knockout: false,
13971                blend_mode: blend,
13972                alpha,
13973                color_space: stet_graphics::display_list::GroupColorSpace::Inherited,
13974            },
13975        }
13976    }
13977
13978    fn dl(elements: Vec<DisplayElement>) -> DisplayList {
13979        let mut d = DisplayList::new();
13980        for e in elements {
13981            d.push(e);
13982        }
13983        d
13984    }
13985
13986    #[test]
13987    fn obscured_skip_fires_on_matching_fill_plus_iso_group() {
13988        // Classic GWG pattern: parent Fill, then a clip, then an isolated
13989        // alpha-1 Group whose first paint is a matching Fill.
13990        let parent = fill(x_path(), 1.0, 0);
13991        let inner = vec![fill(x_path_perturbed(), 1.0, 0)];
13992        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13993        let d = dl(vec![
13994            parent,
13995            clip_elem(rect_path(0.0, -5.0, 40.0, 30.0)),
13996            grp,
13997        ]);
13998        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
13999    }
14000
14001    #[test]
14002    fn obscured_skip_does_not_fire_on_non_isolated_group() {
14003        let parent = fill(x_path(), 1.0, 0);
14004        let inner = vec![fill(x_path(), 1.0, 0)];
14005        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], false, 1.0, 0);
14006        let d = dl(vec![parent, grp]);
14007        assert!(compute_obscured_fill_skips(&d).is_empty());
14008    }
14009
14010    #[test]
14011    fn obscured_skip_does_not_fire_on_partial_alpha_group() {
14012        let parent = fill(x_path(), 1.0, 0);
14013        let inner = vec![fill(x_path(), 1.0, 0)];
14014        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 0.5, 0);
14015        let d = dl(vec![parent, grp]);
14016        assert!(compute_obscured_fill_skips(&d).is_empty());
14017    }
14018
14019    #[test]
14020    fn obscured_skip_does_not_fire_on_non_normal_blend() {
14021        let parent = fill(x_path(), 1.0, 0);
14022        let inner = vec![fill(x_path(), 1.0, 0)];
14023        // blend_mode = 10 (Difference) on the group — composite-back
14024        // semantics differ from Normal, so skipping parent is unsafe.
14025        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 10);
14026        let d = dl(vec![parent, grp]);
14027        assert!(compute_obscured_fill_skips(&d).is_empty());
14028    }
14029
14030    #[test]
14031    fn obscured_skip_does_not_fire_when_paths_differ() {
14032        let parent = fill(rect_path(0.0, 0.0, 5.0, 5.0), 1.0, 0);
14033        let inner = vec![fill(x_path(), 1.0, 0)];
14034        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
14035        let d = dl(vec![parent, grp]);
14036        assert!(compute_obscured_fill_skips(&d).is_empty());
14037    }
14038
14039    #[test]
14040    fn obscured_skip_does_not_fire_when_group_bbox_too_small() {
14041        // Parent fills a rectangle larger than the group's declared
14042        // bbox — the form's BBox would clip the inner fill to a subset
14043        // of the parent's extent, so the parent cannot be dropped.
14044        let big = rect_path(0.0, 0.0, 100.0, 100.0);
14045        let parent = fill(big.clone(), 1.0, 0);
14046        let inner = vec![fill(big, 1.0, 0)];
14047        // Group bbox only covers [0..10, 0..10], much smaller than parent.
14048        let grp = group_elem(inner, [0.0, 0.0, 10.0, 10.0], true, 1.0, 0);
14049        let d = dl(vec![parent, grp]);
14050        assert!(compute_obscured_fill_skips(&d).is_empty());
14051    }
14052
14053    #[test]
14054    fn obscured_skip_does_not_fire_when_intervening_clip_too_small() {
14055        // A clip between the parent fill and the group is narrower than
14056        // the parent's extent — dropping the parent's fill would reveal
14057        // backdrop where the group couldn't paint.
14058        let parent = fill(x_path(), 1.0, 0);
14059        let narrow_clip = clip_elem(rect_path(12.0, 5.0, 18.0, 15.0));
14060        let inner = vec![fill(x_path(), 1.0, 0)];
14061        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
14062        let d = dl(vec![parent, narrow_clip, grp]);
14063        assert!(compute_obscured_fill_skips(&d).is_empty());
14064    }
14065
14066    #[test]
14067    fn obscured_skip_does_not_fire_when_inner_clip_too_small() {
14068        // Clip *inside* the group is narrower than the parent's extent.
14069        let parent = fill(x_path(), 1.0, 0);
14070        let inner = vec![
14071            clip_elem(rect_path(12.0, 5.0, 18.0, 15.0)),
14072            fill(x_path(), 1.0, 0),
14073        ];
14074        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
14075        let d = dl(vec![parent, grp]);
14076        assert!(compute_obscured_fill_skips(&d).is_empty());
14077    }
14078
14079    #[test]
14080    fn obscured_skip_fires_when_inner_clip_is_wider_than_parent_path() {
14081        // A clip inside the group that's larger than the parent's fill
14082        // doesn't threaten coverage; still safe to skip the parent.
14083        let parent = fill(x_path(), 1.0, 0);
14084        let inner = vec![
14085            clip_elem(rect_path(-10.0, -10.0, 40.0, 30.0)),
14086            fill(x_path_perturbed(), 1.0, 0),
14087        ];
14088        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
14089        let d = dl(vec![parent, grp]);
14090        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
14091    }
14092
14093    #[test]
14094    fn obscured_skip_does_not_fire_on_partial_alpha_parent() {
14095        // A parent fill at alpha < 1 might blend with backdrop; dropping
14096        // it changes the visual even when the group overpaints.
14097        let parent = fill(x_path(), 0.5, 0);
14098        let inner = vec![fill(x_path(), 1.0, 0)];
14099        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
14100        let d = dl(vec![parent, grp]);
14101        assert!(compute_obscured_fill_skips(&d).is_empty());
14102    }
14103}