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
18use stet_core::device::OutputDevice;
19use stet_fonts::geometry::{Matrix, PathSegment, PsPath};
20use stet_graphics::color::{DeviceColor, FillRule, LineCap, LineJoin};
21use stet_graphics::device::{
22    AxialShadingParams, ClipParams, FillParams, ImageColorSpace, ImageParams, MeshShadingParams,
23    PageSinkFactory, PatchShadingParams, RadialShadingParams, ShadingColorSpace, ShadingVertex,
24    StrokeParams, TintLookupTable,
25};
26use stet_graphics::icc::IccCache;
27use stet_graphics::layer_set::LayerSet;
28
29/// Axis-aligned rectangle in device pixel coordinates.
30#[derive(Clone, Copy)]
31struct ClipRect {
32    x0: u32,
33    y0: u32, // top-left (inclusive)
34    x1: u32,
35    y1: u32, // bottom-right (exclusive)
36}
37
38impl ClipRect {
39    /// Intersect two rectangles. Result may be empty.
40    fn intersect(&self, other: &ClipRect) -> ClipRect {
41        ClipRect {
42            x0: self.x0.max(other.x0),
43            y0: self.y0.max(other.y0),
44            x1: self.x1.min(other.x1),
45            y1: self.y1.min(other.y1),
46        }
47    }
48
49    fn is_empty(&self) -> bool {
50        self.x0 >= self.x1 || self.y0 >= self.y1
51    }
52
53    /// True if this rect covers the entire page.
54    fn is_full_page(&self, w: u32, h: u32) -> bool {
55        self.x0 == 0 && self.y0 == 0 && self.x1 == w && self.y1 == h
56    }
57
58    /// Create a mask with 255 inside the rect, 0 outside.
59    fn make_mask(self, w: u32, h: u32) -> Option<Mask> {
60        if self.is_empty() {
61            return None;
62        }
63        let mut mask = Mask::new(w, h)?;
64        let data = mask.data_mut();
65        let stride = w as usize;
66        for y in self.y0..self.y1 {
67            let row_start = y as usize * stride + self.x0 as usize;
68            let row_end = y as usize * stride + self.x1 as usize;
69            data[row_start..row_end].fill(255);
70        }
71        Some(mask)
72    }
73}
74
75/// Clip region: either a simple rectangle (fast) or a full rasterized mask.
76enum ClipRegion {
77    Rect(ClipRect),
78    Mask(Mask),
79}
80
81/// tiny-skia based raster device.
82pub struct SkiaDevice {
83    pixmap: Pixmap,
84    /// Page dimensions in device pixels. Stored separately so we can shrink
85    /// the pixmap during banded rendering without losing page size info.
86    page_w: u32,
87    page_h: u32,
88    /// Device resolution in DPI (for hairline width decisions).
89    dpi: f64,
90    clip_region: Option<ClipRegion>,
91    /// Cache of rasterized clip masks keyed by path hash.
92    /// Only paths seen more than once are cached (cache-on-second-sight).
93    clip_mask_cache: HashMap<u64, Mask>,
94    clip_mask_seen: HashSet<u64>,
95    /// Recycled mask buffer to avoid repeated alloc/dealloc of large masks.
96    spare_mask: Option<Mask>,
97    /// Receiver for background render result (pipelined multi-page rendering).
98    /// Uses rayon::spawn + oneshot channel to avoid OS thread spawn overhead.
99    pending_render: Option<std::sync::mpsc::Receiver<Result<(), String>>>,
100    /// Factory for creating page sinks (PNG, viewer, etc.).
101    sink_factory: Box<dyn PageSinkFactory>,
102    /// Raw bytes of the system CMYK ICC profile (for building render-thread IccCaches).
103    system_cmyk_bytes: Option<std::sync::Arc<Vec<u8>>>,
104    /// Transient IccCache used during non-banded replay_to_device rendering.
105    render_icc_cache: Option<IccCache>,
106    /// Disable anti-aliasing for all fill/stroke operations (matches GhostScript).
107    no_aa: bool,
108    /// Route `replay_and_show` through the viewport code path instead of the
109    /// banded full-page path. Used by `--device viewport-png` to audit the
110    /// viewport pipeline against the banded PNG baselines — same display list,
111    /// different culling/epoch logic, same expected output.
112    use_viewport_path: bool,
113    /// OCG visibility overrides applied to every render that consults
114    /// the layer system. Defaults to empty (every layer falls back to
115    /// its `default_visible`); a consumer building a layer panel can
116    /// install an explicit set via `set_layer_set`.
117    layer_set: LayerSet,
118}
119
120impl SkiaDevice {
121    /// Create a new device with the given page dimensions and default PNG output.
122    ///
123    /// Defers the full-page pixmap allocation — only a 1×1 placeholder is
124    /// created here. The full pixmap is allocated lazily in `replay_and_show`
125    /// only when the non-banded rendering path is needed.
126    pub fn new(width: u32, height: u32) -> Self {
127        Self::with_sink_factory(width, height, Box::new(crate::PngSinkFactory))
128    }
129
130    /// Create a new device with a custom page sink factory.
131    pub fn with_sink_factory(
132        width: u32,
133        height: u32,
134        sink_factory: Box<dyn PageSinkFactory>,
135    ) -> Self {
136        // Only the lower bound is enforced here, and deliberately so.
137        //
138        // These dimensions are `page_points * dpi / 72`, and the two factors
139        // have different provenance: the points come from the file and are
140        // untrusted, but the DPI is the caller's explicit request. Capping the
141        // product punishes the caller for the file's exaggeration — a 1200 dpi
142        // prepress proof of a large-format page is a legitimate gigapixel
143        // render, and refusing it is worse than the attack it prevents. The
144        // page size is bounded upstream, in points, where the untrusted value
145        // actually enters (see `MAX_PAGE_SIZE_POINTS`).
146        //
147        // Zero, on the other hand, is never meaningful: `Pixmap::new` returns
148        // `None` for a zero dimension and the call below used to `.expect()`
149        // on it, so `<< /PageSize [-1 -1] >> setpagedevice` panicked the
150        // renderer outright.
151        let width = width.max(1);
152        let height = height.max(1);
153        // Estimate DPI from page height (assumes ~792pt US Letter as reference).
154        // Close enough for hairline width threshold decisions.
155        let dpi = height as f64 * 72.0 / 792.0;
156
157        // Start with a tiny placeholder. The full-page pixmap is allocated
158        // lazily only when the non-banded path is used (small pages / low DPI).
159        // For banded rendering, band-sized pixmaps are created in replay_and_show.
160        let pixmap = Pixmap::new(1, 1).expect("Failed to create placeholder pixmap");
161        Self {
162            pixmap,
163            page_w: width,
164            page_h: height,
165            dpi,
166            clip_region: None,
167            clip_mask_cache: HashMap::new(),
168            clip_mask_seen: HashSet::new(),
169            spare_mask: None,
170            pending_render: None,
171            sink_factory,
172            system_cmyk_bytes: None,
173            render_icc_cache: None,
174            no_aa: false,
175            use_viewport_path: false,
176            layer_set: LayerSet::new(),
177        }
178    }
179
180    /// Route rendering through the viewport pipeline. Used by the visual
181    /// test runner's `--device viewport-png` mode.
182    pub fn set_use_viewport_path(&mut self, on: bool) {
183        self.use_viewport_path = on;
184    }
185
186    /// Replace the device's OCG visibility overrides.
187    ///
188    /// The empty default has every layer fall back to its
189    /// `default_visible` baked into the display list. Callers building
190    /// a layer panel hand in a populated [`LayerSet`] each render
191    /// pass.
192    pub fn set_layer_set(&mut self, layer_set: LayerSet) {
193        self.layer_set = layer_set;
194    }
195
196    /// Read-only view of the device's current OCG visibility overrides.
197    pub fn layer_set(&self) -> &LayerSet {
198        &self.layer_set
199    }
200
201    /// Ensure `self.pixmap` is allocated at full page dimensions.
202    /// Called before non-banded rendering which operates on the full pixmap.
203    fn ensure_full_pixmap(&mut self) {
204        if self.pixmap.width() != self.page_w || self.pixmap.height() != self.page_h {
205            // Dimensions are clamped at construction, so this only fails when
206            // the allocation itself does — a page large enough to exhaust
207            // memory. Keep the existing pixmap and carry on: the page renders
208            // wrong, which is what a page that size was always going to do,
209            // rather than taking the process down.
210            let Some(pixmap) = Pixmap::new(self.page_w, self.page_h) else {
211                eprintln!(
212                    "Warning: could not allocate a {}x{} page pixmap; \
213                     rendering into the existing {}x{} buffer instead",
214                    self.page_w,
215                    self.page_h,
216                    self.pixmap.width(),
217                    self.pixmap.height()
218                );
219                return;
220            };
221            self.pixmap = pixmap;
222            self.pixmap.fill(Color::WHITE);
223        }
224    }
225
226    /// Get the underlying pixmap (for testing).
227    pub fn pixmap(&self) -> &Pixmap {
228        &self.pixmap
229    }
230
231    /// Set the system CMYK ICC profile bytes for ICC-aware rendering.
232    pub fn set_system_cmyk_bytes(&mut self, bytes: std::sync::Arc<Vec<u8>>) {
233        self.system_cmyk_bytes = Some(bytes);
234    }
235
236    /// Disable anti-aliasing for all fill/stroke operations.
237    pub fn set_no_aa(&mut self, no_aa: bool) {
238        self.no_aa = no_aa;
239    }
240}
241
242/// Convert a PostScript `Matrix` to tiny-skia `Transform` (f32).
243fn to_transform(m: &Matrix) -> Transform {
244    Transform::from_row(
245        m.a as f32,
246        m.b as f32,
247        m.c as f32,
248        m.d as f32,
249        m.tx as f32,
250        m.ty as f32,
251    )
252}
253
254/// Convert a `DeviceColor` to tiny-skia `Paint`.
255fn to_paint(color: &DeviceColor) -> Paint<'static> {
256    to_paint_alpha(color, 1.0, 0, false)
257}
258
259/// Convert a `DeviceColor` to tiny-skia `Paint` with the given opacity and blend mode.
260fn to_paint_alpha(color: &DeviceColor, alpha: f64, blend_mode: u8, no_aa: bool) -> Paint<'static> {
261    let mut paint = Paint::default();
262    let a = (alpha * 255.0).round().clamp(0.0, 255.0) as u8;
263    paint.set_color_rgba8(
264        (color.r * 255.0).round().clamp(0.0, 255.0) as u8,
265        (color.g * 255.0).round().clamp(0.0, 255.0) as u8,
266        (color.b * 255.0).round().clamp(0.0, 255.0) as u8,
267        a,
268    );
269    paint.anti_alias = !no_aa;
270    paint.blend_mode = u8_to_blend_mode(blend_mode);
271    paint
272}
273
274/// Map a blend mode byte (0–15) to the corresponding tiny-skia `BlendMode`.
275fn u8_to_blend_mode(mode: u8) -> BlendMode {
276    match mode {
277        1 => BlendMode::Multiply,
278        2 => BlendMode::Screen,
279        3 => BlendMode::Overlay,
280        4 => BlendMode::Darken,
281        5 => BlendMode::Lighten,
282        6 => BlendMode::ColorDodge,
283        7 => BlendMode::ColorBurn,
284        8 => BlendMode::HardLight,
285        9 => BlendMode::SoftLight,
286        10 => BlendMode::Difference,
287        11 => BlendMode::Exclusion,
288        12 => BlendMode::Hue,
289        13 => BlendMode::Saturation,
290        14 => BlendMode::Color,
291        15 => BlendMode::Luminosity,
292        _ => BlendMode::SourceOver,
293    }
294}
295
296/// Convert a `PsPath` to tiny-skia `Path`.
297/// Maximum coordinate magnitude for path rasterization.
298/// Coordinates beyond this cause integer overflow in the scanline rasterizer.
299/// 1e6 is well beyond any real page (e.g. 612×792 pt at 600 DPI = ~5100×6600 px)
300/// but safely within f32 precision and fixed-point limits.
301const MAX_PATH_COORD: f32 = 1e6;
302
303fn build_skia_path(path: &PsPath) -> Option<stet_tiny_skia::Path> {
304    let mut pb = PathBuilder::new();
305
306    for seg in &path.segments {
307        match seg {
308            PathSegment::MoveTo(x, y) => {
309                pb.move_to(*x as f32, *y as f32);
310            }
311            PathSegment::LineTo(x, y) => {
312                pb.line_to(*x as f32, *y as f32);
313            }
314            PathSegment::CurveTo {
315                x1,
316                y1,
317                x2,
318                y2,
319                x3,
320                y3,
321            } => {
322                pb.cubic_to(
323                    *x1 as f32, *y1 as f32, *x2 as f32, *y2 as f32, *x3 as f32, *y3 as f32,
324                );
325            }
326            PathSegment::ClosePath => {
327                pb.close();
328            }
329        }
330    }
331
332    let result = pb.finish()?;
333
334    // Reject paths with extreme coordinates that would overflow the scanline
335    // rasterizer's integer math. This handles corrupted PDF content streams
336    // with garbled coordinates.
337    let b = result.bounds();
338    if b.left().abs() > MAX_PATH_COORD
339        || b.top().abs() > MAX_PATH_COORD
340        || b.right().abs() > MAX_PATH_COORD
341        || b.bottom().abs() > MAX_PATH_COORD
342    {
343        return None;
344    }
345
346    Some(result)
347}
348
349/// Detect degenerate fill paths that have zero extent in one dimension.
350///
351/// PDFs commonly draw table grid lines as zero-width or zero-height filled
352/// rectangles (e.g., `8 0 1031 0 re f`). Since these have no area, the
353/// fill rasterizer produces zero pixels. This function detects such paths
354/// so they can be rendered as hairline strokes instead.
355///
356/// The check is performed in the path's own coordinate space (pre-transform)
357/// using a very tight epsilon, so only paths with *exactly* zero extent in
358/// one dimension are detected. Paths containing curves are never degenerate
359/// — only MoveTo/LineTo/ClosePath segments qualify.
360fn is_degenerate_fill(path: &PsPath) -> bool {
361    let mut x_min = f64::INFINITY;
362    let mut x_max = f64::NEG_INFINITY;
363    let mut y_min = f64::INFINITY;
364    let mut y_max = f64::NEG_INFINITY;
365
366    for seg in &path.segments {
367        let (x, y) = match seg {
368            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => (*x, *y),
369            // Paths with curves are real shapes, not degenerate lines
370            PathSegment::CurveTo { .. } => return false,
371            PathSegment::ClosePath => continue,
372        };
373        x_min = x_min.min(x);
374        x_max = x_max.max(x);
375        y_min = y_min.min(y);
376        y_max = y_max.max(y);
377    }
378
379    if x_min > x_max {
380        return false; // empty path
381    }
382
383    let w = x_max - x_min;
384    let h = y_max - y_min;
385
386    // Degenerate if one dimension is exactly zero (within f64 epsilon)
387    // while the other has real extent. This catches `re` rects with
388    // zero width or height but not legitimate small shapes.
389    let eps = 1e-6;
390    (w < eps && h > eps) || (h < eps && w > eps)
391}
392
393/// Convert a tiny-skia Path back to a PsPath.
394/// Used for overprint stroke handling where we convert a stroked outline to a fill.
395
396/// Convert PostScript FillRule to tiny-skia FillRule.
397fn to_fill_rule(rule: &FillRule) -> SkiaFillRule {
398    match rule {
399        FillRule::NonZeroWinding => SkiaFillRule::Winding,
400        FillRule::EvenOdd => SkiaFillRule::EvenOdd,
401        _ => SkiaFillRule::Winding,
402    }
403}
404
405/// Convert PostScript LineCap to tiny-skia LineCap.
406fn to_line_cap(cap: LineCap) -> SkiaLineCap {
407    match cap {
408        LineCap::Butt => SkiaLineCap::Butt,
409        LineCap::Round => SkiaLineCap::Round,
410        LineCap::Square => SkiaLineCap::Square,
411        _ => SkiaLineCap::Butt,
412    }
413}
414
415/// Convert PostScript LineJoin to tiny-skia LineJoin.
416fn to_line_join(join: LineJoin) -> SkiaLineJoin {
417    match join {
418        LineJoin::Miter => SkiaLineJoin::Miter,
419        LineJoin::Round => SkiaLineJoin::Round,
420        LineJoin::Bevel => SkiaLineJoin::Bevel,
421        _ => SkiaLineJoin::Miter,
422    }
423}
424
425/// Detect if a path is an axis-aligned rectangle. Returns pixel-coordinate ClipRect if so.
426/// Handles both CW and CCW winding, with optional trailing ClosePath.
427fn detect_rect(path: &PsPath, page_w: u32, page_h: u32) -> Option<ClipRect> {
428    let segs = &path.segments;
429    // Expect: MoveTo + 3 LineTo + ClosePath (5 segments)
430    // or MoveTo + 3 LineTo + LineTo(back to start) + ClosePath (6 segments)
431    // or MoveTo + 3 LineTo (4 segments, implicitly closed)
432    let (move_to, lines, _has_close) = match segs.len() {
433        5 => {
434            // MoveTo + 3 LineTo + ClosePath
435            if !matches!(segs[4], PathSegment::ClosePath) {
436                return None;
437            }
438            (&segs[0], &segs[1..4], true)
439        }
440        6 => {
441            // MoveTo + 4 LineTo + ClosePath (4th LineTo returns to start)
442            if !matches!(segs[5], PathSegment::ClosePath) {
443                return None;
444            }
445            (&segs[0], &segs[1..5], true)
446        }
447        4 => {
448            // MoveTo + 3 LineTo (no explicit close)
449            (&segs[0], &segs[1..4], false)
450        }
451        _ => return None,
452    };
453
454    let PathSegment::MoveTo(mx, my) = move_to else {
455        return None;
456    };
457
458    // Collect all corner points
459    let mut pts = vec![(*mx, *my)];
460    for seg in lines {
461        match seg {
462            PathSegment::LineTo(x, y) => pts.push((*x, *y)),
463            _ => return None,
464        }
465    }
466
467    // If 5 points (4 LineTos), last must return to start
468    if pts.len() == 5 {
469        let (fx, fy) = pts[0];
470        let (lx, ly) = pts[4];
471        if (fx - lx).abs() > 0.01 || (fy - ly).abs() > 0.01 {
472            return None;
473        }
474        pts.truncate(4);
475    }
476
477    // Check axis-aligned: each edge must be horizontal or vertical
478    for i in 0..4 {
479        let (x1, y1) = pts[i];
480        let (x2, y2) = pts[(i + 1) % 4];
481        let dx = (x2 - x1).abs();
482        let dy = (y2 - y1).abs();
483        if dx > 0.01 && dy > 0.01 {
484            return None; // diagonal edge
485        }
486    }
487
488    // Compute bounding box
489    let min_x = pts.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
490    let min_y = pts.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
491    let max_x = pts.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max);
492    let max_y = pts.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
493
494    // Convert to pixel coords: floor for top-left, ceil for bottom-right, clamp to page
495    let x0 = (min_x.floor().max(0.0) as u32).min(page_w);
496    let y0 = (min_y.floor().max(0.0) as u32).min(page_h);
497    let x1 = (max_x.ceil().max(0.0) as u32).min(page_w);
498    let y1 = (max_y.ceil().max(0.0) as u32).min(page_h);
499
500    Some(ClipRect { x0, y0, x1, y1 })
501}
502
503/// Zero out mask pixels outside the given rectangle bounds.
504fn intersect_mask_with_rect(mask: &mut Mask, rect: &ClipRect, w: u32, h: u32) {
505    let data = mask.data_mut();
506    let stride = w as usize;
507
508    // Zero rows above rect
509    if rect.y0 > 0 {
510        let end = (rect.y0 as usize * stride).min(data.len());
511        data[..end].fill(0);
512    }
513
514    // Zero rows below rect
515    if rect.y1 < h {
516        let start = (rect.y1 as usize * stride).min(data.len());
517        data[start..].fill(0);
518    }
519
520    // Zero left and right margins within rect rows
521    for y in rect.y0..rect.y1.min(h) {
522        let row_start = y as usize * stride;
523        // Left margin
524        if rect.x0 > 0 {
525            let end = row_start + rect.x0 as usize;
526            data[row_start..end].fill(0);
527        }
528        // Right margin
529        if rect.x1 < w {
530            let start = row_start + rect.x1 as usize;
531            let end = row_start + stride;
532            data[start..end].fill(0);
533        }
534    }
535}
536
537/// Resolve a ClipRegion to an Option<&Mask> for paint operations.
538/// Returns `None` if the clip is empty (caller should skip painting).
539/// Returns `Some(None)` if no mask is needed (full page or no clip).
540/// Returns `Some(Some(&Mask))` if a mask should be applied.
541fn resolve_clip_mask<'a>(
542    clip_region: &'a Option<ClipRegion>,
543    temp_mask: &'a mut Option<Mask>,
544    w: u32,
545    h: u32,
546) -> Option<Option<&'a Mask>> {
547    match clip_region {
548        None => Some(None),
549        Some(ClipRegion::Mask(m)) => Some(Some(m)),
550        Some(ClipRegion::Rect(rect)) => {
551            if rect.is_empty() {
552                return None; // empty clip → skip painting
553            }
554            if rect.is_full_page(w, h) {
555                return Some(None); // full page → no mask needed
556            }
557            *temp_mask = rect.make_mask(w, h);
558            Some(temp_mask.as_ref())
559        }
560    }
561}
562
563/// Hash a PsPath's segments for clip mask caching. Uses bit-exact f64 comparison
564/// since paths are already in device space.
565fn hash_clip_path(path: &PsPath, fill_rule: &FillRule) -> u64 {
566    let mut hasher = std::collections::hash_map::DefaultHasher::new();
567    std::mem::discriminant(fill_rule).hash(&mut hasher);
568    for seg in &path.segments {
569        match seg {
570            PathSegment::MoveTo(x, y) => {
571                0u8.hash(&mut hasher);
572                x.to_bits().hash(&mut hasher);
573                y.to_bits().hash(&mut hasher);
574            }
575            PathSegment::LineTo(x, y) => {
576                1u8.hash(&mut hasher);
577                x.to_bits().hash(&mut hasher);
578                y.to_bits().hash(&mut hasher);
579            }
580            PathSegment::CurveTo {
581                x1,
582                y1,
583                x2,
584                y2,
585                x3,
586                y3,
587            } => {
588                2u8.hash(&mut hasher);
589                x1.to_bits().hash(&mut hasher);
590                y1.to_bits().hash(&mut hasher);
591                x2.to_bits().hash(&mut hasher);
592                y2.to_bits().hash(&mut hasher);
593                x3.to_bits().hash(&mut hasher);
594                y3.to_bits().hash(&mut hasher);
595            }
596            PathSegment::ClosePath => {
597                3u8.hash(&mut hasher);
598            }
599        }
600    }
601    hasher.finish()
602}
603
604/// Pixel-multiply two masks: dst[i] = dst[i] * src[i] / 255.
605fn intersect_masks(dst: &mut Mask, src: &Mask) {
606    let dst_data = dst.data_mut();
607    let src_data = src.data();
608    for (d, s) in dst_data.iter_mut().zip(src_data.iter()) {
609        *d = ((*d as u16 * *s as u16 + 127) / 255) as u8;
610    }
611}
612
613// ---- Banded rendering support ----
614
615use stet_graphics::display_list::{DisplayElement, DisplayList};
616
617/// Band-local clip state, rebuilt for each band.
618struct BandState {
619    clip_region: Option<ClipRegion>,
620    spare_mask: Option<Mask>,
621    /// Per-band cache (cleared each band since masks are band-sized).
622    clip_mask_cache: HashMap<u64, Mask>,
623    /// Persists across bands for cache-on-second-sight.
624    clip_mask_seen: HashSet<u64>,
625    /// Pool of recycled masks to avoid alloc/dealloc (mmap/munmap) per band.
626    mask_pool: Vec<Mask>,
627    /// Per-pixel CMYK tracking buffer for overprint simulation.
628    /// Only allocated when the display list contains overprint elements.
629    /// Layout: [C, M, Y, K] as f32 per pixel, band_w * band_h * 4 entries.
630    cmyk_buffer: Option<Vec<f32>>,
631    /// Per-pixel snapshot of pixmap RGBA *before* the first overprint paint
632    /// touched that pixel in this band. Subsequent overprint paints at the
633    /// same pixel blend their result against this snapshot instead of the
634    /// current (already-overprinted) pixmap, so AA edges of stacked overprints
635    /// do not leak earlier colour through later paints.
636    /// Lazily allocated on first overprint paint. 4 bytes per pixel.
637    op_bg_snapshot: Option<Vec<u8>>,
638    /// Parallel to `op_bg_snapshot`: 1 byte per pixel, non-zero iff the
639    /// snapshot for that pixel has been captured. Reset to zero over the
640    /// paint bbox on non-overprint writes so a later non-overprint fill
641    /// establishes a fresh backdrop for subsequent overprints.
642    op_touched: Option<Vec<u8>>,
643    /// Per-pixel marker for "this pixel's pixmap colour includes spot-
644    /// colorant contribution not reflected in `cmyk_buffer`". Set by
645    /// DeviceN/Separation paints that include at least one spot colorant
646    /// (i.e. `process_cmyk != native_cmyk`). Consulted by CMYK overprint
647    /// rendering so the no-op-delta skip only fires on pixels where
648    /// preserving the pixmap actually preserves spot colour — other pixels
649    /// still go through the ICC(new_cmyk) replace path.
650    spot_mask: Option<Vec<u8>>,
651}
652
653/// Maximum masks to keep in the recycling pool. Enough to avoid alloc churn
654/// without accumulating unbounded memory across bands.
655const MAX_POOL_MASKS: usize = 8;
656
657impl BandState {
658    /// Recycle all cached masks into the pool, clearing the cache for the next band.
659    #[allow(dead_code)]
660    fn recycle_cache(&mut self) {
661        for (_, mask) in self.clip_mask_cache.drain() {
662            if self.mask_pool.len() < MAX_POOL_MASKS {
663                self.mask_pool.push(mask);
664            }
665            // else: drop mask, returning memory to OS
666        }
667    }
668
669    /// Return a mask to the pool if under capacity, otherwise drop it.
670    fn recycle_mask(&mut self, mask: Mask) {
671        if self.mask_pool.len() < MAX_POOL_MASKS {
672            self.mask_pool.push(mask);
673        }
674    }
675
676    /// Get a recycled mask or allocate a new one.
677    fn take_mask(&mut self, w: u32, h: u32) -> Mask {
678        self.spare_mask
679            .take()
680            .or_else(|| self.mask_pool.pop())
681            .unwrap_or_else(|| Mask::new(w, h).expect("Failed to create mask"))
682    }
683
684    /// Take (or lazily allocate) the overprint background snapshot and
685    /// touched-flag buffers. Caller must pass them back via
686    /// `restore_op_buffers`. Layout: snapshot is 4 bytes/pixel (RGBA),
687    /// touched is 1 byte/pixel.
688    fn take_op_buffers(&mut self, w: u32, h: u32) -> (Vec<u8>, Vec<u8>) {
689        let n = w as usize * h as usize;
690        let bg = self
691            .op_bg_snapshot
692            .take()
693            .unwrap_or_else(|| vec![0u8; n * 4]);
694        let touched = self.op_touched.take().unwrap_or_else(|| vec![0u8; n]);
695        (bg, touched)
696    }
697
698    /// Put the overprint buffers back after an overprint render pass.
699    fn restore_op_buffers(&mut self, bg: Vec<u8>, touched: Vec<u8>) {
700        self.op_bg_snapshot = Some(bg);
701        self.op_touched = Some(touched);
702    }
703
704    /// Take (or lazily allocate) the spot-contribution mask (1 byte/pixel).
705    fn take_spot_mask(&mut self, w: u32, h: u32) -> Vec<u8> {
706        let n = w as usize * h as usize;
707        self.spot_mask.take().unwrap_or_else(|| vec![0u8; n])
708    }
709
710    /// Put the spot-contribution mask back after a paint.
711    fn restore_spot_mask(&mut self, mask: Vec<u8>) {
712        self.spot_mask = Some(mask);
713    }
714
715    /// Clear the overprint touched flag for pixels in the given bbox. Called
716    /// by non-overprint paints so a subsequent overprint at those pixels
717    /// captures a fresh backdrop snapshot instead of reusing a stale one.
718    #[allow(dead_code)]
719    fn invalidate_op_snapshot(
720        &mut self,
721        bbox_x0: usize,
722        bbox_y0: usize,
723        bbox_x1: usize,
724        bbox_y1: usize,
725        stride: usize,
726    ) {
727        if let Some(touched) = self.op_touched.as_mut() {
728            for y in bbox_y0..bbox_y1 {
729                let row = y * stride;
730                for x in bbox_x0..bbox_x1 {
731                    touched[row + x] = 0;
732                }
733            }
734        }
735    }
736}
737
738/// Unified rendering context that parameterizes both band and viewport rendering.
739///
740/// Band rendering is viewport rendering with `scale_x = scale_y = 1.0`.
741/// `viewport_transform(t, vp_x, vp_y, 1.0, 1.0)` == `offset_transform_xy(t, vp_x, vp_y)`.
742struct RenderContext<'a> {
743    /// Viewport/band origin X in device space.
744    vp_x: f32,
745    /// Viewport/band origin Y in device space.
746    vp_y: f32,
747    /// Horizontal scale (1.0 for band rendering, zoom for viewport).
748    scale_x: f32,
749    /// Vertical scale (1.0 for band rendering, zoom for viewport).
750    scale_y: f32,
751    /// Output pixmap width in pixels.
752    out_w: u32,
753    /// Output pixmap height in pixels.
754    out_h: u32,
755    /// Effective DPI at output scale.
756    effective_dpi: f64,
757    /// ICC color profile cache (for CMYK conversions).
758    icc: Option<&'a IccCache>,
759    /// Pre-converted image data cache (for viewport rendering).
760    image_cache: Option<&'a ImageCache>,
761    /// Pre-converted and prescaled images (for banded rendering).
762    preprocessed: Option<&'a [Option<PreprocessedImage>]>,
763    /// Element index in parent display list (for image cache lookup).
764    elem_idx: usize,
765    /// Disable anti-aliasing for all fill/stroke operations.
766    no_aa: bool,
767    /// When true, CMYK(0,0,0,0) pixels in images produce alpha=0 (OPM=1).
768    opm_zero_transparent: bool,
769    /// Knockout group painter rendering pass override. The knockout group
770    /// renders each Group painter twice — once for the blended-color result
771    /// (`ColorPass`), once for the painter's coverage mask (`CoveragePass`).
772    /// Both passes need to override `render_group`'s usual decisions:
773    ///   * `ColorPass` expands the per-pixel CMYK composite-back gate to all
774    ///     non-Normal blend modes so painters with separable blends like
775    ///     Screen / ColorDodge / Overlay / SoftLight blend in DeviceCMYK
776    ///     (matching the spec for `/CS DeviceCMYK` knockout groups) instead
777    ///     of in tiny-skia's sRGB blend.
778    ///   * `CoveragePass` disables the CMYK composite-back (its
779    ///     "source==backdrop" guard would discard white-CMYK painters
780    ///     against the transparent coverage backdrop) and forces the
781    ///     painter's alpha to 1.0 with Normal blend so the coverage offscreen
782    ///     captures the painter's *shape* even when the original alpha was 0
783    ///     (Opacity 0% test) or its blend mode would erase the source.
784    knockout_painter_pass: KnockoutPainterPass,
785    /// True when the immediately enclosing transparency group was isolated.
786    /// GWG 16.2's nested CMYK painter pattern (Painter B → Sub A/B) only
787    /// requires CMYK math at the inner non-isolated layer when Painter B
788    /// itself is isolated; for non-isolated parents (the 907 p28 financial
789    /// chart pattern) the existing sRGB compositing path produces the right
790    /// result and the new CMYK math would over-darken anti-aliased gray
791    /// strokes.
792    parent_group_isolated: bool,
793    /// True when rendering an alpha-extraction pass for a non-isolated group
794    /// with non-Normal blend mode.  Nested groups must render as isolated
795    /// (no backdrop preload, no two-pass) so the alpha channel reflects
796    /// pure element coverage rather than backdrop-blended results.
797    alpha_extraction_pass: bool,
798    /// OCG visibility overrides. Empty (every layer at its
799    /// `default_visible`) when the caller didn't supply one.
800    layer_set: &'a LayerSet,
801}
802
803/// Override mode applied to `render_group` while the knockout group renders
804/// one of its painters; see [`RenderContext::knockout_painter_pass`].
805#[derive(Clone, Copy, PartialEq, Eq)]
806enum KnockoutPainterPass {
807    /// Default rendering — no knockout overrides.
808    None,
809    /// Pass 1 (color): widen `plan_cmyk_compose` to any non-Normal blend mode.
810    ColorPass,
811    /// Pass 2 (coverage): disable CMYK composite-back, force full alpha and
812    /// Normal blend so the coverage offscreen captures the painter's shape.
813    CoveragePass,
814}
815
816impl RenderContext<'_> {
817    /// Apply viewport transform to a PostScript matrix.
818    fn transform(&self, m: &Matrix) -> Transform {
819        viewport_transform(
820            to_transform(m),
821            self.vp_x,
822            self.vp_y,
823            self.scale_x,
824            self.scale_y,
825        )
826    }
827}
828
829/// Y-axis bounding box in device pixels.
830struct YBBox {
831    y_min: f64,
832    y_max: f64,
833}
834
835/// A group of display list elements between consecutive InitClip boundaries.
836/// Each epoch starts with an InitClip (except possibly the first) and contains
837/// all elements up to the next InitClip. Epochs whose paint elements don't
838/// overlap a band can be skipped entirely.
839struct ClipEpoch {
840    /// Index of the first element in this epoch (the InitClip, or 0).
841    start_idx: usize,
842    /// One past the last element in this epoch.
843    end_idx: usize,
844    /// Y bounding box of all paint elements (Fill/Stroke/Image) in this epoch.
845    /// None if the epoch has no paint elements (pure clip setup).
846    paint_bbox: Option<YBBox>,
847    /// True if this epoch contains an ErasePage element (must process for all bands).
848    has_erase_page: bool,
849}
850
851/// Choose band height so that band pixmap + 2 clip masks fit in ~2 MB (L2 cache).
852/// Returns `page_h` when banding is not worthwhile (≤2 bands).
853fn select_band_height(w: u32, h: u32) -> u32 {
854    if w == 0 || h == 0 {
855        return h;
856    }
857    // Per-row cost: w*4 (RGBA) + w*1 (clip mask) + w*1 (spare mask) = w*6
858    let per_row = w as u64 * 6;
859    let budget = 2 * 1024 * 1024u64; // 2 MB (L2)
860    let max_rows = budget / per_row;
861
862    // Floor to power of 2, clamp to [16, h]
863    let band = if max_rows >= h as u64 {
864        h
865    } else {
866        let mut p = 1u32;
867        while (p as u64) * 2 <= max_rows {
868            p *= 2;
869        }
870        // Minimum 128 rows per band. At very high DPI the L2 budget yields
871        // tiny bands (16 rows at 2400 DPI = 1650 bands) where display list
872        // replay overhead dominates. 128-row minimum balances L3 cache fit
873        // (~15 MB working set at 2400 DPI) against per-band overhead (207 bands).
874        // Benchmarked: 16→31.3s, 64→22.5s, 128→21.8s, 256→22.1s.
875        p.clamp(128, h)
876    };
877
878    // Skip banding if ≤2 bands
879    if h.div_ceil(band) <= 2 {
880        return h;
881    }
882    band
883}
884
885/// True if this display list contains any `Clip`/`InitClip` op, recursively
886/// descending into `OcgGroup` / `Group` / `SoftMasked` children. When an
887/// `OcgGroup` wraps clip ops, Y-bbox culling would skip the whole group for
888/// bands its paint content doesn't overlap, but the clip state changes inside
889/// must still be applied — otherwise subsequent top-level elements inherit a
890/// stale clip. Use this to force such `OcgGroup`s to always be processed.
891fn contains_clip_op(list: &DisplayList) -> bool {
892    list.elements().iter().any(|e| match e {
893        DisplayElement::Clip { .. } | DisplayElement::InitClip => true,
894        DisplayElement::OcgGroup { elements, .. } => contains_clip_op(elements),
895        DisplayElement::Group { elements, .. } => contains_clip_op(elements),
896        DisplayElement::SoftMasked { content, .. } => contains_clip_op(content),
897        _ => false,
898    })
899}
900
901/// Compute conservative Y bounding boxes for display list elements.
902/// Returns `None` for elements that must always be processed (Clip, InitClip, ErasePage).
903///
904/// All returned Y values are in **device space** (pixel coordinates) so they can be
905/// compared directly against band boundaries.
906fn precompute_bboxes(list: &DisplayList, dpi: f64) -> Vec<Option<YBBox>> {
907    list.elements()
908        .iter()
909        .map(|elem| match elem {
910            DisplayElement::Fill { path, params } => fill_device_y_bbox(path, &params.ctm),
911            DisplayElement::Stroke { path, params } => stroke_device_y_bbox(path, params, dpi),
912            DisplayElement::Image { params, .. } => image_y_bbox(params),
913            DisplayElement::AxialShading { params } => {
914                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
915            }
916            DisplayElement::RadialShading { params } => {
917                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
918            }
919            DisplayElement::MeshShading { params } => {
920                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
921            }
922            DisplayElement::PatchShading { params } => {
923                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
924            }
925            DisplayElement::PatternFill { params } => pattern_fill_y_bbox(params),
926            DisplayElement::Group { params, .. } => Some(YBBox {
927                y_min: params.bbox[1],
928                y_max: params.bbox[3],
929            }),
930            DisplayElement::SoftMasked { params, .. } => Some(YBBox {
931                y_min: params.bbox[1],
932                y_max: params.bbox[3],
933            }),
934            DisplayElement::OcgGroup {
935                elements,
936                visibility,
937            } => {
938                // Hidden groups without clip ops contribute nothing — cull.
939                // (Hidden + has clip ops is handled below: we return paint
940                // bounds so the epoch has correct extent, and the band loop
941                // skips per-element culling for OcgGroups so the clip ops
942                // always execute.)
943                if !visibility.default_visible() && !contains_clip_op(elements) {
944                    return None;
945                }
946                let child_bboxes = precompute_bboxes(elements, dpi);
947                let mut y_min = f64::INFINITY;
948                let mut y_max = f64::NEG_INFINITY;
949                for cb in child_bboxes.into_iter().flatten() {
950                    y_min = y_min.min(cb.y_min);
951                    y_max = y_max.max(cb.y_max);
952                }
953                if y_min <= y_max {
954                    Some(YBBox { y_min, y_max })
955                } else {
956                    None
957                }
958            }
959            _ => None, // Clip, InitClip, ErasePage: always process
960        })
961        .collect()
962}
963
964/// Compute device-space Y bounding box for a shading element.
965/// Uses the BBox if present, otherwise returns a full-page sentinel
966/// (y_min=0, y_max=very large) so the element is never culled.
967fn shading_y_bbox_from_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<YBBox> {
968    if let Some(bbox) = bbox {
969        let corners = [
970            (bbox[0], bbox[1]),
971            (bbox[2], bbox[1]),
972            (bbox[0], bbox[3]),
973            (bbox[2], bbox[3]),
974        ];
975        let mut y_min = f64::INFINITY;
976        let mut y_max = f64::NEG_INFINITY;
977        for (x, y) in &corners {
978            let (_, dy) = ctm.transform_point(*x, *y);
979            y_min = y_min.min(dy);
980            y_max = y_max.max(dy);
981        }
982        Some(YBBox { y_min, y_max })
983    } else {
984        // No BBox — shading covers unbounded area; return sentinel so it's
985        // never culled by band processing.
986        Some(YBBox {
987            y_min: 0.0,
988            y_max: 1e9,
989        })
990    }
991}
992
993/// Compute device-space Y bounding box for a stroke element.
994///
995/// Isotropic strokes have paths already in device space (Identity CTM), so
996/// `path_y_bbox` gives device-space bounds directly. Anisotropic strokes have
997/// paths in user space with the full CTM — we must transform the bounding box
998/// through the CTM to get device-space bounds.
999fn stroke_device_y_bbox(path: &PsPath, params: &StrokeParams, dpi: f64) -> Option<YBBox> {
1000    let m = &params.ctm;
1001    let is_identity =
1002        m.a == 1.0 && m.b == 0.0 && m.c == 0.0 && m.d == 1.0 && m.tx == 0.0 && m.ty == 0.0;
1003
1004    // Use effective line width: actual width or hairline minimum, whichever is larger
1005    let effective_lw = params.line_width.max(hairline_min_width(&params.ctm, dpi));
1006
1007    if is_identity {
1008        // Path in device space — just read Y coords and expand for stroke width.
1009        return path_y_bbox(path).map(|mut bbox| {
1010            let expand = effective_lw * params.miter_limit * 0.5;
1011            bbox.y_min -= expand;
1012            bbox.y_max += expand;
1013            bbox
1014        });
1015    }
1016
1017    // Anisotropic: path in user space. Compute full XY bbox, transform corners
1018    // through CTM to get device-space Y range.
1019    let (mut x_min, mut x_max) = (f64::INFINITY, f64::NEG_INFINITY);
1020    let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY);
1021    for seg in &path.segments {
1022        match seg {
1023            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => {
1024                x_min = x_min.min(*x);
1025                x_max = x_max.max(*x);
1026                y_min = y_min.min(*y);
1027                y_max = y_max.max(*y);
1028            }
1029            PathSegment::CurveTo {
1030                x1,
1031                y1,
1032                x2,
1033                y2,
1034                x3,
1035                y3,
1036            } => {
1037                x_min = x_min.min(*x1).min(*x2).min(*x3);
1038                x_max = x_max.max(*x1).max(*x2).max(*x3);
1039                y_min = y_min.min(*y1).min(*y2).min(*y3);
1040                y_max = y_max.max(*y1).max(*y2).max(*y3);
1041            }
1042            PathSegment::ClosePath => {}
1043        }
1044    }
1045    if x_min > x_max {
1046        return None;
1047    }
1048
1049    // Transform all 4 corners of user-space bbox to device space
1050    let corners = [
1051        (x_min, y_min),
1052        (x_max, y_min),
1053        (x_min, y_max),
1054        (x_max, y_max),
1055    ];
1056    let mut dev_y_min = f64::INFINITY;
1057    let mut dev_y_max = f64::NEG_INFINITY;
1058    for (x, y) in &corners {
1059        let dy = m.b * x + m.d * y + m.ty;
1060        dev_y_min = dev_y_min.min(dy);
1061        dev_y_max = dev_y_max.max(dy);
1062    }
1063
1064    // Expand for stroke width + miter in device-space units.
1065    // ||[c,d]|| converts user-space line_width to device-space Y expansion.
1066    let col_y_len = (m.c * m.c + m.d * m.d).sqrt().max(1.0);
1067    let expand = effective_lw * col_y_len * params.miter_limit * 0.5;
1068    dev_y_min -= expand;
1069    dev_y_max += expand;
1070
1071    Some(YBBox {
1072        y_min: dev_y_min,
1073        y_max: dev_y_max,
1074    })
1075}
1076
1077/// Compute device-space Y bounds for a Fill element, accounting for CTM.
1078/// Mirrors `stroke_device_y_bbox` but without stroke-width expansion.
1079/// Paths may be stored either in device space (identity CTM, content streams)
1080/// or user space (non-identity CTM, synthesized annotation appearances).
1081fn fill_device_y_bbox(path: &PsPath, ctm: &Matrix) -> Option<YBBox> {
1082    let is_identity = ctm.a == 1.0
1083        && ctm.b == 0.0
1084        && ctm.c == 0.0
1085        && ctm.d == 1.0
1086        && ctm.tx == 0.0
1087        && ctm.ty == 0.0;
1088    if is_identity {
1089        return path_y_bbox(path);
1090    }
1091    let bbox = path_full_bbox(path)?;
1092    let corners = [
1093        (bbox.x_min, bbox.y_min),
1094        (bbox.x_max, bbox.y_min),
1095        (bbox.x_min, bbox.y_max),
1096        (bbox.x_max, bbox.y_max),
1097    ];
1098    let mut dev_y_min = f64::INFINITY;
1099    let mut dev_y_max = f64::NEG_INFINITY;
1100    for (x, y) in &corners {
1101        let dy = ctm.b * x + ctm.d * y + ctm.ty;
1102        dev_y_min = dev_y_min.min(dy);
1103        dev_y_max = dev_y_max.max(dy);
1104    }
1105    Some(YBBox {
1106        y_min: dev_y_min,
1107        y_max: dev_y_max,
1108    })
1109}
1110
1111/// Compute Y bounds from path segments (conservative: uses control points for curves).
1112fn path_y_bbox(path: &PsPath) -> Option<YBBox> {
1113    let mut y_min = f64::INFINITY;
1114    let mut y_max = f64::NEG_INFINITY;
1115    for seg in &path.segments {
1116        match seg {
1117            PathSegment::MoveTo(_, y) | PathSegment::LineTo(_, y) => {
1118                y_min = y_min.min(*y);
1119                y_max = y_max.max(*y);
1120            }
1121            PathSegment::CurveTo { y1, y2, y3, .. } => {
1122                y_min = y_min.min(*y1).min(*y2).min(*y3);
1123                y_max = y_max.max(*y1).max(*y2).max(*y3);
1124            }
1125            PathSegment::ClosePath => {}
1126        }
1127    }
1128    if y_min <= y_max {
1129        Some(YBBox { y_min, y_max })
1130    } else {
1131        None
1132    }
1133}
1134
1135/// Compute Y bounds for an image element from its transform.
1136fn image_y_bbox(params: &ImageParams) -> Option<YBBox> {
1137    let image_inv = params.image_matrix.invert()?;
1138    let combined = params.ctm.concat(&image_inv);
1139    let corners = [
1140        (0.0, 0.0),
1141        (params.width as f64, 0.0),
1142        (params.width as f64, params.height as f64),
1143        (0.0, params.height as f64),
1144    ];
1145    let mut y_min = f64::INFINITY;
1146    let mut y_max = f64::NEG_INFINITY;
1147    for (x, y) in &corners {
1148        let (_, dy) = combined.transform_point(*x, *y);
1149        y_min = y_min.min(dy);
1150        y_max = y_max.max(dy);
1151    }
1152    Some(YBBox { y_min, y_max })
1153}
1154
1155/// Pre-populate clip_mask_seen with hashes of clip paths that appear ≥2 times.
1156/// This lets the first band immediately cache repeated clip paths.
1157fn precompute_clip_seen(list: &DisplayList) -> HashSet<u64> {
1158    let mut counts: HashMap<u64, u32> = HashMap::new();
1159    for elem in list.elements() {
1160        if let DisplayElement::Clip { path, params } = elem {
1161            let hash = hash_clip_path(path, &params.fill_rule);
1162            *counts.entry(hash).or_insert(0) += 1;
1163        }
1164    }
1165    counts
1166        .into_iter()
1167        .filter(|(_, c)| *c > 1)
1168        .map(|(h, _)| h)
1169        .collect()
1170}
1171
1172/// Build clip epochs — groups of elements between InitClip boundaries.
1173/// Each epoch's paint_bbox is the union of Y ranges for all paint elements in it.
1174fn build_clip_epochs(list: &DisplayList, bboxes: &[Option<YBBox>]) -> Vec<ClipEpoch> {
1175    let elements = list.elements();
1176    let mut epochs = Vec::new();
1177    let mut epoch_start = 0;
1178    let mut y_min = f64::INFINITY;
1179    let mut y_max = f64::NEG_INFINITY;
1180    let mut has_erase = false;
1181
1182    for (i, element) in elements.iter().enumerate() {
1183        // InitClip starts a new epoch (close the previous one first)
1184        if matches!(element, DisplayElement::InitClip) && i > epoch_start {
1185            epochs.push(ClipEpoch {
1186                start_idx: epoch_start,
1187                end_idx: i,
1188                paint_bbox: if y_min <= y_max {
1189                    Some(YBBox { y_min, y_max })
1190                } else {
1191                    None
1192                },
1193                has_erase_page: has_erase,
1194            });
1195            epoch_start = i;
1196            y_min = f64::INFINITY;
1197            y_max = f64::NEG_INFINITY;
1198            has_erase = false;
1199        }
1200        if matches!(element, DisplayElement::ErasePage) {
1201            has_erase = true;
1202        }
1203        if let Some(ref bbox) = bboxes[i] {
1204            y_min = y_min.min(bbox.y_min);
1205            y_max = y_max.max(bbox.y_max);
1206        }
1207    }
1208    // Final epoch
1209    if epoch_start < elements.len() {
1210        epochs.push(ClipEpoch {
1211            start_idx: epoch_start,
1212            end_idx: elements.len(),
1213            paint_bbox: if y_min <= y_max {
1214                Some(YBBox { y_min, y_max })
1215            } else {
1216                None
1217            },
1218            has_erase_page: has_erase,
1219        });
1220    }
1221    epochs
1222}
1223
1224/// Apply a device-space Y offset to a tiny-skia Transform.
1225/// The original transform maps from path space to full-page device space;
1226/// we subtract `y_offset` from `ty` so band rows [y_start, y_start+band_h)
1227/// map to pixmap rows [0, band_h).
1228/// Composite premultiplied-alpha RGBA pixels onto a white background.
1229/// After this, all pixels are fully opaque (alpha=255).
1230fn composite_onto_white(data: &mut [u8]) {
1231    for pixel in data.chunks_exact_mut(4) {
1232        let a = pixel[3] as u16;
1233        if a == 255 {
1234            continue; // fully opaque — no compositing needed
1235        }
1236        let inv_a = 255 - a;
1237        pixel[0] = (pixel[0] as u16 + inv_a).min(255) as u8;
1238        pixel[1] = (pixel[1] as u16 + inv_a).min(255) as u8;
1239        pixel[2] = (pixel[2] as u16 + inv_a).min(255) as u8;
1240        pixel[3] = 255;
1241    }
1242}
1243
1244/// Extract the contribution of a non-isolated transparency group and composite
1245/// it onto the parent using the group's blend mode and alpha.
1246///
1247/// Composite a (possibly cropped) non-isolated group offscreen onto the parent pixmap.
1248///
1249/// Like `extract_and_composite_contribution`, but the offscreen and backdrop
1250/// are crop-sized (only covering the group's bounding box region), positioned
1251/// at `(crop_x, crop_y)` in the parent's coordinate system.
1252fn composite_non_isolated_group_cropped(
1253    target: &mut Pixmap,
1254    source: &Pixmap,
1255    backdrop: &[u8],
1256    params: &stet_graphics::display_list::GroupParams,
1257    clip_mask: Option<&stet_tiny_skia::Mask>,
1258    crop_x: i32,
1259    crop_y: i32,
1260) {
1261    let cw = source.width();
1262    let ch = source.height();
1263
1264    // Build a contribution pixmap: pixels that changed vs backdrop
1265    let Some(mut contribution) = Pixmap::new(cw, ch) else {
1266        return;
1267    };
1268    let src_data = source.data();
1269    let contrib_data = contribution.data_mut();
1270
1271    for (i, chunk) in contrib_data.chunks_exact_mut(4).enumerate() {
1272        let off = i * 4;
1273        if src_data[off] != backdrop[off]
1274            || src_data[off + 1] != backdrop[off + 1]
1275            || src_data[off + 2] != backdrop[off + 2]
1276            || src_data[off + 3] != backdrop[off + 3]
1277        {
1278            chunk.copy_from_slice(&src_data[off..off + 4]);
1279        }
1280    }
1281
1282    let paint = stet_tiny_skia::PixmapPaint {
1283        opacity: params.alpha as f32,
1284        blend_mode: u8_to_blend_mode(params.blend_mode),
1285        quality: stet_tiny_skia::FilterQuality::Nearest,
1286    };
1287    target.draw_pixmap(
1288        crop_x,
1289        crop_y,
1290        contribution.as_ref(),
1291        &paint,
1292        Transform::identity(),
1293        clip_mask,
1294    );
1295}
1296
1297/// Non-isolated group composite-back using the proper source-extraction
1298/// formula (ISO 32000-1 §11.4.8).
1299///
1300/// `source` was rendered against the `backdrop`; `isolated` was rendered
1301/// against transparent.  The isolated render's alpha channel gives the
1302/// group's shape, which lets us extract the source color:
1303///
1304///   C_g_premul = R - B · (1 - α_g)      (premultiplied source color)
1305///   α_g        = isolated alpha channel
1306///
1307/// The extracted contribution is then composited onto `target` with the
1308/// group's blend mode and opacity.
1309fn composite_non_isolated_extracted(
1310    target: &mut Pixmap,
1311    source: &Pixmap,
1312    isolated: &Pixmap,
1313    backdrop: &[u8],
1314    params: &stet_graphics::display_list::GroupParams,
1315    clip_mask: Option<&stet_tiny_skia::Mask>,
1316    crop_x: i32,
1317    crop_y: i32,
1318) {
1319    let cw = source.width();
1320    let ch = source.height();
1321
1322    let Some(mut contribution) = Pixmap::new(cw, ch) else {
1323        return;
1324    };
1325    let src_data = source.data();
1326    let iso_data = isolated.data();
1327    let contrib_data = contribution.data_mut();
1328
1329    for i in 0..(cw as usize * ch as usize) {
1330        let off = i * 4;
1331        let alpha_g = iso_data[off + 3];
1332        if alpha_g == 0 {
1333            continue; // no group contribution at this pixel
1334        }
1335
1336        // Extract premultiplied source: C_g_premul = R - B · (1 - α_g/255)
1337        let inv_alpha = 255 - alpha_g as i32;
1338        for c in 0..3 {
1339            let r = src_data[off + c] as i32;
1340            let b = backdrop[off + c] as i32;
1341            let raw = r - (b * inv_alpha + 127) / 255;
1342            contrib_data[off + c] = raw.clamp(0, 255) as u8;
1343        }
1344        contrib_data[off + 3] = alpha_g;
1345    }
1346
1347    let paint = stet_tiny_skia::PixmapPaint {
1348        opacity: params.alpha as f32,
1349        blend_mode: u8_to_blend_mode(params.blend_mode),
1350        quality: stet_tiny_skia::FilterQuality::Nearest,
1351    };
1352    target.draw_pixmap(
1353        crop_x,
1354        crop_y,
1355        contribution.as_ref(),
1356        &paint,
1357        Transform::identity(),
1358        clip_mask,
1359    );
1360}
1361
1362/// Apply a combined offset + scale to a tiny-skia Transform for viewport rendering.
1363/// Maps device-space coordinates into viewport-local pixel coordinates:
1364///   output_x = (device_x - vp_x) * scale_x
1365///   output_y = (device_y - vp_y) * scale_y
1366fn viewport_transform(t: Transform, vp_x: f32, vp_y: f32, scale_x: f32, scale_y: f32) -> Transform {
1367    // Post-compose: first apply `t` (path→device), then translate(-vp_x,-vp_y), then scale
1368    Transform::from_row(
1369        t.sx * scale_x,
1370        t.ky * scale_y,
1371        t.kx * scale_x,
1372        t.sy * scale_y,
1373        (t.tx - vp_x) * scale_x,
1374        (t.ty - vp_y) * scale_y,
1375    )
1376}
1377
1378/// Fast area-average box filter resample for downscaling.
1379///
1380/// Each output pixel averages all source pixels that fall within its footprint.
1381/// Two-pass separable (horizontal then vertical) for O(src) total work regardless
1382/// of scale ratio. Produces quality equivalent to Lanczos3 for downscaling at a
1383/// fraction of the cost.
1384fn box_resample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
1385    if dw == 0 || dh == 0 {
1386        return Vec::new();
1387    }
1388    let (sw, sh, dw, dh) = (sw as usize, sh as usize, dw as usize, dh as usize);
1389
1390    // Pass 1: horizontal (sw → dw) with fractional edge weights.
1391    // Each output pixel covers [left_f, right_f] in source space. Edge source
1392    // pixels get proportional weight; interior pixels get weight 1.0.
1393    let ratio_x = sw as f32 / dw as f32;
1394    let mut tmp = vec![0.0f32; dw * sh * 4];
1395    let tmp_stride = dw * 4;
1396
1397    for y in 0..sh {
1398        let row_off = y * sw * 4;
1399        let dst_row = y * tmp_stride;
1400        for dx in 0..dw {
1401            let left_f = dx as f32 * ratio_x;
1402            let right_f = (dx + 1) as f32 * ratio_x;
1403            let left = (left_f as usize).min(sw - 1);
1404            let right = (right_f.ceil() as usize).min(sw);
1405            let inv_area = 1.0 / (right_f - left_f);
1406            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1407            for sx in left..right {
1408                // Weight: fraction of this source pixel covered by the output pixel
1409                let pixel_left = sx as f32;
1410                let pixel_right = (sx + 1) as f32;
1411                let w = pixel_right.min(right_f) - pixel_left.max(left_f);
1412                let i = row_off + sx * 4;
1413                r += src[i] as f32 * w;
1414                g += src[i + 1] as f32 * w;
1415                b += src[i + 2] as f32 * w;
1416                a += src[i + 3] as f32 * w;
1417            }
1418            let di = dst_row + dx * 4;
1419            tmp[di] = r * inv_area;
1420            tmp[di + 1] = g * inv_area;
1421            tmp[di + 2] = b * inv_area;
1422            tmp[di + 3] = a * inv_area;
1423        }
1424    }
1425
1426    // Pass 2: vertical (sh → dh) with fractional edge weights, row-major order.
1427    let ratio_y = sh as f32 / dh as f32;
1428    let mut out = vec![0u8; dw * dh * 4];
1429    let out_stride = dw * 4;
1430
1431    for dy in 0..dh {
1432        let top_f = dy as f32 * ratio_y;
1433        let bottom_f = (dy + 1) as f32 * ratio_y;
1434        let top = (top_f as usize).min(sh - 1);
1435        let bottom = (bottom_f.ceil() as usize).min(sh);
1436        let inv_area = 1.0 / (bottom_f - top_f);
1437
1438        // Pre-compute row weights
1439        let n_rows = bottom - top;
1440        let mut row_weights_buf: [(usize, f32); 8] = [(0, 0.0); 8];
1441        let row_weights_vec: Vec<(usize, f32)>;
1442        let row_weights: &[(usize, f32)] = if n_rows <= 8 {
1443            for (i, sy) in (top..bottom).enumerate() {
1444                let pixel_top = sy as f32;
1445                let pixel_bottom = (sy + 1) as f32;
1446                let w = pixel_bottom.min(bottom_f) - pixel_top.max(top_f);
1447                row_weights_buf[i] = (sy, w);
1448            }
1449            &row_weights_buf[..n_rows]
1450        } else {
1451            row_weights_vec = (top..bottom)
1452                .map(|sy| {
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                    (sy, w)
1457                })
1458                .collect();
1459            &row_weights_vec
1460        };
1461
1462        let dst_row = dy * out_stride;
1463        for dx in 0..dw {
1464            let col = dx * 4;
1465            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1466            for &(sy, w) in row_weights {
1467                let i = sy * tmp_stride + col;
1468                r += tmp[i] * w;
1469                g += tmp[i + 1] * w;
1470                b += tmp[i + 2] * w;
1471                a += tmp[i + 3] * w;
1472            }
1473            let di = dst_row + col;
1474            out[di] = (r * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1475            out[di + 1] = (g * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1476            out[di + 2] = (b * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1477            out[di + 3] = (a * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1478        }
1479    }
1480
1481    out
1482}
1483
1484/// Bicubic (Catmull-Rom) resample for upscaling — two-pass separable.
1485///
1486/// Pass 1: horizontal resample (sw → dw) at f32 precision.
1487/// Pass 2: vertical resample (sh → dh) and quantize to u8.
1488///
1489/// Separable approach: O(dw×sh + dw×dh) × 4 taps instead of O(dw×dh) × 16 taps.
1490fn bicubic_resample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
1491    if dw == 0 || dh == 0 {
1492        return Vec::new();
1493    }
1494
1495    let (sw, sh, dw, dh) = (sw as usize, sh as usize, dw as usize, dh as usize);
1496    let ratio_x = sw as f32 / dw as f32;
1497    let ratio_y = sh as f32 / dh as f32;
1498
1499    // Pass 1: horizontal (sw → dw), keep sh rows, store as f32.
1500    let mut tmp = vec![0.0f32; dw * sh * 4];
1501    for y in 0..sh {
1502        let src_row = y * sw * 4;
1503        let dst_row = y * dw * 4;
1504        for dx in 0..dw {
1505            let sx = (dx as f32 + 0.5) * ratio_x - 0.5;
1506            let sx_floor = sx.floor() as i32;
1507            let fx = sx - sx_floor as f32;
1508            let w0 = catmull_rom(fx + 1.0);
1509            let w1 = catmull_rom(fx);
1510            let w2 = catmull_rom(1.0 - fx);
1511            let w3 = catmull_rom(2.0 - fx);
1512            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1513            for (k, w) in [
1514                (sx_floor - 1, w0),
1515                (sx_floor, w1),
1516                (sx_floor + 1, w2),
1517                (sx_floor + 2, w3),
1518            ] {
1519                let px = k.clamp(0, sw as i32 - 1) as usize;
1520                let i = src_row + px * 4;
1521                r += src[i] as f32 * w;
1522                g += src[i + 1] as f32 * w;
1523                b += src[i + 2] as f32 * w;
1524                a += src[i + 3] as f32 * w;
1525            }
1526            let di = dst_row + dx * 4;
1527            tmp[di] = r;
1528            tmp[di + 1] = g;
1529            tmp[di + 2] = b;
1530            tmp[di + 3] = a;
1531        }
1532    }
1533
1534    // Pass 2: vertical (sh → dh) on the dw-wide tmp, quantize to u8.
1535    // Row-major order for cache-friendly access.
1536    let mut out = vec![0u8; dw * dh * 4];
1537    let tmp_stride = dw * 4;
1538    let out_stride = dw * 4;
1539    for dy in 0..dh {
1540        let sy = (dy as f32 + 0.5) * ratio_y - 0.5;
1541        let sy_floor = sy.floor() as i32;
1542        let fy = sy - sy_floor as f32;
1543        let w0 = catmull_rom(fy + 1.0);
1544        let w1 = catmull_rom(fy);
1545        let w2 = catmull_rom(1.0 - fy);
1546        let w3 = catmull_rom(2.0 - fy);
1547        let py0 = (sy_floor - 1).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1548        let py1 = sy_floor.clamp(0, sh as i32 - 1) as usize * tmp_stride;
1549        let py2 = (sy_floor + 1).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1550        let py3 = (sy_floor + 2).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1551        let dst_row = dy * out_stride;
1552        for dx in 0..dw {
1553            let col = dx * 4;
1554            let r = tmp[py0 + col] * w0
1555                + tmp[py1 + col] * w1
1556                + tmp[py2 + col] * w2
1557                + tmp[py3 + col] * w3;
1558            let g = tmp[py0 + col + 1] * w0
1559                + tmp[py1 + col + 1] * w1
1560                + tmp[py2 + col + 1] * w2
1561                + tmp[py3 + col + 1] * w3;
1562            let b = tmp[py0 + col + 2] * w0
1563                + tmp[py1 + col + 2] * w1
1564                + tmp[py2 + col + 2] * w2
1565                + tmp[py3 + col + 2] * w3;
1566            let a = tmp[py0 + col + 3] * w0
1567                + tmp[py1 + col + 3] * w1
1568                + tmp[py2 + col + 3] * w2
1569                + tmp[py3 + col + 3] * w3;
1570            let di = dst_row + col;
1571            out[di] = r.round().clamp(0.0, 255.0) as u8;
1572            out[di + 1] = g.round().clamp(0.0, 255.0) as u8;
1573            out[di + 2] = b.round().clamp(0.0, 255.0) as u8;
1574            out[di + 3] = a.round().clamp(0.0, 255.0) as u8;
1575        }
1576    }
1577
1578    out
1579}
1580
1581/// Catmull-Rom spline weight (a = -0.5).
1582#[inline]
1583fn catmull_rom(t: f32) -> f32 {
1584    let t = t.abs();
1585    if t < 1.0 {
1586        (1.5 * t - 2.5) * t * t + 1.0
1587    } else if t < 2.0 {
1588        ((-0.5 * t + 2.5) * t - 4.0) * t + 2.0
1589    } else {
1590        0.0
1591    }
1592}
1593
1594/// Pre-downsample an image when the transform indicates significant downscaling.
1595///
1596/// tiny-skia's bilinear filter only samples a 2×2 neighborhood — it has no mipmap
1597/// support, so large downscale ratios cause severe aliasing (e.g., 300 DPI bitmap
1598/// fonts rendered at screen resolution).
1599///
1600/// For axis-aligned transforms: box-filter resample to the exact target dimensions.
1601///
1602/// Build an `IccCache` from ICC profiles found in a display list.
1603///
1604/// Registers all unique ICCBased profiles and optionally the system CMYK
1605/// profile. When `proofing_enabled` is true, ICCBased profiles registered
1606/// while scanning the display list are color-managed *through* the system
1607/// CMYK (the PDF's OutputIntent), so a render-thread cache built from the
1608/// effective OutputIntent matches the bake-time cache that produced the
1609/// display list. PostScript callers should pass `false` (no
1610/// PDF/X OutputIntent semantics).
1611pub fn build_icc_cache_for_list(
1612    list: &DisplayList,
1613    system_cmyk_bytes: Option<&std::sync::Arc<Vec<u8>>>,
1614    proofing_enabled: bool,
1615) -> IccCache {
1616    let mut cache = IccCache::new();
1617    let mut seen = HashSet::new();
1618
1619    // Register system CMYK profile first. Proofing must stay off here: the
1620    // OutputIntent itself converts directly to sRGB, not through itself.
1621    if let Some(cmyk_bytes) = system_cmyk_bytes
1622        && let Some(hash) = cache.register_profile(cmyk_bytes)
1623    {
1624        seen.insert(hash);
1625        // Set the default CMYK hash so convert_image_8bit works for DeviceCMYK
1626        cache.set_default_cmyk_hash(hash);
1627        // Pre-warm the sRGB→CMYK reverse transform so band renderers, which
1628        // only hold an `&IccCache`, can use `convert_rgb_to_cmyk_readonly`
1629        // when populating the parallel CMYK buffer for non-CMYK painters.
1630        cache.prepare_reverse_cmyk();
1631        // Pre-build the per-intent Lab → OI CMYK samplers so Lab fills can
1632        // populate `native_cmyk` from `&IccCache` (mirrors the PNG path's
1633        // `apply_output_intent_as_default_cmyk`). Required for GWG 22.1.
1634        cache.prepare_lab_to_oi_cmyk();
1635    }
1636
1637    // Enable proofing AFTER the OutputIntent itself is registered so the
1638    // chain logic in `register_profile` sees `default_cmyk_hash` set when
1639    // subsequent ICCBased profiles arrive — those get chained through the
1640    // OutputIntent.
1641    cache.set_proofing_enabled(proofing_enabled);
1642
1643    // Scan display list for ICCBased images and shadings (recursing into Groups)
1644    fn scan_elements(
1645        elements: &[DisplayElement],
1646        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1647        cache: &mut IccCache,
1648    ) {
1649        for element in elements {
1650            // Recurse into groups
1651            if let DisplayElement::Group { elements: sub, .. } = element {
1652                scan_elements(sub.elements(), seen, cache);
1653            }
1654            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1655                scan_elements(content.elements(), seen, cache);
1656                scan_elements(mask.elements(), seen, cache);
1657            }
1658            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1659                scan_elements(sub.elements(), seen, cache);
1660            }
1661            // Shading color spaces
1662            let shading_cs = match element {
1663                DisplayElement::AxialShading { params } => Some(&params.color_space),
1664                DisplayElement::RadialShading { params } => Some(&params.color_space),
1665                DisplayElement::MeshShading { params } => Some(&params.color_space),
1666                DisplayElement::PatchShading { params } => Some(&params.color_space),
1667                _ => None,
1668            };
1669            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1670                n,
1671                profile_hash,
1672                profile_data,
1673            }) = shading_cs
1674            {
1675                if seen.insert(*profile_hash) {
1676                    cache.register_profile_with_n(profile_data, Some(*n));
1677                }
1678            }
1679            // Image color spaces
1680            if let DisplayElement::Image { params, .. } = element {
1681                match &params.color_space {
1682                    ImageColorSpace::ICCBased {
1683                        n,
1684                        profile_hash,
1685                        profile_data,
1686                    } if seen.insert(*profile_hash) => {
1687                        cache.register_profile_with_n(profile_data, Some(*n));
1688                    }
1689                    ImageColorSpace::Indexed { base, .. }
1690                        if matches!(base.as_ref(), ImageColorSpace::ICCBased { .. }) =>
1691                    {
1692                        if let ImageColorSpace::ICCBased {
1693                            n,
1694                            profile_hash,
1695                            profile_data,
1696                        } = base.as_ref()
1697                        {
1698                            if seen.insert(*profile_hash) {
1699                                cache.register_profile_with_n(profile_data, Some(*n));
1700                            }
1701                        }
1702                    }
1703                    _ => {}
1704                }
1705            }
1706        }
1707    }
1708    scan_elements(list.elements(), &mut seen, &mut cache);
1709
1710    cache
1711}
1712
1713/// Register ICC profiles from shading elements in a display list.
1714///
1715/// Recursively scans Groups and SoftMasks for ICCBased shading color spaces
1716/// and registers their profiles in the cache.
1717fn register_shading_icc_profiles(list: &DisplayList, cache: &mut IccCache) {
1718    fn register_image_iccs(
1719        cs: &ImageColorSpace,
1720        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1721        cache: &mut IccCache,
1722    ) {
1723        match cs {
1724            ImageColorSpace::ICCBased {
1725                n,
1726                profile_hash,
1727                profile_data,
1728            } => {
1729                if seen.insert(*profile_hash) {
1730                    cache.register_profile_with_n(profile_data, Some(*n));
1731                }
1732            }
1733            ImageColorSpace::Indexed { base, .. } => register_image_iccs(base, seen, cache),
1734            ImageColorSpace::Separation { alt_space, .. }
1735            | ImageColorSpace::DeviceN { alt_space, .. } => {
1736                register_image_iccs(alt_space, seen, cache)
1737            }
1738            _ => {}
1739        }
1740    }
1741    fn scan(
1742        elements: &[DisplayElement],
1743        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1744        cache: &mut IccCache,
1745    ) {
1746        for element in elements {
1747            if let DisplayElement::Group { elements: sub, .. } = element {
1748                scan(sub.elements(), seen, cache);
1749            }
1750            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1751                scan(content.elements(), seen, cache);
1752                scan(mask.elements(), seen, cache);
1753            }
1754            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1755                scan(sub.elements(), seen, cache);
1756            }
1757            let shading_cs = match element {
1758                DisplayElement::AxialShading { params } => Some(&params.color_space),
1759                DisplayElement::RadialShading { params } => Some(&params.color_space),
1760                DisplayElement::MeshShading { params } => Some(&params.color_space),
1761                DisplayElement::PatchShading { params } => Some(&params.color_space),
1762                _ => None,
1763            };
1764            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1765                n,
1766                profile_hash,
1767                profile_data,
1768            }) = shading_cs
1769                && seen.insert(*profile_hash)
1770            {
1771                cache.register_profile_with_n(profile_data, Some(*n));
1772            }
1773            if let DisplayElement::Image { params, .. } = element {
1774                register_image_iccs(&params.color_space, seen, cache);
1775            }
1776        }
1777    }
1778    let mut seen = HashSet::new();
1779    scan(list.elements(), &mut seen, cache);
1780}
1781
1782/// Convert raw image samples to RGBA for rasterization.
1783///
1784/// Handles all `ImageColorSpace` variants, producing width×height×4 RGBA bytes.
1785fn samples_to_rgba(
1786    data: &[u8],
1787    params: &ImageParams,
1788    icc: Option<&IccCache>,
1789    opm_zero_transparent: bool,
1790) -> Vec<u8> {
1791    let w = params.width as usize;
1792    let h = params.height as usize;
1793    let npixels = w * h;
1794    let bpc = params.bits_per_component;
1795    match &params.color_space {
1796        ImageColorSpace::PreconvertedRGBA => {
1797            // Already RGBA — just return as-is
1798            data.to_vec()
1799        }
1800        ImageColorSpace::DeviceGray => {
1801            let mut rgba = vec![255u8; npixels * 4];
1802            if bpc == 16 {
1803                for i in 0..npixels {
1804                    let g = data.get(i * 2).copied().unwrap_or(0);
1805                    let pi = i * 4;
1806                    rgba[pi] = g;
1807                    rgba[pi + 1] = g;
1808                    rgba[pi + 2] = g;
1809                }
1810            } else {
1811                for i in 0..npixels {
1812                    let g = data.get(i).copied().unwrap_or(0);
1813                    let pi = i * 4;
1814                    rgba[pi] = g;
1815                    rgba[pi + 1] = g;
1816                    rgba[pi + 2] = g;
1817                }
1818            }
1819            rgba
1820        }
1821        ImageColorSpace::DeviceRGB => {
1822            let mut rgba = vec![255u8; npixels * 4];
1823            if bpc == 16 {
1824                // 16 BPC: 6 bytes per pixel (R_hi R_lo G_hi G_lo B_hi B_lo)
1825                // Take high byte of each 16-bit sample
1826                for i in 0..npixels {
1827                    let si = i * 6;
1828                    let pi = i * 4;
1829                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1830                    rgba[pi + 1] = data.get(si + 2).copied().unwrap_or(0);
1831                    rgba[pi + 2] = data.get(si + 4).copied().unwrap_or(0);
1832                }
1833            } else {
1834                for i in 0..npixels {
1835                    let si = i * 3;
1836                    let pi = i * 4;
1837                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1838                    rgba[pi + 1] = data.get(si + 1).copied().unwrap_or(0);
1839                    rgba[pi + 2] = data.get(si + 2).copied().unwrap_or(0);
1840                }
1841            }
1842            rgba
1843        }
1844        ImageColorSpace::DeviceCMYK => {
1845            // Try ICC-based CMYK→RGB conversion via system CMYK profile.
1846            // Convert as many complete pixels as the data allows; PLRM-fallback
1847            // for any remaining pixels with insufficient data.
1848            if let Some(cache) = icc
1849                && let Some(cmyk_hash) = cache.default_cmyk_hash()
1850            {
1851                let avail_pixels = data.len() / 4;
1852                let icc_pixels = avail_pixels.min(npixels);
1853                if icc_pixels > 0
1854                    && let Some(rgb) = cache.convert_image_8bit(cmyk_hash, data, icc_pixels)
1855                {
1856                    let mut rgba = vec![255u8; npixels * 4];
1857                    for i in 0..icc_pixels {
1858                        rgba[i * 4] = rgb[i * 3];
1859                        rgba[i * 4 + 1] = rgb[i * 3 + 1];
1860                        rgba[i * 4 + 2] = rgb[i * 3 + 2];
1861                        // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1862                        if opm_zero_transparent {
1863                            let si = i * 4;
1864                            if data[si] == 0
1865                                && data[si + 1] == 0
1866                                && data[si + 2] == 0
1867                                && data[si + 3] == 0
1868                            {
1869                                rgba[i * 4 + 3] = 0;
1870                            }
1871                        }
1872                    }
1873                    // Remaining pixels (if data was short) stay white (0xFF)
1874                    return rgba;
1875                }
1876            }
1877            // Fallback: PLRM CMYK→RGB formula
1878            let mut rgba = vec![255u8; npixels * 4];
1879            for i in 0..npixels {
1880                let si = i * 4;
1881                let c = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1882                let m = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1883                let y = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1884                let k = data.get(si + 3).copied().unwrap_or(0) as f64 / 255.0;
1885                let r = (1.0 - c.min(1.0)) * (1.0 - k.min(1.0));
1886                let g = (1.0 - m.min(1.0)) * (1.0 - k.min(1.0));
1887                let b = (1.0 - y.min(1.0)) * (1.0 - k.min(1.0));
1888                let pi = i * 4;
1889                rgba[pi] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
1890                rgba[pi + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
1891                rgba[pi + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
1892                // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1893                if opm_zero_transparent
1894                    && data.get(si).copied().unwrap_or(0) == 0
1895                    && data.get(si + 1).copied().unwrap_or(0) == 0
1896                    && data.get(si + 2).copied().unwrap_or(0) == 0
1897                    && data.get(si + 3).copied().unwrap_or(0) == 0
1898                {
1899                    rgba[pi + 3] = 0;
1900                }
1901            }
1902            rgba
1903        }
1904        ImageColorSpace::ICCBased {
1905            n,
1906            profile_hash,
1907            profile_data,
1908        } => {
1909            // Try ICC-based conversion if cache is available. Routes through
1910            // the proofing chain (`chain_per_intent_8bit[intent]`) when the
1911            // chain has been populated for this intent — the proofing chain
1912            // is what `convert_color_with_intent` uses for vector paints,
1913            // so images need it too to match. Without this, an Adobe-RGB
1914            // image renders via the source profile's direct RGB→sRGB while
1915            // the surrounding CMYK paint goes through the OutputIntent
1916            // CMYK→sRGB; the two sRGB outputs diverge. GWG 17.2 calibrates
1917            // both so they match under correct CMS, and the test's "X"
1918            // appears whenever the image bypasses the OI roundtrip.
1919            let intent = stet_graphics::icc::intent_from_pdf_byte(params.rendering_intent);
1920            if let Some(cache) = icc
1921                && cache.has_profile(profile_hash)
1922                && let Some(rgb) =
1923                    cache.convert_image_8bit_with_intent(profile_hash, data, npixels, intent)
1924            {
1925                let mut rgba = vec![255u8; npixels * 4];
1926                for i in 0..npixels {
1927                    rgba[i * 4] = rgb[i * 3];
1928                    rgba[i * 4 + 1] = rgb[i * 3 + 1];
1929                    rgba[i * 4 + 2] = rgb[i * 3 + 2];
1930                    // OPM=1 on 4-component (CMYK) ICC profiles
1931                    if opm_zero_transparent && *n == 4 {
1932                        let si = i * *n as usize;
1933                        if si + 3 < data.len()
1934                            && data[si] == 0
1935                            && data[si + 1] == 0
1936                            && data[si + 2] == 0
1937                            && data[si + 3] == 0
1938                        {
1939                            rgba[i * 4 + 3] = 0;
1940                        }
1941                    }
1942                }
1943                return rgba;
1944            }
1945            // Fallback to device equivalent based on component count
1946            let _ = (profile_hash, profile_data);
1947            let fallback = match n {
1948                1 => ImageColorSpace::DeviceGray,
1949                4 => ImageColorSpace::DeviceCMYK,
1950                _ => ImageColorSpace::DeviceRGB,
1951            };
1952            let p = ImageParams {
1953                color_space: fallback,
1954                bits_per_component: 8,
1955                ..params.clone()
1956            };
1957            samples_to_rgba(data, &p, icc, opm_zero_transparent)
1958        }
1959        ImageColorSpace::Indexed {
1960            base,
1961            hival,
1962            lookup,
1963        } => {
1964            let base_ncomp = base.num_components() as usize;
1965            // Expand indexed samples to base color space, then convert
1966            let mut expanded = Vec::with_capacity(npixels * base_ncomp);
1967            for i in 0..npixels {
1968                let idx = data.get(i).copied().unwrap_or(0) as usize;
1969                let idx = idx.min(*hival as usize);
1970                let offset = idx * base_ncomp;
1971                for c in 0..base_ncomp {
1972                    expanded.push(lookup.get(offset + c).copied().unwrap_or(0));
1973                }
1974            }
1975            let p = ImageParams {
1976                color_space: *base.clone(),
1977                bits_per_component: 8,
1978                ..params.clone()
1979            };
1980            samples_to_rgba(&expanded, &p, icc, opm_zero_transparent)
1981        }
1982        ImageColorSpace::CIEBasedABC { params: cie_params } => {
1983            let mut rgba = vec![255u8; npixels * 4];
1984            for i in 0..npixels {
1985                let si = i * 3;
1986                let a = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1987                let b = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1988                let c = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1989                let color = DeviceColor::from_cie_abc(a, b, c, cie_params);
1990                let pi = i * 4;
1991                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1992                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1993                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1994            }
1995            rgba
1996        }
1997        ImageColorSpace::CIEBasedA { params: cie_params } => {
1998            let mut rgba = vec![255u8; npixels * 4];
1999            for i in 0..npixels {
2000                let val = data.get(i).copied().unwrap_or(0) as f64 / 255.0;
2001                let color = DeviceColor::from_cie_a(val, cie_params);
2002                let pi = i * 4;
2003                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
2004                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
2005                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
2006            }
2007            rgba
2008        }
2009        ImageColorSpace::Lab { range, .. } => {
2010            let mut rgba = vec![255u8; npixels * 4];
2011            let a_span = range[1] - range[0];
2012            let b_span = range[3] - range[2];
2013            for i in 0..npixels {
2014                let si = i * 3;
2015                let l = data.get(si).copied().unwrap_or(0) as f64 / 255.0 * 100.0;
2016                let a = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0 * a_span + range[0];
2017                let b = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0 * b_span + range[2];
2018                let color = DeviceColor::from_lab(l, a, b, range);
2019                let pi = i * 4;
2020                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
2021                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
2022                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
2023            }
2024            rgba
2025        }
2026        ImageColorSpace::Separation {
2027            alt_space,
2028            tint_table,
2029            ..
2030        } => {
2031            // 1 byte per pixel → lookup in tint table → convert alt space to RGB
2032            // For CMYK alt space with ICC, build bulk CMYK data and convert via ICC
2033            if matches!(alt_space.as_ref(), ImageColorSpace::DeviceCMYK)
2034                && let Some(rgba) = tint_separation_via_icc(data, npixels, tint_table, icc)
2035            {
2036                return rgba;
2037            }
2038            let mut rgba = vec![255u8; npixels * 4];
2039            let no = tint_table.num_outputs as usize;
2040            let mut alt_comps = vec![0.0f32; no];
2041            for i in 0..npixels {
2042                let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
2043                tint_table.lookup_1d(tint, &mut alt_comps);
2044                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
2045                let pi = i * 4;
2046                rgba[pi] = r;
2047                rgba[pi + 1] = g;
2048                rgba[pi + 2] = b;
2049            }
2050            rgba
2051        }
2052        ImageColorSpace::DeviceN {
2053            alt_space,
2054            tint_table,
2055            ..
2056        } => {
2057            let ni = tint_table.num_inputs as usize;
2058            let no = tint_table.num_outputs as usize;
2059            // For CMYK alt space with ICC, build bulk CMYK data and convert via ICC
2060            if matches!(alt_space.as_ref(), ImageColorSpace::DeviceCMYK)
2061                && let Some(rgba) = tint_devicen_via_icc(data, npixels, ni, tint_table, icc)
2062            {
2063                return rgba;
2064            }
2065            let mut rgba = vec![255u8; npixels * 4];
2066            let mut inputs = vec![0.0f32; ni];
2067            let mut alt_comps = vec![0.0f32; no];
2068            for i in 0..npixels {
2069                let si = i * ni;
2070                for (c, inp) in inputs.iter_mut().enumerate() {
2071                    *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
2072                }
2073                tint_table.lookup_nd(&inputs, &mut alt_comps);
2074                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
2075                let pi = i * 4;
2076                rgba[pi] = r;
2077                rgba[pi + 1] = g;
2078                rgba[pi + 2] = b;
2079            }
2080            rgba
2081        }
2082        ImageColorSpace::Mask {
2083            color, polarity, ..
2084        } => {
2085            let mut rgba = vec![0u8; npixels * 4];
2086            let r = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
2087            let g = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
2088            let b = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
2089            let bytes_per_row = (w).div_ceil(8);
2090            for row in 0..h {
2091                for col in 0..w {
2092                    let byte_idx = row * bytes_per_row + col / 8;
2093                    let bit_offset = 7 - (col % 8);
2094                    let bit = if byte_idx < data.len() {
2095                        (data[byte_idx] >> bit_offset) & 1
2096                    } else {
2097                        0
2098                    };
2099                    let paint = if *polarity { bit == 1 } else { bit == 0 };
2100                    if paint {
2101                        let pi = (row * w + col) * 4;
2102                        rgba[pi] = r;
2103                        rgba[pi + 1] = g;
2104                        rgba[pi + 2] = b;
2105                        rgba[pi + 3] = 255;
2106                    }
2107                }
2108            }
2109            rgba
2110        }
2111        _ => vec![0u8; npixels * 4],
2112    }
2113}
2114
2115/// Convert Separation (1-input) tint table output through ICC CMYK profile.
2116/// Builds 4-byte CMYK data from tint table, then bulk-converts via ICC 8-bit transform.
2117fn tint_separation_via_icc(
2118    data: &[u8],
2119    npixels: usize,
2120    tint_table: &TintLookupTable,
2121    icc: Option<&IccCache>,
2122) -> Option<Vec<u8>> {
2123    let cache = icc?;
2124    let cmyk_hash = cache.default_cmyk_hash()?;
2125    // Build CMYK byte buffer from tint table
2126    let mut cmyk_data = vec![0u8; npixels * 4];
2127    let mut alt_comps = [0.0f32; 4];
2128    for i in 0..npixels {
2129        let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
2130        tint_table.lookup_1d(tint, &mut alt_comps);
2131        let si = i * 4;
2132        cmyk_data[si] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2133        cmyk_data[si + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2134        cmyk_data[si + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2135        cmyk_data[si + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2136    }
2137    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2138    let mut rgba = vec![255u8; npixels * 4];
2139    for i in 0..npixels {
2140        rgba[i * 4] = rgb[i * 3];
2141        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2142        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2143    }
2144    Some(rgba)
2145}
2146
2147/// Convert DeviceN (N-input) tint table output through ICC CMYK profile.
2148fn tint_devicen_via_icc(
2149    data: &[u8],
2150    npixels: usize,
2151    ni: usize,
2152    tint_table: &TintLookupTable,
2153    icc: Option<&IccCache>,
2154) -> Option<Vec<u8>> {
2155    let cache = icc?;
2156    let cmyk_hash = cache.default_cmyk_hash()?;
2157    let mut cmyk_data = vec![0u8; npixels * 4];
2158    let mut inputs = vec![0.0f32; ni];
2159    let mut alt_comps = [0.0f32; 4];
2160    for i in 0..npixels {
2161        let si = i * ni;
2162        for (c, inp) in inputs.iter_mut().enumerate() {
2163            *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
2164        }
2165        tint_table.lookup_nd(&inputs, &mut alt_comps);
2166        let di = i * 4;
2167        cmyk_data[di] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2168        cmyk_data[di + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2169        cmyk_data[di + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2170        cmyk_data[di + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2171    }
2172    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2173    let mut rgba = vec![255u8; npixels * 4];
2174    for i in 0..npixels {
2175        rgba[i * 4] = rgb[i * 3];
2176        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2177        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2178    }
2179    Some(rgba)
2180}
2181
2182/// Convert alt-space f32 component values to RGB bytes.
2183fn alt_comps_to_rgb(comps: &[f32], alt_space: &ImageColorSpace) -> (u8, u8, u8) {
2184    match alt_space {
2185        ImageColorSpace::DeviceGray => {
2186            let g = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2187            (g, g, g)
2188        }
2189        ImageColorSpace::DeviceRGB => {
2190            let r = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2191            let g = (comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2192            let b = (comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2193            (r, g, b)
2194        }
2195        ImageColorSpace::DeviceCMYK => {
2196            let c = comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0);
2197            let m = comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2198            let y = comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2199            let k = comps.get(3).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2200            let r = ((1.0 - (c + k).min(1.0)) * 255.0).round() as u8;
2201            let g = ((1.0 - (m + k).min(1.0)) * 255.0).round() as u8;
2202            let b = ((1.0 - (y + k).min(1.0)) * 255.0).round() as u8;
2203            (r, g, b)
2204        }
2205        _ => (0, 0, 0),
2206    }
2207}
2208
2209/// Apply ImageType 4 mask color transparency to RGBA data.
2210fn apply_mask_color_rgba(rgba: &mut [u8], sample_data: &[u8], params: &ImageParams) {
2211    let mask_color = match &params.mask_color {
2212        Some(mc) => mc,
2213        None => return,
2214    };
2215    let ncomp = params.color_space.num_components() as usize;
2216    let npixels = params.width as usize * params.height as usize;
2217    let is_range = mask_color.len() == 2 * ncomp;
2218
2219    for i in 0..npixels {
2220        let si = i * ncomp;
2221        let matched = if is_range {
2222            (0..ncomp).all(|c| {
2223                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2224                let min_val = mask_color.get(c * 2).copied().unwrap_or(0);
2225                let max_val = mask_color.get(c * 2 + 1).copied().unwrap_or(0);
2226                sample >= min_val && sample <= max_val
2227            })
2228        } else {
2229            (0..ncomp).all(|c| {
2230                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2231                let target = mask_color.get(c).copied().unwrap_or(0);
2232                sample == target
2233            })
2234        };
2235        if matched {
2236            let pi = i * 4;
2237            if pi + 3 < rgba.len() {
2238                rgba[pi] = 0;
2239                rgba[pi + 1] = 0;
2240                rgba[pi + 2] = 0;
2241                rgba[pi + 3] = 0;
2242            }
2243        }
2244    }
2245}
2246
2247/// Choose filter quality for image drawing.
2248///
2249/// When `interpolate` is false, use Nearest for upscaling (crisp pixel edges)
2250/// and Bilinear only for downscaling (proper area averaging). When `interpolate`
2251/// is true, use Bilinear for any scaling.
2252fn image_filter_quality(transform: Transform, interpolate: bool) -> stet_tiny_skia::FilterQuality {
2253    let eff_sx = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2254    let eff_sy = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2255    let min_scale = eff_sx.min(eff_sy);
2256    // Near-exact 1:1: Nearest is pixel-perfect and faster
2257    if (eff_sx - 1.0).abs() < 0.01 && (eff_sy - 1.0).abs() < 0.01 {
2258        stet_tiny_skia::FilterQuality::Nearest
2259    } else if !interpolate && min_scale >= 0.95 {
2260        // Non-interpolated upscaling: nearest-neighbor for crisp pixel edges
2261        stet_tiny_skia::FilterQuality::Nearest
2262    } else {
2263        stet_tiny_skia::FilterQuality::Bilinear
2264    }
2265}
2266
2267/// For rotated/sheared transforms: integer box-filter pre-downsample, leaving
2268/// the fractional remainder to tiny-skia's bilinear.
2269///
2270/// Returns `None` if no pre-scaling is needed.
2271fn prescale_image(
2272    rgba_data: &[u8],
2273    w: u32,
2274    h: u32,
2275    transform: Transform,
2276    interpolate: bool,
2277) -> Option<(Vec<u8>, u32, u32, Transform)> {
2278    // Compute effective scale factors from the 2×2 part of the transform.
2279    let scale_x = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2280    let scale_y = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2281    let min_scale = scale_x.min(scale_y);
2282
2283    // Upscaling: only apply bicubic resampling when Interpolate is true.
2284    // Per PLRM/PDF spec, non-interpolated images should use nearest-neighbor
2285    // for upscaling (crisp pixel boundaries, no smoothing).
2286    if min_scale > 1.05 {
2287        if interpolate {
2288            let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2289            if is_axis_aligned && w >= 2 && h >= 2 {
2290                let dw = (w as f32 * transform.sx.abs()).round().max(1.0) as u32;
2291                let dh = (h as f32 * transform.sy.abs()).round().max(1.0) as u32;
2292                if dw > w || dh > h {
2293                    let resampled = bicubic_resample(rgba_data, w, h, dw, dh);
2294                    let new_sx = transform.sx * w as f32 / dw as f32;
2295                    let new_sy = transform.sy * h as f32 / dh as f32;
2296                    let adjusted = Transform::from_row(
2297                        new_sx,
2298                        transform.ky,
2299                        transform.kx,
2300                        new_sy,
2301                        transform.tx,
2302                        transform.ty,
2303                    );
2304                    return Some((resampled, dw, dh, adjusted));
2305                }
2306            }
2307        }
2308        return None;
2309    }
2310
2311    // Near 1:1 — no prescaling needed.
2312    if min_scale >= 0.95 {
2313        return None;
2314    }
2315
2316    // Axis-aligned: use area-average box filter to target dimensions.
2317    // Much faster than Lanczos3 and produces equally good results for downscaling.
2318    let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2319    if is_axis_aligned && w >= 2 && h >= 2 {
2320        let dw = (w as f32 * transform.sx.abs()).ceil().max(1.0) as u32;
2321        let dh = (h as f32 * transform.sy.abs()).ceil().max(1.0) as u32;
2322        if dw < w || dh < h {
2323            let resampled = box_resample(rgba_data, w, h, dw, dh);
2324            // Adjust transform so scale ≈ ±1 (sign preserved), same translation.
2325            let new_sx = transform.sx * w as f32 / dw as f32;
2326            let new_sy = transform.sy * h as f32 / dh as f32;
2327            let adjusted = Transform::from_row(
2328                new_sx,
2329                transform.ky,
2330                transform.kx,
2331                new_sy,
2332                transform.tx,
2333                transform.ty,
2334            );
2335            return Some((resampled, dw, dh, adjusted));
2336        }
2337    }
2338
2339    // Fallback for rotated/sheared: integer box filter.
2340    let factor = (1.0 / min_scale) as u32;
2341    if factor < 2 || w < factor || h < factor {
2342        return None;
2343    }
2344    let nw = w / factor;
2345    let nh = h / factor;
2346    if nw == 0 || nh == 0 {
2347        return None;
2348    }
2349    let area = factor * factor;
2350    let half = area / 2;
2351    let stride = w as usize * 4;
2352    let mut out = vec![0u8; (nw * nh * 4) as usize];
2353    for dy in 0..nh {
2354        for dx in 0..nw {
2355            let (mut r, mut g, mut b, mut a) = (0u32, 0u32, 0u32, 0u32);
2356            let sy0 = (dy * factor) as usize;
2357            let sx0 = (dx * factor) as usize;
2358            for iy in 0..factor as usize {
2359                let row = (sy0 + iy) * stride + sx0 * 4;
2360                for ix in 0..factor as usize {
2361                    let i = row + ix * 4;
2362                    r += rgba_data[i] as u32;
2363                    g += rgba_data[i + 1] as u32;
2364                    b += rgba_data[i + 2] as u32;
2365                    a += rgba_data[i + 3] as u32;
2366                }
2367            }
2368            let di = (dy * nw + dx) as usize * 4;
2369            out[di] = ((r + half) / area) as u8;
2370            out[di + 1] = ((g + half) / area) as u8;
2371            out[di + 2] = ((b + half) / area) as u8;
2372            out[di + 3] = ((a + half) / area) as u8;
2373        }
2374    }
2375    let f = factor as f32;
2376    let adjusted = Transform::from_row(
2377        transform.sx * f,
2378        transform.ky * f,
2379        transform.kx * f,
2380        transform.sy * f,
2381        transform.tx,
2382        transform.ty,
2383    );
2384    Some((out, nw, nh, adjusted))
2385}
2386
2387/// Translate a device-space ClipRect into band-local coordinates.
2388fn translate_clip_rect(rect: &ClipRect, y_start: u32, band_h: u32) -> ClipRect {
2389    ClipRect {
2390        x0: rect.x0,
2391        y0: rect.y0.saturating_sub(y_start).min(band_h),
2392        x1: rect.x1,
2393        y1: rect.y1.saturating_sub(y_start).min(band_h),
2394    }
2395}
2396
2397/// Ensure an image transform maps to at least 1 device pixel in each dimension.
2398///
2399/// PDFs commonly draw rules and borders using tiny image masks (1×1 or 4×1 pixels)
2400/// scaled via the CTM to thin rectangles. At low DPI these can map to sub-pixel
2401/// device dimensions and vanish. This adjusts the transform's scale components
2402/// so the image covers at least 1 pixel in each direction.
2403fn enforce_min_image_size(transform: Transform, img_w: u32, img_h: u32) -> Transform {
2404    // Effective device-space dimensions
2405    let eff_w =
2406        ((transform.sx * img_w as f32).powi(2) + (transform.ky * img_w as f32).powi(2)).sqrt();
2407    let eff_h =
2408        ((transform.kx * img_h as f32).powi(2) + (transform.sy * img_h as f32).powi(2)).sqrt();
2409
2410    if eff_w >= 1.0 && eff_h >= 1.0 {
2411        return transform;
2412    }
2413
2414    // Only boost if the image is a thin rule (large aspect ratio).
2415    // Small images that are sub-pixel in both dimensions (e.g. tiny dots)
2416    // are left as-is — boosting them would create visible artifacts.
2417    let ratio = eff_w.max(eff_h) / eff_w.min(eff_h).max(0.001);
2418    if ratio < 3.0 {
2419        return transform;
2420    }
2421
2422    let mut t = transform;
2423    if eff_w < 1.0 && eff_w > 0.001 {
2424        let boost = 1.0 / eff_w;
2425        t.sx *= boost;
2426        t.ky *= boost;
2427    }
2428    if eff_h < 1.0 && eff_h > 0.001 {
2429        let boost = 1.0 / eff_h;
2430        t.kx *= boost;
2431        t.sy *= boost;
2432    }
2433    t
2434}
2435
2436/// Compute minimum line width for hairline strokes at a given DPI and CTM.
2437/// Returns the minimum width in user-space units that ensures at least
2438/// 0.5 device pixels at ≤150 DPI or 1.0 device pixel above 150 DPI.
2439fn hairline_min_width(ctm: &Matrix, dpi: f64) -> f64 {
2440    let (a, b, c, d) = (ctm.a, ctm.b, ctm.c, ctm.d);
2441    let sum_sq = a * a + b * b + c * c + d * d;
2442    let diff = ((a * a + b * b - c * c - d * d).powi(2) + 4.0 * (a * c + b * d).powi(2)).sqrt();
2443    let s_max = (0.5 * (sum_sq + diff)).max(0.0).sqrt();
2444    let min_px = if dpi <= 150.0 { 0.5 } else { 1.0 };
2445    if s_max > 1e-10 {
2446        min_px / s_max
2447    } else {
2448        min_px
2449    }
2450}
2451
2452/// True when the paint's source CMYK is K-only (C=M=Y=0, any K).
2453/// Used to route OPM 0 DeviceCMYK paints that encode "K-only" — like
2454/// `0 0 0 0.5 k` — through the per-pixel overprint path, so the no-op delta
2455/// skip can preserve a spot-painted backdrop at pixels where K already equals
2456/// the source value.
2457fn is_k_only_src(color: &DeviceColor) -> bool {
2458    if let Some((c, m, y, _k)) = color.native_cmyk {
2459        c == 0.0 && m == 0.0 && y == 0.0
2460    } else {
2461        false
2462    }
2463}
2464
2465/// Detect a DeviceGray paint that should be promoted to CMYK_K for overprint.
2466///
2467/// DeviceGray `g` sets `painted_channels = 0` and leaves `native_cmyk = None`,
2468/// so overprint dispatch can't see it as a K-ink paint. When overprint is
2469/// active we re-describe the paint as DeviceCMYK `(0, 0, 0, 1-g)` with
2470/// `painted_channels = CMYK_K`: it flows through the subset path, only the K
2471/// plate is touched, and the pixmap is updated multiplicatively so any
2472/// backdrop spot contribution survives.
2473fn needs_gray_promotion(
2474    overprint: bool,
2475    painted_channels: u8,
2476    is_device_cmyk: bool,
2477    color: &DeviceColor,
2478) -> Option<f64> {
2479    if !overprint
2480        || painted_channels != 0
2481        || is_device_cmyk
2482        || color.native_cmyk.is_some()
2483        || color.process_cmyk.is_some()
2484    {
2485        return None;
2486    }
2487    let r = color.r;
2488    if (r - color.g).abs() > f64::EPSILON || (r - color.b).abs() > f64::EPSILON {
2489        return None;
2490    }
2491    Some(r.clamp(0.0, 1.0))
2492}
2493
2494/// Promote a gray `FillParams` to a DeviceCMYK K-only overprint description if
2495/// the paint qualifies (see [`needs_gray_promotion`]).
2496fn maybe_promote_gray_fill<'a>(
2497    params: &'a FillParams,
2498    buf: &'a mut Option<FillParams>,
2499) -> &'a FillParams {
2500    if let Some(gray) = needs_gray_promotion(
2501        params.overprint,
2502        params.painted_channels,
2503        params.is_device_cmyk,
2504        &params.color,
2505    ) {
2506        let mut promoted = params.clone();
2507        promoted.is_device_cmyk = true;
2508        promoted.painted_channels = stet_graphics::device::CMYK_K;
2509        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2510        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2511        *buf = Some(promoted);
2512        return buf.as_ref().unwrap();
2513    }
2514    params
2515}
2516
2517/// Promote a gray `StrokeParams` to a DeviceCMYK K-only overprint description.
2518fn maybe_promote_gray_stroke<'a>(
2519    params: &'a StrokeParams,
2520    buf: &'a mut Option<StrokeParams>,
2521) -> &'a StrokeParams {
2522    if let Some(gray) = needs_gray_promotion(
2523        params.overprint,
2524        params.painted_channels,
2525        params.is_device_cmyk,
2526        &params.color,
2527    ) {
2528        let mut promoted = params.clone();
2529        promoted.is_device_cmyk = true;
2530        promoted.painted_channels = stet_graphics::device::CMYK_K;
2531        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2532        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2533        *buf = Some(promoted);
2534        return buf.as_ref().unwrap();
2535    }
2536    params
2537}
2538
2539/// Build a stroke with minimum line-width enforcement (shared by trait impl and band rendering).
2540/// `dpi` is the device resolution, used to select the hairline minimum width:
2541/// at ≤150 DPI use 0.6 device pixels; above 150 DPI use 1.0 device pixel.
2542fn build_stroke(params: &StrokeParams, dpi: f64) -> Stroke {
2543    let min_lw = hairline_min_width(&params.ctm, dpi);
2544    let mut stroke = Stroke {
2545        width: (params.line_width as f32).max(min_lw as f32),
2546        line_cap: to_line_cap(params.line_cap),
2547        line_join: to_line_join(params.line_join),
2548        miter_limit: params.miter_limit as f32,
2549        ..Stroke::default()
2550    };
2551    if !params.dash_pattern.array.is_empty() {
2552        let mut dash_array: Vec<f32> = params
2553            .dash_pattern
2554            .array
2555            .iter()
2556            .map(|&v| v as f32)
2557            .collect();
2558        // PostScript allows odd-length dash arrays (implicitly doubled),
2559        // but tiny-skia requires even length. Double odd arrays to match PS semantics.
2560        if dash_array.len() % 2 == 1 {
2561            let clone = dash_array.clone();
2562            dash_array.extend_from_slice(&clone);
2563        }
2564        if let Some(dash) = StrokeDash::new(dash_array, params.dash_pattern.offset as f32) {
2565            stroke.dash = Some(dash);
2566        }
2567    }
2568    stroke
2569}
2570
2571/// Apply stroke adjustment: snap axis-aligned path segments to device pixel
2572/// centers so thin strokes render with consistent weight.
2573///
2574/// For a stroke of width W in device pixels:
2575/// - Odd-integer width (1, 3, ...): snap to half-pixel (floor(x) + 0.5)
2576/// - Even-integer width or non-integer: snap to pixel edge (round(x))
2577/// - For hairlines (device width < 1.5): always snap to half-pixel
2578///
2579/// Only axis-aligned segments (horizontal/vertical lines) are snapped.
2580/// Diagonal/curved segments are left as-is since snapping would distort them.
2581///
2582/// Check whether a CTM indicates the path is already in device space (identity
2583/// or simple Y-flip/translation). Stroke adjustment snaps coordinates to pixel
2584/// boundaries, which only makes sense when path coordinates are device pixels.
2585/// PDF Form XObjects with large scale factors (e.g. [405, 0, 0, 283, ...]) would
2586/// cause catastrophic snapping if treated as device-space paths.
2587fn ctm_is_device_space(ctm: &Matrix) -> bool {
2588    (ctm.a.abs() - 1.0).abs() < 0.01
2589        && ctm.b.abs() < 0.01
2590        && ctm.c.abs() < 0.01
2591        && (ctm.d.abs() - 1.0).abs() < 0.01
2592}
2593
2594/// Apply stroke adjustment for viewport rendering.
2595///
2596/// Path coordinates are in reference-DPI device space. The viewport transform
2597/// maps them to output pixels: out = (ref - vp_origin) * scale.
2598/// We snap in output pixel space then map back to reference space.
2599fn stroke_adjust_path_viewport(
2600    path: &PsPath,
2601    device_width: f64,
2602    scale_x: f64,
2603    scale_y: f64,
2604    vp_x: f64,
2605    vp_y: f64,
2606) -> PsPath {
2607    let use_half_pixel = device_width < 1.5 || (device_width.round() as i32) % 2 == 1;
2608
2609    // Snap a reference-space coordinate to the output pixel grid, then map back
2610    let snap_x = |v: f64| -> f64 {
2611        let out = (v - vp_x) * scale_x;
2612        let snapped = if use_half_pixel {
2613            out.floor() + 0.5
2614        } else {
2615            out.round()
2616        };
2617        snapped / scale_x + vp_x
2618    };
2619    let snap_y = |v: f64| -> f64 {
2620        let out = (v - vp_y) * scale_y;
2621        let snapped = if use_half_pixel {
2622            out.floor() + 0.5
2623        } else {
2624            out.round()
2625        };
2626        snapped / scale_y + vp_y
2627    };
2628
2629    let mut result = PsPath::new();
2630    let mut prev_x = 0.0_f64;
2631    let mut prev_y = 0.0_f64;
2632
2633    for seg in &path.segments {
2634        match *seg {
2635            PathSegment::MoveTo(x, y) => {
2636                prev_x = x;
2637                prev_y = y;
2638                result.segments.push(PathSegment::MoveTo(x, y));
2639            }
2640            PathSegment::LineTo(x, y) => {
2641                let is_horizontal = (y - prev_y).abs() < 1e-6;
2642                let is_vertical = (x - prev_x).abs() < 1e-6;
2643
2644                if is_horizontal {
2645                    let snapped_y = snap_y(y);
2646                    if let Some(PathSegment::MoveTo(_, ly) | PathSegment::LineTo(_, ly)) =
2647                        result.segments.last_mut()
2648                    {
2649                        *ly = snapped_y;
2650                    }
2651                    result.segments.push(PathSegment::LineTo(x, snapped_y));
2652                    prev_x = x;
2653                    prev_y = snapped_y;
2654                } else if is_vertical {
2655                    let snapped_x = snap_x(x);
2656                    if let Some(PathSegment::MoveTo(lx, _) | PathSegment::LineTo(lx, _)) =
2657                        result.segments.last_mut()
2658                    {
2659                        *lx = snapped_x;
2660                    }
2661                    result.segments.push(PathSegment::LineTo(snapped_x, y));
2662                    prev_x = snapped_x;
2663                    prev_y = y;
2664                } else {
2665                    result.segments.push(PathSegment::LineTo(x, y));
2666                    prev_x = x;
2667                    prev_y = y;
2668                }
2669            }
2670            PathSegment::CurveTo {
2671                x1,
2672                y1,
2673                x2,
2674                y2,
2675                x3,
2676                y3,
2677            } => {
2678                result.segments.push(PathSegment::CurveTo {
2679                    x1,
2680                    y1,
2681                    x2,
2682                    y2,
2683                    x3,
2684                    y3,
2685                });
2686                prev_x = x3;
2687                prev_y = y3;
2688            }
2689            PathSegment::ClosePath => {
2690                result.segments.push(PathSegment::ClosePath);
2691            }
2692        }
2693    }
2694    result
2695}
2696
2697/// Process a single display list element into a pixmap using the given render context.
2698///
2699/// This unified function handles both band rendering (scale=1.0) and viewport
2700/// rendering (arbitrary scale). Band rendering is viewport rendering with
2701/// `scale_x = scale_y = 1.0`.
2702fn render_element(
2703    pixmap: &mut Pixmap,
2704    band_state: &mut BandState,
2705    element: &DisplayElement,
2706    ctx: &RenderContext<'_>,
2707) {
2708    match element {
2709        DisplayElement::Fill { path, params } => {
2710            // DeviceGray with overprint behaves as a K-only process paint —
2711            // promote it to DeviceCMYK (0, 0, 0, 1-gray) with painted_channels
2712            // set to CMYK_K so it flows through the overprint subset path,
2713            // preserving backdrop CMY plates and the spot-derived visual
2714            // instead of knocking the pixmap out with plain RGB gray.
2715            let mut promoted_fill: Option<FillParams> = None;
2716            let params = maybe_promote_gray_fill(params, &mut promoted_fill);
2717            // Use the overprint compositing path whenever the fill needs
2718            // per-channel CMYK rendering. Five cases trigger it:
2719            //   1. Subset painted_channels (Separation /Magenta, DeviceN, etc.)
2720            //      — only the named channels touch the buffer; the rest are
2721            //      preserved from the backdrop.
2722            //   2. DeviceCMYK + OPM 1 — zero-valued components don't paint, so
2723            //      a per-pixel filter is required.
2724            //   3. Custom spot (painted_channels=0, non-CMYK, with native_cmyk)
2725            //      under overprint — process plates must be preserved; the
2726            //      spot's alt-CMYK only contributes multiplicatively to RGB.
2727            //   4. DeviceCMYK + overprint (any OPM) with CMYK_ALL — the per-
2728            //      pixel path lets us recognise a "no-op" overprint (src CMYK
2729            //      == backdrop CMYK) and leave the pixmap untouched, which
2730            //      preserves any spot-derived colour already visible there.
2731            //   5. (Combinations of the above.)
2732            // Only fires for Normal blend; non-Normal blend modes handle zero
2733            // values through their blend math, not through overprint filtering.
2734            // Includes text glyphs: when overprint is meaningful (the test
2735            // suite's GWG 1.0 swatches f/a use Separation /Magenta + glyphs),
2736            // correctness wins over the slight AA difference vs tiny-skia.
2737            let painted = params.painted_channels;
2738            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2739            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2740            // Real Separation/DeviceN custom spots set `process_cmyk` (even pure
2741            // spots set it to `(0, 0, 0, 0)`); ICCBased RGB routed through the
2742            // proofing chain has `native_cmyk` populated but leaves
2743            // `process_cmyk == None`. Per PDF 1.7 §11.7.4.5 a non-process source
2744            // colour space (CalGray/CalRGB/Lab/ICCBased) must paint as if /OP
2745            // were false — gating on `process_cmyk.is_some()` keeps ICCBased RGB
2746            // out of the overprint path so GWG 13.3 (ICC RGB X over CMYK BG)
2747            // knocks out instead of preserving the backdrop's CMYK plates.
2748            let custom_spot = painted == 0
2749                && !params.is_device_cmyk
2750                && params.color.native_cmyk.is_some()
2751                && params.color.process_cmyk.is_some();
2752            // A "near-K-only" DeviceCMYK paint under OPM 0 — e.g. `0 0 0 0.5 k`
2753            // — matches the Black-component plate of a DeviceN [Black, spot]
2754            // backdrop exactly. Routing it through the per-pixel path lets the
2755            // no-op-delta skip preserve the spot-derived colour instead of
2756            // wiping it with plain grey (GWG 3.0 "50% K over spot").
2757            let is_k_only_cmyk =
2758                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2759            let needs_overprint = params.overprint
2760                && band_state.cmyk_buffer.is_some()
2761                && params.blend_mode == 0
2762                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2763
2764            if needs_overprint {
2765                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2766                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2767                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2768                render_overprint_fill(
2769                    pixmap,
2770                    &mut cmyk_buf,
2771                    &mut op_bg,
2772                    &mut op_touched,
2773                    &spot_mask,
2774                    band_state,
2775                    path,
2776                    params,
2777                    ctx.vp_x,
2778                    ctx.vp_y,
2779                    ctx.scale_x,
2780                    ctx.scale_y,
2781                    ctx.out_w,
2782                    ctx.out_h,
2783                    ctx.icc,
2784                    ctx.no_aa,
2785                );
2786                band_state.cmyk_buffer = Some(cmyk_buf);
2787                band_state.restore_op_buffers(op_bg, op_touched);
2788                band_state.restore_spot_mask(spot_mask);
2789            } else {
2790                let Some(skia_path) = build_skia_path(path) else {
2791                    return;
2792                };
2793                let mut temp_mask = None;
2794                let Some(mask_ref) = resolve_clip_mask(
2795                    &band_state.clip_region,
2796                    &mut temp_mask,
2797                    ctx.out_w,
2798                    ctx.out_h,
2799                ) else {
2800                    return;
2801                };
2802                let paint =
2803                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2804                let transform = ctx.transform(&params.ctm);
2805
2806                // Detect degenerate fill paths: rectangles/lines with zero extent
2807                // in one dimension. These are commonly used in PDFs to draw table
2808                // grid lines as zero-width or zero-height filled rectangles.
2809                // Since they have no area, fill_path produces nothing. Render them
2810                // as hairline strokes instead.
2811                if is_degenerate_fill(path) {
2812                    let stroke = Stroke {
2813                        width: 1.0,
2814                        ..Stroke::default()
2815                    };
2816                    pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2817                } else {
2818                    let fill_rule = to_fill_rule(&params.fill_rule);
2819                    pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
2820                }
2821
2822                // Update CMYK tracking buffer for non-overprint fills
2823                if band_state.cmyk_buffer.is_some() {
2824                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2825                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2826                    update_cmyk_buffer_for_fill(
2827                        &mut cmyk_buf,
2828                        &mut spot_mask,
2829                        path,
2830                        params,
2831                        ctx.vp_x,
2832                        ctx.vp_y,
2833                        ctx.scale_x,
2834                        ctx.scale_y,
2835                        ctx.out_w,
2836                        ctx.out_h,
2837                        &band_state.clip_region,
2838                        ctx.no_aa,
2839                        ctx.icc,
2840                    );
2841                    band_state.cmyk_buffer = Some(cmyk_buf);
2842                    band_state.restore_spot_mask(spot_mask);
2843                }
2844            }
2845        }
2846        DisplayElement::Stroke { path, params } => {
2847            let mut promoted_stroke: Option<StrokeParams> = None;
2848            let params = maybe_promote_gray_stroke(params, &mut promoted_stroke);
2849            let transform = ctx.transform(&params.ctm);
2850            // Build stroke using the composited transform so hairline width
2851            // calculations account for the actual output resolution.
2852            let vp_ctm = Matrix {
2853                a: transform.sx as f64,
2854                b: transform.ky as f64,
2855                c: transform.kx as f64,
2856                d: transform.sy as f64,
2857                tx: 0.0,
2858                ty: 0.0,
2859            };
2860            let vp_params = StrokeParams {
2861                ctm: vp_ctm,
2862                ..params.clone()
2863            };
2864            let stroke = build_stroke(&vp_params, ctx.effective_dpi);
2865
2866            // Apply stroke adjustment — snap in output device space
2867            let adjusted;
2868            let draw_path = if params.stroke_adjust
2869                && stroke.width <= 2.0
2870                && ctm_is_device_space(&params.ctm)
2871            {
2872                adjusted = stroke_adjust_path_viewport(
2873                    path,
2874                    stroke.width as f64,
2875                    ctx.scale_x as f64,
2876                    ctx.scale_y as f64,
2877                    ctx.vp_x as f64,
2878                    ctx.vp_y as f64,
2879                );
2880                &adjusted
2881            } else {
2882                path
2883            };
2884
2885            // Mirror the Fill gating: per-channel CMYK rendering kicks in for
2886            // subset painted_channels (Separation /Magenta, DeviceN, etc.), for
2887            // DeviceCMYK + OPM 1 (zero-valued source components don't paint),
2888            // or for a custom spot (painted=0, non-CMYK) under overprint — so
2889            // the spot applies multiplicatively to RGB without disturbing the
2890            // process plates. GWG 1.0 swatch a/b/f/g need this for the magenta
2891            // X stroke that overlays the same path the fill already drew.
2892            let painted = params.painted_channels;
2893            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2894            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2895            // Mirror the Fill custom-spot gate: ICCBased RGB (proofing-chain
2896            // `native_cmyk`, no `process_cmyk`) must not reach the overprint
2897            // path. PDF 1.7 §11.7.4.5: non-process source spaces paint as if
2898            // /OP were false.
2899            let custom_spot = painted == 0
2900                && !params.is_device_cmyk
2901                && params.color.native_cmyk.is_some()
2902                && params.color.process_cmyk.is_some();
2903            let is_k_only_cmyk =
2904                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2905            let needs_overprint = params.overprint
2906                && band_state.cmyk_buffer.is_some()
2907                && params.blend_mode == 0
2908                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2909
2910            let Some(skia_path) = build_skia_path(draw_path) else {
2911                return;
2912            };
2913            let mut temp_mask = None;
2914            let Some(mask_ref) = resolve_clip_mask(
2915                &band_state.clip_region,
2916                &mut temp_mask,
2917                ctx.out_w,
2918                ctx.out_h,
2919            ) else {
2920                return;
2921            };
2922
2923            if needs_overprint {
2924                // Convert the stroke outline to a fill path and route it
2925                // through the same per-channel CMYK compositing logic the
2926                // fill path uses, so the post-overprint result lands in the
2927                // pixmap (not the raw source colour).
2928                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2929                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2930                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2931                render_overprint_stroke(
2932                    pixmap,
2933                    &mut cmyk_buf,
2934                    &mut op_bg,
2935                    &mut op_touched,
2936                    &spot_mask,
2937                    band_state,
2938                    &skia_path,
2939                    &stroke,
2940                    transform,
2941                    params,
2942                    ctx.out_w,
2943                    ctx.out_h,
2944                    ctx.icc,
2945                    ctx.no_aa,
2946                );
2947                band_state.cmyk_buffer = Some(cmyk_buf);
2948                band_state.restore_op_buffers(op_bg, op_touched);
2949                band_state.restore_spot_mask(spot_mask);
2950            } else {
2951                let paint =
2952                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2953                pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2954
2955                if band_state.cmyk_buffer.is_some() {
2956                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2957                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2958                    update_cmyk_buffer_for_stroke(
2959                        &mut cmyk_buf,
2960                        &mut spot_mask,
2961                        draw_path,
2962                        params,
2963                        &stroke,
2964                        transform,
2965                        ctx.out_w,
2966                        ctx.out_h,
2967                        &band_state.clip_region,
2968                        ctx.no_aa,
2969                        ctx.icc,
2970                    );
2971                    band_state.cmyk_buffer = Some(cmyk_buf);
2972                    band_state.restore_spot_mask(spot_mask);
2973                }
2974            }
2975        }
2976        DisplayElement::Clip { path, params } => {
2977            clip_path_unified(band_state, path, params, ctx);
2978        }
2979        DisplayElement::InitClip => {
2980            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2981                band_state.recycle_mask(mask);
2982            }
2983            band_state.clip_region = None;
2984        }
2985        DisplayElement::ErasePage => {
2986            pixmap.fill(Color::TRANSPARENT);
2987            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2988                band_state.recycle_mask(mask);
2989            }
2990            band_state.clip_region = None;
2991        }
2992        DisplayElement::Image {
2993            sample_data,
2994            params,
2995        } => {
2996            let iw = params.width;
2997            let ih = params.height;
2998            if iw == 0 || ih == 0 {
2999                return;
3000            }
3001
3002            let needs_overprint = params.overprint
3003                && band_state.cmyk_buffer.is_some()
3004                && image_supports_overprint(&params.color_space);
3005
3006            if needs_overprint {
3007                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
3008                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
3009                render_overprint_image(
3010                    pixmap,
3011                    &mut cmyk_buf,
3012                    &mut op_bg,
3013                    &mut op_touched,
3014                    band_state,
3015                    sample_data,
3016                    params,
3017                    ctx.vp_x,
3018                    ctx.vp_y,
3019                    ctx.scale_x,
3020                    ctx.scale_y,
3021                    ctx.out_w,
3022                    ctx.out_h,
3023                    ctx.icc,
3024                );
3025                band_state.cmyk_buffer = Some(cmyk_buf);
3026                band_state.restore_op_buffers(op_bg, op_touched);
3027            } else if let Some(pp) = ctx
3028                .preprocessed
3029                .and_then(|pp| pp.get(ctx.elem_idx))
3030                .and_then(|e| e.as_ref())
3031            {
3032                // Fast path: use pre-converted and prescaled image data.
3033                // Only the per-band translation differs; scale factors are cached.
3034                let Some(image_inv) = params.image_matrix.invert() else {
3035                    return;
3036                };
3037                let combined = params.ctm.concat(&image_inv);
3038                let raw_transform = ctx.transform(&combined);
3039                let transform = Transform::from_row(
3040                    pp.adj_sx,
3041                    pp.adj_ky,
3042                    pp.adj_kx,
3043                    pp.adj_sy,
3044                    raw_transform.tx,
3045                    raw_transform.ty,
3046                );
3047
3048                let Some(img_pixmap) =
3049                    stet_tiny_skia::PixmapRef::from_bytes(&pp.data, pp.width, pp.height)
3050                else {
3051                    return;
3052                };
3053                #[allow(unused_assignments)]
3054                let mut temp_mask = None;
3055                let mask_ref = match &band_state.clip_region {
3056                    None => None,
3057                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
3058                    Some(ClipRegion::Rect(rect)) => {
3059                        if rect.is_empty() {
3060                            return;
3061                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
3062                            None
3063                        } else {
3064                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
3065                            temp_mask.as_ref()
3066                        }
3067                    }
3068                };
3069                let img_paint = stet_tiny_skia::PixmapPaint {
3070                    quality: pp.quality,
3071                    opacity: params.alpha as f32,
3072                    blend_mode: u8_to_blend_mode(params.blend_mode),
3073                };
3074                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
3075
3076                // Update CMYK tracking buffer for non-overprint images on the
3077                // fast path. Reading from the post-draw pixmap means the same
3078                // helper handles native-CMYK and non-CMYK source images, even
3079                // though `pp.data` is prescaled and we no longer have a
3080                // matching native RGBA buffer.
3081                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
3082                    update_cmyk_buffer_for_image(
3083                        cmyk_buf,
3084                        sample_data,
3085                        pixmap.data(),
3086                        params,
3087                        ctx.vp_x,
3088                        ctx.vp_y,
3089                        ctx.scale_x,
3090                        ctx.scale_y,
3091                        ctx.out_w,
3092                        ctx.out_h,
3093                        &band_state.clip_region,
3094                        ctx.icc,
3095                    );
3096                }
3097            } else {
3098                // Use pre-converted RGBA from image cache when available
3099                let owned_rgba;
3100                let rgba_data: &[u8] = if let Some(cached) =
3101                    ctx.image_cache.and_then(|c| c.get(ctx.elem_idx))
3102                {
3103                    cached
3104                } else {
3105                    owned_rgba = {
3106                        let mut rgba =
3107                            samples_to_rgba(sample_data, params, ctx.icc, ctx.opm_zero_transparent);
3108                        if params.mask_color.is_some() {
3109                            apply_mask_color_rgba(&mut rgba, sample_data, params);
3110                        }
3111                        rgba
3112                    };
3113                    &owned_rgba
3114                };
3115                let expected = (iw * ih * 4) as usize;
3116                if rgba_data.len() < expected {
3117                    return;
3118                }
3119                let Some(image_inv) = params.image_matrix.invert() else {
3120                    return;
3121                };
3122                let combined = params.ctm.concat(&image_inv);
3123                let raw_transform = enforce_min_image_size(ctx.transform(&combined), iw, ih);
3124
3125                // Pre-scale images that are being downscaled. Even non-interpolated
3126                // images need proper area averaging when shrinking — "no interpolation"
3127                // means don't smooth when *upscaling*, but downscaling without averaging
3128                // produces aliased garbage.
3129                let prescaled =
3130                    prescale_image(rgba_data, iw, ih, raw_transform, params.interpolate);
3131                let (img_data, img_w, img_h, transform) = match &prescaled {
3132                    Some((data, w, h, t)) => (data.as_slice(), *w, *h, *t),
3133                    None => (rgba_data, iw, ih, raw_transform),
3134                };
3135
3136                let Some(img_pixmap) =
3137                    stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h)
3138                else {
3139                    return;
3140                };
3141                #[allow(unused_assignments)]
3142                let mut temp_mask = None;
3143                let mask_ref = match &band_state.clip_region {
3144                    None => None,
3145                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
3146                    Some(ClipRegion::Rect(rect)) => {
3147                        if rect.is_empty() {
3148                            return;
3149                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
3150                            None
3151                        } else {
3152                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
3153                            temp_mask.as_ref()
3154                        }
3155                    }
3156                };
3157                let img_paint = stet_tiny_skia::PixmapPaint {
3158                    quality: image_filter_quality(transform, params.interpolate),
3159                    opacity: params.alpha as f32,
3160                    blend_mode: u8_to_blend_mode(params.blend_mode),
3161                };
3162                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
3163
3164                // Update CMYK tracking buffer for non-overprint images. Sample
3165                // the now-composited pixmap so non-CMYK source images can be
3166                // reverse-converted to CMYK via the system profile.
3167                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
3168                    update_cmyk_buffer_for_image(
3169                        cmyk_buf,
3170                        sample_data,
3171                        pixmap.data(),
3172                        params,
3173                        ctx.vp_x,
3174                        ctx.vp_y,
3175                        ctx.scale_x,
3176                        ctx.scale_y,
3177                        ctx.out_w,
3178                        ctx.out_h,
3179                        &band_state.clip_region,
3180                        ctx.icc,
3181                    );
3182                }
3183            }
3184        }
3185        DisplayElement::AxialShading { params } => {
3186            let mut temp_mask = None;
3187            let Some(mask_ref) = resolve_clip_mask(
3188                &band_state.clip_region,
3189                &mut temp_mask,
3190                ctx.out_w,
3191                ctx.out_h,
3192            ) else {
3193                return;
3194            };
3195            render_axial_shading(
3196                pixmap,
3197                params,
3198                ctx.vp_x,
3199                ctx.vp_y,
3200                ctx.scale_x,
3201                ctx.scale_y,
3202                mask_ref,
3203                ctx.no_aa,
3204                band_state.cmyk_buffer.as_deref_mut(),
3205                ctx.icc,
3206            );
3207        }
3208        DisplayElement::RadialShading { params } => {
3209            let mut temp_mask = None;
3210            let Some(mask_ref) = resolve_clip_mask(
3211                &band_state.clip_region,
3212                &mut temp_mask,
3213                ctx.out_w,
3214                ctx.out_h,
3215            ) else {
3216                return;
3217            };
3218            render_radial_shading(
3219                pixmap,
3220                params,
3221                ctx.vp_x,
3222                ctx.vp_y,
3223                ctx.scale_x,
3224                ctx.scale_y,
3225                mask_ref,
3226                ctx.no_aa,
3227                band_state.cmyk_buffer.as_deref_mut(),
3228                ctx.icc,
3229            );
3230        }
3231        DisplayElement::MeshShading { params } => {
3232            let mut temp_mask = None;
3233            let Some(mask_ref) = resolve_clip_mask(
3234                &band_state.clip_region,
3235                &mut temp_mask,
3236                ctx.out_w,
3237                ctx.out_h,
3238            ) else {
3239                return;
3240            };
3241            render_mesh_shading(
3242                pixmap,
3243                params,
3244                ctx.vp_x,
3245                ctx.vp_y,
3246                ctx.scale_x,
3247                ctx.scale_y,
3248                mask_ref,
3249                band_state.cmyk_buffer.as_deref_mut(),
3250                ctx.icc,
3251            );
3252        }
3253        DisplayElement::PatchShading { params } => {
3254            let mut temp_mask = None;
3255            let Some(mask_ref) = resolve_clip_mask(
3256                &band_state.clip_region,
3257                &mut temp_mask,
3258                ctx.out_w,
3259                ctx.out_h,
3260            ) else {
3261                return;
3262            };
3263            render_patch_shading(
3264                pixmap,
3265                params,
3266                ctx.vp_x,
3267                ctx.vp_y,
3268                ctx.scale_x,
3269                ctx.scale_y,
3270                mask_ref,
3271                band_state.cmyk_buffer.as_deref_mut(),
3272                ctx.icc,
3273            );
3274        }
3275        DisplayElement::PatternFill { params } => {
3276            render_pattern_fill(pixmap, band_state, params, ctx);
3277        }
3278        DisplayElement::Group { elements, params } => {
3279            render_group(pixmap, band_state, elements, params, ctx);
3280        }
3281        DisplayElement::SoftMasked {
3282            mask,
3283            content,
3284            params,
3285            mask_cache,
3286        } => {
3287            render_soft_masked(pixmap, band_state, mask, content, params, mask_cache, ctx);
3288        }
3289        DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
3290        DisplayElement::OcgGroup {
3291            elements,
3292            visibility,
3293        } => {
3294            // Visible groups render every child. OFF-by-default groups still
3295            // apply Clip/InitClip so the band's clip state stays in sync —
3296            // otherwise a transient clip from the previous group would leak
3297            // into the next visible one. Paint ops are skipped; that's what
3298            // "hidden layer" means.
3299            let visible = ctx.layer_set.evaluate(visibility);
3300            for (idx, elem) in elements.elements().iter().enumerate() {
3301                if !visible
3302                    && !matches!(elem, DisplayElement::Clip { .. } | DisplayElement::InitClip)
3303                {
3304                    continue;
3305                }
3306                let elem_ctx = RenderContext {
3307                    elem_idx: idx,
3308                    ..*ctx
3309                };
3310                render_element(pixmap, band_state, elem, &elem_ctx);
3311            }
3312        }
3313        _ => {}
3314    }
3315}
3316
3317/// Compute the cropped output-pixel region for a group's device-space bounding box.
3318///
3319/// Returns `(crop_x, crop_y, crop_w, crop_h)` in output pixels, or `None` if
3320/// the group is entirely outside the viewport or cropping isn't worthwhile.
3321fn compute_group_crop(bbox: &[f64; 4], ctx: &RenderContext<'_>) -> Option<(i32, i32, u32, u32)> {
3322    // Transform device-space bbox to output pixel coords
3323    let px_min = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
3324    let py_min = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
3325    let px_max = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
3326    let py_max = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
3327
3328    // Clip to output bounds
3329    let x0 = px_min.max(0);
3330    let y0 = py_min.max(0);
3331    let x1 = px_max.min(ctx.out_w as i32);
3332    let y1 = py_max.min(ctx.out_h as i32);
3333
3334    if x0 >= x1 || y0 >= y1 {
3335        return None;
3336    }
3337
3338    let crop_w = (x1 - x0) as u32;
3339    let crop_h = (y1 - y0) as u32;
3340
3341    // Only crop if it saves at least 25% of pixels
3342    let crop_pixels = crop_w as u64 * crop_h as u64;
3343    let full_pixels = ctx.out_w as u64 * ctx.out_h as u64;
3344    if crop_pixels * 4 >= full_pixels * 3 {
3345        return None;
3346    }
3347
3348    Some((x0, y0, crop_w, crop_h))
3349}
3350
3351/// Apply a separable PDF blend mode in DeviceCMYK using the spec's "effective"
3352/// inversion convention (PDF 1.7 §11.3.5.2): the inverse value `1−c` is used as
3353/// input to the RGB-style blend function, and the result is inverted back.
3354fn blend_cmyk_separable_channel(cb: f64, cs: f64, mode: u8) -> f64 {
3355    let cbi = 1.0 - cb;
3356    let csi = 1.0 - cs;
3357    let result_inv = match mode {
3358        1 => cbi * csi,             // Multiply
3359        2 => cbi + csi - cbi * csi, // Screen
3360        3 => {
3361            // Overlay(b, s) = HardLight(s, b)
3362            if cbi <= 0.5 {
3363                2.0 * cbi * csi
3364            } else {
3365                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3366            }
3367        }
3368        4 => cbi.min(csi), // Darken
3369        5 => cbi.max(csi), // Lighten
3370        6 => {
3371            // ColorDodge
3372            if csi >= 1.0 {
3373                1.0
3374            } else {
3375                (cbi / (1.0 - csi)).min(1.0)
3376            }
3377        }
3378        7 => {
3379            // ColorBurn
3380            if csi <= 0.0 {
3381                0.0
3382            } else {
3383                1.0 - ((1.0 - cbi) / csi).min(1.0)
3384            }
3385        }
3386        8 => {
3387            // HardLight
3388            if csi <= 0.5 {
3389                2.0 * cbi * csi
3390            } else {
3391                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3392            }
3393        }
3394        9 => {
3395            // SoftLight (Adobe formulation)
3396            let d = if cbi <= 0.25 {
3397                ((16.0 * cbi - 12.0) * cbi + 4.0) * cbi
3398            } else {
3399                cbi.sqrt()
3400            };
3401            if csi <= 0.5 {
3402                cbi - (1.0 - 2.0 * csi) * cbi * (1.0 - cbi)
3403            } else {
3404                cbi + (2.0 * csi - 1.0) * (d - cbi)
3405            }
3406        }
3407        10 => (cbi - csi).abs(),           // Difference
3408        11 => cbi + csi - 2.0 * cbi * csi, // Exclusion
3409        _ => csi,                          // Normal/fallback
3410    };
3411    1.0 - result_inv.clamp(0.0, 1.0)
3412}
3413
3414/// Apply a non-separable HSL-style PDF blend mode (Hue, Saturation, Color,
3415/// Luminosity) in DeviceCMYK. Per the spec, the inverted CMY components are
3416/// treated as "effective RGB" and the standard non-separable formulas are
3417/// applied; the K channel is taken from the source (it acts as the source's
3418/// luminosity contribution for the purposes of the blend).
3419fn blend_cmyk_nonseparable(cb: [f64; 4], cs: [f64; 4], mode: u8) -> [f64; 4] {
3420    fn lum(c: [f64; 3]) -> f64 {
3421        0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
3422    }
3423    fn clip_color(mut c: [f64; 3]) -> [f64; 3] {
3424        let l = lum(c);
3425        let n = c[0].min(c[1]).min(c[2]);
3426        let x = c[0].max(c[1]).max(c[2]);
3427        if n < 0.0 {
3428            for ci in c.iter_mut() {
3429                *ci = l + (*ci - l) * l / (l - n);
3430            }
3431        }
3432        if x > 1.0 {
3433            for ci in c.iter_mut() {
3434                *ci = l + (*ci - l) * (1.0 - l) / (x - l);
3435            }
3436        }
3437        c
3438    }
3439    fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
3440        let d = l - lum(c);
3441        clip_color([c[0] + d, c[1] + d, c[2] + d])
3442    }
3443    fn sat(c: [f64; 3]) -> f64 {
3444        c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
3445    }
3446    fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
3447        // Index components by rank: min, mid, max.
3448        let mut idx = [0usize, 1, 2];
3449        idx.sort_by(|a, b| {
3450            c[*a]
3451                .partial_cmp(&c[*b])
3452                .unwrap_or(std::cmp::Ordering::Equal)
3453        });
3454        let (i_min, i_mid, i_max) = (idx[0], idx[1], idx[2]);
3455        let mut out = c;
3456        if c[i_max] > c[i_min] {
3457            out[i_mid] = (c[i_mid] - c[i_min]) * s / (c[i_max] - c[i_min]);
3458            out[i_max] = s;
3459        } else {
3460            out[i_mid] = 0.0;
3461            out[i_max] = 0.0;
3462        }
3463        out[i_min] = 0.0;
3464        out
3465    }
3466
3467    let cb_rgb = [1.0 - cb[0], 1.0 - cb[1], 1.0 - cb[2]];
3468    let cs_rgb = [1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2]];
3469    let result_rgb = match mode {
3470        12 => set_lum(set_sat(cs_rgb, sat(cb_rgb)), lum(cb_rgb)), // Hue
3471        13 => set_lum(set_sat(cb_rgb, sat(cs_rgb)), lum(cb_rgb)), // Saturation
3472        14 => set_lum(cs_rgb, lum(cb_rgb)),                       // Color
3473        15 => set_lum(cb_rgb, lum(cs_rgb)),                       // Luminosity
3474        _ => cs_rgb,
3475    };
3476    // Hue/Saturation/Color preserve the backdrop's luminosity, which in CMYK
3477    // is carried primarily by the K channel. Luminosity transfers the source's
3478    // luminosity, so it takes K from the source.
3479    let result_k = if mode == 15 { cs[3] } else { cb[3] };
3480    [
3481        (1.0 - result_rgb[0]).clamp(0.0, 1.0),
3482        (1.0 - result_rgb[1]).clamp(0.0, 1.0),
3483        (1.0 - result_rgb[2]).clamp(0.0, 1.0),
3484        result_k,
3485    ]
3486}
3487
3488/// Render a transparency group into a pixmap.
3489/// Device-space axis-aligned bbox of a path, computed from its segment
3490/// endpoints and curve control points. Returned as (x0, y0, x1, y1) with
3491/// x0 ≤ x1, y0 ≤ y1. Returns `None` for an empty path.
3492fn ps_path_bbox(path: &PsPath) -> Option<(f64, f64, f64, f64)> {
3493    let mut it = path.segments.iter().filter_map(|seg| match *seg {
3494        PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => Some(vec![(x, y)]),
3495        PathSegment::CurveTo {
3496            x1,
3497            y1,
3498            x2,
3499            y2,
3500            x3,
3501            y3,
3502        } => Some(vec![(x1, y1), (x2, y2), (x3, y3)]),
3503        PathSegment::ClosePath => None,
3504    });
3505    let first = it.next()?.into_iter().next()?;
3506    let (mut x0, mut y0) = first;
3507    let (mut x1, mut y1) = first;
3508    for seg_points in std::iter::once(vec![first]).chain(it) {
3509        for (x, y) in seg_points {
3510            x0 = x0.min(x);
3511            y0 = y0.min(y);
3512            x1 = x1.max(x);
3513            y1 = y1.max(y);
3514        }
3515    }
3516    Some((x0, y0, x1, y1))
3517}
3518
3519/// True when rectangle `inner` fits inside `outer` with `tolerance` slack
3520/// (positive tolerance = inner may protrude by up to `tolerance` units).
3521fn bbox_contains(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64), tolerance: f64) -> bool {
3522    inner.0 >= outer.0 - tolerance
3523        && inner.1 >= outer.1 - tolerance
3524        && inner.2 <= outer.2 + tolerance
3525        && inner.3 <= outer.3 + tolerance
3526}
3527
3528/// Detect the GWG "reference-under-test" authoring pattern: a parent Fill
3529/// that will be fully covered by the first Fill of a following isolated
3530/// transparency group. When detected, the parent's Fill can be skipped —
3531/// its AA edges otherwise bleed into the dest under the group's partial-
3532/// alpha source during composite-back, producing a visible outline where
3533/// Acrobat shows none (see GWG 16.2 Opacity(0%) analysis in
3534/// `project_icc_profile_stability.md`).
3535///
3536/// Returns indices in `elements` that should be skipped. Safety conditions:
3537///   1. Parent fill is fully opaque, Normal blend.
3538///   2. Next paint (ignoring Clip/InitClip) is an isolated, alpha-1,
3539///      Normal-blend Group whose first paint is a Fill with matching
3540///      path (within tolerance) and the same opacity/blend conditions.
3541///   3. The group's declared bbox fully contains the parent path's bbox
3542///      — i.e. the form's own BBox clip won't carve the fill away.
3543///   4. Every Clip element between the parent fill and the group, and
3544///      every Clip between the group's start and its first fill, has a
3545///      bbox that also fully contains the parent path — so no additional
3546///      clip can cut the group's first fill to a subset of the parent's
3547///      extent.
3548///   5. PDF's isolated transparency semantics guarantee that once the
3549///      first fill establishes alpha=1 at the parent-path pixels, later
3550///      Normal-blend paints can only add colour there; alpha can't
3551///      decrease. So nothing in the group's tail can re-expose backdrop,
3552///      even without auditing those elements explicitly.
3553fn compute_obscured_fill_skips(elements: &DisplayList) -> Vec<usize> {
3554    let mut skips = Vec::new();
3555    let els = elements.elements();
3556    for i in 0..els.len() {
3557        let DisplayElement::Fill {
3558            path: parent_path,
3559            params: parent_params,
3560        } = &els[i]
3561        else {
3562            continue;
3563        };
3564        if (parent_params.alpha - 1.0).abs() > 1e-6 || parent_params.blend_mode != 0 {
3565            continue;
3566        }
3567        let Some(parent_bbox) = ps_path_bbox(parent_path) else {
3568            continue;
3569        };
3570        // Walk forward past Clip/InitClip between parent fill and the
3571        // group. Each such clip must contain the parent's extent; any
3572        // other element type ends the scan.
3573        let mut j = i + 1;
3574        let mut clips_ok = true;
3575        while j < els.len() {
3576            match &els[j] {
3577                DisplayElement::InitClip => {}
3578                DisplayElement::Clip {
3579                    path: clip_path, ..
3580                } => match ps_path_bbox(clip_path) {
3581                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3582                    _ => {
3583                        clips_ok = false;
3584                        break;
3585                    }
3586                },
3587                _ => break,
3588            }
3589            j += 1;
3590        }
3591        if !clips_ok {
3592            continue;
3593        }
3594        let Some(DisplayElement::Group {
3595            elements: group_elements,
3596            params: group_params,
3597        }) = els.get(j)
3598        else {
3599            continue;
3600        };
3601        if !group_params.isolated
3602            || (group_params.alpha - 1.0).abs() > 1e-6
3603            || group_params.blend_mode != 0
3604        {
3605            continue;
3606        }
3607        // The form's declared BBox acts as a clip inside the group; the
3608        // parent's fill must fit inside it or the group's output will be
3609        // carved away where we'd rely on coverage.
3610        let group_bbox = (
3611            group_params.bbox[0],
3612            group_params.bbox[1],
3613            group_params.bbox[2],
3614            group_params.bbox[3],
3615        );
3616        if !bbox_contains(group_bbox, parent_bbox, 0.5) {
3617            continue;
3618        }
3619        // Walk past Clip/InitClip inside the group to its first paint,
3620        // requiring each clip to contain the parent's extent.
3621        let inner_els = group_elements.elements();
3622        let mut k = 0;
3623        let mut inner_clips_ok = true;
3624        while k < inner_els.len() {
3625            match &inner_els[k] {
3626                DisplayElement::InitClip => {}
3627                DisplayElement::Clip {
3628                    path: clip_path, ..
3629                } => match ps_path_bbox(clip_path) {
3630                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3631                    _ => {
3632                        inner_clips_ok = false;
3633                        break;
3634                    }
3635                },
3636                _ => break,
3637            }
3638            k += 1;
3639        }
3640        if !inner_clips_ok {
3641            continue;
3642        }
3643        let Some(DisplayElement::Fill {
3644            path: group_path,
3645            params: group_fill_params,
3646        }) = inner_els.get(k)
3647        else {
3648            continue;
3649        };
3650        if (group_fill_params.alpha - 1.0).abs() > 1e-6 || group_fill_params.blend_mode != 0 {
3651            continue;
3652        }
3653        if paths_approximately_equal(parent_path, group_path, 0.5) {
3654            skips.push(i);
3655        }
3656    }
3657    skips
3658}
3659
3660/// True when two device-space paths have the same segment sequence and
3661/// matching endpoints within `tolerance` device pixels per coordinate.
3662/// Used by `compute_obscured_fill_skips` to recognise PDF-authored patterns
3663/// where the same logical X path is emitted twice with sub-unit rounding
3664/// differences (GWG test suite authoring style from InDesign CS6).
3665fn paths_approximately_equal(a: &PsPath, b: &PsPath, tolerance: f64) -> bool {
3666    if a.segments.len() != b.segments.len() {
3667        return false;
3668    }
3669    for (sa, sb) in a.segments.iter().zip(b.segments.iter()) {
3670        let close_pair = |(x1, y1): (f64, f64), (x2, y2): (f64, f64)| -> bool {
3671            (x1 - x2).abs() <= tolerance && (y1 - y2).abs() <= tolerance
3672        };
3673        match (sa, sb) {
3674            (PathSegment::MoveTo(x1, y1), PathSegment::MoveTo(x2, y2)) => {
3675                if !close_pair((*x1, *y1), (*x2, *y2)) {
3676                    return false;
3677                }
3678            }
3679            (PathSegment::LineTo(x1, y1), PathSegment::LineTo(x2, y2)) => {
3680                if !close_pair((*x1, *y1), (*x2, *y2)) {
3681                    return false;
3682                }
3683            }
3684            (
3685                PathSegment::CurveTo {
3686                    x1: ax1,
3687                    y1: ay1,
3688                    x2: ax2,
3689                    y2: ay2,
3690                    x3: ax3,
3691                    y3: ay3,
3692                },
3693                PathSegment::CurveTo {
3694                    x1: bx1,
3695                    y1: by1,
3696                    x2: bx2,
3697                    y2: by2,
3698                    x3: bx3,
3699                    y3: by3,
3700                },
3701            ) => {
3702                if !close_pair((*ax1, *ay1), (*bx1, *by1))
3703                    || !close_pair((*ax2, *ay2), (*bx2, *by2))
3704                    || !close_pair((*ax3, *ay3), (*bx3, *by3))
3705                {
3706                    return false;
3707                }
3708            }
3709            (PathSegment::ClosePath, PathSegment::ClosePath) => {}
3710            _ => return false,
3711        }
3712    }
3713    true
3714}
3715
3716///
3717/// Creates an offscreen pixmap, renders the group's child elements into it,
3718/// then composites back onto the parent with the group's blend mode and alpha.
3719fn render_group(
3720    pixmap: &mut Pixmap,
3721    band_state: &mut BandState,
3722    elements: &DisplayList,
3723    params: &stet_graphics::display_list::GroupParams,
3724    ctx: &RenderContext<'_>,
3725) {
3726    if params.knockout {
3727        render_knockout_group(pixmap, band_state, elements, params, ctx);
3728        return;
3729    }
3730
3731    let crop = compute_group_crop(&params.bbox, ctx);
3732
3733    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
3734        Some((cx, cy, cw, ch)) => (
3735            cw,
3736            ch,
3737            cx,
3738            cy,
3739            ctx.vp_x + cx as f32 / ctx.scale_x,
3740            ctx.vp_y + cy as f32 / ctx.scale_y,
3741        ),
3742        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
3743    };
3744
3745    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
3746        return;
3747    };
3748
3749    // Decide upfront whether the composite-back will run in CMYK. The CMYK
3750    // path needs the parent backdrop pre-loaded into the offscreen so that
3751    // per-element painting accumulates in the right starting state. The
3752    // sRGB contribution-extraction path renders against an empty offscreen
3753    // for non-Normal BMs to avoid anti-aliased clip artifacts at the BBox
3754    // edges (the diff-against-backdrop logic mishandles partially-blended
3755    // edge pixels otherwise).
3756    use stet_graphics::display_list::GroupColorSpace;
3757
3758    // Allocate a CMYK buffer for the group when:
3759    //   - it tracks overprint, OR
3760    //   - the parent already has one (CMYK context inheritance), OR
3761    //   - this group itself or one of its descendants declares an explicit
3762    //     `/CS DeviceCMYK`, meaning compositing within it needs CMYK math.
3763    let needs_group_cmyk = has_overprint_elements(elements)
3764        || band_state.cmyk_buffer.is_some()
3765        || params.color_space == GroupColorSpace::DeviceCMYK
3766        || has_cmyk_group(elements);
3767
3768    // Decide whether to run the per-pixel CMYK composite-back. The default
3769    // (gated) rule restricts it to the cases the prior rendering session
3770    // explicitly validated. The `STET_FORCE_CMYK_COMPOSITE_BACK=1` env var
3771    // bypasses both gates and switches to the principled rule that the rest
3772    // of this plan will adopt — useful for A/B-comparing the broader fix
3773    // before flipping the default in Step 9.
3774    let force_cmyk_compose =
3775        std::env::var_os("STET_FORCE_CMYK_COMPOSITE_BACK").as_deref() == Some("1".as_ref());
3776    // The knockout group's coverage pass disables CMYK composite-back so the
3777    // painter falls through to the simple sRGB draw_pixmap path. Without this,
3778    // a white-source painter (CMYK 0,0,0,0) would be skipped by the
3779    // composite-back's "source==backdrop" guard against the transparent
3780    // coverage backdrop, and pass 2 wouldn't capture the painter's coverage.
3781    //
3782    // The color pass widens the gate to all non-Normal blend modes so a
3783    // `/CS DeviceCMYK` knockout group's painters with separable blends like
3784    // Screen / ColorDodge / Overlay / SoftLight blend in CMYK math (matching
3785    // the spec) instead of in tiny-skia's sRGB blend.
3786    let plan_cmyk_compose = match ctx.knockout_painter_pass {
3787        KnockoutPainterPass::CoveragePass => false,
3788        KnockoutPainterPass::ColorPass => {
3789            !params.isolated
3790                && params.blend_mode != 0
3791                && needs_group_cmyk
3792                && band_state.cmyk_buffer.is_some()
3793                && group_content_is_native_cmyk(elements)
3794        }
3795        KnockoutPainterPass::None if force_cmyk_compose => {
3796            // Principled rule: non-isolated group with an inversion-sensitive
3797            // blend mode (Difference, Exclusion, Hue, Saturation, Color,
3798            // Luminosity) whose painters all supply native CMYK source colors.
3799            //
3800            // The blend-mode restriction is intentional: bm 10..=15 produce
3801            // visibly *wrong* results in sRGB (the GWG 16.0 transparency test
3802            // exists exactly to expose this), so CMYK math is unambiguously
3803            // correct there. The separable modes 1..=9 (Multiply, Screen, etc.)
3804            // are spec-defensible in either color space but look noticeably
3805            // different — most renderers blend them in sRGB, and PDFs authored
3806            // for that look "wrong" if we suddenly switch them to CMYK math.
3807            //
3808            // The painter-set restriction (no shadings, no non-CMYK content)
3809            // exists because the parallel CMYK buffer can only faithfully track
3810            // single-CMYK-value-per-pixel painters; gradients interpolate
3811            // differently in pixmap RGB vs buffer CMYK and the divergence makes
3812            // the composite-back read stale source values.
3813            !params.isolated
3814                && matches!(params.blend_mode, 10..=15)
3815                && needs_group_cmyk
3816                && band_state.cmyk_buffer.is_some()
3817                && group_content_is_native_cmyk(elements)
3818        }
3819        KnockoutPainterPass::None => {
3820            // Default rule: only the inversion-sensitive blend modes
3821            // (Difference, Exclusion, HSL non-separable) need CMYK math; the
3822            // separable modes 1..=9 are spec-defensible in either color space
3823            // and most sRGB-authored PDFs expect them to blend in sRGB.
3824            let inversion_sensitive = !params.isolated
3825                && matches!(params.blend_mode, 10..=15)
3826                && group_only_native_cmyk_fills(elements);
3827            // GWG 16.2 ("Transparency Basic Blend Modes — DeviceCMYK,
3828            // Isolated") nests non-isolated `/CS DeviceCMYK` painter sub-groups
3829            // inside an isolated `/CS DeviceCMYK` group, with the swatch's
3830            // blend mode applied at the inner Do. Per PDF spec §11.6.7 the
3831            // compositing for those inner groups must happen in DeviceCMYK,
3832            // not sRGB — otherwise their colored X-shape produces the wrong
3833            // color and fails to cover the painter-A black X. The explicit
3834            // `/CS DeviceCMYK` declaration plus the isolated parent are the
3835            // spec signal that the author wants CMYK-space compositing for
3836            // a fresh transparent backdrop. The `parent_group_isolated`
3837            // gate keeps the rule from firing for non-isolated parents like
3838            // 907 page 28's chart panels, where the existing sRGB
3839            // contribution-extraction path correctly preserves anti-aliased
3840            // gray strokes.
3841            //
3842            // GWG 16.1 ("Transparency Basic Blend Modes — ICCBasedRGB")
3843            // exercises the same DeviceCMYK page group but the parent is
3844            // *non-isolated*, so the `parent_group_isolated` gate refused
3845            // to fire and every separable blend swatch fell back to sRGB
3846            // blending (visible as the test's "X" markers). PDF/X
3847            // workflows already declare their target compositing space via
3848            // `/OutputIntents`, and the proofing chain in
3849            // `register_profile_with_n` flips `IccCache::proofing_enabled`
3850            // on once that's been honoured. Use that as the PDF/X-specific
3851            // signal for "blend in DeviceCMYK regardless of group
3852            // isolation"; non-proofing documents (907 p28 et al.) keep
3853            // the original `parent_group_isolated` requirement.
3854            let proofing_enabled = ctx.icc.is_some_and(|c| c.proofing_enabled());
3855            // Per PDF 1.7 §11.6.6, a transparency group with no `/CS` inherits
3856            // its color space from the enclosing group. When the parent has
3857            // already allocated a CMYK buffer (the only way `cmyk_buffer` is
3858            // `Some` on this band_state when we enter `render_group`), the
3859            // parent's effective compositing space is DeviceCMYK and an
3860            // `Inherited` child should join it. Without this, GWG 16.4 swatch
3861            // groups (no `/CS`) fell back to sRGB blending and the Multiply /
3862            // Color Burn blends produced visible X markers.
3863            let effective_cs_is_cmyk = params.color_space == GroupColorSpace::DeviceCMYK
3864                || (params.color_space == GroupColorSpace::Inherited
3865                    && band_state.cmyk_buffer.is_some());
3866            let cmyk_group_blend = !params.isolated
3867                && (ctx.parent_group_isolated || proofing_enabled)
3868                && params.blend_mode != 0
3869                && effective_cs_is_cmyk
3870                && needs_group_cmyk
3871                && band_state.cmyk_buffer.is_some()
3872                && group_content_is_native_cmyk(elements);
3873            inversion_sensitive || cmyk_group_blend
3874        }
3875    };
3876    // Non-isolated groups with non-Normal blend modes on the sRGB path
3877    // need a two-pass render: once against the backdrop (for correct
3878    // internal blending) and once against transparent (to extract the
3879    // group's shape/alpha for the proper source-contribution formula).
3880    let needs_alpha_extraction = !params.isolated
3881        && params.blend_mode != 0
3882        && !plan_cmyk_compose
3883        && !ctx.alpha_extraction_pass;
3884    let needs_backdrop_preload =
3885        !params.isolated && (params.blend_mode == 0 || plan_cmyk_compose || needs_alpha_extraction);
3886    let backdrop = if needs_backdrop_preload {
3887        let data = if crop.is_some() {
3888            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
3889        } else {
3890            pixmap.data().to_vec()
3891        };
3892        offscreen.data_mut().copy_from_slice(&data);
3893        Some(data)
3894    } else {
3895        None
3896    };
3897    let group_cmyk = if needs_group_cmyk {
3898        let buf_size = eff_w as usize * eff_h as usize * 4;
3899        let mut buf = vec![0.0f32; buf_size];
3900        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
3901            let parent_stride = ctx.out_w as usize * 4;
3902            let group_stride = eff_w as usize * 4;
3903            for gy in 0..eff_h as usize {
3904                let py = crop_y as usize + gy;
3905                if py < ctx.out_h as usize {
3906                    let p_start = py * parent_stride + crop_x as usize * 4;
3907                    let g_start = gy * group_stride;
3908                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
3909                    buf[g_start..g_start + copy_len]
3910                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
3911                }
3912            }
3913        }
3914        Some(buf)
3915    } else {
3916        None
3917    };
3918
3919    // Snapshot the pre-load CMYK so the composite-back can identify pixels
3920    // the group actually modified. Without a separate snapshot we'd have to
3921    // diff against the parent CMYK buffer, which would lose any in-place
3922    // updates to the parent across the group's lifetime.
3923    let backdrop_cmyk: Option<Vec<f32>> = if !params.isolated {
3924        group_cmyk.clone()
3925    } else {
3926        None
3927    };
3928
3929    let mut group_band = BandState {
3930        clip_region: None,
3931        spare_mask: None,
3932        clip_mask_cache: HashMap::new(),
3933        clip_mask_seen: HashSet::new(),
3934        mask_pool: Vec::new(),
3935        cmyk_buffer: group_cmyk,
3936        op_bg_snapshot: None,
3937        op_touched: None,
3938        spot_mask: None,
3939    };
3940
3941    let group_ctx = RenderContext {
3942        vp_x: eff_vp_x,
3943        vp_y: eff_vp_y,
3944        scale_x: ctx.scale_x,
3945        scale_y: ctx.scale_y,
3946        out_w: eff_w,
3947        out_h: eff_h,
3948        effective_dpi: ctx.effective_dpi,
3949        icc: ctx.icc,
3950        image_cache: None, // Group elements don't use parent image cache
3951        preprocessed: None,
3952        elem_idx: 0,
3953        no_aa: ctx.no_aa,
3954        opm_zero_transparent: ctx.opm_zero_transparent,
3955        knockout_painter_pass: ctx.knockout_painter_pass,
3956        // The children of this group see *this* group as their parent.
3957        parent_group_isolated: params.isolated,
3958        alpha_extraction_pass: ctx.alpha_extraction_pass,
3959        layer_set: ctx.layer_set,
3960    };
3961
3962    let skip_indices = compute_obscured_fill_skips(elements);
3963    for (idx, elem) in elements.elements().iter().enumerate() {
3964        if skip_indices.contains(&idx) {
3965            continue;
3966        }
3967        let elem_ctx = RenderContext {
3968            elem_idx: idx,
3969            ..group_ctx
3970        };
3971        render_element(&mut offscreen, &mut group_band, elem, &elem_ctx);
3972    }
3973
3974    // Second pass: render against transparent to extract the group's
3975    // shape/alpha.  Only needed for the sRGB two-pass composite-back
3976    // path (non-isolated, non-Normal blend, no CMYK compose).
3977    let alpha_offscreen = if needs_alpha_extraction {
3978        let mut iso = Pixmap::new(eff_w, eff_h);
3979        if let Some(ref mut iso_pm) = iso {
3980            let mut iso_band = BandState {
3981                clip_region: None,
3982                spare_mask: None,
3983                clip_mask_cache: HashMap::new(),
3984                clip_mask_seen: HashSet::new(),
3985                mask_pool: Vec::new(),
3986                cmyk_buffer: None,
3987                op_bg_snapshot: None,
3988                op_touched: None,
3989                spot_mask: None,
3990            };
3991            let iso_ctx = RenderContext {
3992                parent_group_isolated: true,
3993                alpha_extraction_pass: true,
3994                ..group_ctx
3995            };
3996            for (idx, elem) in elements.elements().iter().enumerate() {
3997                let elem_ctx = RenderContext {
3998                    elem_idx: idx,
3999                    ..iso_ctx
4000                };
4001                render_element(iso_pm, &mut iso_band, elem, &elem_ctx);
4002            }
4003        }
4004        iso
4005    } else {
4006        None
4007    };
4008
4009    let mut temp_mask = None;
4010    let mask_ref = match resolve_clip_mask(
4011        &band_state.clip_region,
4012        &mut temp_mask,
4013        ctx.out_w,
4014        ctx.out_h,
4015    ) {
4016        None => return, // empty clip → nothing visible
4017        Some(m) => m,
4018    };
4019
4020    // Coverage pass override: force opacity 1.0 + Normal blend so the
4021    // painter's shape reaches the coverage offscreen even when the
4022    // original alpha was 0 (Opacity 0% test) or the blend mode would
4023    // erase the source against the transparent coverage backdrop.
4024    let coverage_params;
4025    let effective_params: &stet_graphics::display_list::GroupParams =
4026        if ctx.knockout_painter_pass == KnockoutPainterPass::CoveragePass {
4027            coverage_params = stet_graphics::display_list::GroupParams {
4028                alpha: 1.0,
4029                blend_mode: 0,
4030                ..params.clone()
4031            };
4032            &coverage_params
4033        } else {
4034            params
4035        };
4036
4037    let mut cmyk_compose_done = false;
4038    if let Some(backdrop) = &backdrop {
4039        // Non-isolated group. For the inversion-sensitive blend modes
4040        // (Difference, Exclusion) and the HSL non-separable modes (Hue,
4041        // Saturation, Color, Luminosity), tiny-skia's sRGB blend math gives
4042        // visibly wrong results for the GWG 16.0 transparency test, where
4043        // the source colors are chosen so that, in CMYK, the blend produces
4044        // the backdrop color exactly. Run the composite-back per pixel in
4045        // CMYK for those modes when the inner content is exclusively
4046        // native-CMYK fills (so the inner CMYK buffer faithfully represents
4047        // the source). The other separable modes (Multiply / Lighten /
4048        // Darken / etc.) and non-CMYK content stay on the existing sRGB
4049        // contribution-extraction path because their CMYK pipeline currently
4050        // depends on `interpolate_cmyk_from_stops`, which derives CMYK from
4051        // sRGB via the lossy `(1−r,1−g,1−b,0)` inverse for shadings/images
4052        // and would shift their colors. Lifting that restriction requires
4053        // computing exact CMYK from each shading/image's source color space
4054        // (e.g. running the DeviceN tint transform), which is a larger
4055        // change than this fix attempts.
4056        let inner_cmyk = group_band.cmyk_buffer.as_deref();
4057        let pre_cmyk = backdrop_cmyk.as_deref();
4058        if plan_cmyk_compose && let (Some(inner), Some(pre)) = (inner_cmyk, pre_cmyk) {
4059            composite_non_isolated_cmyk(
4060                pixmap,
4061                band_state.cmyk_buffer.as_deref_mut(),
4062                &offscreen,
4063                inner,
4064                pre,
4065                backdrop,
4066                effective_params,
4067                mask_ref,
4068                crop_x,
4069                crop_y,
4070                ctx.icc,
4071            );
4072            cmyk_compose_done = true;
4073        } else if let Some(ref alpha_os) = alpha_offscreen {
4074            composite_non_isolated_extracted(
4075                pixmap,
4076                &offscreen,
4077                alpha_os,
4078                backdrop,
4079                effective_params,
4080                mask_ref,
4081                crop_x,
4082                crop_y,
4083            );
4084        } else {
4085            composite_non_isolated_group_cropped(
4086                pixmap,
4087                &offscreen,
4088                backdrop,
4089                effective_params,
4090                mask_ref,
4091                crop_x,
4092                crop_y,
4093            );
4094        }
4095    } else {
4096        let paint = stet_tiny_skia::PixmapPaint {
4097            opacity: effective_params.alpha as f32,
4098            blend_mode: u8_to_blend_mode(effective_params.blend_mode),
4099            quality: stet_tiny_skia::FilterQuality::Nearest,
4100        };
4101        pixmap.draw_pixmap(
4102            crop_x,
4103            crop_y,
4104            offscreen.as_ref(),
4105            &paint,
4106            Transform::identity(),
4107            mask_ref,
4108        );
4109    }
4110
4111    // Write group CMYK buffer back to parent. Skip when the CMYK composite-back
4112    // already wrote the blended values into the parent CMYK buffer — running
4113    // `copy_cmyk_buffer_to_parent` afterwards would overwrite those blended
4114    // values with the inner buffer's raw source colors, breaking subsequent
4115    // siblings that read the parent CMYK as their backdrop.
4116    if !cmyk_compose_done
4117        && let (Some(group_cmyk), Some(parent_cmyk)) =
4118            (&group_band.cmyk_buffer, &mut band_state.cmyk_buffer)
4119    {
4120        copy_cmyk_buffer_to_parent(
4121            parent_cmyk,
4122            group_cmyk,
4123            offscreen.data(),
4124            crop_x as usize,
4125            crop_y as usize,
4126            eff_w as usize,
4127            eff_h as usize,
4128            ctx.out_w as usize,
4129            ctx.out_h as usize,
4130        );
4131    }
4132}
4133
4134/// CMYK-aware composite-back for a non-isolated transparency group.
4135///
4136/// For each pixel in the group's region:
4137///   1. If the inner CMYK buffer matches the snapshot taken when the group
4138///      started, the group painted nothing there → leave the parent unchanged.
4139///   2. Otherwise apply the group blend mode in DeviceCMYK using the spec's
4140///      effective inversion formulas (`blend_cmyk_separable_channel` or
4141///      `blend_cmyk_nonseparable`), convert the result to sRGB through the
4142///      ICC system CMYK profile so it sits seamlessly next to the rest of the
4143///      page, and write the result to both the parent pixmap and (when
4144///      present) the parent CMYK buffer.
4145#[allow(clippy::too_many_arguments)]
4146fn composite_non_isolated_cmyk(
4147    target: &mut Pixmap,
4148    parent_cmyk: Option<&mut [f32]>,
4149    source: &Pixmap,
4150    source_cmyk: &[f32],
4151    backdrop_cmyk: &[f32],
4152    backdrop_pixels: &[u8],
4153    params: &stet_graphics::display_list::GroupParams,
4154    clip_mask: Option<&stet_tiny_skia::Mask>,
4155    crop_x: i32,
4156    crop_y: i32,
4157    icc: Option<&IccCache>,
4158) {
4159    let cw = source.width() as usize;
4160    let ch = source.height() as usize;
4161    let target_w = target.width() as usize;
4162    let target_h = target.height() as usize;
4163
4164    let opacity = params.alpha.clamp(0.0, 1.0);
4165    let blend_mode = params.blend_mode;
4166    let is_nonseparable = matches!(blend_mode, 12..=15);
4167
4168    let target_data = target.data_mut();
4169    let target_stride = target_w * 4;
4170    let group_stride = cw * 4;
4171
4172    let clip_data = clip_mask.map(|m| m.data());
4173
4174    for gy in 0..ch {
4175        let ty = crop_y + gy as i32;
4176        if ty < 0 || ty as usize >= target_h {
4177            continue;
4178        }
4179        let ty = ty as usize;
4180        let group_row = gy * group_stride;
4181        let target_row = ty * target_stride;
4182
4183        for gx in 0..cw {
4184            let tx = crop_x + gx as i32;
4185            if tx < 0 || tx as usize >= target_w {
4186                continue;
4187            }
4188            let tx = tx as usize;
4189            let gi = group_row + gx * 4;
4190            let ti = target_row + tx * 4;
4191
4192            // Did the group actually paint this pixel?
4193            let bc = backdrop_cmyk[gi] as f64;
4194            let bm = backdrop_cmyk[gi + 1] as f64;
4195            let by_ = backdrop_cmyk[gi + 2] as f64;
4196            let bk = backdrop_cmyk[gi + 3] as f64;
4197            let sc = source_cmyk[gi] as f64;
4198            let sm = source_cmyk[gi + 1] as f64;
4199            let sy_ = source_cmyk[gi + 2] as f64;
4200            let sk = source_cmyk[gi + 3] as f64;
4201            if (sc - bc).abs() < 1.0 / 255.0
4202                && (sm - bm).abs() < 1.0 / 255.0
4203                && (sy_ - by_).abs() < 1.0 / 255.0
4204                && (sk - bk).abs() < 1.0 / 255.0
4205            {
4206                continue;
4207            }
4208
4209            // Clip mask coverage in target coordinates.
4210            let cov = if let Some(cd) = clip_data {
4211                cd[ty * target_w + tx] as f64 / 255.0
4212            } else {
4213                1.0
4214            };
4215            if cov <= 0.0 {
4216                continue;
4217            }
4218
4219            // Transparent-backdrop fast path: when the backdrop pixmap's alpha
4220            // is 0 the parent group hasn't painted this pixel, so PDF spec
4221            // §11.4.6 says the blended result reduces to α_s · source — the
4222            // blend formula must NOT be applied. Without this check, formulas
4223            // like ColorBurn / ColorDodge / Lighten / Screen produce visibly
4224            // wrong colors (yellow instead of orange-yellow, white instead of
4225            // the source) because an all-zero CMYK backdrop is identical to
4226            // opaque white in CMYK terms. Using the pixmap alpha as the
4227            // sentinel correctly distinguishes "truly nothing painted"
4228            // (alpha 0) from "white painted" (alpha 1, CMYK 0,0,0,0).
4229            //
4230            // For this branch we composite the source pixmap directly via
4231            // SourceOver (rather than converting source CMYK→sRGB) so the
4232            // source's per-pixel alpha — including anti-aliased edges and
4233            // partially-transparent paint like 907 page 28's gray rules —
4234            // is preserved. The CMYK→sRGB direct path used the un-modulated
4235            // painter color and the group opacity, which forced antialiased
4236            // gray strokes to opaque black.
4237            let backdrop_alpha = backdrop_pixels[gi + 3];
4238            let backdrop_transparent = backdrop_alpha == 0;
4239
4240            let mix = cov * opacity;
4241            let dst_a = target_data[ti + 3] as f64 / 255.0;
4242
4243            if backdrop_transparent {
4244                // SourceOver of the source pixmap (already correctly rendered
4245                // for transparent-backdrop semantics) modulated by the group's
4246                // mix factor. To ensure inner-group AA edges don't leave
4247                // sliver gaps where the outer parent pixmap had previously
4248                // drawn a near-identical path (GWG 16.2 directly-drawn black
4249                // X covered by Painter B's slightly-offset colored X), we
4250                // promote any non-zero source alpha to the painter's full
4251                // unpremultiplied source CMYK converted to sRGB. This
4252                // produces fully-opaque coverage at edge pixels matching
4253                // what the inner painter would render at the path interior,
4254                // so the inner group can fully knock out the outer's AA
4255                // edge when composited back to its parent.
4256                let src_data = source.data();
4257                let src_a_pm = src_data[gi + 3] as f64 / 255.0;
4258                if src_a_pm <= 0.0 {
4259                    continue;
4260                }
4261                // Convert source CMYK directly to sRGB. The CMYK at this
4262                // pixel was written by the inner painter at its full
4263                // un-modulated value (the cmyk_buf doesn't track AA), so
4264                // this is the pure painter color regardless of AA cov.
4265                let (full_r, full_g, full_b) = icc
4266                    .and_then(|i| i.convert_cmyk_readonly(sc, sm, sy_, sk))
4267                    .unwrap_or_else(|| cmyk_to_rgb_plrm(sc, sm, sy_, sk));
4268                let alpha_s = mix;
4269                let inv_sa = 1.0 - alpha_s;
4270                let dst_r_pm = target_data[ti] as f64 / 255.0;
4271                let dst_g_pm = target_data[ti + 1] as f64 / 255.0;
4272                let dst_b_pm = target_data[ti + 2] as f64 / 255.0;
4273                let out_r = full_r * alpha_s + dst_r_pm * inv_sa;
4274                let out_g = full_g * alpha_s + dst_g_pm * inv_sa;
4275                let out_b = full_b * alpha_s + dst_b_pm * inv_sa;
4276                let out_a = alpha_s + dst_a * inv_sa;
4277                target_data[ti] = (out_r * 255.0).round().clamp(0.0, 255.0) as u8;
4278                target_data[ti + 1] = (out_g * 255.0).round().clamp(0.0, 255.0) as u8;
4279                target_data[ti + 2] = (out_b * 255.0).round().clamp(0.0, 255.0) as u8;
4280                target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4281                continue;
4282            }
4283
4284            // Apply the group's blend mode in CMYK.
4285            let (rc, rm, ry, rk) = if is_nonseparable {
4286                let r = blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4287                (r[0], r[1], r[2], r[3])
4288            } else {
4289                (
4290                    blend_cmyk_separable_channel(bc, sc, blend_mode),
4291                    blend_cmyk_separable_channel(bm, sm, blend_mode),
4292                    blend_cmyk_separable_channel(by_, sy_, blend_mode),
4293                    blend_cmyk_separable_channel(bk, sk, blend_mode),
4294                )
4295            };
4296
4297            let (new_r, new_g, new_b) = icc
4298                .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
4299                .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
4300
4301            // tiny-skia stores premultiplied sRGB. Apply the PDF
4302            // §11.4.6 result formula in straight-color form. We force the
4303            // source alpha to 1 (subject to clip + group opacity) at any
4304            // pixel where the source CMYK was written by the inner painter
4305            // — the cmyk_buf flags coverage at the path's full extent, even
4306            // at AA edges. Using full alpha here ensures the inner group
4307            // fully covers the outer parent's previously-drawn content
4308            // when both reference near-identical paths (GWG 16.2 directly-
4309            // drawn outer X path covered by Painter B's slightly-offset
4310            // colored X path). Without this, the formula's partial-cover
4311            // mix produces a 1-pixel sliver of darker color where the two
4312            // paths' rasterizations diverge sub-pixel-wise.
4313            let alpha_s = mix;
4314            let alpha_b = dst_a;
4315            let out_a = alpha_s + alpha_b * (1.0 - alpha_s);
4316            if out_a <= 0.0 {
4317                continue;
4318            }
4319            let (dst_r, dst_g, dst_b) = if alpha_b > 0.0 {
4320                let inv_a = 1.0 / alpha_b;
4321                (
4322                    (target_data[ti] as f64 / 255.0) * inv_a,
4323                    (target_data[ti + 1] as f64 / 255.0) * inv_a,
4324                    (target_data[ti + 2] as f64 / 255.0) * inv_a,
4325                )
4326            } else {
4327                (0.0, 0.0, 0.0)
4328            };
4329            // Spec §11.4.6 result computation:
4330            //   C_o = (α_s·(1−α_b)·C_s + α_s·α_b·B(C_b,C_s) + (1−α_s)·α_b·C_b) / α_o
4331            // Here we already have B(C_b,C_s) computed in CMYK and converted
4332            // to sRGB as (new_r, new_g, new_b). The "C_s" term — the source
4333            // color un-blended — uses the same value because the spec says
4334            // when α_b = 0 the formula reduces to source-as-is, which the
4335            // (1−α_b) coefficient already handles.
4336            let coef_b = alpha_s * alpha_b;
4337            let coef_s = alpha_s * (1.0 - alpha_b);
4338            let coef_d = (1.0 - alpha_s) * alpha_b;
4339            let out_r = (coef_s * new_r + coef_b * new_r + coef_d * dst_r) / out_a;
4340            let out_g = (coef_s * new_g + coef_b * new_g + coef_d * dst_g) / out_a;
4341            let out_b = (coef_s * new_b + coef_b * new_b + coef_d * dst_b) / out_a;
4342
4343            target_data[ti] = (out_r * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4344            target_data[ti + 1] = (out_g * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4345            target_data[ti + 2] = (out_b * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4346            target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4347        }
4348    }
4349
4350    // Write the blended CMYK back to the parent CMYK buffer so subsequent
4351    // sibling groups see consistent backdrop values. We re-walk the same
4352    // region — keeps the inner loop above tight (no double-borrow on the
4353    // parent buffer) and only touches pixels we actually modified.
4354    if let Some(parent_cmyk) = parent_cmyk {
4355        for gy in 0..ch {
4356            let ty = crop_y + gy as i32;
4357            if ty < 0 || ty as usize >= target_h {
4358                continue;
4359            }
4360            let ty = ty as usize;
4361            let group_row = gy * group_stride;
4362            let parent_row = ty * target_stride;
4363
4364            for gx in 0..cw {
4365                let tx = crop_x + gx as i32;
4366                if tx < 0 || tx as usize >= target_w {
4367                    continue;
4368                }
4369                let tx = tx as usize;
4370                let gi = group_row + gx * 4;
4371                let pi = parent_row + tx * 4;
4372
4373                let bc = backdrop_cmyk[gi] as f64;
4374                let bm = backdrop_cmyk[gi + 1] as f64;
4375                let by_ = backdrop_cmyk[gi + 2] as f64;
4376                let bk = backdrop_cmyk[gi + 3] as f64;
4377                let sc = source_cmyk[gi] as f64;
4378                let sm = source_cmyk[gi + 1] as f64;
4379                let sy_ = source_cmyk[gi + 2] as f64;
4380                let sk = source_cmyk[gi + 3] as f64;
4381                if (sc - bc).abs() < 1.0 / 255.0
4382                    && (sm - bm).abs() < 1.0 / 255.0
4383                    && (sy_ - by_).abs() < 1.0 / 255.0
4384                    && (sk - bk).abs() < 1.0 / 255.0
4385                {
4386                    continue;
4387                }
4388
4389                // Same transparent-backdrop fast path as above: use source
4390                // as-is. We read the original backdrop alpha from the saved
4391                // backdrop_pixels slice, NOT the live target — the live
4392                // target's alpha was already updated by the first loop's
4393                // composite-back writes.
4394                let backdrop_transparent = backdrop_pixels[gi + 3] == 0;
4395                let (rc, rm, ry, rk) = if backdrop_transparent {
4396                    (sc, sm, sy_, sk)
4397                } else if is_nonseparable {
4398                    let r =
4399                        blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4400                    (r[0], r[1], r[2], r[3])
4401                } else {
4402                    (
4403                        blend_cmyk_separable_channel(bc, sc, blend_mode),
4404                        blend_cmyk_separable_channel(bm, sm, blend_mode),
4405                        blend_cmyk_separable_channel(by_, sy_, blend_mode),
4406                        blend_cmyk_separable_channel(bk, sk, blend_mode),
4407                    )
4408                };
4409                parent_cmyk[pi] = rc as f32;
4410                parent_cmyk[pi + 1] = rm as f32;
4411                parent_cmyk[pi + 2] = ry as f32;
4412                parent_cmyk[pi + 3] = rk as f32;
4413            }
4414        }
4415    }
4416}
4417
4418/// Render a knockout transparency group into a pixmap.
4419///
4420/// In a knockout group, each element composites against the group's initial
4421/// backdrop (not the accumulated result of previous elements).
4422fn render_knockout_group(
4423    pixmap: &mut Pixmap,
4424    band_state: &mut BandState,
4425    elements: &DisplayList,
4426    params: &stet_graphics::display_list::GroupParams,
4427    ctx: &RenderContext<'_>,
4428) {
4429    let crop = compute_group_crop(&params.bbox, ctx);
4430
4431    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
4432        Some((cx, cy, cw, ch)) => (
4433            cw,
4434            ch,
4435            cx,
4436            cy,
4437            ctx.vp_x + cx as f32 / ctx.scale_x,
4438            ctx.vp_y + cy as f32 / ctx.scale_y,
4439        ),
4440        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
4441    };
4442
4443    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
4444        return;
4445    };
4446
4447    let initial_backdrop = if !params.isolated {
4448        if crop.is_some() {
4449            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
4450        } else {
4451            pixmap.data().to_vec()
4452        }
4453    } else {
4454        vec![0u8; (eff_w * eff_h * 4) as usize]
4455    };
4456
4457    let Some(mut accumulated) = Pixmap::new(eff_w, eff_h) else {
4458        return;
4459    };
4460    accumulated.data_mut().copy_from_slice(&initial_backdrop);
4461
4462    // Initial CMYK values for the knockout group
4463    let needs_cmyk = has_overprint_elements(elements) || band_state.cmyk_buffer.is_some();
4464    let initial_cmyk = if needs_cmyk {
4465        let buf_size = eff_w as usize * eff_h as usize * 4;
4466        let mut buf = vec![0.0f32; buf_size];
4467        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4468            let parent_stride = ctx.out_w as usize * 4;
4469            let group_stride = eff_w as usize * 4;
4470            for gy in 0..eff_h as usize {
4471                let py = crop_y as usize + gy;
4472                if py < ctx.out_h as usize {
4473                    let p_start = py * parent_stride + crop_x as usize * 4;
4474                    let g_start = gy * group_stride;
4475                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4476                    buf[g_start..g_start + copy_len]
4477                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
4478                }
4479            }
4480        }
4481        Some(buf)
4482    } else {
4483        None
4484    };
4485
4486    let mut accumulated_cmyk = initial_cmyk.clone();
4487
4488    // Disable anti-aliasing in knockout groups to prevent seam artifacts.
4489    // Each element composites independently against the backdrop, so adjacent
4490    // fills' AA edges don't mesh — both blend toward the backdrop color,
4491    // creating visible 1px white lines at shared boundaries.
4492    let group_ctx = RenderContext {
4493        vp_x: eff_vp_x,
4494        vp_y: eff_vp_y,
4495        scale_x: ctx.scale_x,
4496        scale_y: ctx.scale_y,
4497        out_w: eff_w,
4498        out_h: eff_h,
4499        effective_dpi: ctx.effective_dpi,
4500        icc: ctx.icc,
4501        image_cache: None,
4502        preprocessed: None,
4503        elem_idx: 0,
4504        no_aa: true,
4505        opm_zero_transparent: ctx.opm_zero_transparent,
4506        knockout_painter_pass: ctx.knockout_painter_pass,
4507        // Knockout groups composite each element against the initial backdrop;
4508        // children effectively see this group's "fresh" backdrop. Treat the
4509        // knockout group as isolated for the purposes of the inner CMYK rule.
4510        parent_group_isolated: true,
4511        alpha_extraction_pass: false,
4512        layer_set: ctx.layer_set,
4513    };
4514
4515    // Persistent band state for clip tracking — clips must accumulate across
4516    // elements in the knockout group (each paint element still composites
4517    // against the initial backdrop, but it must respect the current clip).
4518    let mut ko_band = BandState {
4519        clip_region: None,
4520        spare_mask: None,
4521        clip_mask_cache: HashMap::new(),
4522        clip_mask_seen: HashSet::new(),
4523        mask_pool: Vec::new(),
4524        cmyk_buffer: None,
4525        op_bg_snapshot: None,
4526        op_touched: None,
4527        spot_mask: None,
4528    };
4529
4530    // Coverage offscreen for two-pass painter rendering of nested transparency
4531    // groups. Reused (zeroed) across painters; allocated lazily on first need.
4532    let mut coverage_offscreen: Option<Pixmap> = None;
4533
4534    for elem in elements.elements() {
4535        match elem {
4536            // State-only elements: update persistent clip, no knockout compositing
4537            DisplayElement::Clip { .. } | DisplayElement::InitClip => {
4538                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4539            }
4540            // Group painters need two-pass rendering. Knockout semantics
4541            // require each painter to overwrite previous siblings within its
4542            // coverage area, even when the painter's blend mode happens to
4543            // produce a result that equals the initial backdrop (e.g.
4544            // Darken(red, white)=red, SoftLight(red, black)=red,
4545            // Multiply(red, magenta)=red — which is exactly what GWG 16.1
4546            // tests). The single-pass change-against-backdrop check used for
4547            // simpler painter types would miss those pixels, and earlier
4548            // siblings' contributions would bleed through.
4549            DisplayElement::Group { .. } => {
4550                // Pass 1: render painter against initial_backdrop to compute
4551                // the blended-color result (the painter's contribution).
4552                // Use ColorPass mode so any non-Normal blend mode goes through
4553                // the per-pixel CMYK composite-back — required for separable
4554                // blends like Screen / ColorDodge / Overlay / SoftLight whose
4555                // sRGB result drifts away from the CMYK-math result.
4556                let pass1_ctx = RenderContext {
4557                    knockout_painter_pass: KnockoutPainterPass::ColorPass,
4558                    ..group_ctx
4559                };
4560                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4561                ko_band.cmyk_buffer = initial_cmyk.clone();
4562                render_element(&mut offscreen, &mut ko_band, elem, &pass1_ctx);
4563                let pass1_cmyk = ko_band.cmyk_buffer.take();
4564
4565                // Pass 2: render painter into a fresh transparent offscreen so
4566                // the alpha channel captures the painter's coverage, which the
4567                // result-color comparison cannot recover when the blend mode
4568                // outputs the backdrop color exactly.
4569                let cov = match coverage_offscreen.as_mut() {
4570                    Some(p) => {
4571                        p.data_mut().fill(0);
4572                        p
4573                    }
4574                    None => {
4575                        let Some(p) = Pixmap::new(eff_w, eff_h) else {
4576                            // Out of memory for coverage buffer — fall back
4577                            // to the change-detection path so the painter
4578                            // still appears (just without proper knockout).
4579                            replace_changed_pixels(
4580                                accumulated.data_mut(),
4581                                offscreen.data(),
4582                                &initial_backdrop,
4583                            );
4584                            if let (Some(p1), Some(acc)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4585                                replace_changed_cmyk(acc, p1, offscreen.data(), &initial_backdrop);
4586                            }
4587                            continue;
4588                        };
4589                        coverage_offscreen = Some(p);
4590                        coverage_offscreen.as_mut().unwrap()
4591                    }
4592                };
4593                ko_band.cmyk_buffer = None;
4594                // Coverage pass: render through the simple sRGB path with
4595                // alpha forced to 1.0 and Normal blend so the painter's
4596                // shape reaches the coverage offscreen even for white-source
4597                // CMYK painters and zero-alpha painters (Opacity 0% test).
4598                let coverage_ctx = RenderContext {
4599                    knockout_painter_pass: KnockoutPainterPass::CoveragePass,
4600                    ..group_ctx
4601                };
4602                render_element(cov, &mut ko_band, elem, &coverage_ctx);
4603
4604                // Use the coverage offscreen's alpha as a knockout mask: the
4605                // painter's contribution from pass 1 source-overs onto
4606                // accumulated weighted by the coverage alpha.
4607                replace_with_coverage_mask(accumulated.data_mut(), offscreen.data(), cov.data());
4608
4609                if let (Some(p1_cmyk), Some(acc_cmyk)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4610                    replace_cmyk_with_coverage_mask(acc_cmyk, p1_cmyk, cov.data());
4611                }
4612                ko_band.cmyk_buffer = None;
4613            }
4614            // Other paint elements: single-pass with change-against-backdrop.
4615            // Direct path/image/shading paints always change pixels they cover,
4616            // so the simpler detection works and avoids the second-pass cost.
4617            _ => {
4618                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4619
4620                ko_band.cmyk_buffer = initial_cmyk.clone();
4621
4622                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4623
4624                if let (Some(elem_cmyk), Some(acc_cmyk)) =
4625                    (&ko_band.cmyk_buffer, &mut accumulated_cmyk)
4626                {
4627                    replace_changed_cmyk(acc_cmyk, elem_cmyk, offscreen.data(), &initial_backdrop);
4628                }
4629                ko_band.cmyk_buffer = None;
4630
4631                replace_changed_pixels(accumulated.data_mut(), offscreen.data(), &initial_backdrop);
4632            }
4633        }
4634    }
4635
4636    let mut temp_mask = None;
4637    let mask_ref = resolve_clip_mask(
4638        &band_state.clip_region,
4639        &mut temp_mask,
4640        ctx.out_w,
4641        ctx.out_h,
4642    );
4643    let mask_ref = match mask_ref {
4644        None => return,
4645        Some(m) => m,
4646    };
4647
4648    composite_non_isolated_group_cropped(
4649        pixmap,
4650        &accumulated,
4651        &initial_backdrop,
4652        params,
4653        mask_ref,
4654        crop_x,
4655        crop_y,
4656    );
4657
4658    if let (Some(acc_cmyk), Some(parent_cmyk)) = (&accumulated_cmyk, &mut band_state.cmyk_buffer) {
4659        copy_cmyk_buffer_to_parent(
4660            parent_cmyk,
4661            acc_cmyk,
4662            accumulated.data(),
4663            crop_x as usize,
4664            crop_y as usize,
4665            eff_w as usize,
4666            eff_h as usize,
4667            ctx.out_w as usize,
4668            ctx.out_h as usize,
4669        );
4670    }
4671}
4672/// Source-over `source` onto `target` weighted by `coverage`'s alpha channel.
4673/// Used for the two-pass knockout group rendering: `coverage` is rendered
4674/// into a transparent offscreen so its alpha records the painter's coverage
4675/// regardless of whether the painter's blend mode produced backdrop-equal
4676/// pixels in the color pass. Both `source` and `target` are assumed fully
4677/// opaque pixmaps (alpha=255 everywhere) since the knockout offscreens are
4678/// pre-loaded with the opaque initial backdrop.
4679fn replace_with_coverage_mask(target: &mut [u8], source: &[u8], coverage: &[u8]) {
4680    for i in (0..target.len()).step_by(4) {
4681        let cov_a = coverage[i + 3];
4682        if cov_a == 0 {
4683            continue;
4684        }
4685        if cov_a == 255 {
4686            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4687            continue;
4688        }
4689        let a = cov_a as u32;
4690        let inv = 255 - a;
4691        for c in 0..4 {
4692            let s = source[i + c] as u32;
4693            let t = target[i + c] as u32;
4694            target[i + c] = ((s * a + t * inv + 127) / 255) as u8;
4695        }
4696    }
4697}
4698
4699/// Source-over CMYK values from `source` onto `target` weighted by the
4700/// coverage offscreen's alpha channel. Companion to
4701/// `replace_with_coverage_mask` for the parallel CMYK buffer.
4702fn replace_cmyk_with_coverage_mask(target: &mut [f32], source: &[f32], coverage: &[u8]) {
4703    let pixel_count = target.len() / 4;
4704    for i in 0..pixel_count {
4705        let pi = i * 4;
4706        let cov_a = coverage[pi + 3];
4707        if cov_a == 0 {
4708            continue;
4709        }
4710        if cov_a == 255 {
4711            target[pi..pi + 4].copy_from_slice(&source[pi..pi + 4]);
4712            continue;
4713        }
4714        let a = cov_a as f32 / 255.0;
4715        let inv = 1.0 - a;
4716        for c in 0..4 {
4717            target[pi + c] = source[pi + c] * a + target[pi + c] * inv;
4718        }
4719    }
4720}
4721
4722/// Replace pixels in `target` with pixels from `source` wherever `source`
4723/// differs from `backdrop`. Used for knockout group per-element compositing
4724/// where each element replaces (not blends with) previous elements.
4725fn replace_changed_pixels(target: &mut [u8], source: &[u8], backdrop: &[u8]) {
4726    for i in (0..target.len()).step_by(4) {
4727        if source[i] != backdrop[i]
4728            || source[i + 1] != backdrop[i + 1]
4729            || source[i + 2] != backdrop[i + 2]
4730            || source[i + 3] != backdrop[i + 3]
4731        {
4732            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4733        }
4734    }
4735}
4736
4737/// Copy a group's CMYK buffer back to the parent's CMYK buffer after compositing.
4738/// Only copies values for pixels where the group offscreen has non-zero alpha,
4739/// indicating the group actually painted something at that position.
4740#[allow(clippy::too_many_arguments)]
4741fn copy_cmyk_buffer_to_parent(
4742    parent_cmyk: &mut [f32],
4743    group_cmyk: &[f32],
4744    group_pixels: &[u8],
4745    crop_x: usize,
4746    crop_y: usize,
4747    group_w: usize,
4748    group_h: usize,
4749    parent_w: usize,
4750    parent_h: usize,
4751) {
4752    let parent_stride = parent_w * 4;
4753    let group_stride = group_w * 4;
4754    for gy in 0..group_h {
4755        let py = crop_y + gy;
4756        if py >= parent_h {
4757            break;
4758        }
4759        for gx in 0..group_w {
4760            let px = crop_x + gx;
4761            if px >= parent_w {
4762                break;
4763            }
4764            // Only copy if the group pixel has non-zero alpha AND
4765            // the group's cmyk at that pixel is non-zero.
4766            // Zero cmyk means "not tracked by a CMYK fill in this group"
4767            // — writing it back would erase the parent's tracked values.
4768            let g_pixel_idx = (gy * group_w + gx) * 4;
4769            let g_cmyk_idx = gy * group_stride + gx * 4;
4770            if group_pixels[g_pixel_idx + 3] > 0
4771                && (group_cmyk[g_cmyk_idx] != 0.0
4772                    || group_cmyk[g_cmyk_idx + 1] != 0.0
4773                    || group_cmyk[g_cmyk_idx + 2] != 0.0
4774                    || group_cmyk[g_cmyk_idx + 3] != 0.0)
4775            {
4776                let p_cmyk_idx = py * parent_stride + px * 4;
4777                parent_cmyk[p_cmyk_idx..p_cmyk_idx + 4]
4778                    .copy_from_slice(&group_cmyk[g_cmyk_idx..g_cmyk_idx + 4]);
4779            }
4780        }
4781    }
4782}
4783
4784/// Copy CMYK values for pixels that changed in a knockout element.
4785/// Used alongside replace_changed_pixels to keep CMYK in sync with RGB.
4786fn replace_changed_cmyk(
4787    target_cmyk: &mut [f32],
4788    source_cmyk: &[f32],
4789    source_pixels: &[u8],
4790    backdrop_pixels: &[u8],
4791) {
4792    let pixel_count = target_cmyk.len() / 4;
4793    for i in 0..pixel_count {
4794        let pi = i * 4;
4795        if source_pixels[pi] != backdrop_pixels[pi]
4796            || source_pixels[pi + 1] != backdrop_pixels[pi + 1]
4797            || source_pixels[pi + 2] != backdrop_pixels[pi + 2]
4798            || source_pixels[pi + 3] != backdrop_pixels[pi + 3]
4799        {
4800            target_cmyk[pi..pi + 4].copy_from_slice(&source_cmyk[pi..pi + 4]);
4801        }
4802    }
4803}
4804
4805/// Render soft-masked content.
4806///
4807/// 1. Renders the mask display list to an offscreen pixmap.
4808/// 2. Extracts a grayscale mask (luminosity or alpha).
4809/// 3. Renders content into another offscreen pixmap.
4810/// 4. Multiplies content alpha by the mask values.
4811/// 5. Composites the masked content onto the parent.
4812#[allow(clippy::too_many_arguments)]
4813fn render_soft_masked(
4814    pixmap: &mut Pixmap,
4815    band_state: &mut BandState,
4816    mask_list: &DisplayList,
4817    content_list: &DisplayList,
4818    params: &stet_graphics::display_list::SoftMaskParams,
4819    mask_cache: &Arc<Mutex<Option<Option<stet_graphics::display_list::MaskRaster>>>>,
4820    ctx: &RenderContext<'_>,
4821) {
4822    // The SoftMask's display list elements are in absolute device space (page coords).
4823    // params.bbox is the SoftMasked element's compositing bounds, derived
4824    // from the form's /BBox transformed by the gs-time CTM. The mask raster
4825    // (built lazily by `rasterize_mask` and cached on the display-list
4826    // element) is anchored independently to the *actual* mask paint bounds,
4827    // which may differ from params.bbox when the form's internal `cm`
4828    // operators translated paint elements outside the form bbox.
4829    //
4830    // The cached-raster path can produce truncated output when the
4831    // SoftMasked is rendered inside an outer offscreen (a Group, an
4832    // outer SoftMasked, etc.) — the nested offscreen's coordinate
4833    // system clips the mask raster's right edge unexpectedly. Detect
4834    // "nested" via `ctx.vp_x != 0.0` (top-level banded rendering uses
4835    // vp_x = 0; nested rendering inherits the parent offscreen's vp).
4836    // For nested cases, fall back to the inline band-local mask
4837    // rendering that worked before Step 4 of cosmic-masking-bird.
4838    let use_inline_mask = ctx.vp_x != 0.0;
4839    let bbox = &params.bbox;
4840    let smask_px_x0 = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
4841    let smask_px_y0 = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
4842    let smask_px_x1 = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
4843    let smask_px_y1 = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
4844
4845    // Clip to parent output bounds
4846    let crop_x = smask_px_x0.max(0);
4847    let crop_y = smask_px_y0.max(0);
4848    let crop_x1 = smask_px_x1.min(ctx.out_w as i32);
4849    let crop_y1 = smask_px_y1.min(ctx.out_h as i32);
4850    if crop_x >= crop_x1 || crop_y >= crop_y1 {
4851        return;
4852    }
4853    let eff_w = (crop_x1 - crop_x) as u32;
4854    let eff_h = (crop_y1 - crop_y) as u32;
4855
4856    // Viewport for the content offscreen: derived from the SoftMask's bbox
4857    // position relative to the parent's viewport. The content offscreen
4858    // still uses params.bbox because params.bbox correctly bounds where
4859    // the content can paint.
4860    let eff_vp_x = ctx.vp_x + crop_x as f32 / ctx.scale_x;
4861    let eff_vp_y = ctx.vp_y + crop_y as f32 / ctx.scale_y;
4862
4863    let sub_ctx = RenderContext {
4864        vp_x: eff_vp_x,
4865        vp_y: eff_vp_y,
4866        scale_x: ctx.scale_x,
4867        scale_y: ctx.scale_y,
4868        out_w: eff_w,
4869        out_h: eff_h,
4870        effective_dpi: ctx.effective_dpi,
4871        icc: ctx.icc,
4872        image_cache: None,
4873        preprocessed: None,
4874        elem_idx: 0,
4875        no_aa: ctx.no_aa,
4876        opm_zero_transparent: ctx.opm_zero_transparent,
4877        knockout_painter_pass: ctx.knockout_painter_pass,
4878        parent_group_isolated: ctx.parent_group_isolated,
4879        // Soft masks render into their own independent offscreen and must
4880        // not inherit the alpha extraction pass — their groups need normal
4881        // backdrop preloading regardless of the outer extraction context.
4882        alpha_extraction_pass: false,
4883        layer_set: ctx.layer_set,
4884    };
4885
4886    // 1a. INLINE PATH: Mask form contains nested offscreens.
4887    // Render the mask form into a band-local offscreen sized to the
4888    // SoftMasked's bbox crop. This matches the pre-Step-4 behavior.
4889    let mut mask_values_inline: Vec<u8> = Vec::new();
4890    if use_inline_mask {
4891        let Some(mut mask_pixmap) = Pixmap::new(eff_w, eff_h) else {
4892            return;
4893        };
4894        let mut mask_band = BandState {
4895            clip_region: None,
4896            spare_mask: None,
4897            clip_mask_cache: HashMap::new(),
4898            clip_mask_seen: HashSet::new(),
4899            mask_pool: Vec::new(),
4900            cmyk_buffer: None,
4901            op_bg_snapshot: None,
4902            op_touched: None,
4903            spot_mask: None,
4904        };
4905        for (idx, elem) in mask_list.elements().iter().enumerate() {
4906            let elem_ctx = RenderContext {
4907                elem_idx: idx,
4908                ..sub_ctx
4909            };
4910            render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
4911        }
4912        if params.has_nested_mask_scope
4913            && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
4914        {
4915            let bc = params.backdrop_color.as_ref();
4916            let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4917            let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4918            let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4919            for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
4920                let a = chunk[3] as u16;
4921                if a == 255 {
4922                    continue;
4923                }
4924                let inv_a = 255 - a;
4925                chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
4926                chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
4927                chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
4928                chunk[3] = 255;
4929            }
4930        }
4931        mask_values_inline = vec![0u8; (eff_w * eff_h) as usize];
4932        extract_soft_mask_values(mask_pixmap.data(), &mut mask_values_inline, params);
4933    }
4934
4935    // 1b. CACHED RASTER PATH: simple masks (no nested offscreens).
4936    let raster_owned: Option<stet_graphics::display_list::MaskRaster> = if use_inline_mask {
4937        None
4938    } else {
4939        let mut guard = mask_cache.lock().unwrap();
4940        let needs_build = match guard.as_ref() {
4941            None => true,
4942            Some(None) => false, // memoized "no mask"
4943            Some(Some(r)) => {
4944                (r.scale_x - ctx.scale_x).abs() > 1e-4 || (r.scale_y - ctx.scale_y).abs() > 1e-4
4945            }
4946        };
4947        if needs_build {
4948            let built = rasterize_mask(
4949                mask_list,
4950                params,
4951                ctx.icc,
4952                ctx.no_aa,
4953                ctx.effective_dpi,
4954                ctx.scale_x,
4955                ctx.scale_y,
4956                ctx.layer_set,
4957            );
4958            *guard = Some(built);
4959        }
4960        guard.as_ref().and_then(|inner| inner.clone())
4961    };
4962
4963    // Default mask value for content pixels that fall outside the mask
4964    // raster (e.g. backdrop region for a Luminosity mask with non-black
4965    // /BC, or always 0 for Alpha masks).
4966    let fallback_mask = out_of_bounds_mask_value(params) as i32;
4967
4968    // 2. Render content into an offscreen, initialized with the parent's
4969    // backdrop so non-isolated groups with blend modes (e.g. Multiply) see
4970    // the correct background and produce the right composited result.
4971    let Some(mut content_pixmap) = Pixmap::new(eff_w, eff_h) else {
4972        return;
4973    };
4974    let backdrop = copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h);
4975    content_pixmap.data_mut().copy_from_slice(&backdrop);
4976
4977    let content_cmyk = if has_overprint_elements(content_list) || band_state.cmyk_buffer.is_some() {
4978        let buf_size = eff_w as usize * eff_h as usize * 4;
4979        let mut buf = vec![0.0f32; buf_size];
4980        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4981            let parent_stride = ctx.out_w as usize * 4;
4982            let group_stride = eff_w as usize * 4;
4983            for gy in 0..eff_h as usize {
4984                let py = crop_y as usize + gy;
4985                if py < ctx.out_h as usize {
4986                    let p_start = py * parent_stride + crop_x as usize * 4;
4987                    let g_start = gy * group_stride;
4988                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4989                    buf[g_start..g_start + copy_len]
4990                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
4991                }
4992            }
4993        }
4994        Some(buf)
4995    } else {
4996        None
4997    };
4998    // Snapshot the pre-content CMYK state so the mask blend can run in CMYK
4999    // space. Without this, the downstream sRGB blend interpolates between
5000    // CMYK backdrop and source after each has been ICC-converted separately,
5001    // which shifts the midtones away from the CMYK-interpolated result the
5002    // source was authored against (pink cast vs warm peach on GWG 16.10
5003    // inner-glow in PDFX-ready_Output-Test_X4.pdf).
5004    let backdrop_cmyk: Option<Vec<f32>> = content_cmyk.clone();
5005    let mut content_band = BandState {
5006        clip_region: None,
5007        spare_mask: None,
5008        clip_mask_cache: HashMap::new(),
5009        clip_mask_seen: HashSet::new(),
5010        mask_pool: Vec::new(),
5011        cmyk_buffer: content_cmyk,
5012        op_bg_snapshot: None,
5013        op_touched: None,
5014        spot_mask: None,
5015    };
5016    for (idx, elem) in content_list.elements().iter().enumerate() {
5017        let elem_ctx = RenderContext {
5018            elem_idx: idx,
5019            ..sub_ctx
5020        };
5021        render_element(&mut content_pixmap, &mut content_band, elem, &elem_ctx);
5022    }
5023
5024    // 3. Apply soft mask: compute per-pixel masked contribution and write
5025    // to parent. result[c] = parent[c] + m * (content_on_backdrop[c] - backdrop[c]) / 255
5026    //
5027    // Mask sampling: the mask raster is in page-pixel coordinates at the
5028    // current render scale, anchored at `(raster.origin_x, raster.origin_y)`.
5029    // The combine loop iterates over content pixel `(x, y)` band-local in
5030    // the content offscreen. To translate to a mask raster index:
5031    //
5032    //   page_x = vp_x_pixels + crop_x + x
5033    //   page_y = vp_y_pixels + crop_y + y
5034    //   mask_x = page_x - raster.origin_x
5035    //   mask_y = page_y - raster.origin_y
5036    //
5037    // where `vp_x_pixels = round(ctx.vp_x * ctx.scale_x)` is the page-pixel
5038    // offset of the band's top-left. For banded rendering this is exact
5039    // (vp = 0, scale = 1, so vp_x_pixels = 0). For viewport rendering with
5040    // a fractional `vp_x`, there is at most a 0.5-pixel sub-pixel offset
5041    // between the content render grid and the cached mask grid; this is
5042    // bounded and visually acceptable for nearest-neighbor sampling.
5043    let vp_x_pixels = (ctx.vp_x * ctx.scale_x).round() as i32;
5044    let vp_y_pixels = (ctx.vp_y * ctx.scale_y).round() as i32;
5045
5046    let mut temp_mask = None;
5047    let clip_ref = resolve_clip_mask(
5048        &band_state.clip_region,
5049        &mut temp_mask,
5050        ctx.out_w,
5051        ctx.out_h,
5052    );
5053    let clip_ref = match clip_ref {
5054        None => return,
5055        Some(m) => m,
5056    };
5057
5058    // Decide whether to interpolate the masked delta in CMYK (with ICC→sRGB
5059    // on the way out) instead of sRGB. The CMYK path matches Acrobat's
5060    // behaviour when the transparency group declares /CS DeviceCMYK and all
5061    // content is native CMYK — the blend color space is then CMYK, and
5062    // sRGB-space interpolation on ICC-converted endpoints loses the warm
5063    // midtone that M+Y mixing produces under a proper CMYK profile.
5064    //
5065    // Gate strictly: content_list must be a flat list of native-CMYK fills
5066    // or strokes with Normal blend and full opacity. Any nested Group,
5067    // SoftMasked, Image, or blend-mode-modulated paint means the parallel
5068    // cmyk_buffer can't be trusted to match the pixmap — running CMYK
5069    // interpolation against a mismatched CMYK snapshot produced wrong
5070    // colors on GWG 16.10 outer-glow C (Fm5 is a Screen-blend white rect
5071    // inside a Group; cmyk_buffer held raw white while pixmap held the
5072    // screen-blended light gray).
5073    let use_cmyk_blend = ctx.icc.is_some()
5074        && backdrop_cmyk.is_some()
5075        && content_band.cmyk_buffer.is_some()
5076        && content_list_is_simple_native_cmyk(content_list);
5077
5078    let content_data = content_pixmap.data();
5079    let parent_data = pixmap.data_mut();
5080    let parent_stride = ctx.out_w as usize * 4;
5081    let content_stride = eff_w as usize * 4;
5082
5083    for y in 0..eff_h as usize {
5084        let py = crop_y as usize + y;
5085        if py >= ctx.out_h as usize {
5086            break;
5087        }
5088        let ci_row = y * content_stride;
5089        let pi_row = py * parent_stride;
5090        let page_y = vp_y_pixels + crop_y + y as i32;
5091
5092        for x in 0..eff_w as usize {
5093            let px = crop_x as usize + x;
5094            if px >= ctx.out_w as usize {
5095                break;
5096            }
5097
5098            // Check clip mask (in parent coordinates)
5099            if let Some(clip) = clip_ref {
5100                if clip.data()[py * ctx.out_w as usize + px] == 0 {
5101                    continue;
5102                }
5103            }
5104
5105            // Sample the mask: inline-rendered values for masks with
5106            // nested offscreens, cached raster for simple masks.
5107            let m = if use_inline_mask {
5108                mask_values_inline[y * eff_w as usize + x] as i32
5109            } else if let Some(ref raster) = raster_owned {
5110                let page_x = vp_x_pixels + crop_x + x as i32;
5111                let mx = page_x - raster.origin_x;
5112                let my = page_y - raster.origin_y;
5113                if mx >= 0 && (mx as u32) < raster.width && my >= 0 && (my as u32) < raster.height {
5114                    raster.data[my as usize * raster.width as usize + mx as usize] as i32
5115                } else {
5116                    fallback_mask
5117                }
5118            } else {
5119                fallback_mask
5120            };
5121            if m == 0 {
5122                continue;
5123            }
5124
5125            let ci = ci_row + x * 4;
5126            let pi = pi_row + px * 4;
5127
5128            // Per-pixel gate: CMYK interpolation is only safe when both
5129            // endpoints are faithfully tracked. ICC-convert both cmyk
5130            // snapshots and compare with the sRGB endpoints; only take
5131            // the CMYK path if BOTH agree within tolerance. The backdrop
5132            // check catches image/RGB paints upstream (tile_clamp_bug.pdf
5133            // photo background) where cmyk_buffer is an approximate
5134            // reverse-transform. The content check catches cases where
5135            // non-CMYK paints inside content leave the cmyk_buffer stale
5136            // relative to the sRGB content pixmap.
5137            let ci_cmyk = (y * eff_w as usize + x) * 4;
5138            let cmyk_path_ok = use_cmyk_blend && {
5139                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5140                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5141                let icc_match = |cmyk: &[f32], rgb: &[u8]| -> bool {
5142                    let (r, g, b) = ctx
5143                        .icc
5144                        .and_then(|i| {
5145                            i.convert_cmyk_readonly(
5146                                cmyk[0] as f64,
5147                                cmyk[1] as f64,
5148                                cmyk[2] as f64,
5149                                cmyk[3] as f64,
5150                            )
5151                        })
5152                        .unwrap_or_else(|| {
5153                            cmyk_to_rgb_plrm(
5154                                cmyk[0] as f64,
5155                                cmyk[1] as f64,
5156                                cmyk[2] as f64,
5157                                cmyk[3] as f64,
5158                            )
5159                        });
5160                    let r = (r * 255.0).round() as i32;
5161                    let g = (g * 255.0).round() as i32;
5162                    let b = (b * 255.0).round() as i32;
5163                    (r - rgb[0] as i32).abs() <= 3
5164                        && (g - rgb[1] as i32).abs() <= 3
5165                        && (b - rgb[2] as i32).abs() <= 3
5166                };
5167                icc_match(bc_cmyk, &backdrop[ci..ci + 3])
5168                    && icc_match(cc_cmyk, &content_data[ci..ci + 3])
5169            };
5170
5171            if cmyk_path_ok {
5172                // CMYK-space mask blend: result_cmyk = backdrop + m*(content - backdrop)
5173                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5174                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5175                let mf = m as f64 / 255.0;
5176                let rc = bc_cmyk[0] as f64 + mf * (cc_cmyk[0] as f64 - bc_cmyk[0] as f64);
5177                let rm = bc_cmyk[1] as f64 + mf * (cc_cmyk[1] as f64 - bc_cmyk[1] as f64);
5178                let ry = bc_cmyk[2] as f64 + mf * (cc_cmyk[2] as f64 - bc_cmyk[2] as f64);
5179                let rk = bc_cmyk[3] as f64 + mf * (cc_cmyk[3] as f64 - bc_cmyk[3] as f64);
5180                let (fr, fg, fb) = ctx
5181                    .icc
5182                    .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
5183                    .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
5184                parent_data[pi] = (fr * 255.0).round().clamp(0.0, 255.0) as u8;
5185                parent_data[pi + 1] = (fg * 255.0).round().clamp(0.0, 255.0) as u8;
5186                parent_data[pi + 2] = (fb * 255.0).round().clamp(0.0, 255.0) as u8;
5187                // Alpha channel: keep sRGB delta blend.
5188                let content_a = content_data[ci + 3] as i32;
5189                let backdrop_a = backdrop[ci + 3] as i32;
5190                let delta = content_a - backdrop_a;
5191                if delta != 0 {
5192                    let masked_delta = if delta > 0 {
5193                        (delta * m + 128) / 255
5194                    } else {
5195                        (delta * m - 128) / 255
5196                    };
5197                    let result = (parent_data[pi + 3] as i32 + masked_delta).clamp(0, 255);
5198                    parent_data[pi + 3] = result as u8;
5199                }
5200                // The parent's cmyk_buffer is deliberately NOT written here.
5201                // Writing back mask-blended CMYK would overwrite backdrop
5202                // tracking that downstream CMYK consumers (outer groups,
5203                // subsequent masks) depend on and cause them to render
5204                // nearby pixels as pure CMYK channels (e.g. the outer-glow
5205                // C regression: adjacent gray pixels ICC-resolved to a
5206                // black K silhouette). The sRGB pixmap carries the mask-
5207                // blended color; parent_cmyk stays untouched.
5208            } else {
5209                for c in 0..4 {
5210                    let content_val = content_data[ci + c] as i32;
5211                    let backdrop_val = backdrop[ci + c] as i32;
5212                    let delta = content_val - backdrop_val;
5213                    if delta != 0 {
5214                        let masked_delta = if delta > 0 {
5215                            (delta * m + 128) / 255
5216                        } else {
5217                            (delta * m - 128) / 255
5218                        };
5219                        let result = (parent_data[pi + c] as i32 + masked_delta).clamp(0, 255);
5220                        parent_data[pi + c] = result as u8;
5221                    }
5222                }
5223            }
5224        }
5225    }
5226
5227    // Write content CMYK buffer back to parent. Skip when the CMYK blend
5228    // loop already updated band_state.cmyk_buffer with mask-blended values
5229    // — copying the unmodulated content CMYK here would overwrite them.
5230    if !use_cmyk_blend {
5231        if let (Some(content_cmyk), Some(parent_cmyk)) =
5232            (&content_band.cmyk_buffer, &mut band_state.cmyk_buffer)
5233        {
5234            copy_cmyk_buffer_to_parent(
5235                parent_cmyk,
5236                content_cmyk,
5237                content_pixmap.data(),
5238                crop_x as usize,
5239                crop_y as usize,
5240                eff_w as usize,
5241                eff_h as usize,
5242                ctx.out_w as usize,
5243                ctx.out_h as usize,
5244            );
5245        }
5246    }
5247}
5248/// Extract grayscale mask values from rendered RGBA pixels.
5249fn extract_soft_mask_values(
5250    rgba: &[u8],
5251    out: &mut [u8],
5252    params: &stet_graphics::display_list::SoftMaskParams,
5253) {
5254    use stet_graphics::display_list::SoftMaskSubtype;
5255    let pixel_count = out.len();
5256
5257    match params.subtype {
5258        SoftMaskSubtype::Alpha => {
5259            for i in 0..pixel_count {
5260                let a = rgba[i * 4 + 3]; // alpha channel
5261                out[i] = if params.transfer_invert { 255 - a } else { a };
5262            }
5263        }
5264        SoftMaskSubtype::Luminosity => {
5265            // Backdrop luminosity for transparent pixels
5266            let backdrop_lum = if let Some(bc) = &params.backdrop_color {
5267                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5268            } else {
5269                0.0 // black backdrop
5270            };
5271            let backdrop_byte = (backdrop_lum * 255.0 + 0.5) as u8;
5272
5273            #[allow(clippy::needless_range_loop)]
5274            for i in 0..pixel_count {
5275                let off = i * 4;
5276                let a = rgba[off + 3];
5277                let lum_byte = if a == 0 {
5278                    backdrop_byte
5279                } else if a < 255 {
5280                    // Composite premultiplied RGB onto backdrop before computing
5281                    // luminosity (PDF spec 11.6.5.3): premul_rgb + BC × (1 - α/255)
5282                    let af = a as f64;
5283                    let bd = backdrop_lum * 255.0;
5284                    let r = rgba[off] as f64 + bd * (255.0 - af) / 255.0;
5285                    let g = rgba[off + 1] as f64 + bd * (255.0 - af) / 255.0;
5286                    let b = rgba[off + 2] as f64 + bd * (255.0 - af) / 255.0;
5287                    let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
5288                    (lum + 0.5).clamp(0.0, 255.0) as u8
5289                } else {
5290                    // Fully opaque: premultiplied == straight RGB
5291                    let lum = 0.2126 * rgba[off] as f64
5292                        + 0.7152 * rgba[off + 1] as f64
5293                        + 0.0722 * rgba[off + 2] as f64;
5294                    (lum + 0.5).clamp(0.0, 255.0) as u8
5295                };
5296                // Apply transfer function inversion: {1 exch sub} → 255 - value
5297                out[i] = if params.transfer_invert {
5298                    255 - lum_byte
5299                } else {
5300                    lum_byte
5301                };
5302            }
5303        }
5304    }
5305}
5306
5307/// Compute the byte the mask sample loop should use for content pixels
5308/// that fall outside the rasterized mask raster.
5309///
5310/// For Luminosity masks, transparent pixels (no rendered mask paint)
5311/// composite onto the backdrop color, so the effective mask value is the
5312/// backdrop's luminosity. For Alpha masks, transparent = 0 = mask off.
5313/// Both subtypes apply the `/TR {1 exch sub}` transfer inversion.
5314fn out_of_bounds_mask_value(params: &stet_graphics::display_list::SoftMaskParams) -> u8 {
5315    use stet_graphics::display_list::SoftMaskSubtype;
5316    let raw = match params.subtype {
5317        SoftMaskSubtype::Alpha => 0u8,
5318        SoftMaskSubtype::Luminosity => {
5319            let lum = if let Some(bc) = &params.backdrop_color {
5320                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5321            } else {
5322                0.0
5323            };
5324            (lum * 255.0 + 0.5) as u8
5325        }
5326    };
5327    if params.transfer_invert {
5328        255 - raw
5329    } else {
5330        raw
5331    }
5332}
5333
5334/// Maximum mask raster area in pixels.  A malformed PDF that asks for a
5335/// gigantic mask form would otherwise OOM. 64 megapixels = 64 MB for
5336/// grayscale or 256 MB for RGBA — generous but bounded.  Using an area
5337/// limit instead of a per-dimension limit correctly handles narrow-but-tall
5338/// pages (e.g. infographics that exceed 8192 pixels in height while being
5339/// only ~1000 pixels wide).
5340const MAX_MASK_RASTER_PIXELS: u64 = 64 * 1024 * 1024;
5341
5342/// Rasterize a soft mask form's display list into a `MaskRaster`.
5343///
5344/// Walks the mask display list to compute its actual paint bounds (which
5345/// may differ from the SoftMasked element's `params.bbox` because the
5346/// form's internal `cm` operators may translate paint elements outside
5347/// the form's `/BBox`), allocates a pixmap that exactly covers those
5348/// bounds in device-space pixels, and renders the mask elements with the
5349/// viewport set to the bounds origin so each element rasterizes at
5350/// `(device_x - origin_x, device_y - origin_y)`.
5351///
5352/// Returns `None` when the mask paints nothing.
5353fn rasterize_mask(
5354    mask_list: &DisplayList,
5355    params: &stet_graphics::display_list::SoftMaskParams,
5356    icc: Option<&IccCache>,
5357    no_aa: bool,
5358    effective_dpi: f64,
5359    scale_x: f32,
5360    scale_y: f32,
5361    layer_set: &LayerSet,
5362) -> Option<stet_graphics::display_list::MaskRaster> {
5363    // 1. Find the actual paint bounds in device space, then cap them to
5364    // the parent gstate's clip path bbox if known. The cap is critical
5365    // for masks whose form contains an unbounded shading inside a
5366    // sentinel-sized internal clip — without it, the raster blows past
5367    // the size limit and produces no output. Pixels outside the parent
5368    // clip can never affect the final image, so the cap is safe.
5369    let mut bounds = compute_paint_bounds(mask_list, effective_dpi)?;
5370    if let Some(cap) = params.parent_clip_bbox {
5371        let cap_bbox = BBox2D {
5372            x_min: cap[0],
5373            y_min: cap[1],
5374            x_max: cap[2],
5375            y_max: cap[3],
5376        };
5377        bounds = intersect_bbox(&bounds, &cap_bbox)?;
5378    }
5379
5380    // 2. Snap to integer device pixels at the current render scale, with a
5381    // 1-pixel pad on each side to avoid antialiasing edge clipping.
5382    let px_x_min = (bounds.x_min as f32 * scale_x).floor() as i32 - 1;
5383    let px_y_min = (bounds.y_min as f32 * scale_y).floor() as i32 - 1;
5384    let px_x_max = (bounds.x_max as f32 * scale_x).ceil() as i32 + 1;
5385    let px_y_max = (bounds.y_max as f32 * scale_y).ceil() as i32 + 1;
5386    if px_x_min >= px_x_max || px_y_min >= px_y_max {
5387        return None;
5388    }
5389    let raster_w = (px_x_max - px_x_min) as u32;
5390    let raster_h = (px_y_max - px_y_min) as u32;
5391    if raster_w == 0 || raster_h == 0 {
5392        return None;
5393    }
5394    if (raster_w as u64) * (raster_h as u64) > MAX_MASK_RASTER_PIXELS {
5395        return None;
5396    }
5397
5398    // 3. Allocate the offscreen pixmap (transparent backdrop).
5399    let mut mask_pixmap = Pixmap::new(raster_w, raster_h)?;
5400
5401    // 4. Build a RenderContext that maps device pixel `(dx, dy)` to
5402    // raster pixel `(dx - px_x_min, dy - px_y_min)`. The viewport is in
5403    // device-space units (not pixels), so divide by scale.
5404    let sub_ctx = RenderContext {
5405        vp_x: px_x_min as f32 / scale_x,
5406        vp_y: px_y_min as f32 / scale_y,
5407        scale_x,
5408        scale_y,
5409        out_w: raster_w,
5410        out_h: raster_h,
5411        effective_dpi,
5412        icc,
5413        image_cache: None,
5414        preprocessed: None,
5415        elem_idx: 0,
5416        no_aa,
5417        opm_zero_transparent: false,
5418        knockout_painter_pass: KnockoutPainterPass::None,
5419        parent_group_isolated: false,
5420        alpha_extraction_pass: false,
5421        layer_set,
5422    };
5423
5424    // 5. Mask rendering doesn't participate in CMYK overprint compositing.
5425    let mut mask_band = BandState {
5426        clip_region: None,
5427        spare_mask: None,
5428        clip_mask_cache: HashMap::new(),
5429        clip_mask_seen: HashSet::new(),
5430        mask_pool: Vec::new(),
5431        cmyk_buffer: None,
5432        op_bg_snapshot: None,
5433        op_touched: None,
5434        spot_mask: None,
5435    };
5436
5437    // 6. Render every element of the mask display list into the offscreen.
5438    for (idx, elem) in mask_list.elements().iter().enumerate() {
5439        let elem_ctx = RenderContext {
5440            elem_idx: idx,
5441            ..sub_ctx
5442        };
5443        render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
5444    }
5445
5446    // 7. If the mask form contained nested gs-set SMask scopes, composite
5447    // the rendered mask onto the backdrop color before extracting
5448    // luminosity. Nested masks produce semi-transparent pixels where
5449    // alpha encodes the mask modulation; without compositing,
5450    // un-premultiplying would amplify the color and lose the modulation.
5451    // Only Luminosity: Alpha masks extract the alpha channel directly,
5452    // so forcing alpha=255 via compositing would destroy the mask info.
5453    if params.has_nested_mask_scope
5454        && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
5455    {
5456        let bc = params.backdrop_color.as_ref();
5457        let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5458        let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5459        let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5460        for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
5461            let a = chunk[3] as u16;
5462            if a == 255 {
5463                continue;
5464            }
5465            let inv_a = 255 - a;
5466            chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
5467            chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
5468            chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
5469            chunk[3] = 255;
5470        }
5471    }
5472
5473    // 8. Extract grayscale mask values into a flat single-channel buffer.
5474    let pixel_count = (raster_w * raster_h) as usize;
5475    let mut data = vec![0u8; pixel_count];
5476    extract_soft_mask_values(mask_pixmap.data(), &mut data, params);
5477
5478    Some(stet_graphics::display_list::MaskRaster {
5479        data,
5480        width: raster_w,
5481        height: raster_h,
5482        origin_x: px_x_min,
5483        origin_y: px_y_min,
5484        scale_x,
5485        scale_y,
5486    })
5487}
5488
5489/// Transform a display element's CTM through a matrix so that pattern-space
5490/// coordinates map to device space.  Recursively transforms children of
5491/// Group and SoftMasked elements, and adjusts their bboxes.
5492fn transform_element_ctm(elem: &DisplayElement, pm: &Matrix) -> DisplayElement {
5493    match elem {
5494        DisplayElement::Fill { path, params } => {
5495            let mut p = params.clone();
5496            p.ctm = pm.concat(&p.ctm);
5497            DisplayElement::Fill {
5498                path: path.clone(),
5499                params: p,
5500            }
5501        }
5502        DisplayElement::Stroke { path, params } => {
5503            let mut p = params.clone();
5504            p.ctm = pm.concat(&p.ctm);
5505            DisplayElement::Stroke {
5506                path: path.clone(),
5507                params: p,
5508            }
5509        }
5510        DisplayElement::Clip { path, params } => {
5511            let mut p = params.clone();
5512            p.ctm = pm.concat(&p.ctm);
5513            if let Some(ref mut sp) = p.stroke_params {
5514                sp.ctm = pm.concat(&sp.ctm);
5515            }
5516            DisplayElement::Clip {
5517                path: path.clone(),
5518                params: p,
5519            }
5520        }
5521        DisplayElement::Image {
5522            sample_data,
5523            params,
5524        } => {
5525            let mut p = params.clone();
5526            p.ctm = pm.concat(&p.ctm);
5527            DisplayElement::Image {
5528                sample_data: sample_data.clone(),
5529                params: p,
5530            }
5531        }
5532        DisplayElement::MeshShading { params } => {
5533            let mut p = params.clone();
5534            p.ctm = pm.concat(&p.ctm);
5535            DisplayElement::MeshShading { params: p }
5536        }
5537        DisplayElement::PatchShading { params } => {
5538            let mut p = params.clone();
5539            p.ctm = pm.concat(&p.ctm);
5540            DisplayElement::PatchShading { params: p }
5541        }
5542        DisplayElement::AxialShading { params } => {
5543            let mut p = params.clone();
5544            p.ctm = pm.concat(&p.ctm);
5545            DisplayElement::AxialShading { params: p }
5546        }
5547        DisplayElement::RadialShading { params } => {
5548            let mut p = params.clone();
5549            p.ctm = pm.concat(&p.ctm);
5550            DisplayElement::RadialShading { params: p }
5551        }
5552        DisplayElement::Group { elements, params } => {
5553            let mut t = DisplayList::new();
5554            for child in elements.elements() {
5555                t.push(transform_element_ctm(child, pm));
5556            }
5557            let mut p = params.clone();
5558            let corners = [
5559                pm.transform_point(p.bbox[0], p.bbox[1]),
5560                pm.transform_point(p.bbox[2], p.bbox[1]),
5561                pm.transform_point(p.bbox[0], p.bbox[3]),
5562                pm.transform_point(p.bbox[2], p.bbox[3]),
5563            ];
5564            p.bbox = [
5565                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5566                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5567                corners
5568                    .iter()
5569                    .map(|c| c.0)
5570                    .fold(f64::NEG_INFINITY, f64::max),
5571                corners
5572                    .iter()
5573                    .map(|c| c.1)
5574                    .fold(f64::NEG_INFINITY, f64::max),
5575            ];
5576            DisplayElement::Group {
5577                elements: t,
5578                params: p,
5579            }
5580        }
5581        DisplayElement::SoftMasked {
5582            mask,
5583            content,
5584            params,
5585            ..
5586        } => {
5587            let mut t_mask = DisplayList::new();
5588            for child in mask.elements() {
5589                t_mask.push(transform_element_ctm(child, pm));
5590            }
5591            let mut t_content = DisplayList::new();
5592            for child in content.elements() {
5593                t_content.push(transform_element_ctm(child, pm));
5594            }
5595            let mut p = params.clone();
5596            let corners = [
5597                pm.transform_point(p.bbox[0], p.bbox[1]),
5598                pm.transform_point(p.bbox[2], p.bbox[1]),
5599                pm.transform_point(p.bbox[0], p.bbox[3]),
5600                pm.transform_point(p.bbox[2], p.bbox[3]),
5601            ];
5602            p.bbox = [
5603                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5604                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5605                corners
5606                    .iter()
5607                    .map(|c| c.0)
5608                    .fold(f64::NEG_INFINITY, f64::max),
5609                corners
5610                    .iter()
5611                    .map(|c| c.1)
5612                    .fold(f64::NEG_INFINITY, f64::max),
5613            ];
5614            // parent_clip_bbox was captured in the original (pattern)
5615            // coordinate system. Transform it through pm to match the
5616            // device-space coords that mask/content elements were just
5617            // moved into; otherwise the renderer would intersect a
5618            // device-space mask bbox with a pattern-space clip and get
5619            // an empty raster.
5620            if let Some(pcb) = p.parent_clip_bbox {
5621                let pcb_corners = [
5622                    pm.transform_point(pcb[0], pcb[1]),
5623                    pm.transform_point(pcb[2], pcb[1]),
5624                    pm.transform_point(pcb[0], pcb[3]),
5625                    pm.transform_point(pcb[2], pcb[3]),
5626                ];
5627                p.parent_clip_bbox = Some([
5628                    pcb_corners
5629                        .iter()
5630                        .map(|c| c.0)
5631                        .fold(f64::INFINITY, f64::min),
5632                    pcb_corners
5633                        .iter()
5634                        .map(|c| c.1)
5635                        .fold(f64::INFINITY, f64::min),
5636                    pcb_corners
5637                        .iter()
5638                        .map(|c| c.0)
5639                        .fold(f64::NEG_INFINITY, f64::max),
5640                    pcb_corners
5641                        .iter()
5642                        .map(|c| c.1)
5643                        .fold(f64::NEG_INFINITY, f64::max),
5644                ]);
5645            }
5646            // The transformed element's coordinate system is different
5647            // from the original; the original cache (if any) is invalid.
5648            // Allocate a fresh cache cell.
5649            DisplayElement::SoftMasked {
5650                mask: t_mask,
5651                content: t_content,
5652                params: p,
5653                mask_cache: Arc::new(Mutex::new(None)),
5654            }
5655        }
5656        DisplayElement::PatternFill { params } => {
5657            let mut p = params.clone();
5658            p.pattern_matrix = pm.concat(&p.pattern_matrix);
5659            // Transform the fill path (device-space coordinates)
5660            p.path = transform_path_by_matrix(&p.path, pm);
5661            if let Some(ref mut sp) = p.stroke_params {
5662                sp.ctm = pm.concat(&sp.ctm);
5663            }
5664            DisplayElement::PatternFill { params: p }
5665        }
5666        DisplayElement::OcgGroup {
5667            elements,
5668            visibility,
5669        } => {
5670            let mut t = DisplayList::new();
5671            for child in elements.elements() {
5672                t.push(transform_element_ctm(child, pm));
5673            }
5674            DisplayElement::OcgGroup {
5675                elements: t,
5676                visibility: visibility.clone(),
5677            }
5678        }
5679        other => other.clone(),
5680    }
5681}
5682
5683/// Transform all points in a path through a matrix.
5684fn transform_path_by_matrix(path: &PsPath, m: &Matrix) -> PsPath {
5685    use stet_fonts::geometry::PathSegment;
5686    let mut out = PsPath::new();
5687    for seg in &path.segments {
5688        out.segments.push(match *seg {
5689            PathSegment::MoveTo(x, y) => {
5690                let (nx, ny) = m.transform_point(x, y);
5691                PathSegment::MoveTo(nx, ny)
5692            }
5693            PathSegment::LineTo(x, y) => {
5694                let (nx, ny) = m.transform_point(x, y);
5695                PathSegment::LineTo(nx, ny)
5696            }
5697            PathSegment::CurveTo {
5698                x1,
5699                y1,
5700                x2,
5701                y2,
5702                x3,
5703                y3,
5704            } => {
5705                let (nx1, ny1) = m.transform_point(x1, y1);
5706                let (nx2, ny2) = m.transform_point(x2, y2);
5707                let (nx3, ny3) = m.transform_point(x3, y3);
5708                PathSegment::CurveTo {
5709                    x1: nx1,
5710                    y1: ny1,
5711                    x2: nx2,
5712                    y2: ny2,
5713                    x3: nx3,
5714                    y3: ny3,
5715                }
5716            }
5717            PathSegment::ClosePath => PathSegment::ClosePath,
5718        });
5719    }
5720    out
5721}
5722
5723/// Render a tiled pattern fill.
5724/// Bilinear downscale of premultiplied RGBA image data.
5725///
5726/// Used to pre-scale pattern tile images when the device-space tile is smaller
5727/// than the image resolution, since tiny-skia's `draw_pixmap` doesn't handle
5728/// sub-1.0 scale transforms.
5729fn bilinear_prescale(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
5730    let mut dst = vec![0u8; (dw * dh * 4) as usize];
5731    for dy in 0..dh {
5732        let sy_f = (dy as f64 + 0.5) * sh as f64 / dh as f64 - 0.5;
5733        let sy0 = sy_f.floor().max(0.0) as u32;
5734        let sy1 = (sy0 + 1).min(sh - 1);
5735        let fy = (sy_f - sy0 as f64) as f32;
5736        let ify = 1.0 - fy;
5737        for dx in 0..dw {
5738            let sx_f = (dx as f64 + 0.5) * sw as f64 / dw as f64 - 0.5;
5739            let sx0 = sx_f.floor().max(0.0) as u32;
5740            let sx1 = (sx0 + 1).min(sw - 1);
5741            let fx = (sx_f - sx0 as f64) as f32;
5742            let ifx = 1.0 - fx;
5743
5744            let i00 = (sy0 * sw + sx0) as usize * 4;
5745            let i10 = (sy0 * sw + sx1) as usize * 4;
5746            let i01 = (sy1 * sw + sx0) as usize * 4;
5747            let i11 = (sy1 * sw + sx1) as usize * 4;
5748            let di = (dy * dw + dx) as usize * 4;
5749            for c in 0..4 {
5750                dst[di + c] = (src[i00 + c] as f32 * ifx * ify
5751                    + src[i10 + c] as f32 * fx * ify
5752                    + src[i01 + c] as f32 * ifx * fy
5753                    + src[i11 + c] as f32 * fx * fy)
5754                    .round() as u8;
5755            }
5756        }
5757    }
5758    dst
5759}
5760
5761fn render_pattern_fill(
5762    pixmap: &mut Pixmap,
5763    band_state: &mut BandState,
5764    params: &stet_graphics::device::PatternFillParams,
5765    ctx: &RenderContext<'_>,
5766) {
5767    let mut temp_mask = None;
5768    let Some(mask_ref) = resolve_clip_mask(
5769        &band_state.clip_region,
5770        &mut temp_mask,
5771        ctx.out_w,
5772        ctx.out_h,
5773    ) else {
5774        return;
5775    };
5776
5777    let pm = &params.pattern_matrix;
5778
5779    // Tile step vectors in device space (handles rotation/shear)
5780    let (step_ux, step_uy) = pm.transform_delta(params.xstep, 0.0);
5781    let (step_vx, step_vy) = pm.transform_delta(0.0, params.ystep);
5782
5783    let step_u_len = (step_ux * step_ux + step_uy * step_uy).sqrt();
5784    let step_v_len = (step_vx * step_vx + step_vy * step_vy).sqrt();
5785    if step_u_len < 0.01 || step_v_len < 0.01 {
5786        return;
5787    }
5788
5789    let origin_x = pm.tx;
5790    let origin_y = pm.ty;
5791
5792    // Viewport bounds in device space
5793    let dev_vp_x = ctx.vp_x as f64;
5794    let dev_vp_y = ctx.vp_y as f64;
5795    let dev_vp_w = ctx.out_w as f64 / ctx.scale_x as f64;
5796    let dev_vp_h = ctx.out_h as f64 / ctx.scale_y as f64;
5797
5798    let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
5799    for seg in &params.path.segments {
5800        let (x, y) = match seg {
5801            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => (*x, *y),
5802            PathSegment::CurveTo { x3, y3, .. } => (*x3, *y3),
5803            PathSegment::ClosePath => continue,
5804        };
5805        min_x = min_x.min(x);
5806        min_y = min_y.min(y);
5807        max_x = max_x.max(x);
5808        max_y = max_y.max(y);
5809    }
5810
5811    // For stroke patterns, the path extends beyond the centerline by half
5812    // the stroke width.  The path is in user space; transform the bbox
5813    // corners through the CTM to get device-space bounds.
5814    if let Some(ref sp) = params.stroke_params {
5815        // Transform user-space bbox corners through CTM to device space
5816        let ctm = &sp.ctm;
5817        let corners = [
5818            ctm.transform_point(min_x, min_y),
5819            ctm.transform_point(max_x, min_y),
5820            ctm.transform_point(min_x, max_y),
5821            ctm.transform_point(max_x, max_y),
5822        ];
5823        min_x = f64::MAX;
5824        min_y = f64::MAX;
5825        max_x = f64::MIN;
5826        max_y = f64::MIN;
5827        for (cx, cy) in &corners {
5828            min_x = min_x.min(*cx);
5829            min_y = min_y.min(*cy);
5830            max_x = max_x.max(*cx);
5831            max_y = max_y.max(*cy);
5832        }
5833        // Expand by half stroke width in device space
5834        let half_w = sp.line_width
5835            * 0.5
5836            * (ctm.a * ctm.a + ctm.b * ctm.b)
5837                .sqrt()
5838                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
5839        min_x -= half_w;
5840        min_y -= half_w;
5841        max_x += half_w;
5842        max_y += half_w;
5843    }
5844
5845    // Clamp to viewport bounds in device space
5846    min_x = min_x.max(dev_vp_x);
5847    min_y = min_y.max(dev_vp_y);
5848    max_x = max_x.min(dev_vp_x + dev_vp_w);
5849    max_y = max_y.min(dev_vp_y + dev_vp_h);
5850    if min_x >= max_x || min_y >= max_y {
5851        return;
5852    }
5853
5854    let det = step_ux * step_vy - step_uy * step_vx;
5855    if det.abs() < 1e-10 {
5856        return;
5857    }
5858    let inv_det = 1.0 / det;
5859
5860    let mut tu_min = f64::MAX;
5861    let mut tu_max = f64::MIN;
5862    let mut tv_min = f64::MAX;
5863    let mut tv_max = f64::MIN;
5864    for &(cx, cy) in &[
5865        (min_x, min_y),
5866        (max_x, min_y),
5867        (min_x, max_y),
5868        (max_x, max_y),
5869    ] {
5870        let dx = cx - origin_x;
5871        let dy = cy - origin_y;
5872        let tu = (dx * step_vy - dy * step_vx) * inv_det;
5873        let tv = (-dx * step_uy + dy * step_ux) * inv_det;
5874        tu_min = tu_min.min(tu);
5875        tu_max = tu_max.max(tu);
5876        tv_min = tv_min.min(tv);
5877        tv_max = tv_max.max(tv);
5878    }
5879
5880    let tile_x_start = tu_min.floor() as i32 - 1;
5881    let tile_x_end = tu_max.ceil() as i32 + 1;
5882    let tile_y_start = tv_min.floor() as i32 - 1;
5883    let tile_y_end = tv_max.ceil() as i32 + 1;
5884
5885    let tile_count = (tile_x_end - tile_x_start) as i64 * (tile_y_end - tile_y_start) as i64;
5886    if tile_count > 10000 {
5887        return;
5888    }
5889
5890    let Some(mut tile_buf) = Pixmap::new(ctx.out_w, ctx.out_h) else {
5891        return;
5892    };
5893
5894    let sx_f = ctx.scale_x as f64;
5895    let sy_f = ctx.scale_y as f64;
5896
5897    if params.device_space_tile {
5898        // Device-space tile path: tile elements have CTMs in device space
5899        // (pattern matrix baked in). Use the full render_element pipeline
5900        // which handles all element types (clips, soft masks, shadings,
5901        // groups). For each tile position, shift the viewport origin by the
5902        // tile offset in device space.
5903        for tv in tile_y_start..tile_y_end {
5904            for tu in tile_x_start..tile_x_end {
5905                let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
5906                let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
5907
5908                let tile_ctx = RenderContext {
5909                    vp_x: ctx.vp_x - offset_x as f32,
5910                    vp_y: ctx.vp_y - offset_y as f32,
5911                    scale_x: ctx.scale_x,
5912                    scale_y: ctx.scale_y,
5913                    out_w: ctx.out_w,
5914                    out_h: ctx.out_h,
5915                    effective_dpi: ctx.effective_dpi,
5916                    icc: ctx.icc,
5917                    image_cache: None,
5918                    preprocessed: None,
5919                    elem_idx: 0,
5920                    no_aa: ctx.no_aa,
5921                    opm_zero_transparent: params.overprint_mode == 1,
5922                    knockout_painter_pass: ctx.knockout_painter_pass,
5923                    parent_group_isolated: ctx.parent_group_isolated,
5924                    alpha_extraction_pass: ctx.alpha_extraction_pass,
5925                    layer_set: ctx.layer_set,
5926                };
5927
5928                let mut tile_band = BandState {
5929                    clip_region: None,
5930                    spare_mask: None,
5931                    clip_mask_cache: HashMap::new(),
5932                    clip_mask_seen: HashSet::new(),
5933                    mask_pool: Vec::new(),
5934                    cmyk_buffer: None,
5935                    op_bg_snapshot: None,
5936                    op_touched: None,
5937                    spot_mask: None,
5938                };
5939
5940                for (idx, elem) in params.tile.elements().iter().enumerate() {
5941                    let elem_ctx = RenderContext {
5942                        elem_idx: idx,
5943                        ..tile_ctx
5944                    };
5945                    render_element(&mut tile_buf, &mut tile_band, elem, &elem_ctx);
5946                }
5947            }
5948        }
5949    } else if params.tile.elements().iter().any(|e| {
5950        !matches!(
5951            e,
5952            DisplayElement::Fill { .. }
5953                | DisplayElement::Stroke { .. }
5954                | DisplayElement::Image { .. }
5955                | DisplayElement::Clip { .. }
5956                | DisplayElement::InitClip
5957        )
5958    }) {
5959        // Complex tile path: pre-render one tile into a small pixmap using
5960        // the full render_element pipeline (handles shadings, groups,
5961        // soft masks, etc.), then stamp copies at each tile position.
5962        let bbox = &params.bbox;
5963        let corners_dev = [
5964            pm.transform_point(bbox[0], bbox[1]),
5965            pm.transform_point(bbox[2], bbox[1]),
5966            pm.transform_point(bbox[0], bbox[3]),
5967            pm.transform_point(bbox[2], bbox[3]),
5968        ];
5969        let (mut td_x0, mut td_y0) = (f64::MAX, f64::MAX);
5970        let (mut td_x1, mut td_y1) = (f64::MIN, f64::MIN);
5971        for (x, y) in &corners_dev {
5972            td_x0 = td_x0.min(*x);
5973            td_y0 = td_y0.min(*y);
5974            td_x1 = td_x1.max(*x);
5975            td_y1 = td_y1.max(*y);
5976        }
5977        let tile_pw = ((td_x1 - td_x0) * sx_f).ceil().max(1.0) as u32;
5978        let tile_ph = ((td_y1 - td_y0) * sy_f).ceil().max(1.0) as u32;
5979        let tile_pw = tile_pw.min(8192);
5980        let tile_ph = tile_ph.min(8192);
5981
5982        if let Some(mut one_tile) = Pixmap::new(tile_pw, tile_ph) {
5983            let tile_render_ctx = RenderContext {
5984                vp_x: td_x0 as f32,
5985                vp_y: td_y0 as f32,
5986                scale_x: ctx.scale_x,
5987                scale_y: ctx.scale_y,
5988                out_w: tile_pw,
5989                out_h: tile_ph,
5990                effective_dpi: ctx.effective_dpi,
5991                icc: ctx.icc,
5992                image_cache: None,
5993                preprocessed: None,
5994                elem_idx: 0,
5995                no_aa: ctx.no_aa,
5996                opm_zero_transparent: params.overprint_mode == 1,
5997                knockout_painter_pass: ctx.knockout_painter_pass,
5998                parent_group_isolated: ctx.parent_group_isolated,
5999                alpha_extraction_pass: ctx.alpha_extraction_pass,
6000                layer_set: ctx.layer_set,
6001            };
6002            let mut tile_bs = BandState {
6003                clip_region: None,
6004                spare_mask: None,
6005                clip_mask_cache: HashMap::new(),
6006                clip_mask_seen: HashSet::new(),
6007                mask_pool: Vec::new(),
6008                cmyk_buffer: None,
6009                op_bg_snapshot: None,
6010                op_touched: None,
6011                spot_mask: None,
6012            };
6013            for (idx, elem) in params.tile.elements().iter().enumerate() {
6014                let transformed = transform_element_ctm(elem, pm);
6015                let elem_ctx = RenderContext {
6016                    elem_idx: idx,
6017                    ..tile_render_ctx
6018                };
6019                render_element(&mut one_tile, &mut tile_bs, &transformed, &elem_ctx);
6020            }
6021            // Stamp pre-rendered tile at each position
6022            for tv in tile_y_start..tile_y_end {
6023                for tu in tile_x_start..tile_x_end {
6024                    let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
6025                    let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
6026                    let px = ((td_x0 + offset_x - dev_vp_x) * sx_f) as i32;
6027                    let py = ((td_y0 + offset_y - dev_vp_y) * sy_f) as i32;
6028                    let paint = stet_tiny_skia::PixmapPaint {
6029                        opacity: 1.0,
6030                        blend_mode: BlendMode::SourceOver,
6031                        quality: stet_tiny_skia::FilterQuality::Nearest,
6032                    };
6033                    tile_buf.draw_pixmap(
6034                        px,
6035                        py,
6036                        one_tile.as_ref(),
6037                        &paint,
6038                        Transform::identity(),
6039                        None,
6040                    );
6041                }
6042            }
6043        }
6044    } else {
6045        // Simple tile path: tile elements have identity CTMs.
6046        // Manually apply the pattern matrix + tile offset for each element.
6047        // Only handles Fill, Stroke, Image, and Clip.
6048
6049        // Pre-process Image elements: convert to RGBA once and pre-scale if
6050        // the combined transform would require downscaling (scale < 1.0).
6051        // tiny-skia's draw_pixmap doesn't handle sub-1.0 scale transforms.
6052        struct PreprocessedImage {
6053            rgba: Vec<u8>,
6054            width: u32,
6055            height: u32,
6056            /// Transform from pixel coords to pattern space, possibly adjusted
6057            /// to account for pre-scaling.
6058            img_transform: Transform,
6059        }
6060        let tile_elements = params.tile.elements();
6061        let mut preprocessed: Vec<Option<PreprocessedImage>> =
6062            Vec::with_capacity(tile_elements.len());
6063        // Tile transform scale components (constant across all tiles)
6064        let tt_sx = (pm.a * sx_f) as f32;
6065        let tt_sy = (pm.d * sy_f) as f32;
6066        let tt_kx = (pm.c * sx_f) as f32;
6067        let tt_ky = (pm.b * sy_f) as f32;
6068        for elem in tile_elements {
6069            if let DisplayElement::Image {
6070                sample_data,
6071                params: ip,
6072            } = elem
6073            {
6074                let iw = ip.width;
6075                let ih = ip.height;
6076                if iw > 0 && ih > 0 {
6077                    let mut rgba =
6078                        samples_to_rgba(sample_data, ip, ctx.icc, ctx.opm_zero_transparent);
6079                    if ip.mask_color.is_some() {
6080                        apply_mask_color_rgba(&mut rgba, sample_data, ip);
6081                    }
6082                    let expected = (iw * ih * 4) as usize;
6083                    if rgba.len() >= expected {
6084                        if let Some(inv) = ip.image_matrix.invert() {
6085                            let combined_mat = ip.ctm.concat(&inv);
6086                            let t = to_transform(&combined_mat);
6087                            // Check effective scale: t maps image pixels → pattern space,
6088                            // tile_transform maps pattern space → device space.
6089                            let test = t.post_concat(Transform::from_row(
6090                                tt_sx, tt_ky, tt_kx, tt_sy, 0.0, 0.0,
6091                            ));
6092                            let eff_sx = (test.sx * test.sx + test.ky * test.ky).sqrt();
6093                            let eff_sy = (test.kx * test.kx + test.sy * test.sy).sqrt();
6094                            if eff_sx < 0.99 || eff_sy < 0.99 {
6095                                // Pre-scale image to avoid sub-1.0 draw_pixmap transform.
6096                                // Use floor so the scaled image is smaller than the
6097                                // device-space tile, ensuring the adjusted scale >= 1.0.
6098                                let tw = (iw as f32 * eff_sx).floor().max(1.0) as u32;
6099                                let th = (ih as f32 * eff_sy).floor().max(1.0) as u32;
6100                                let scaled = bilinear_prescale(&rgba, iw, ih, tw, th);
6101                                // Adjust transform: pre-multiply a scale that maps new
6102                                // pixel coords back to original pixel coords
6103                                let adj = Transform::from_scale(
6104                                    iw as f32 / tw as f32,
6105                                    ih as f32 / th as f32,
6106                                );
6107                                preprocessed.push(Some(PreprocessedImage {
6108                                    rgba: scaled,
6109                                    width: tw,
6110                                    height: th,
6111                                    img_transform: t.pre_concat(adj),
6112                                }));
6113                            } else {
6114                                preprocessed.push(Some(PreprocessedImage {
6115                                    rgba,
6116                                    width: iw,
6117                                    height: ih,
6118                                    img_transform: t,
6119                                }));
6120                            }
6121                        } else {
6122                            preprocessed.push(None);
6123                        }
6124                    } else {
6125                        preprocessed.push(None);
6126                    }
6127                } else {
6128                    preprocessed.push(None);
6129                }
6130                // Note: only Image elements push to preprocessed, so img_idx
6131                // in the tile loop correctly indexes this array.
6132            }
6133        }
6134
6135        for tv in tile_y_start..tile_y_end {
6136            for tu in tile_x_start..tile_x_end {
6137                let pat_offset_x = tu as f64 * params.xstep;
6138                let pat_offset_y = tv as f64 * params.ystep;
6139
6140                let tile_transform = Transform::from_row(
6141                    tt_sx,
6142                    tt_ky,
6143                    tt_kx,
6144                    tt_sy,
6145                    ((pm.a * pat_offset_x + pm.c * pat_offset_y + pm.tx - dev_vp_x) * sx_f) as f32,
6146                    ((pm.b * pat_offset_x + pm.d * pat_offset_y + pm.ty - dev_vp_y) * sy_f) as f32,
6147                );
6148
6149                // Clip tile elements to BBox (PDF spec 8.7.4.2)
6150                let bbox_clip = {
6151                    let bb = &params.bbox;
6152                    let mut bp = stet_tiny_skia::PathBuilder::new();
6153                    bp.move_to(bb[0] as f32, bb[1] as f32);
6154                    bp.line_to(bb[2] as f32, bb[1] as f32);
6155                    bp.line_to(bb[2] as f32, bb[3] as f32);
6156                    bp.line_to(bb[0] as f32, bb[3] as f32);
6157                    bp.close();
6158                    bp.finish().and_then(|sp| {
6159                        let mut m = Mask::new(ctx.out_w, ctx.out_h)?;
6160                        m.fill_path(
6161                            &sp,
6162                            stet_tiny_skia::FillRule::Winding,
6163                            false,
6164                            tile_transform,
6165                        );
6166                        Some(m)
6167                    })
6168                };
6169                let mut tile_clip: Option<Mask> = bbox_clip;
6170                let mut img_idx = 0usize;
6171                for elem in tile_elements {
6172                    let clip_ref = tile_clip.as_ref();
6173                    match elem {
6174                        DisplayElement::Clip { path, params: cp } => {
6175                            if let Some(sp) = build_skia_path(path) {
6176                                let t = to_transform(&cp.ctm);
6177                                let combined = t.post_concat(tile_transform);
6178                                let mut mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6179                                mask.fill_path(&sp, to_fill_rule(&cp.fill_rule), false, combined);
6180                                if let Some(prev) = tile_clip.take() {
6181                                    intersect_masks(&mut mask, &prev);
6182                                }
6183                                tile_clip = Some(mask);
6184                            }
6185                        }
6186                        DisplayElement::InitClip => {
6187                            tile_clip = None;
6188                        }
6189                        DisplayElement::Fill { path, params: fp } => {
6190                            if let Some(sp) = build_skia_path(path) {
6191                                let mut paint = if params.paint_type == 1 {
6192                                    to_paint(&fp.color)
6193                                } else {
6194                                    to_paint(
6195                                        params
6196                                            .underlying_color
6197                                            .as_ref()
6198                                            .unwrap_or(&DeviceColor::black()),
6199                                    )
6200                                };
6201                                paint.anti_alias = false;
6202                                let t = to_transform(&fp.ctm);
6203                                let combined = t.post_concat(tile_transform);
6204                                let fr = to_fill_rule(&fp.fill_rule);
6205                                tile_buf.fill_path(&sp, &paint, fr, combined, clip_ref);
6206                            }
6207                        }
6208                        DisplayElement::Stroke { path, params: sp } => {
6209                            if let Some(skp) = build_skia_path(path) {
6210                                // Compose element CTM with pattern matrix so
6211                                // hairline_min_width sees the real device scale,
6212                                // not the tile's identity CTM.
6213                                let effective_ctm = pm.concat(&sp.ctm);
6214                                let mut sp_adj = sp.clone();
6215                                sp_adj.ctm = effective_ctm;
6216                                let stroke = build_stroke(&sp_adj, ctx.effective_dpi);
6217                                let paint = if params.paint_type == 1 {
6218                                    to_paint(&sp.color)
6219                                } else {
6220                                    to_paint(
6221                                        params
6222                                            .underlying_color
6223                                            .as_ref()
6224                                            .unwrap_or(&DeviceColor::black()),
6225                                    )
6226                                };
6227                                let t = to_transform(&sp.ctm);
6228                                let combined = t.post_concat(tile_transform);
6229                                tile_buf.stroke_path(&skp, &paint, &stroke, combined, clip_ref);
6230                            }
6231                        }
6232                        DisplayElement::Image { .. } => {
6233                            if let Some(ref pi) = preprocessed[img_idx] {
6234                                let combined = pi.img_transform.post_concat(tile_transform);
6235                                if let Some(img_ref) = stet_tiny_skia::PixmapRef::from_bytes(
6236                                    &pi.rgba, pi.width, pi.height,
6237                                ) {
6238                                    let paint = stet_tiny_skia::PixmapPaint {
6239                                        opacity: 1.0,
6240                                        blend_mode: BlendMode::SourceOver,
6241                                        quality: stet_tiny_skia::FilterQuality::Nearest,
6242                                    };
6243                                    tile_buf.draw_pixmap(0, 0, img_ref, &paint, combined, clip_ref);
6244                                }
6245                            }
6246                            img_idx += 1;
6247                        }
6248                        _ => {}
6249                    }
6250                }
6251            }
6252        }
6253    }
6254
6255    // Composite tile_buf onto main pixmap through the fill/stroke path
6256    let Some(fill_skia_path) = build_skia_path(&params.path) else {
6257        return;
6258    };
6259    let fill_rule = to_fill_rule(&params.fill_rule);
6260    let mut fill_mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6261    let path_transform = viewport_transform(
6262        Transform::identity(),
6263        ctx.vp_x,
6264        ctx.vp_y,
6265        ctx.scale_x,
6266        ctx.scale_y,
6267    );
6268    if let Some(ref sp) = params.stroke_params {
6269        // Stroke pattern: expand the centerline path to a fill outline
6270        // using the stroke parameters (width, cap, join, miter, dash).
6271        // Apply dash pattern first (Path::stroke doesn't handle dashing).
6272        let stroke = build_stroke(sp, ctx.effective_dpi);
6273        let ctm_transform = to_transform(&sp.ctm);
6274        let combined = ctm_transform.post_concat(path_transform);
6275        let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&combined);
6276        let dashed;
6277        let stroke_path = if let Some(ref dash) = stroke.dash {
6278            dashed = fill_skia_path.dash(dash, res_scale);
6279            match dashed.as_ref() {
6280                Some(p) => p,
6281                None => &fill_skia_path,
6282            }
6283        } else {
6284            &fill_skia_path
6285        };
6286        if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6287            fill_mask.fill_path(
6288                &outline,
6289                stet_tiny_skia::FillRule::Winding,
6290                !ctx.no_aa,
6291                combined,
6292            );
6293        }
6294    } else {
6295        fill_mask.fill_path(&fill_skia_path, fill_rule, !ctx.no_aa, path_transform);
6296    }
6297
6298    if let Some(clip_mask) = mask_ref {
6299        intersect_masks(&mut fill_mask, clip_mask);
6300    }
6301
6302    let img_paint = stet_tiny_skia::PixmapPaint::default();
6303    pixmap.draw_pixmap(
6304        0,
6305        0,
6306        tile_buf.as_ref(),
6307        &img_paint,
6308        Transform::identity(),
6309        Some(&fill_mask),
6310    );
6311}
6312
6313/// Unified clip path handling for both band and viewport rendering.
6314///
6315/// For band rendering (scale=1.0), includes rect fast-path and Y-bbox early exit.
6316/// For viewport rendering (scale!=1.0), uses the general mask path.
6317fn clip_path_unified(
6318    band_state: &mut BandState,
6319    path: &PsPath,
6320    params: &ClipParams,
6321    ctx: &RenderContext<'_>,
6322) {
6323    let is_unit_scale = ctx.scale_x == 1.0 && ctx.scale_y == 1.0;
6324
6325    // Band-mode optimizations (scale=1.0): Y-bbox early exit and rect fast-path
6326    if is_unit_scale {
6327        let y_start = ctx.vp_y as u32;
6328        let x_start = ctx.vp_x as u32;
6329
6330        // Y-bbox early exit: if clip path doesn't overlap this band, set empty clip
6331        // (only valid when CTM is identity — path coords must be in device space).
6332        // Skip when stroke_params is present: the path is in user space and
6333        // needs the stroke CTM transform, so raw Y bounds are meaningless here.
6334        if x_start == 0
6335            && params.stroke_params.is_none()
6336            && params.ctm.a == 1.0
6337            && params.ctm.d == 1.0
6338            && params.ctm.tx == 0.0
6339            && params.ctm.ty == 0.0
6340            && let Some(bbox) = path_y_bbox(path)
6341            && (bbox.y_max <= y_start as f64 || bbox.y_min >= (y_start + ctx.out_h) as f64)
6342        {
6343            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
6344                band_state.recycle_mask(mask);
6345            }
6346            band_state.clip_region = Some(ClipRegion::Rect(ClipRect {
6347                x0: 0,
6348                y0: 0,
6349                x1: 0,
6350                y1: 0,
6351            }));
6352            return;
6353        }
6354
6355        // Rect fast-path (only when x_start==0 and CTM is identity —
6356        // detect_rect uses raw path coords which are only in device space
6357        // when the CTM is identity)
6358        let ctm_is_identity = params.ctm.a == 1.0
6359            && params.ctm.b == 0.0
6360            && params.ctm.c == 0.0
6361            && params.ctm.d == 1.0
6362            && params.ctm.tx == 0.0
6363            && params.ctm.ty == 0.0;
6364        if x_start == 0
6365            && ctm_is_identity
6366            && params.stroke_params.is_none()
6367            && let Some(dev_rect) = detect_rect(path, ctx.out_w, u32::MAX)
6368        {
6369            let new_rect = translate_clip_rect(&dev_rect, y_start, ctx.out_h);
6370            match band_state.clip_region.take() {
6371                None => {
6372                    band_state.clip_region = Some(ClipRegion::Rect(new_rect));
6373                }
6374                Some(ClipRegion::Rect(existing)) => {
6375                    band_state.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6376                }
6377                Some(ClipRegion::Mask(mut mask)) => {
6378                    intersect_mask_with_rect(&mut mask, &new_rect, ctx.out_w, ctx.out_h);
6379                    band_state.clip_region = Some(ClipRegion::Mask(mask));
6380                }
6381            }
6382            return;
6383        }
6384    }
6385
6386    // General path: non-rectangular clip with cache + mask reuse
6387    let fill_rule = to_fill_rule(&params.fill_rule);
6388    let path_hash = hash_clip_path(path, &params.fill_rule);
6389    let prev_region = band_state.clip_region.take();
6390
6391    let mut mask = band_state.take_mask(ctx.out_w, ctx.out_h);
6392
6393    let path_mask = if let Some(cached) = band_state.clip_mask_cache.get(&path_hash) {
6394        mask.data_mut().copy_from_slice(cached.data());
6395        mask
6396    } else {
6397        let Some(skia_path) = build_skia_path(path) else {
6398            band_state.recycle_mask(mask);
6399            band_state.clip_region = prev_region;
6400            return;
6401        };
6402        mask.data_mut().fill(0);
6403        if let Some(ref sp) = params.stroke_params {
6404            // Stroke-based clip: expand centerline to stroke outline.
6405            // Apply dash pattern first (Path::stroke doesn't handle dashing).
6406            let stroke = build_stroke(sp, ctx.effective_dpi);
6407            let transform = ctx.transform(&sp.ctm);
6408            let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&transform);
6409            let dashed;
6410            let stroke_path = if let Some(ref dash) = stroke.dash {
6411                dashed = skia_path.dash(dash, res_scale);
6412                match dashed.as_ref() {
6413                    Some(p) => p,
6414                    None => &skia_path,
6415                }
6416            } else {
6417                &skia_path
6418            };
6419            if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6420                mask.fill_path(
6421                    &outline,
6422                    stet_tiny_skia::FillRule::Winding,
6423                    false,
6424                    transform,
6425                );
6426            }
6427        } else {
6428            let transform = ctx.transform(&params.ctm);
6429            mask.fill_path(&skia_path, fill_rule, false, transform);
6430        }
6431        if !band_state.clip_mask_seen.insert(path_hash) {
6432            band_state.clip_mask_cache.insert(path_hash, mask.clone());
6433        }
6434        mask
6435    };
6436
6437    match prev_region {
6438        None => {
6439            band_state.clip_region = Some(ClipRegion::Mask(path_mask));
6440        }
6441        Some(ClipRegion::Rect(rect)) => {
6442            if rect.is_empty() {
6443                band_state.recycle_mask(path_mask);
6444                // Intersection with empty clip is still empty — preserve empty state.
6445                // Without this, clip_region stays None (= no clip = paint everything).
6446                band_state.clip_region = Some(ClipRegion::Rect(rect));
6447            } else {
6448                let mut mask = path_mask;
6449                intersect_mask_with_rect(&mut mask, &rect, ctx.out_w, ctx.out_h);
6450                band_state.clip_region = Some(ClipRegion::Mask(mask));
6451            }
6452        }
6453        Some(ClipRegion::Mask(mut existing)) => {
6454            intersect_masks(&mut existing, &path_mask);
6455            band_state.recycle_mask(path_mask);
6456            band_state.clip_region = Some(ClipRegion::Mask(existing));
6457        }
6458    }
6459}
6460impl OutputDevice for SkiaDevice {
6461    fn fill_path(&mut self, path: &PsPath, params: &FillParams) {
6462        self.ensure_full_pixmap();
6463        let Some(skia_path) = build_skia_path(path) else {
6464            return;
6465        };
6466        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6467        let mut temp_mask = None;
6468        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6469            return; // empty clip
6470        };
6471
6472        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6473        let transform = to_transform(&params.ctm);
6474        let fill_rule = to_fill_rule(&params.fill_rule);
6475
6476        self.pixmap
6477            .fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
6478    }
6479
6480    fn stroke_path(&mut self, path: &PsPath, params: &StrokeParams) {
6481        self.ensure_full_pixmap();
6482        let stroke = build_stroke(params, self.dpi);
6483        let adjusted;
6484        let draw_path =
6485            if params.stroke_adjust && stroke.width <= 2.0 && ctm_is_device_space(&params.ctm) {
6486                adjusted =
6487                    stroke_adjust_path_viewport(path, stroke.width as f64, 1.0, 1.0, 0.0, 0.0);
6488                &adjusted
6489            } else {
6490                path
6491            };
6492        let Some(skia_path) = build_skia_path(draw_path) else {
6493            return;
6494        };
6495        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6496        let transform = to_transform(&params.ctm);
6497
6498        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6499        let mut temp_mask = None;
6500        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6501            return; // empty clip
6502        };
6503
6504        self.pixmap
6505            .stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
6506    }
6507
6508    fn clip_path(&mut self, path: &PsPath, params: &ClipParams) {
6509        self.ensure_full_pixmap();
6510        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6511
6512        // Fast path: detect axis-aligned rectangle
6513        if let Some(new_rect) = detect_rect(path, w, h) {
6514            match self.clip_region.take() {
6515                None => {
6516                    self.clip_region = Some(ClipRegion::Rect(new_rect));
6517                }
6518                Some(ClipRegion::Rect(existing)) => {
6519                    // O(1) rect-rect intersection
6520                    self.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6521                }
6522                Some(ClipRegion::Mask(mut mask)) => {
6523                    // Zero mask pixels outside rect
6524                    intersect_mask_with_rect(&mut mask, &new_rect, w, h);
6525                    self.clip_region = Some(ClipRegion::Mask(mask));
6526                }
6527            }
6528            return;
6529        }
6530
6531        // Slow path: non-rectangular clip with mask caching + allocation reuse.
6532        let fill_rule = to_fill_rule(&params.fill_rule);
6533        let path_hash = hash_clip_path(path, &params.fill_rule);
6534        let prev_region = self.clip_region.take();
6535
6536        // Reuse a spare mask buffer if available (avoids alloc/dealloc per tile).
6537        macro_rules! take_spare {
6538            ($self:expr, $w:expr, $h:expr) => {
6539                $self
6540                    .spare_mask
6541                    .take()
6542                    .unwrap_or_else(|| Mask::new($w, $h).expect("Failed to create mask"))
6543            };
6544        }
6545
6546        // Try cache first; rasterize only on miss
6547        let path_mask = if let Some(cached) = self.clip_mask_cache.get(&path_hash) {
6548            // Cache hit: copy cached data into reused buffer (memcpy, no alloc)
6549            let mut mask = take_spare!(self, w, h);
6550            mask.data_mut().copy_from_slice(cached.data());
6551            mask
6552        } else {
6553            let Some(skia_path) = build_skia_path(path) else {
6554                self.clip_region = prev_region;
6555                return;
6556            };
6557            let transform = to_transform(&params.ctm);
6558            let mut mask = take_spare!(self, w, h);
6559            mask.data_mut().fill(0); // zero before rasterizing (spare may have old data)
6560            mask.fill_path(&skia_path, fill_rule, false, transform);
6561            // Cache on second sight: first time just record, second time store
6562            if !self.clip_mask_seen.insert(path_hash) {
6563                // Seen before — cache it (this clone only happens once per unique path)
6564                self.clip_mask_cache.insert(path_hash, mask.clone());
6565            }
6566            mask
6567        };
6568
6569        match prev_region {
6570            None => {
6571                self.clip_region = Some(ClipRegion::Mask(path_mask));
6572            }
6573            Some(ClipRegion::Rect(rect)) => {
6574                if rect.is_empty() {
6575                    self.spare_mask = Some(path_mask); // recycle
6576                } else {
6577                    let mut mask = path_mask;
6578                    intersect_mask_with_rect(&mut mask, &rect, w, h);
6579                    self.clip_region = Some(ClipRegion::Mask(mask));
6580                }
6581            }
6582            Some(ClipRegion::Mask(mut existing)) => {
6583                intersect_masks(&mut existing, &path_mask);
6584                self.spare_mask = Some(path_mask); // recycle the copy
6585                self.clip_region = Some(ClipRegion::Mask(existing));
6586            }
6587        }
6588    }
6589
6590    fn init_clip(&mut self) {
6591        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6592            self.spare_mask = Some(mask);
6593        }
6594        self.clip_region = None;
6595    }
6596
6597    fn erase_page(&mut self) {
6598        // Only fill the full pixmap when it's actually allocated (non-banded path).
6599        // During banding, self.pixmap is a 1×1 placeholder — filling it is harmless.
6600        self.pixmap.fill(Color::WHITE);
6601        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6602            self.spare_mask = Some(mask);
6603        }
6604        self.clip_region = None;
6605    }
6606
6607    fn show_page(&mut self, output_path: &str) -> Result<(), String> {
6608        let w = self.pixmap.width();
6609        let h = self.pixmap.height();
6610        // Composite onto white background before output
6611        composite_onto_white(self.pixmap.data_mut());
6612        let mut sink = self.sink_factory.create_sink(output_path)?;
6613        sink.begin_page(w, h)?;
6614        sink.write_rows(self.pixmap.data(), h)?;
6615        sink.end_page()
6616    }
6617
6618    fn draw_image(&mut self, sample_data: &[u8], params: &ImageParams) {
6619        self.ensure_full_pixmap();
6620        let w = params.width;
6621        let h = params.height;
6622        if w == 0 || h == 0 {
6623            return;
6624        }
6625        let mut rgba_data =
6626            samples_to_rgba(sample_data, params, self.render_icc_cache.as_ref(), false);
6627        if params.mask_color.is_some() {
6628            apply_mask_color_rgba(&mut rgba_data, sample_data, params);
6629        }
6630        let expected = (w * h * 4) as usize;
6631        if rgba_data.len() < expected {
6632            return;
6633        }
6634
6635        let Some(image_inv) = params.image_matrix.invert() else {
6636            return;
6637        };
6638        let combined = params.ctm.concat(&image_inv);
6639        let raw_transform = enforce_min_image_size(to_transform(&combined), w, h);
6640
6641        let prescaled = prescale_image(&rgba_data, w, h, raw_transform, params.interpolate);
6642        let (img_data, img_w, img_h, transform) = match &prescaled {
6643            Some((data, pw, ph, t)) => (data.as_slice(), *pw, *ph, *t),
6644            None => (rgba_data.as_slice(), w, h, raw_transform),
6645        };
6646
6647        let Some(img_pixmap) = stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h) else {
6648            return;
6649        };
6650
6651        let (pw, ph) = (self.pixmap.width(), self.pixmap.height());
6652        let mut temp_mask = None;
6653        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, pw, ph) else {
6654            return;
6655        };
6656
6657        let paint = stet_tiny_skia::PixmapPaint {
6658            quality: image_filter_quality(transform, params.interpolate),
6659            opacity: params.alpha as f32,
6660            blend_mode: u8_to_blend_mode(params.blend_mode),
6661        };
6662        self.pixmap
6663            .draw_pixmap(0, 0, img_pixmap, &paint, transform, mask_ref);
6664    }
6665
6666    fn paint_axial_shading(&mut self, params: &AxialShadingParams) {
6667        self.ensure_full_pixmap();
6668        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6669        let mut temp_mask = None;
6670        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6671            return;
6672        };
6673        render_axial_shading(
6674            &mut self.pixmap,
6675            params,
6676            0.0,
6677            0.0,
6678            1.0,
6679            1.0,
6680            mask_ref,
6681            self.no_aa,
6682            None,
6683            None,
6684        );
6685    }
6686
6687    fn paint_radial_shading(&mut self, params: &RadialShadingParams) {
6688        self.ensure_full_pixmap();
6689        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6690        let mut temp_mask = None;
6691        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6692            return;
6693        };
6694        render_radial_shading(
6695            &mut self.pixmap,
6696            params,
6697            0.0,
6698            0.0,
6699            1.0,
6700            1.0,
6701            mask_ref,
6702            self.no_aa,
6703            None,
6704            None,
6705        );
6706    }
6707
6708    fn paint_mesh_shading(&mut self, params: &MeshShadingParams) {
6709        self.ensure_full_pixmap();
6710        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6711        let mut temp_mask = None;
6712        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6713            return;
6714        };
6715        render_mesh_shading(
6716            &mut self.pixmap,
6717            params,
6718            0.0,
6719            0.0,
6720            1.0,
6721            1.0,
6722            mask_ref,
6723            None,
6724            None,
6725        );
6726    }
6727
6728    fn paint_patch_shading(&mut self, params: &PatchShadingParams) {
6729        self.ensure_full_pixmap();
6730        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6731        let mut temp_mask = None;
6732        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6733            return;
6734        };
6735        render_patch_shading(
6736            &mut self.pixmap,
6737            params,
6738            0.0,
6739            0.0,
6740            1.0,
6741            1.0,
6742            mask_ref,
6743            None,
6744            None,
6745        );
6746    }
6747
6748    fn paint_pattern_fill(&mut self, params: &stet_graphics::device::PatternFillParams) {
6749        self.ensure_full_pixmap();
6750        let w = self.pixmap.width();
6751        let h = self.pixmap.height();
6752        let mut band_state = BandState {
6753            clip_region: self.clip_region.take(),
6754            spare_mask: self.spare_mask.take(),
6755            clip_mask_cache: HashMap::new(),
6756            clip_mask_seen: HashSet::new(),
6757            mask_pool: Vec::new(),
6758            cmyk_buffer: None,
6759            op_bg_snapshot: None,
6760            op_touched: None,
6761            spot_mask: None,
6762        };
6763        {
6764            let ctx = RenderContext {
6765                vp_x: 0.0,
6766                vp_y: 0.0,
6767                scale_x: 1.0,
6768                scale_y: 1.0,
6769                out_w: w,
6770                out_h: h,
6771                effective_dpi: self.dpi,
6772                icc: None,
6773                image_cache: None,
6774                preprocessed: None,
6775                elem_idx: 0,
6776                no_aa: self.no_aa,
6777                opm_zero_transparent: false,
6778                knockout_painter_pass: KnockoutPainterPass::None,
6779                parent_group_isolated: false,
6780                alpha_extraction_pass: false,
6781                layer_set: &self.layer_set,
6782            };
6783            render_pattern_fill(&mut self.pixmap, &mut band_state, params, &ctx);
6784        }
6785        self.clip_region = band_state.clip_region.take();
6786        if let Some(mask) = band_state.spare_mask.take() {
6787            self.spare_mask = Some(mask);
6788        }
6789    }
6790
6791    fn page_size(&self) -> (u32, u32) {
6792        (self.page_w, self.page_h)
6793    }
6794
6795    fn replay_and_show(&mut self, list: DisplayList, output_path: &str) -> Result<(), String> {
6796        // Wait for any previous background render to complete
6797        self.join_pending()?;
6798
6799        let (page_w, page_h) = self.page_size();
6800
6801        // Audit mode: re-render through the viewport pipeline so visual tests
6802        // can catch viewport-only bugs against the same baselines. Same
6803        // `render_element`, same display list — differs only in how culling
6804        // and epochs are computed.
6805        if self.use_viewport_path {
6806            let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref(), false);
6807            let rgba = render_to_rgba_viewport(
6808                &list,
6809                page_w,
6810                page_h,
6811                self.dpi,
6812                Some(&icc_cache),
6813                self.no_aa,
6814            );
6815            let mut sink = self.sink_factory.create_sink(output_path)?;
6816            sink.begin_page(page_w, page_h)?;
6817            sink.write_rows(&rgba, page_h)?;
6818            sink.end_page()?;
6819            return Ok(());
6820        }
6821
6822        let band_h = select_band_height(page_w, page_h);
6823
6824        // Build ICC cache for this page's display list
6825        let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref(), false);
6826
6827        // If banding not worthwhile, render the full page as a single band.
6828        // This still uses render_element (same as banded path) so that Group
6829        // and SoftMasked elements get proper offscreen compositing.
6830        if band_h >= page_h {
6831            self.ensure_full_pixmap();
6832            let ctx = RenderContext {
6833                vp_x: 0.0,
6834                vp_y: 0.0,
6835                scale_x: 1.0,
6836                scale_y: 1.0,
6837                out_w: page_w,
6838                out_h: page_h,
6839                effective_dpi: self.dpi,
6840                icc: Some(&icc_cache),
6841                image_cache: None,
6842                preprocessed: None,
6843                elem_idx: 0,
6844                no_aa: self.no_aa,
6845                opm_zero_transparent: false,
6846                knockout_painter_pass: KnockoutPainterPass::None,
6847                parent_group_isolated: false,
6848                alpha_extraction_pass: false,
6849                layer_set: &self.layer_set,
6850            };
6851            let mut band_state = BandState {
6852                clip_region: None,
6853                spare_mask: None,
6854                clip_mask_cache: HashMap::new(),
6855                clip_mask_seen: HashSet::new(),
6856                mask_pool: Vec::new(),
6857                cmyk_buffer: None,
6858                op_bg_snapshot: None,
6859                op_touched: None,
6860                spot_mask: None,
6861            };
6862            for (idx, elem) in list.elements().iter().enumerate() {
6863                let elem_ctx = RenderContext {
6864                    elem_idx: idx,
6865                    ..ctx
6866                };
6867                render_element(&mut self.pixmap, &mut band_state, elem, &elem_ctx);
6868            }
6869            return self.show_page(output_path);
6870        }
6871
6872        // Banded path: shrink self.pixmap to free memory — we use a
6873        // band-sized pixmap instead. This avoids holding a multi-GB
6874        // full-page buffer during rendering.
6875        if self.pixmap.width() > 1 {
6876            self.pixmap = Pixmap::new(1, 1).expect("Failed to create placeholder pixmap");
6877        }
6878
6879        // Create the sink for this page before spawning background work
6880        let mut sink = self.sink_factory.create_sink(output_path)?;
6881        let dpi = self.dpi;
6882        let layer_set = self.layer_set.clone();
6883
6884        #[cfg(feature = "parallel")]
6885        {
6886            // Spawn banded rendering on rayon's thread pool, overlapping with
6887            // interpretation of the next page. Using rayon::spawn avoids OS thread
6888            // creation overhead and keeps work on the warmed-up pool.
6889            let no_aa = self.no_aa;
6890            let (tx, rx) = std::sync::mpsc::sync_channel(1);
6891            rayon::spawn(move || {
6892                let result = render_banded_to_sink(
6893                    page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, no_aa, &layer_set,
6894                );
6895                let _ = tx.send(result);
6896            });
6897            self.pending_render = Some(rx);
6898        }
6899        #[cfg(not(feature = "parallel"))]
6900        {
6901            render_banded_to_sink(
6902                page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, self.no_aa, &layer_set,
6903            )?;
6904        }
6905
6906        Ok(())
6907    }
6908
6909    fn finish(&mut self) -> Result<(), String> {
6910        self.join_pending()
6911    }
6912}
6913
6914impl Drop for SkiaDevice {
6915    fn drop(&mut self) {
6916        // Safety net: ensure background render completes before device is destroyed.
6917        if let Some(rx) = self.pending_render.take() {
6918            let _ = rx.recv();
6919        }
6920    }
6921}
6922
6923impl SkiaDevice {
6924    /// Wait for the pending background render to complete, if any.
6925    fn join_pending(&mut self) -> Result<(), String> {
6926        if let Some(rx) = self.pending_render.take() {
6927            match rx.recv() {
6928                Ok(result) => result?,
6929                Err(_) => return Err("Background render task failed".to_string()),
6930            }
6931        }
6932        Ok(())
6933    }
6934}
6935
6936/// Returns true if any descendant transparency group declares an explicit
6937/// `/CS DeviceCMYK`. The renderer uses this to decide whether to allocate a
6938/// parallel CMYK buffer for the band/page so that compositing inside CMYK
6939/// groups can read the exact backdrop CMYK rather than rounding-trip via sRGB.
6940fn has_cmyk_group(list: &DisplayList) -> bool {
6941    use stet_graphics::display_list::GroupColorSpace;
6942    for elem in list.elements() {
6943        match elem {
6944            DisplayElement::Group { elements, params } => {
6945                if params.color_space == GroupColorSpace::DeviceCMYK {
6946                    return true;
6947                }
6948                if has_cmyk_group(elements) {
6949                    return true;
6950                }
6951            }
6952            DisplayElement::SoftMasked { content, mask, .. } => {
6953                if has_cmyk_group(content) || has_cmyk_group(mask) {
6954                    return true;
6955                }
6956            }
6957            DisplayElement::OcgGroup { elements, .. } => {
6958                if has_cmyk_group(elements) {
6959                    return true;
6960                }
6961            }
6962            _ => {}
6963        }
6964    }
6965    false
6966}
6967
6968/// Returns true if every visible element in `elements` is a `Fill` whose
6969/// color carries `native_cmyk`. Clip and `InitClip` ops are skipped (they
6970/// don't paint). Returns `false` for any other shape (shadings, images,
6971/// patterns, nested groups, etc.) where the inner CMYK buffer would be
6972/// derived from sRGB via the lossy `interpolate_cmyk_from_stops` /
6973/// `(1-r,1-g,1-b,0)` inverse rather than tracked from the source CMYK.
6974fn group_only_native_cmyk_fills(elements: &DisplayList) -> bool {
6975    let mut found_paint = false;
6976    for elem in elements.elements() {
6977        match elem {
6978            DisplayElement::InitClip => continue,
6979            DisplayElement::Clip { .. } => continue,
6980            DisplayElement::Fill { params, .. } => {
6981                if params.color.native_cmyk.is_none() {
6982                    return false;
6983                }
6984                found_paint = true;
6985            }
6986            DisplayElement::Stroke { params, .. } => {
6987                // Strokes write a single CMYK value per painted pixel just
6988                // like fills, so the parallel CMYK buffer stays in sync with
6989                // the pixmap. Including strokes here is required by GWG 16.1
6990                // painters whose X path is both filled and stroked with the
6991                // same registration color.
6992                if params.color.native_cmyk.is_none() {
6993                    return false;
6994                }
6995                found_paint = true;
6996            }
6997            _ => return false,
6998        }
6999    }
7000    found_paint
7001}
7002
7003/// Stronger predicate: returns `true` when every paint operation in `elements`
7004/// supplies its color directly as CMYK with one CMYK value per painted pixel
7005/// — i.e. the parallel CMYK buffer is *guaranteed* to match the rendered
7006/// pixmap on a per-pixel basis. When this holds, the per-pixel CMYK
7007/// composite-back can run safely.
7008///
7009/// Importantly, this excludes **shadings** even when their declared color
7010/// space is DeviceCMYK. The pixmap rasterizer interpolates the per-stop
7011/// `.color` (RGB) linearly across the gradient via [`build_gradient_lut`],
7012/// while [`interpolate_cmyk_from_stops`] interpolates the per-stop CMYK
7013/// `raw_components` linearly. Because the system CMYK ICC profile is
7014/// non-linear, the two interpolation strategies produce different intermediate
7015/// colors at each gradient pixel — the buffer no longer represents what the
7016/// pixmap shows, and feeding that into the composite-back yields visibly
7017/// shifted colors. Until the per-pixel rasterizer is taught to interpolate
7018/// CMYK directly (or the buffer is filled by ICC-reversing the pixmap), keep
7019/// shadings on the existing sRGB compositing path.
7020///
7021/// Recurses into nested groups and soft masks. Returns `false` if the group
7022/// contains no paint operations at all (so the composite-back has no work).
7023fn group_content_is_native_cmyk(elements: &DisplayList) -> bool {
7024    let mut found_paint = false;
7025    for elem in elements.elements() {
7026        match elem {
7027            DisplayElement::InitClip => continue,
7028            DisplayElement::Clip { .. } => continue,
7029            DisplayElement::Text { .. } => continue,
7030            DisplayElement::ErasePage => continue,
7031            DisplayElement::Fill { params, .. } => {
7032                if params.color.native_cmyk.is_none() {
7033                    return false;
7034                }
7035                found_paint = true;
7036            }
7037            DisplayElement::Stroke { params, .. } => {
7038                if params.color.native_cmyk.is_none() {
7039                    return false;
7040                }
7041                found_paint = true;
7042            }
7043            DisplayElement::Image { params, .. } => {
7044                if !is_cmyk_color_space(&params.color_space) {
7045                    return false;
7046                }
7047                found_paint = true;
7048            }
7049            DisplayElement::AxialShading { .. }
7050            | DisplayElement::RadialShading { .. }
7051            | DisplayElement::MeshShading { .. }
7052            | DisplayElement::PatchShading { .. } => {
7053                // See doc comment above: shading interpolation strategies
7054                // diverge between pixmap and buffer.
7055                return false;
7056            }
7057            DisplayElement::PatternFill { .. } => {
7058                // Pattern tiles render through their own BandState with
7059                // `cmyk_buffer: None`, so the parallel CMYK buffer can't track
7060                // per-tile source CMYK. Treat patterns as non-CMYK content.
7061                return false;
7062            }
7063            DisplayElement::Group { elements: sub, .. } => {
7064                if !group_content_is_native_cmyk(sub) {
7065                    return false;
7066                }
7067                found_paint = true;
7068            }
7069            DisplayElement::SoftMasked { .. } => {
7070                // Soft masks apply a per-pixel alpha modulation that the
7071                // parallel CMYK buffer cannot represent: the buffer holds raw
7072                // source CMYK while the pixmap holds the soft-masked blend
7073                // (`backdrop * (1 − mask) + source * mask`). Running
7074                // `composite_non_isolated_cmyk` over a soft-masked region
7075                // would feed the unmodulated source CMYK into the blend
7076                // formula and produce the wrong result for any non-Normal
7077                // parent blend mode (5310.pdf phone highlight regression).
7078                // Fall back to the sRGB contribution-extraction path, which
7079                // handles soft masks correctly.
7080                return false;
7081            }
7082            DisplayElement::OcgGroup { elements: sub, .. } => {
7083                if !group_content_is_native_cmyk(sub) {
7084                    return false;
7085                }
7086                found_paint = true;
7087            }
7088            _ => return false,
7089        }
7090    }
7091    found_paint
7092}
7093
7094/// True when `list` is a flat sequence of native-CMYK Fill/Stroke paints
7095/// with Normal blend and full opacity — i.e. the cmyk_buffer's content
7096/// faithfully represents what the pixmap shows. Used by `render_soft_masked`
7097/// to decide whether to interpolate the mask blend in CMYK (ICC→sRGB).
7098/// Rejects Group/SoftMasked/Image/Shading/Pattern and any blend-mode-modulated
7099/// paint because those would diverge from the parallel CMYK snapshot.
7100fn content_list_is_simple_native_cmyk(list: &DisplayList) -> bool {
7101    let mut found_paint = false;
7102    for elem in list.elements() {
7103        match elem {
7104            DisplayElement::InitClip
7105            | DisplayElement::Clip { .. }
7106            | DisplayElement::Text { .. }
7107            | DisplayElement::ErasePage => continue,
7108            DisplayElement::Fill { params, .. } => {
7109                if params.color.native_cmyk.is_none() {
7110                    return false;
7111                }
7112                if params.blend_mode != 0 || params.alpha != 1.0 {
7113                    return false;
7114                }
7115                found_paint = true;
7116            }
7117            DisplayElement::Stroke { params, .. } => {
7118                if params.color.native_cmyk.is_none() {
7119                    return false;
7120                }
7121                if params.blend_mode != 0 || params.alpha != 1.0 {
7122                    return false;
7123                }
7124                found_paint = true;
7125            }
7126            // Recurse into a transparency Group only when the group itself is
7127            // Normal-blend / full-opacity AND its contents are themselves
7128            // simple native CMYK. This lets gradient-feather-style content
7129            // (a Group wrapping a single CMYK fill, GWG 16.11) qualify for
7130            // CMYK-domain mask blending while the prior outer-glow C
7131            // regression (a Group wrapping a Screen-blend white rect, GWG
7132            // 16.10) still gets rejected on the inner blend_mode check.
7133            DisplayElement::Group { params, elements } => {
7134                if params.blend_mode != 0 || params.alpha != 1.0 {
7135                    return false;
7136                }
7137                if !content_list_is_simple_native_cmyk(elements) {
7138                    return false;
7139                }
7140                // A Group whose contents are all clip/text without paint
7141                // adds no paint of its own; don't flip `found_paint` here —
7142                // the recursive call already counted any inner paints.
7143                if elements.elements().iter().any(|e| {
7144                    matches!(
7145                        e,
7146                        DisplayElement::Fill { .. } | DisplayElement::Stroke { .. }
7147                    )
7148                }) {
7149                    found_paint = true;
7150                }
7151            }
7152            _ => return false,
7153        }
7154    }
7155    found_paint
7156}
7157
7158/// Scan a display list for any overprint fill/stroke elements that need CMYK simulation.
7159fn has_overprint_elements(list: &DisplayList) -> bool {
7160    for elem in list.elements() {
7161        match elem {
7162            DisplayElement::Fill { params, .. } => {
7163                if params.overprint {
7164                    return true;
7165                }
7166            }
7167            DisplayElement::Stroke { params, .. } => {
7168                if params.overprint {
7169                    return true;
7170                }
7171            }
7172            DisplayElement::Image { params, .. } => {
7173                if params.overprint {
7174                    return true;
7175                }
7176            }
7177            DisplayElement::AxialShading { params } => {
7178                if params.overprint {
7179                    return true;
7180                }
7181            }
7182            DisplayElement::RadialShading { params } => {
7183                if params.overprint {
7184                    return true;
7185                }
7186            }
7187            DisplayElement::MeshShading { params } => {
7188                if params.overprint {
7189                    return true;
7190                }
7191            }
7192            DisplayElement::PatchShading { params } => {
7193                if params.overprint {
7194                    return true;
7195                }
7196            }
7197            DisplayElement::Group { elements, .. } => {
7198                if has_overprint_elements(elements) {
7199                    return true;
7200                }
7201            }
7202            DisplayElement::SoftMasked { content, mask, .. } => {
7203                if has_overprint_elements(content) || has_overprint_elements(mask) {
7204                    return true;
7205                }
7206            }
7207            DisplayElement::OcgGroup { elements, .. } => {
7208                if has_overprint_elements(elements) {
7209                    return true;
7210                }
7211            }
7212            _ => {}
7213        }
7214    }
7215    false
7216}
7217
7218/// Render an overprint fill: rasterize path to coverage mask, then composite
7219/// at the CMYK level, converting the result to RGB for the pixmap.
7220#[allow(clippy::too_many_arguments)]
7221fn render_overprint_fill(
7222    pixmap: &mut Pixmap,
7223    cmyk_buf: &mut [f32],
7224    op_bg: &mut [u8],
7225    op_touched: &mut [u8],
7226    spot_mask: &[u8],
7227    band_state: &mut BandState,
7228    path: &PsPath,
7229    params: &FillParams,
7230    vp_x: f32,
7231    vp_y: f32,
7232    scale_x: f32,
7233    scale_y: f32,
7234    out_w: u32,
7235    out_h: u32,
7236    icc: Option<&IccCache>,
7237    no_aa: bool,
7238) {
7239    let Some(skia_path) = build_skia_path(path) else {
7240        return;
7241    };
7242    let fill_rule = to_fill_rule(&params.fill_rule);
7243
7244    let mut coverage_mask = match Mask::new(out_w, out_h) {
7245        Some(m) => m,
7246        None => return,
7247    };
7248    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7249    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7250
7251    // Compute path bbox for constrained iteration
7252    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7253        path_device_bbox(&skia_path, transform, out_w, out_h);
7254
7255    // Intersect with clip mask
7256    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7257        None => None,
7258        Some(ClipRegion::Rect(r)) => {
7259            // Only zero coverage within the path bbox (not the full page)
7260            let data = coverage_mask.data_mut();
7261            let stride = out_w as usize;
7262            for y in bbox_y0..bbox_y1 {
7263                let row_start = y * stride;
7264                for x in bbox_x0..bbox_x1 {
7265                    let yu = y as u32;
7266                    let xu = x as u32;
7267                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7268                        data[row_start + x] = 0;
7269                    }
7270                }
7271            }
7272            None
7273        }
7274        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7275    };
7276
7277    // Custom spot paints (Separation/DeviceN whose named colorants don't include
7278    // any process channel) go to a separation plate, not CMYK. In the composite
7279    // preview we layer the spot's alt-CMYK onto the pixmap via multiplicative
7280    // ink stacking and leave the cmyk_buffer untouched — otherwise a later OPM 1
7281    // overprint would see the spot's alt-CMYK as "backdrop" and knock it out.
7282    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7283
7284    // Source CMYK preference: for paints with a process colorant in the mix
7285    // (Separation /Black, DeviceN [Black, …]), prefer `process_cmyk` — it
7286    // carries the named-colorant tint at full f64 precision (e.g. `(0, 0, 0,
7287    // 0.5)` for 50% /Black), matching what `update_cmyk_buffer_for_fill` writes
7288    // into the process buffer. Without this, the X paint reads native (e.g.
7289    // 0.502 from an 8-bit-quantized sampled Function) while the BG wrote
7290    // process (0.500), the per-pixel delta clears the 1e-4 no-op skip
7291    // threshold, and the X over-paints the spot backdrop with plain ICC-grey
7292    // (GWG 3.0 swatches c/i, "50% sep. black over spot").
7293    //
7294    // Custom spots (no process colorant) keep reading `native_cmyk` — that's
7295    // the spot's visual alt-CMYK representation, while `process_cmyk` is
7296    // `(0, 0, 0, 0)` for pure spots (the process buffer should not record
7297    // their tint). Falling back to native here keeps spot-coloured text
7298    // visible (1307.pdf "Business of the Meeting" in PANTONE 7427 C).
7299    let (src_c, src_m, src_y, src_k) = if !is_custom_spot && let Some(c) = params.color.process_cmyk
7300    {
7301        c
7302    } else if let Some(c) = params.color.native_cmyk {
7303        c
7304    } else {
7305        let r = params.color.r;
7306        let g = params.color.g;
7307        let b = params.color.b;
7308        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7309    };
7310
7311    let mut channels = params.painted_channels;
7312    // Non-CMYK fills (painted_channels=0, e.g. Separation spot colors, RGB, Gray)
7313    // replace all color at each pixel — update all CMYK channels to keep buffer in sync.
7314    if channels == 0 {
7315        channels = stet_graphics::device::CMYK_ALL;
7316    }
7317    // OPM 1 per-pixel zero filtering only applies to DeviceCMYK, not DeviceN/Separation
7318    if params.overprint_mode == 1
7319        && channels == stet_graphics::device::CMYK_ALL
7320        && params.is_device_cmyk
7321    {
7322        channels = 0;
7323        if src_c != 0.0 {
7324            channels |= stet_graphics::device::CMYK_C;
7325        }
7326        if src_m != 0.0 {
7327            channels |= stet_graphics::device::CMYK_M;
7328        }
7329        if src_y != 0.0 {
7330            channels |= stet_graphics::device::CMYK_Y;
7331        }
7332        if src_k != 0.0 {
7333            channels |= stet_graphics::device::CMYK_K;
7334        }
7335        // PDF 1.7 §7.6.4.5: OPM 1 with /op true preserves zero-source
7336        // components — leave `channels = 0` for an all-zero CMYK source only
7337        // when the gstate signals "strict overprint": /OPM and /op|/OP were
7338        // set together in the same ExtGState dict (as Adobe Illustrator
7339        // emits) OR /OP and /op were paired in one dict (legacy old-style
7340        // overprint, e.g. GWG 12.0 White Overprint where /GS6 sets both).
7341        // When the current /op was set standalone and OPM was merely
7342        // inherited (e.g. 2495.pdf page 5 page-icon, where /R20 has only
7343        // /op and OPM=1 came from /R11), fall back to legacy knockout so
7344        // a `0 0 0 0 k` paint still acts as a white knockout.
7345        if channels == 0 && !params.opm_paired {
7346            channels = stet_graphics::device::CMYK_ALL;
7347        }
7348    }
7349
7350    // Bulk tiny-skia fast path for the plain CMYK_ALL replace case. Skipped
7351    // only for K-only DeviceCMYK paints under OPM 0 (C=M=Y=0, any K) because
7352    // those match the Black plate of a DeviceN [Black, spot] backdrop and
7353    // need the per-pixel no-op-delta skip to preserve spot-derived colour —
7354    // the bulk fill_path here would otherwise wipe the spot. Other CMYK
7355    // overprints (teal, full-colour, etc.) stay on the fast path to avoid
7356    // AA drift vs the non-overprint rasteriser.
7357    let is_k_only_cmyk = params.is_device_cmyk
7358        && params.overprint_mode == 0
7359        && src_c == 0.0
7360        && src_m == 0.0
7361        && src_y == 0.0;
7362    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7363        let cov_data = coverage_mask.data();
7364        let stride = out_w as usize;
7365        for y in bbox_y0..bbox_y1 {
7366            for x in bbox_x0..bbox_x1 {
7367                let mi = y * stride + x;
7368                let mut cov = cov_data[mi] as f32 / 255.0;
7369                if let Some(clip) = clip_coverage {
7370                    cov *= clip[mi] as f32 / 255.0;
7371                }
7372                if cov > 0.0 {
7373                    let ci = mi * 4;
7374                    cmyk_buf[ci] = src_c as f32;
7375                    cmyk_buf[ci + 1] = src_m as f32;
7376                    cmyk_buf[ci + 2] = src_y as f32;
7377                    cmyk_buf[ci + 3] = src_k as f32;
7378                }
7379            }
7380        }
7381        let mut temp_mask = None;
7382        let Some(mask_ref) =
7383            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
7384        else {
7385            return;
7386        };
7387        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
7388        pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
7389        return;
7390    }
7391
7392    let cov_data = coverage_mask.data();
7393    let stride = out_w as usize;
7394    let px_data = pixmap.data_mut();
7395    let px_stride = out_w as usize * 4;
7396
7397    for y in bbox_y0..bbox_y1 {
7398        for x in bbox_x0..bbox_x1 {
7399            let mi = y * stride + x;
7400            let mut cov = cov_data[mi] as f32 / 255.0;
7401            if let Some(clip) = clip_coverage {
7402                cov *= clip[mi] as f32 / 255.0;
7403            }
7404            if cov <= 0.0 {
7405                continue;
7406            }
7407
7408            let ci = mi * 4;
7409            let pi = y * px_stride + x * 4;
7410            // Snapshot-based AA blending: on the first overprint touch of a
7411            // pixel that already has a backdrop (alpha > 0), capture the
7412            // pre-paint pixmap RGBA. Subsequent overprints at the same pixel
7413            // blend against the snapshot rather than the current pixmap, so
7414            // AA edges of stacked OPM-1 overprints do not leak colour from
7415            // earlier paints into later ones.
7416            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
7417                op_bg[pi] = px_data[pi];
7418                op_bg[pi + 1] = px_data[pi + 1];
7419                op_bg[pi + 2] = px_data[pi + 2];
7420                op_bg[pi + 3] = px_data[pi + 3];
7421                op_touched[mi] = 1;
7422            }
7423            let cur_c = cmyk_buf[ci] as f64;
7424            let cur_m = cmyk_buf[ci + 1] as f64;
7425            let cur_y = cmyk_buf[ci + 2] as f64;
7426            let cur_k = cmyk_buf[ci + 3] as f64;
7427            // Switch to multiplicative ink-stacking when the pixmap carries a
7428            // contribution not reflected in cmyk_buffer: either this paint is
7429            // itself a custom spot (painted_channels=0, non-CMYK) or the
7430            // process-ink state is empty while the pixmap shows colour *and*
7431            // is actually opaque — that signals a spot (or RGB) paint landed
7432            // here and the "replace" CMYK→RGB model would erase the
7433            // contribution for the channels being overwritten. Fully
7434            // transparent pixels are stored as premultiplied (0,0,0,0), so we
7435            // must require alpha>0 before trusting the RGB — otherwise fresh
7436            // paper (alpha=0) looks like "black backdrop" and multiplicative
7437            // darkening would paint the fill pure black.
7438            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
7439            let pixmap_has_colour = px_data[pi + 3] > 0
7440                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
7441            // Multiplicative ink-stacking only when the pixmap carries a real
7442            // backdrop: either this paint is a custom spot landing on an
7443            // already-coloured pixel, or the process-ink buffer is empty but
7444            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
7445            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
7446            // the fill to pure black, so those pixels fall through to the
7447            // replace path where the source RGB paints normally.
7448            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
7449
7450            // Promoted DeviceGray on a non-spot backdrop: fall back to a
7451            // plain knockout that replaces all four CMYK plates. The
7452            // `maybe_promote_gray_fill` path describes the paint as a
7453            // K-only subset so spot-backed swatches can preserve the spot
7454            // plate (GWG 3.0 "50% gray over spot"), but on a plain CMYK
7455            // backdrop that would preserve the old CMY values and turn the
7456            // cross into the bg colour (GWG 3.0 "50% gray over CMYK" e/k).
7457            // Expanding to CMYK_ALL here restores the regular-fill result
7458            // at those pixels.
7459            //
7460            // Gate on `params.painted_channels == CMYK_K` so this only fires
7461            // for genuinely-promoted DeviceGray. A `0 0 0 0.5 k` DeviceCMYK
7462            // paint filtered to CMYK_K by OPM 1 has `params.painted_channels
7463            // = CMYK_ALL`, and must stay K-subset so its CMY=0 values do
7464            // not wipe a CMYK backdrop (GWG 3.0 "50% K over CMYK" j/d).
7465            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
7466                && channels == stet_graphics::device::CMYK_K
7467                && params.is_device_cmyk
7468                && src_c == 0.0
7469                && src_m == 0.0
7470                && src_y == 0.0;
7471            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
7472                stet_graphics::device::CMYK_ALL
7473            } else {
7474                channels
7475            };
7476
7477            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
7478                src_c
7479            } else {
7480                cur_c
7481            };
7482            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
7483                src_m
7484            } else {
7485                cur_m
7486            };
7487            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
7488                src_y
7489            } else {
7490                cur_y
7491            };
7492            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
7493                src_k
7494            } else {
7495                cur_k
7496            };
7497
7498            // Custom spot paints live on a separation plate — skip the
7499            // cmyk_buffer write so a later OPM 1 overprint still sees the
7500            // original process-ink state as backdrop.
7501            if !is_custom_spot {
7502                cmyk_buf[ci] = new_c as f32;
7503                cmyk_buf[ci + 1] = new_m as f32;
7504                cmyk_buf[ci + 2] = new_y as f32;
7505                cmyk_buf[ci + 3] = new_k as f32;
7506            }
7507
7508            // No-op overprint: the paint's effective CMYK equals the existing
7509            // process state, so no plate actually changes. Skip the pixmap
7510            // write entirely — otherwise ICC(new_cmyk) paints a plain process
7511            // composite that erases any spot-derived colour already visible
7512            // at this pixel (GWG 3.0 "50% K over spot" swatches where the
7513            // backdrop's Black component and the cross's K value match).
7514            //
7515            // Only fire when a DeviceN/Separation paint with spot colorants
7516            // actually landed on this pixel (spot_mask[mi] != 0). On plain
7517            // CMYK backdrops, ICC(cmyk_buf) == pixmap_rgb already, and
7518            // skipping vs replacing produces the same result — but making
7519            // the skip unconditional subtly drifts AA edges because prior
7520            // stroke/fill precision accumulates (regressed GWG 1.0/1.1).
7521            let delta = (new_c - cur_c)
7522                .abs()
7523                .max((new_m - cur_m).abs())
7524                .max((new_y - cur_y).abs())
7525                .max((new_k - cur_k).abs());
7526            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
7527                continue;
7528            }
7529
7530            let (r, g, b) =
7531                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
7532                    // Promoted DeviceGray collapsing to a full replace — use the
7533                    // paint's RGB directly so the pixmap matches the colour a
7534                    // regular non-overprint gray fill would paint at the same
7535                    // pixel. Going through ICC(CMYK) here would produce a
7536                    // slightly different gray (e.g. 151 vs 127) and leave a
7537                    // darker outline where a subsequent non-promoted gray
7538                    // stroke overpaints on top of it.
7539                    //
7540                    // Checked before `use_multiplicative` because a white gray
7541                    // paint (`1 g`, native CMYK (0,0,0,0)) on a coloured RGB
7542                    // backdrop (e.g. the red `Reset Form` button in 682.pdf
7543                    // page 2) would otherwise hit the multiplicative branch
7544                    // with all-zero source CMYK, which leaves the backdrop
7545                    // unchanged — hiding the white label.
7546                    (params.color.r, params.color.g, params.color.b)
7547                } else if use_multiplicative {
7548                    // Multiplicative ink stacking: each painted channel attenuates
7549                    // the corresponding RGB component; preserved channels leave
7550                    // the pixmap's existing colour untouched. This keeps any spot
7551                    // contribution already in the pixmap visible under overprints
7552                    // whose zero-valued CMYK components should not erase it.
7553                    let bg_r = px_data[pi] as f64 / 255.0;
7554                    let bg_g = px_data[pi + 1] as f64 / 255.0;
7555                    let bg_b = px_data[pi + 2] as f64 / 255.0;
7556                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
7557                        1.0 - src_c
7558                    } else {
7559                        1.0
7560                    };
7561                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
7562                        1.0 - src_m
7563                    } else {
7564                        1.0
7565                    };
7566                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
7567                        1.0 - src_y
7568                    } else {
7569                        1.0
7570                    };
7571                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
7572                        1.0 - src_k
7573                    } else {
7574                        1.0
7575                    };
7576                    (
7577                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
7578                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
7579                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
7580                    )
7581                } else if let Some(icc_cache) = icc {
7582                    icc_cache
7583                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
7584                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
7585                } else {
7586                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
7587                };
7588
7589            let a = (cov * params.alpha as f32).min(1.0);
7590            // Blend backdrop: prefer the pre-overprint snapshot only when
7591            // this paint's colour is close to the snapshot — that signals
7592            // the paint effectively returns the pixel to its original
7593            // backdrop (e.g. the almost-white cross in GWG 4.1 cancelling
7594            // the red cross's M/Y contributions). In that case blending
7595            // against the snapshot keeps AA edges clean.
7596            //
7597            // When the paint introduces colour (e.g. a magenta stroke
7598            // following a magenta fill — both lay down ink that should
7599            // stack), fall through to the current pixmap so repeated
7600            // same-colour paints keep compounding at edges instead of
7601            // snapping back to bg.
7602            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
7603                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
7604                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
7605                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
7606                let dr = (op_bg[pi] as f32 - new_r).abs();
7607                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
7608                let db = (op_bg[pi + 2] as f32 - new_b).abs();
7609                if dr.max(dg).max(db) <= 4.0 {
7610                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
7611                } else {
7612                    (
7613                        px_data[pi],
7614                        px_data[pi + 1],
7615                        px_data[pi + 2],
7616                        px_data[pi + 3],
7617                    )
7618                }
7619            } else {
7620                (
7621                    px_data[pi],
7622                    px_data[pi + 1],
7623                    px_data[pi + 2],
7624                    px_data[pi + 3],
7625                )
7626            };
7627            let dst_a = bk_a as f32 / 255.0;
7628            let one_minus_a = 1.0 - a;
7629            let out_a = a + dst_a * one_minus_a;
7630            if out_a > 0.0 {
7631                // tiny-skia stores premultiplied RGBA. Use the standard
7632                // src-over formula in premul space: result_pre = src*a + dst_pre*(1-a).
7633                // The backdrop values are already premultiplied, so no
7634                // additional divide-by-out_a step is needed.
7635                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
7636                    .clamp(0.0, 255.0)
7637                    .round() as u8;
7638                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
7639                    .clamp(0.0, 255.0)
7640                    .round() as u8;
7641                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
7642                    .clamp(0.0, 255.0)
7643                    .round() as u8;
7644                px_data[pi + 3] = (out_a * 255.0).round() as u8;
7645            }
7646        }
7647    }
7648}
7649/// PLRM CMYK-to-RGB formula fallback.
7650fn cmyk_to_rgb_plrm(c: f64, m: f64, y: f64, k: f64) -> (f64, f64, f64) {
7651    (
7652        1.0 - (c + k).min(1.0),
7653        1.0 - (m + k).min(1.0),
7654        1.0 - (y + k).min(1.0),
7655    )
7656}
7657
7658/// Update the CMYK buffer for a non-overprint fill (to track backdrop for future overprints).
7659#[allow(clippy::too_many_arguments)]
7660/// Compute the device-space bounding box of a tiny-skia path after transform,
7661/// clamped to `(0, 0, w, h)`. Returns `(x0, y0, x1, y1)` as pixel indices.
7662fn path_device_bbox(
7663    skia_path: &stet_tiny_skia::Path,
7664    transform: Transform,
7665    w: u32,
7666    h: u32,
7667) -> (usize, usize, usize, usize) {
7668    let b = skia_path.bounds();
7669    let mut corners = [
7670        stet_tiny_skia::Point {
7671            x: b.left(),
7672            y: b.top(),
7673        },
7674        stet_tiny_skia::Point {
7675            x: b.right(),
7676            y: b.top(),
7677        },
7678        stet_tiny_skia::Point {
7679            x: b.right(),
7680            y: b.bottom(),
7681        },
7682        stet_tiny_skia::Point {
7683            x: b.left(),
7684            y: b.bottom(),
7685        },
7686    ];
7687    transform.map_points(&mut corners);
7688    let min_x = corners.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
7689    let min_y = corners.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
7690    let max_x = corners
7691        .iter()
7692        .map(|p| p.x)
7693        .fold(f32::NEG_INFINITY, f32::max);
7694    let max_y = corners
7695        .iter()
7696        .map(|p| p.y)
7697        .fold(f32::NEG_INFINITY, f32::max);
7698    // Floor/ceil + clamp to output dimensions (with 1px margin for AA)
7699    let x0 = (min_x.floor() as i32 - 1).max(0) as usize;
7700    let y0 = (min_y.floor() as i32 - 1).max(0) as usize;
7701    let x1 = (max_x.ceil() as i32 + 1).clamp(0, w as i32) as usize;
7702    let y1 = (max_y.ceil() as i32 + 1).clamp(0, h as i32) as usize;
7703    (x0, y0, x1, y1)
7704}
7705
7706fn update_cmyk_buffer_for_fill(
7707    cmyk_buf: &mut [f32],
7708    spot_mask: &mut [u8],
7709    path: &PsPath,
7710    params: &FillParams,
7711    vp_x: f32,
7712    vp_y: f32,
7713    scale_x: f32,
7714    scale_y: f32,
7715    out_w: u32,
7716    out_h: u32,
7717    clip_region: &Option<ClipRegion>,
7718    no_aa: bool,
7719    icc: Option<&IccCache>,
7720) {
7721    // Custom spot paints (Separation/DeviceN naming no process channel) go to
7722    // their own separation plate — the process CMYK buffer must be zeroed
7723    // under the paint (knockout) so a later overprint sees "no process ink"
7724    // and falls into the multiplicative-blend branch that preserves the
7725    // spot's visible contribution in the pixmap.
7726    //
7727    // The `process_cmyk.is_some()` guard distinguishes "Separation/DeviceN
7728    // custom spot" (where `process_cmyk` is `Some((0,0,0,0))` per
7729    // `separation_process_cmyk`) from "any other non-CMYK fill that
7730    // happens to satisfy `painted_channels == 0 && !is_device_cmyk`" —
7731    // notably DeviceRGB, DeviceGray, and ICCBased RGB. The latter need to
7732    // deposit their full process CMYK into the buffer (via `native_cmyk`
7733    // from the proofing chain or via the ICC reverse) so the
7734    // `cmyk_group_blend` composite-back in `composite_non_isolated_cmyk`
7735    // can blend them correctly. Without this guard, GWG 16.1's
7736    // ICCBased-RGB swatches landed `(0,0,0,0)` in the form's CMYK
7737    // buffer; every separable blend then composited the X mark against a
7738    // zero source CMYK, painting the X with the form's source pixmap
7739    // RGB unchanged and producing the test's "X visible" failure.
7740    let is_custom_spot = params.painted_channels == 0
7741        && !params.is_device_cmyk
7742        && params.color.process_cmyk.is_some();
7743
7744    // A DeviceN/Separation paint leaves "spot contribution" on the pixmap
7745    // when its full alt-CMYK (`native_cmyk`) differs from the process-only
7746    // tint (`process_cmyk`) — the extra RGB in the pixmap comes from a spot
7747    // plate that `cmyk_buf` cannot reflect. Pure DeviceCMYK paints have
7748    // `process_cmyk == None` (fall back to native), so no spot contribution.
7749    //
7750    // A "real" custom spot paint (`is_custom_spot && native_cmyk.is_some()`)
7751    // also deposits spot RGB that `cmyk_buf` loses (it's zeroed by the
7752    // custom-spot branch). Exclude DeviceRGB / DeviceGray / ICCBased-RGB
7753    // paints — those also satisfy `is_custom_spot = painted==0 &&
7754    // !is_device_cmyk` but carry no spot-plate contribution, and flagging
7755    // them would gate later OPM-1 cancel skips on a signal that doesn't
7756    // actually mean anything.
7757    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
7758        || matches!(
7759            (params.color.native_cmyk, params.color.process_cmyk),
7760            (Some(nat), Some(proc_))
7761                if (nat.0 - proc_.0).abs() > 1e-6
7762                    || (nat.1 - proc_.1).abs() > 1e-6
7763                    || (nat.2 - proc_.2).abs() > 1e-6
7764                    || (nat.3 - proc_.3).abs() > 1e-6
7765        );
7766
7767    // Source CMYK preference: process-only CMYK (from Separation/DeviceN paints
7768    // so spot-colorant tint contributions stay out of the process buffer) >
7769    // native CMYK (full alt-CMYK tint, fine for pure DeviceCMYK paints) > ICC
7770    // reverse (sRGB→CMYK via the system CMYK profile) > PLRM (1−r, 1−g, 1−b, 0)
7771    // fallback. The ICC reverse keeps non-CMYK fills (RGB/Gray/Lab/etc.)
7772    // representable as accurate CMYK in the parallel buffer so the
7773    // non-isolated CMYK composite-back can blend them correctly.
7774    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
7775        (0.0, 0.0, 0.0, 0.0)
7776    } else if let Some(c) = params.color.process_cmyk {
7777        c
7778    } else if let Some(c) = params.color.native_cmyk {
7779        c
7780    } else if let Some(cmyk) = icc.and_then(|i| {
7781        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
7782    }) {
7783        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
7784    } else {
7785        (
7786            (1.0 - params.color.r).clamp(0.0, 1.0),
7787            (1.0 - params.color.g).clamp(0.0, 1.0),
7788            (1.0 - params.color.b).clamp(0.0, 1.0),
7789            0.0,
7790        )
7791    };
7792    let Some(skia_path) = build_skia_path(path) else {
7793        return;
7794    };
7795
7796    let mut coverage_mask = match Mask::new(out_w, out_h) {
7797        Some(m) => m,
7798        None => return,
7799    };
7800    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7801    let fill_rule = to_fill_rule(&params.fill_rule);
7802    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7803
7804    let cov_data = coverage_mask.data();
7805    let clip_data: Option<&[u8]> = match clip_region {
7806        Some(ClipRegion::Mask(m)) => Some(m.data()),
7807        _ => None,
7808    };
7809
7810    // Constrain iteration to the path's device-space bounding box
7811    let (mut bx0, mut by0, mut bx1, mut by1) =
7812        path_device_bbox(&skia_path, transform, out_w, out_h);
7813    if let Some(ClipRegion::Rect(r)) = clip_region {
7814        bx0 = bx0.max(r.x0 as usize);
7815        by0 = by0.max(r.y0 as usize);
7816        bx1 = bx1.min(r.x1 as usize);
7817        by1 = by1.min(r.y1 as usize);
7818    }
7819
7820    let stride = out_w as usize;
7821    for y in by0..by1 {
7822        for x in bx0..bx1 {
7823            let mi = y * stride + x;
7824            let mut cov = cov_data[mi] as f32 / 255.0;
7825            if let Some(clip) = clip_data {
7826                cov *= clip[mi] as f32 / 255.0;
7827            }
7828            if cov > 0.0 {
7829                let ci = mi * 4;
7830                cmyk_buf[ci] = src_c as f32;
7831                cmyk_buf[ci + 1] = src_m as f32;
7832                cmyk_buf[ci + 2] = src_y as f32;
7833                cmyk_buf[ci + 3] = src_k as f32;
7834                if has_spot_contrib {
7835                    spot_mask[mi] = 1;
7836                }
7837            }
7838        }
7839    }
7840}
7841
7842/// Render an overprint stroke: convert the stroke outline to a fill path,
7843/// rasterize a coverage mask, then composite per-pixel in CMYK so the painted
7844/// channels of the stroke colour replace the matching backdrop channels and
7845/// the result lands in the pixmap as RGB. Mirrors `render_overprint_fill`.
7846#[allow(clippy::too_many_arguments)]
7847fn render_overprint_stroke(
7848    pixmap: &mut Pixmap,
7849    cmyk_buf: &mut [f32],
7850    op_bg: &mut [u8],
7851    op_touched: &mut [u8],
7852    spot_mask: &[u8],
7853    band_state: &mut BandState,
7854    skia_path: &stet_tiny_skia::Path,
7855    stroke: &Stroke,
7856    transform: Transform,
7857    params: &StrokeParams,
7858    out_w: u32,
7859    out_h: u32,
7860    icc: Option<&IccCache>,
7861    no_aa: bool,
7862) {
7863    // Convert stroke outline to fill path. Mirrors update_cmyk_buffer_for_stroke_overprint.
7864    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
7865        .sqrt()
7866        .max(1.0);
7867    let dashed_op;
7868    let stroke_src = if let Some(ref dash) = stroke.dash {
7869        dashed_op = skia_path.dash(dash, resolution_scale);
7870        match dashed_op.as_ref() {
7871            Some(p) => p,
7872            None => skia_path,
7873        }
7874    } else {
7875        skia_path
7876    };
7877    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
7878        return;
7879    };
7880    let Some(stroked) = stroked_user.transform(transform) else {
7881        return;
7882    };
7883
7884    let mut coverage_mask = match Mask::new(out_w, out_h) {
7885        Some(m) => m,
7886        None => return,
7887    };
7888    coverage_mask.fill_path(
7889        &stroked,
7890        SkiaFillRule::Winding,
7891        !no_aa,
7892        Transform::identity(),
7893    );
7894
7895    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7896        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
7897
7898    // Intersect with clip mask (same logic as render_overprint_fill).
7899    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7900        None => None,
7901        Some(ClipRegion::Rect(r)) => {
7902            let data = coverage_mask.data_mut();
7903            let stride = out_w as usize;
7904            for y in bbox_y0..bbox_y1 {
7905                let row_start = y * stride;
7906                for x in bbox_x0..bbox_x1 {
7907                    let yu = y as u32;
7908                    let xu = x as u32;
7909                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7910                        data[row_start + x] = 0;
7911                    }
7912                }
7913            }
7914            None
7915        }
7916        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7917    };
7918
7919    // See render_overprint_fill for the rationale: a custom spot stroke must
7920    // preserve the process CMYK buffer and blend multiplicatively in RGB so
7921    // later OPM 1 overprints don't knock out the spot's visible colour.
7922    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7923
7924    // Source CMYK preference: for paints with a process colorant in the mix,
7925    // prefer `process_cmyk` so the no-op-delta skip in the per-pixel loop sees
7926    // the same exact value the BG paint wrote into `cmyk_buf`. Custom spots
7927    // keep reading `native_cmyk` (the spot's visual alt-CMYK; process_cmyk is
7928    // (0,0,0,0) for pure spots). See `render_overprint_fill` for the full
7929    // rationale (GWG 3.0 swatches c/i, 1307.pdf spot text).
7930    let (src_c, src_m, src_y, src_k) = if !is_custom_spot && let Some(c) = params.color.process_cmyk
7931    {
7932        c
7933    } else if let Some(c) = params.color.native_cmyk {
7934        c
7935    } else {
7936        let r = params.color.r;
7937        let g = params.color.g;
7938        let b = params.color.b;
7939        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7940    };
7941
7942    let mut channels = params.painted_channels;
7943    if channels == 0 {
7944        channels = stet_graphics::device::CMYK_ALL;
7945    }
7946    if params.overprint_mode == 1
7947        && channels == stet_graphics::device::CMYK_ALL
7948        && params.is_device_cmyk
7949    {
7950        channels = 0;
7951        if src_c != 0.0 {
7952            channels |= stet_graphics::device::CMYK_C;
7953        }
7954        if src_m != 0.0 {
7955            channels |= stet_graphics::device::CMYK_M;
7956        }
7957        if src_y != 0.0 {
7958            channels |= stet_graphics::device::CMYK_Y;
7959        }
7960        if src_k != 0.0 {
7961            channels |= stet_graphics::device::CMYK_K;
7962        }
7963        // See render_overprint_fill: an all-zero CMYK source preserves the
7964        // backdrop only when /OPM and /op|/OP were set together (paired) in
7965        // the same ExtGState. Inherited-OPM cases fall back to legacy
7966        // knockout.
7967        if channels == 0 && !params.opm_paired {
7968            channels = stet_graphics::device::CMYK_ALL;
7969        }
7970    }
7971
7972    let is_k_only_cmyk = params.is_device_cmyk
7973        && params.overprint_mode == 0
7974        && src_c == 0.0
7975        && src_m == 0.0
7976        && src_y == 0.0;
7977    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7978        // Full-channel replacement: write source CMYK to buffer for covered
7979        // pixels and let tiny-skia stroke the pixmap with the source colour.
7980        // Only K-only DeviceCMYK OPM 0 paints are routed to the per-pixel
7981        // path (see render_overprint_fill).
7982        let cov_data = coverage_mask.data();
7983        let stride = out_w as usize;
7984        for y in bbox_y0..bbox_y1 {
7985            for x in bbox_x0..bbox_x1 {
7986                let mi = y * stride + x;
7987                let mut cov = cov_data[mi] as f32 / 255.0;
7988                if let Some(clip) = clip_coverage {
7989                    cov *= clip[mi] as f32 / 255.0;
7990                }
7991                if cov > 0.0 {
7992                    let ci = mi * 4;
7993                    cmyk_buf[ci] = src_c as f32;
7994                    cmyk_buf[ci + 1] = src_m as f32;
7995                    cmyk_buf[ci + 2] = src_y as f32;
7996                    cmyk_buf[ci + 3] = src_k as f32;
7997                }
7998            }
7999        }
8000        let mut temp_mask = None;
8001        let Some(mask_ref) =
8002            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
8003        else {
8004            return;
8005        };
8006        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
8007        pixmap.stroke_path(skia_path, &paint, stroke, transform, mask_ref);
8008        return;
8009    }
8010
8011    let cov_data = coverage_mask.data();
8012    let stride = out_w as usize;
8013    let px_data = pixmap.data_mut();
8014    let px_stride = out_w as usize * 4;
8015
8016    for y in bbox_y0..bbox_y1 {
8017        for x in bbox_x0..bbox_x1 {
8018            let mi = y * stride + x;
8019            let mut cov = cov_data[mi] as f32 / 255.0;
8020            if let Some(clip) = clip_coverage {
8021                cov *= clip[mi] as f32 / 255.0;
8022            }
8023            if cov <= 0.0 {
8024                continue;
8025            }
8026
8027            let ci = mi * 4;
8028            let pi = y * px_stride + x * 4;
8029            // Snapshot-based AA blending — see render_overprint_fill for the
8030            // rationale. Capture the pre-paint pixmap on first overprint touch
8031            // so stacked overprints at the same pixel blend against the
8032            // original backdrop rather than each other.
8033            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8034                op_bg[pi] = px_data[pi];
8035                op_bg[pi + 1] = px_data[pi + 1];
8036                op_bg[pi + 2] = px_data[pi + 2];
8037                op_bg[pi + 3] = px_data[pi + 3];
8038                op_touched[mi] = 1;
8039            }
8040            let cur_c = cmyk_buf[ci] as f64;
8041            let cur_m = cmyk_buf[ci + 1] as f64;
8042            let cur_y = cmyk_buf[ci + 2] as f64;
8043            let cur_k = cmyk_buf[ci + 3] as f64;
8044            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8045            let pixmap_has_colour = px_data[pi + 3] > 0
8046                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
8047            // Multiplicative ink-stacking only when the pixmap carries a real
8048            // backdrop: either this paint is a custom spot landing on an
8049            // already-coloured pixel, or the process-ink buffer is empty but
8050            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
8051            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
8052            // the fill to pure black, so those pixels fall through to the
8053            // replace path where the source RGB paints normally.
8054            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
8055
8056            // Promoted DeviceGray on non-spot backdrop: replace all channels
8057            // (see render_overprint_fill).
8058            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
8059                && channels == stet_graphics::device::CMYK_K
8060                && params.is_device_cmyk
8061                && src_c == 0.0
8062                && src_m == 0.0
8063                && src_y == 0.0;
8064            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
8065                stet_graphics::device::CMYK_ALL
8066            } else {
8067                channels
8068            };
8069
8070            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
8071                src_c
8072            } else {
8073                cur_c
8074            };
8075            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
8076                src_m
8077            } else {
8078                cur_m
8079            };
8080            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
8081                src_y
8082            } else {
8083                cur_y
8084            };
8085            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
8086                src_k
8087            } else {
8088                cur_k
8089            };
8090
8091            if !is_custom_spot {
8092                cmyk_buf[ci] = new_c as f32;
8093                cmyk_buf[ci + 1] = new_m as f32;
8094                cmyk_buf[ci + 2] = new_y as f32;
8095                cmyk_buf[ci + 3] = new_k as f32;
8096            }
8097
8098            // No-op overprint skip — see render_overprint_fill for rationale.
8099            let delta = (new_c - cur_c)
8100                .abs()
8101                .max((new_m - cur_m).abs())
8102                .max((new_y - cur_y).abs())
8103                .max((new_k - cur_k).abs());
8104            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
8105                continue;
8106            }
8107
8108            let (r, g, b) =
8109                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
8110                    // Promoted DeviceGray collapsing to a full replace — see
8111                    // render_overprint_fill for the rationale (must run before
8112                    // the multiplicative branch so a `1 g` / `1 G` white paint
8113                    // doesn't get folded into the backdrop via zero-source
8114                    // multiplication).
8115                    (params.color.r, params.color.g, params.color.b)
8116                } else if use_multiplicative {
8117                    let bg_r = px_data[pi] as f64 / 255.0;
8118                    let bg_g = px_data[pi + 1] as f64 / 255.0;
8119                    let bg_b = px_data[pi + 2] as f64 / 255.0;
8120                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
8121                        1.0 - src_c
8122                    } else {
8123                        1.0
8124                    };
8125                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
8126                        1.0 - src_m
8127                    } else {
8128                        1.0
8129                    };
8130                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
8131                        1.0 - src_y
8132                    } else {
8133                        1.0
8134                    };
8135                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
8136                        1.0 - src_k
8137                    } else {
8138                        1.0
8139                    };
8140                    (
8141                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
8142                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
8143                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
8144                    )
8145                } else if let Some(icc_cache) = icc {
8146                    icc_cache
8147                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8148                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8149                } else {
8150                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8151                };
8152
8153            let a = (cov * params.alpha as f32).min(1.0);
8154            // Blend backdrop: prefer snapshot only when this paint's colour
8155            // closely matches the snapshot — see render_overprint_fill for
8156            // the rationale (keeps aw-on-red-style cancel paints clean at
8157            // edges while preserving additive same-colour stacking).
8158            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
8159                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
8160                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
8161                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
8162                let dr = (op_bg[pi] as f32 - new_r).abs();
8163                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
8164                let db = (op_bg[pi + 2] as f32 - new_b).abs();
8165                if dr.max(dg).max(db) <= 4.0 {
8166                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
8167                } else {
8168                    (
8169                        px_data[pi],
8170                        px_data[pi + 1],
8171                        px_data[pi + 2],
8172                        px_data[pi + 3],
8173                    )
8174                }
8175            } else {
8176                (
8177                    px_data[pi],
8178                    px_data[pi + 1],
8179                    px_data[pi + 2],
8180                    px_data[pi + 3],
8181                )
8182            };
8183            let dst_a = bk_a as f32 / 255.0;
8184            let one_minus_a = 1.0 - a;
8185            let out_a = a + dst_a * one_minus_a;
8186            if out_a > 0.0 {
8187                // tiny-skia stores premultiplied RGBA (see render_overprint_fill).
8188                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
8189                    .clamp(0.0, 255.0)
8190                    .round() as u8;
8191                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
8192                    .clamp(0.0, 255.0)
8193                    .round() as u8;
8194                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
8195                    .clamp(0.0, 255.0)
8196                    .round() as u8;
8197                px_data[pi + 3] = (out_a * 255.0).round() as u8;
8198            }
8199        }
8200    }
8201}
8202
8203/// Update the CMYK buffer for a non-overprint stroke. Mirrors
8204/// [`update_cmyk_buffer_for_fill`] but rasterizes a stroked outline path
8205/// instead of a filled one. Source-CMYK selection follows the same
8206/// native_cmyk → ICC reverse → PLRM cascade.
8207#[allow(clippy::too_many_arguments)]
8208fn update_cmyk_buffer_for_stroke(
8209    cmyk_buf: &mut [f32],
8210    spot_mask: &mut [u8],
8211    path: &PsPath,
8212    params: &StrokeParams,
8213    stroke: &Stroke,
8214    transform: Transform,
8215    out_w: u32,
8216    out_h: u32,
8217    clip_region: &Option<ClipRegion>,
8218    no_aa: bool,
8219    icc: Option<&IccCache>,
8220) {
8221    // Custom spot strokes knockout the process CMYK plates — zero the buffer
8222    // under the stroke so later overprints fall into the multiplicative-blend
8223    // branch (see update_cmyk_buffer_for_fill, including the
8224    // `process_cmyk.is_some()` carve-out that keeps DeviceRGB / ICCBased-RGB
8225    // strokes off this branch so their proofing-chain CMYK reaches the
8226    // buffer).
8227    let is_custom_spot = params.painted_channels == 0
8228        && !params.is_device_cmyk
8229        && params.color.process_cmyk.is_some();
8230    // See update_cmyk_buffer_for_fill for rationale.
8231    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
8232        || matches!(
8233            (params.color.native_cmyk, params.color.process_cmyk),
8234            (Some(nat), Some(proc_))
8235                if (nat.0 - proc_.0).abs() > 1e-6
8236                    || (nat.1 - proc_.1).abs() > 1e-6
8237                    || (nat.2 - proc_.2).abs() > 1e-6
8238                    || (nat.3 - proc_.3).abs() > 1e-6
8239        );
8240
8241    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
8242        (0.0, 0.0, 0.0, 0.0)
8243    } else if let Some(c) = params.color.process_cmyk {
8244        c
8245    } else if let Some(c) = params.color.native_cmyk {
8246        c
8247    } else if let Some(cmyk) = icc.and_then(|i| {
8248        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
8249    }) {
8250        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
8251    } else {
8252        (
8253            (1.0 - params.color.r).clamp(0.0, 1.0),
8254            (1.0 - params.color.g).clamp(0.0, 1.0),
8255            (1.0 - params.color.b).clamp(0.0, 1.0),
8256            0.0,
8257        )
8258    };
8259
8260    let Some(skia_path) = build_skia_path(path) else {
8261        return;
8262    };
8263
8264    // Convert the stroke outline into a fill path so we can rasterize it via
8265    // Mask::fill_path. Mirrors the dance in the overprint stroke branch:
8266    // dash → stroke-to-outline (in user space) → device transform.
8267    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
8268        .sqrt()
8269        .max(1.0);
8270    let dashed_op;
8271    let stroke_src = if let Some(ref dash) = stroke.dash {
8272        dashed_op = skia_path.dash(dash, resolution_scale);
8273        match dashed_op.as_ref() {
8274            Some(p) => p,
8275            None => &skia_path,
8276        }
8277    } else {
8278        &skia_path
8279    };
8280    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
8281        return;
8282    };
8283    let Some(stroked) = stroked_user.transform(transform) else {
8284        return;
8285    };
8286
8287    let mut coverage_mask = match Mask::new(out_w, out_h) {
8288        Some(m) => m,
8289        None => return,
8290    };
8291    coverage_mask.fill_path(
8292        &stroked,
8293        SkiaFillRule::Winding,
8294        !no_aa,
8295        Transform::identity(),
8296    );
8297
8298    let cov_data = coverage_mask.data();
8299    let clip_data: Option<&[u8]> = match clip_region {
8300        Some(ClipRegion::Mask(m)) => Some(m.data()),
8301        _ => None,
8302    };
8303
8304    let (mut bx0, mut by0, mut bx1, mut by1) =
8305        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
8306    if let Some(ClipRegion::Rect(r)) = clip_region {
8307        bx0 = bx0.max(r.x0 as usize);
8308        by0 = by0.max(r.y0 as usize);
8309        bx1 = bx1.min(r.x1 as usize);
8310        by1 = by1.min(r.y1 as usize);
8311    }
8312
8313    let stride = out_w as usize;
8314    for y in by0..by1 {
8315        for x in bx0..bx1 {
8316            let mi = y * stride + x;
8317            let mut cov = cov_data[mi] as f32 / 255.0;
8318            if let Some(clip) = clip_data {
8319                cov *= clip[mi] as f32 / 255.0;
8320            }
8321            if cov > 0.0 {
8322                let ci = mi * 4;
8323                cmyk_buf[ci] = src_c as f32;
8324                cmyk_buf[ci + 1] = src_m as f32;
8325                cmyk_buf[ci + 2] = src_y as f32;
8326                cmyk_buf[ci + 3] = src_k as f32;
8327                if has_spot_contrib {
8328                    spot_mask[mi] = 1;
8329                }
8330            }
8331        }
8332    }
8333}
8334
8335/// Render an overprint image with viewport params.
8336#[allow(clippy::too_many_arguments)]
8337fn render_overprint_image(
8338    pixmap: &mut Pixmap,
8339    cmyk_buf: &mut [f32],
8340    op_bg: &mut [u8],
8341    op_touched: &mut [u8],
8342    band_state: &mut BandState,
8343    sample_data: &[u8],
8344    params: &ImageParams,
8345    vp_x: f32,
8346    vp_y: f32,
8347    scale_x: f32,
8348    scale_y: f32,
8349    out_w: u32,
8350    out_h: u32,
8351    icc: Option<&IccCache>,
8352) {
8353    let iw = params.width as usize;
8354    let ih = params.height as usize;
8355    let Some(image_inv) = params.image_matrix.invert() else {
8356        return;
8357    };
8358    let combined = params.ctm.concat(&image_inv);
8359    let Some(inv_combined) = combined.invert() else {
8360        return;
8361    };
8362
8363    let px_data = pixmap.data_mut();
8364    let stride = out_w as usize;
8365    let inv_sx = 1.0 / scale_x as f64;
8366    let inv_sy = 1.0 / scale_y as f64;
8367
8368    let clip_data: Option<&[u8]> = match &band_state.clip_region {
8369        Some(ClipRegion::Mask(m)) => Some(m.data()),
8370        _ => None,
8371    };
8372    let clip_rect = match &band_state.clip_region {
8373        Some(ClipRegion::Rect(r)) => Some(*r),
8374        _ => None,
8375    };
8376
8377    let mask_info = if let ImageColorSpace::Mask {
8378        color, polarity, ..
8379    } = &params.color_space
8380    {
8381        let (src_c, src_m, src_y, src_k) = color.native_cmyk.unwrap_or_else(|| {
8382            let r = color.r;
8383            let g = color.g;
8384            let b = color.b;
8385            (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
8386        });
8387        Some((src_c, src_m, src_y, src_k, *polarity, iw.div_ceil(8)))
8388    } else {
8389        None
8390    };
8391
8392    for by in 0..out_h as usize {
8393        for bx in 0..out_w as usize {
8394            if let Some(ref r) = clip_rect
8395                && ((by as u32) < r.y0
8396                    || (by as u32) >= r.y1
8397                    || (bx as u32) < r.x0
8398                    || (bx as u32) >= r.x1)
8399            {
8400                continue;
8401            }
8402            if let Some(clip) = clip_data {
8403                let ci_clip = by * stride + bx;
8404                if clip[ci_clip] == 0 {
8405                    let bh = out_h as usize;
8406                    let has_neighbor = (bx > 0 && clip[ci_clip - 1] != 0)
8407                        || (bx + 1 < stride && clip[ci_clip + 1] != 0)
8408                        || (by > 0 && clip[ci_clip - stride] != 0)
8409                        || (by + 1 < bh && clip[ci_clip + stride] != 0)
8410                        || (bx > 0 && by > 0 && clip[ci_clip - stride - 1] != 0)
8411                        || (bx + 1 < stride && by > 0 && clip[ci_clip - stride + 1] != 0)
8412                        || (bx > 0 && by + 1 < bh && clip[ci_clip + stride - 1] != 0)
8413                        || (bx + 1 < stride && by + 1 < bh && clip[ci_clip + stride + 1] != 0);
8414                    if !has_neighbor {
8415                        continue;
8416                    }
8417                }
8418            }
8419
8420            // Map output pixel to device space, then to image space
8421            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8422            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8423            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8424            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8425
8426            let col = ix.floor() as i64;
8427            let row = iy.floor() as i64;
8428            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8429                continue;
8430            }
8431            let col = col as usize;
8432            let row = row as usize;
8433
8434            let (src_c, src_m, src_y, src_k) =
8435                if let Some((mc, mm, my, mk, polarity, bytes_per_row)) = mask_info {
8436                    let byte_idx = row * bytes_per_row + col / 8;
8437                    let bit_offset = 7 - (col % 8);
8438                    let bit = if byte_idx < sample_data.len() {
8439                        (sample_data[byte_idx] >> bit_offset) & 1
8440                    } else {
8441                        0
8442                    };
8443                    let paint = if polarity { bit == 1 } else { bit == 0 };
8444                    if !paint {
8445                        continue;
8446                    }
8447                    (mc, mm, my, mk)
8448                } else if let Some(cmyk) =
8449                    sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8450                {
8451                    cmyk
8452                } else {
8453                    continue;
8454                };
8455
8456            let mi = by * stride + bx;
8457            let ci = mi * 4;
8458            let pi = mi * 4;
8459
8460            // Spot-tint images (Separation / DeviceN with CMYK alt and at
8461            // least one non-process colorant): per PDF spec 11.7.4.5 the
8462            // image affects only the device colorants identified by its color
8463            // space.  In composite preview that means:
8464            //   * Where the CMYK buffer is empty (fresh paper or a custom
8465            //     spot painted earlier whose alt-CMYK we never tracked),
8466            //     paint the pixel directly from the image's tint output —
8467            //     the spot's full alt-CMYK contribution shows up, and a
8468            //     same-spot underlying paint (e.g. a /GWG-Green X under an
8469            //     image whose GWG-Green is zero) is knocked out because
8470            //     ICC(0,0,0,0) is white.
8471            //   * Where the CMYK buffer carries prior CMYK (a `1 0 1 0.5 k`
8472            //     ✓ underneath), REPLACE only the NAMED PROCESS plates with
8473            //     the image's tint output and PRESERVE the rest, then
8474            //     recompose the pixmap.  A duotone DeviceN [Black, Green]
8475            //     image's "no ink" pixel knocks the ✓'s K=0.5 down to 0 —
8476            //     lightening it to (C=1, M=0, Y=1, K=0) — while leaving its
8477            //     C=1, Y=1 untouched.
8478            if image_cs_has_spot_tint_transform(&params.color_space) {
8479                let cur_c = cmyk_buf[ci] as f64;
8480                let cur_m = cmyk_buf[ci + 1] as f64;
8481                let cur_y = cmyk_buf[ci + 2] as f64;
8482                let cur_k = cmyk_buf[ci + 3] as f64;
8483                let cur_is_zero = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8484                let named = params.painted_channels;
8485                // OPM=1 zero-source preservation: when the image's tint
8486                // output for a named plate is zero, the underlying value is
8487                // preserved instead of replaced.  Without this, a duotone
8488                // DeviceN [Black, GWG-Green] image's "no ink" pixel
8489                // overwrote the K=0.5 of an underlying CMYK ✓ with 0,
8490                // rendering the checkmark too light versus Adobe Acrobat.
8491                let opm1 = params.overprint_mode == 1;
8492                let (new_c, new_m, new_y, new_k) = if cur_is_zero {
8493                    (src_c, src_m, src_y, src_k)
8494                } else {
8495                    let nc =
8496                        if named & stet_graphics::device::CMYK_C != 0 && !(opm1 && src_c == 0.0) {
8497                            src_c
8498                        } else {
8499                            cur_c
8500                        };
8501                    let nm =
8502                        if named & stet_graphics::device::CMYK_M != 0 && !(opm1 && src_m == 0.0) {
8503                            src_m
8504                        } else {
8505                            cur_m
8506                        };
8507                    let ny =
8508                        if named & stet_graphics::device::CMYK_Y != 0 && !(opm1 && src_y == 0.0) {
8509                            src_y
8510                        } else {
8511                            cur_y
8512                        };
8513                    let nk =
8514                        if named & stet_graphics::device::CMYK_K != 0 && !(opm1 && src_k == 0.0) {
8515                            src_k
8516                        } else {
8517                            cur_k
8518                        };
8519                    (nc, nm, ny, nk)
8520                };
8521                cmyk_buf[ci] = new_c as f32;
8522                cmyk_buf[ci + 1] = new_m as f32;
8523                cmyk_buf[ci + 2] = new_y as f32;
8524                cmyk_buf[ci + 3] = new_k as f32;
8525                // When the alt space is non-CMYK (e.g., DeviceN with Lab alt),
8526                // src_* came from named-colorant extraction and only describes
8527                // the named process plates — spot contributions are missing.
8528                // For fresh-paper pixels (cur_is_zero), reconstruct the visual
8529                // via the tint transform's alt → RGB output instead so the
8530                // spot's true colour shows through. Composite cells (cur not
8531                // zero) still go through CMYK → RGB on the plate-replaced
8532                // values so process plates from the underlay are honoured.
8533                let alt_is_non_cmyk = image_cs_alt_is_non_cmyk(&params.color_space);
8534                let (r, g, b) = if cur_is_zero
8535                    && alt_is_non_cmyk
8536                    && let Some(rgb) =
8537                        sample_pixel_visual_rgb(sample_data, &params.color_space, iw, row, col)
8538                {
8539                    rgb
8540                } else if let Some(icc_cache) = icc {
8541                    icc_cache
8542                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8543                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8544                } else {
8545                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8546                };
8547                if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8548                    op_bg[pi] = px_data[pi];
8549                    op_bg[pi + 1] = px_data[pi + 1];
8550                    op_bg[pi + 2] = px_data[pi + 2];
8551                    op_bg[pi + 3] = px_data[pi + 3];
8552                    op_touched[mi] = 1;
8553                }
8554                px_data[pi] = (r * 255.0).round() as u8;
8555                px_data[pi + 1] = (g * 255.0).round() as u8;
8556                px_data[pi + 2] = (b * 255.0).round() as u8;
8557                px_data[pi + 3] = 255;
8558                continue;
8559            }
8560
8561            let mut channels = params.painted_channels;
8562            // Non-CMYK images (painted_channels=0, e.g. Separation/DeviceN spot colors)
8563            // replace all CMYK channels with the tinted equivalent.
8564            if channels == 0 {
8565                channels = stet_graphics::device::CMYK_ALL;
8566            }
8567            let is_direct_cmyk = matches!(
8568                &params.color_space,
8569                ImageColorSpace::DeviceCMYK
8570                    | ImageColorSpace::ICCBased { n: 4, .. }
8571                    | ImageColorSpace::Mask { .. }
8572            );
8573            // Custom spot image: process plates stay untouched and the per-pixel
8574            // sampled CMYK is the spot's alt-CMYK, which we layer multiplicatively
8575            // onto the pixmap. For image masks, the spot identity lives on the
8576            // fill color (recognise them via painted_channels=0 paired with a
8577            // native-CMYK fill color from the alt-space conversion). Indexed
8578            // images inherit the base space, so an Indexed /DeviceCMYK palette
8579            // is NOT a custom spot even when painted_channels=0. Plain DeviceCMYK
8580            // / ICCBased(4) images keep is_custom_spot=false so standard OPM 1
8581            // behaviour still applies.
8582            let is_custom_spot = params.painted_channels == 0
8583                && !is_cmyk_color_space(&params.color_space)
8584                && match &params.color_space {
8585                    ImageColorSpace::Mask { color, .. } => color.native_cmyk.is_some(),
8586                    _ => true,
8587                };
8588            if params.overprint_mode == 1
8589                && channels == stet_graphics::device::CMYK_ALL
8590                && is_direct_cmyk
8591            {
8592                channels = 0;
8593                if src_c != 0.0 {
8594                    channels |= stet_graphics::device::CMYK_C;
8595                }
8596                if src_m != 0.0 {
8597                    channels |= stet_graphics::device::CMYK_M;
8598                }
8599                if src_y != 0.0 {
8600                    channels |= stet_graphics::device::CMYK_Y;
8601                }
8602                if src_k != 0.0 {
8603                    channels |= stet_graphics::device::CMYK_K;
8604                }
8605            }
8606
8607            let cur_c = cmyk_buf[ci] as f64;
8608            let cur_m = cmyk_buf[ci + 1] as f64;
8609            let cur_y = cmyk_buf[ci + 2] as f64;
8610            let cur_k = cmyk_buf[ci + 3] as f64;
8611            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8612            let pixmap_has_colour = px_data[pi + 3] > 0
8613                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
8614            // Multiplicative ink-stacking only when the pixmap carries a real
8615            // backdrop: either this paint is a custom spot landing on an
8616            // already-coloured pixel, or the process-ink buffer is empty but
8617            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
8618            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
8619            // the fill to pure black, so those pixels fall through to the
8620            // replace path where the source RGB paints normally.
8621            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
8622
8623            let new_c = if channels & stet_graphics::device::CMYK_C != 0 {
8624                src_c
8625            } else {
8626                cur_c
8627            };
8628            let new_m = if channels & stet_graphics::device::CMYK_M != 0 {
8629                src_m
8630            } else {
8631                cur_m
8632            };
8633            let new_y = if channels & stet_graphics::device::CMYK_Y != 0 {
8634                src_y
8635            } else {
8636                cur_y
8637            };
8638            let new_k = if channels & stet_graphics::device::CMYK_K != 0 {
8639                src_k
8640            } else {
8641                cur_k
8642            };
8643
8644            if !is_custom_spot {
8645                cmyk_buf[ci] = new_c as f32;
8646                cmyk_buf[ci + 1] = new_m as f32;
8647                cmyk_buf[ci + 2] = new_y as f32;
8648                cmyk_buf[ci + 3] = new_k as f32;
8649            }
8650
8651            let (r, g, b) = if use_multiplicative {
8652                let bg_r = px_data[pi] as f64 / 255.0;
8653                let bg_g = px_data[pi + 1] as f64 / 255.0;
8654                let bg_b = px_data[pi + 2] as f64 / 255.0;
8655                let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
8656                    1.0 - src_c
8657                } else {
8658                    1.0
8659                };
8660                let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
8661                    1.0 - src_m
8662                } else {
8663                    1.0
8664                };
8665                let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
8666                    1.0 - src_y
8667                } else {
8668                    1.0
8669                };
8670                let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
8671                    1.0 - src_k
8672                } else {
8673                    1.0
8674                };
8675                (
8676                    (bg_r * over_r * k_fac).clamp(0.0, 1.0),
8677                    (bg_g * over_g * k_fac).clamp(0.0, 1.0),
8678                    (bg_b * over_b * k_fac).clamp(0.0, 1.0),
8679                )
8680            } else if let Some(icc_cache) = icc {
8681                icc_cache
8682                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8683                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8684            } else {
8685                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8686            };
8687
8688            // Snapshot the pre-paint pixmap so a later overprint fill/stroke
8689            // at this pixel can blend against it (see render_overprint_fill).
8690            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8691                op_bg[pi] = px_data[pi];
8692                op_bg[pi + 1] = px_data[pi + 1];
8693                op_bg[pi + 2] = px_data[pi + 2];
8694                op_bg[pi + 3] = px_data[pi + 3];
8695                op_touched[mi] = 1;
8696            }
8697
8698            px_data[pi] = (r * 255.0).round() as u8;
8699            px_data[pi + 1] = (g * 255.0).round() as u8;
8700            px_data[pi + 2] = (b * 255.0).round() as u8;
8701            px_data[pi + 3] = 255;
8702        }
8703    }
8704}
8705
8706/// Update CMYK buffer for a non-overprint image.
8707///
8708/// For native-CMYK image color spaces (DeviceCMYK / ICCBased(4) / Separation
8709/// or DeviceN with CMYK alt), the source CMYK is sampled directly via
8710/// `sample_pixel_cmyk`. For non-CMYK source spaces (RGB/Gray/Lab/etc.), the
8711/// already-composited pixmap pixel is read and reverse-converted to CMYK via
8712/// the system CMYK ICC profile, falling back to the PLRM formula. This keeps
8713/// the parallel CMYK buffer faithful for any image painter inside a
8714/// CMYK-tracked context.
8715#[allow(clippy::too_many_arguments)]
8716fn update_cmyk_buffer_for_image(
8717    cmyk_buf: &mut [f32],
8718    sample_data: &[u8],
8719    pixmap_rgba: &[u8],
8720    params: &ImageParams,
8721    vp_x: f32,
8722    vp_y: f32,
8723    scale_x: f32,
8724    scale_y: f32,
8725    out_w: u32,
8726    out_h: u32,
8727    clip_region: &Option<ClipRegion>,
8728    icc: Option<&IccCache>,
8729) {
8730    let iw = params.width as usize;
8731    let ih = params.height as usize;
8732    let Some(image_inv) = params.image_matrix.invert() else {
8733        return;
8734    };
8735    let combined = params.ctm.concat(&image_inv);
8736    let Some(inv_combined) = combined.invert() else {
8737        return;
8738    };
8739    let stride = out_w as usize;
8740    let inv_sx = 1.0 / scale_x as f64;
8741    let inv_sy = 1.0 / scale_y as f64;
8742
8743    let mask_info = if let ImageColorSpace::Mask {
8744        color, polarity, ..
8745    } = &params.color_space
8746    {
8747        let Some((c, m, y, k)) = color.native_cmyk else {
8748            return;
8749        };
8750        Some((
8751            c as f32,
8752            m as f32,
8753            y as f32,
8754            k as f32,
8755            *polarity,
8756            iw.div_ceil(8),
8757        ))
8758    } else {
8759        None
8760    };
8761
8762    let clip_data: Option<&[u8]> = match clip_region {
8763        Some(ClipRegion::Mask(m)) => Some(m.data()),
8764        _ => None,
8765    };
8766    let clip_rect = match clip_region {
8767        Some(ClipRegion::Rect(r)) => Some(*r),
8768        _ => None,
8769    };
8770
8771    for by in 0..out_h as usize {
8772        for bx in 0..out_w as usize {
8773            if let Some(ref r) = clip_rect
8774                && ((by as u32) < r.y0
8775                    || (by as u32) >= r.y1
8776                    || (bx as u32) < r.x0
8777                    || (bx as u32) >= r.x1)
8778            {
8779                continue;
8780            }
8781            if let Some(clip) = clip_data
8782                && clip[by * stride + bx] == 0
8783            {
8784                continue;
8785            }
8786
8787            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8788            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8789            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8790            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8791
8792            let col = ix.floor() as i64;
8793            let row = iy.floor() as i64;
8794            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8795                continue;
8796            }
8797            let col = col as usize;
8798            let row = row as usize;
8799
8800            let ci = (by * stride + bx) * 4;
8801            if let Some((sc, sm, sy, sk, polarity, bytes_per_row)) = mask_info {
8802                let byte_idx = row * bytes_per_row + col / 8;
8803                let bit_offset = 7 - (col % 8);
8804                let bit = if byte_idx < sample_data.len() {
8805                    (sample_data[byte_idx] >> bit_offset) & 1
8806                } else {
8807                    0
8808                };
8809                let paint = if polarity { bit == 1 } else { bit == 0 };
8810                if paint {
8811                    cmyk_buf[ci] = sc;
8812                    cmyk_buf[ci + 1] = sm;
8813                    cmyk_buf[ci + 2] = sy;
8814                    cmyk_buf[ci + 3] = sk;
8815                }
8816            } else if let Some((sc, sm, sy, sk)) =
8817                sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8818            {
8819                cmyk_buf[ci] = sc as f32;
8820                cmyk_buf[ci + 1] = sm as f32;
8821                cmyk_buf[ci + 2] = sy as f32;
8822                cmyk_buf[ci + 3] = sk as f32;
8823            } else if ci + 3 < pixmap_rgba.len() && pixmap_rgba[ci + 3] > 0 {
8824                // Non-CMYK source space: reverse-convert the composited pixmap
8825                // pixel to CMYK via the system profile. Falls back to PLRM
8826                // (1 − r, 1 − g, 1 − b, 0) when no ICC reverse is available.
8827                let r = pixmap_rgba[ci] as f64 / 255.0;
8828                let g = pixmap_rgba[ci + 1] as f64 / 255.0;
8829                let b = pixmap_rgba[ci + 2] as f64 / 255.0;
8830                let cmyk =
8831                    if let Some(c) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
8832                        c
8833                    } else {
8834                        [
8835                            (1.0 - r).clamp(0.0, 1.0),
8836                            (1.0 - g).clamp(0.0, 1.0),
8837                            (1.0 - b).clamp(0.0, 1.0),
8838                            0.0,
8839                        ]
8840                    };
8841                cmyk_buf[ci] = cmyk[0] as f32;
8842                cmyk_buf[ci + 1] = cmyk[1] as f32;
8843                cmyk_buf[ci + 2] = cmyk[2] as f32;
8844                cmyk_buf[ci + 3] = cmyk[3] as f32;
8845            }
8846        }
8847    }
8848}
8849/// Check if an image color space can be rendered through the overprint path.
8850/// Image masks always work (they use the fill color's native CMYK).
8851/// Other color spaces must be CMYK-resolvable via `sample_pixel_cmyk`.
8852fn image_supports_overprint(cs: &ImageColorSpace) -> bool {
8853    use stet_graphics::device::cmyk_channel_for_name;
8854    match cs {
8855        ImageColorSpace::Mask { .. } => true,
8856        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => true,
8857        ImageColorSpace::Separation {
8858            alt_space, name, ..
8859        } => {
8860            matches!(
8861                alt_space.as_ref(),
8862                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8863            ) || cmyk_channel_for_name(name) != 0
8864        }
8865        ImageColorSpace::DeviceN {
8866            alt_space, names, ..
8867        } => {
8868            matches!(
8869                alt_space.as_ref(),
8870                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8871            ) || names.iter().any(|n| cmyk_channel_for_name(n) != 0)
8872        }
8873        ImageColorSpace::Indexed { base, .. } => image_supports_overprint(base),
8874        _ => false,
8875    }
8876}
8877
8878/// Check if an image color space is CMYK-based (DeviceCMYK, ICCBased 4-component, or Indexed over CMYK).
8879fn is_cmyk_color_space(cs: &ImageColorSpace) -> bool {
8880    match cs {
8881        ImageColorSpace::DeviceCMYK => true,
8882        ImageColorSpace::ICCBased { n: 4, .. } => true,
8883        ImageColorSpace::Indexed { base, .. } => is_cmyk_color_space(base),
8884        _ => false,
8885    }
8886}
8887
8888/// True when an image's color space is a Separation/DeviceN with at least
8889/// one non-process spot colorant. These images represent paint that affects
8890/// a virtual spot plate; the per-pixel CMYK produced by the tint transform
8891/// (when alt is CMYK) — or extracted directly from named process colorants
8892/// (when alt is non-CMYK) — must blend with the tracked CMYK buffer per
8893/// OPM=1: named process plates are replaced and unnamed plates are preserved.
8894fn image_cs_has_spot_tint_transform(cs: &ImageColorSpace) -> bool {
8895    use stet_graphics::device::cmyk_channel_for_name;
8896    let is_cmyk_alt = |alt: &ImageColorSpace| {
8897        matches!(
8898            alt,
8899            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8900        )
8901    };
8902    match cs {
8903        ImageColorSpace::Separation {
8904            name, alt_space, ..
8905        } => cmyk_channel_for_name(name) == 0 && is_cmyk_alt(alt_space.as_ref()),
8906        ImageColorSpace::DeviceN {
8907            names, alt_space, ..
8908        } => {
8909            let has_spot = names.iter().any(|n| cmyk_channel_for_name(n) == 0);
8910            let has_process = names.iter().any(|n| cmyk_channel_for_name(n) != 0);
8911            has_spot && (is_cmyk_alt(alt_space.as_ref()) || has_process)
8912        }
8913        ImageColorSpace::Indexed { base, .. } => image_cs_has_spot_tint_transform(base),
8914        _ => false,
8915    }
8916}
8917
8918/// True when the image's tint transform alt is non-CMYK (Lab/RGB/Gray/etc.).
8919/// In that case the per-pixel CMYK from `sample_pixel_cmyk` only carries the
8920/// named process colorants extracted directly — it doesn't capture spot
8921/// colorant contributions, so visual painting (when the buffer is fresh)
8922/// must come from `sample_pixel_visual_rgb` instead of CMYK→RGB conversion.
8923fn image_cs_alt_is_non_cmyk(cs: &ImageColorSpace) -> bool {
8924    let is_cmyk_alt = |alt: &ImageColorSpace| {
8925        matches!(
8926            alt,
8927            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8928        )
8929    };
8930    match cs {
8931        ImageColorSpace::Separation { alt_space, .. }
8932        | ImageColorSpace::DeviceN { alt_space, .. } => !is_cmyk_alt(alt_space.as_ref()),
8933        ImageColorSpace::Indexed { base, .. } => image_cs_alt_is_non_cmyk(base),
8934        _ => false,
8935    }
8936}
8937
8938/// Sample a pixel's visual RGB (0..1) via the tint-transform → alt-space →
8939/// RGB chain. Used by the spot-tint overprint path when the image's alt is
8940/// non-CMYK; for those images the named-colorant CMYK extraction loses the
8941/// spot contribution, but the tint table still produces the correct visual.
8942fn sample_pixel_visual_rgb(
8943    sample_data: &[u8],
8944    cs: &ImageColorSpace,
8945    iw: usize,
8946    row: usize,
8947    col: usize,
8948) -> Option<(f64, f64, f64)> {
8949    let to_f64 = |(r, g, b): (u8, u8, u8)| (r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0);
8950    match cs {
8951        ImageColorSpace::Separation {
8952            alt_space,
8953            tint_table,
8954            ..
8955        } => {
8956            let si = row * iw + col;
8957            if si >= sample_data.len() {
8958                return None;
8959            }
8960            let tint = sample_data[si] as f32 / 255.0;
8961            let no = tint_table.num_outputs as usize;
8962            let mut comps = vec![0.0f32; no];
8963            tint_table.lookup_1d(tint, &mut comps);
8964            Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
8965        }
8966        ImageColorSpace::DeviceN {
8967            alt_space,
8968            tint_table,
8969            ..
8970        } => {
8971            let ni = tint_table.num_inputs as usize;
8972            let si = (row * iw + col) * ni;
8973            if si + ni > sample_data.len() {
8974                return None;
8975            }
8976            let mut inputs = vec![0.0f32; ni];
8977            for (c, inp) in inputs.iter_mut().enumerate() {
8978                *inp = sample_data[si + c] as f32 / 255.0;
8979            }
8980            let no = tint_table.num_outputs as usize;
8981            let mut comps = vec![0.0f32; no];
8982            tint_table.lookup_nd(&inputs, &mut comps);
8983            Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
8984        }
8985        ImageColorSpace::Indexed {
8986            base,
8987            hival,
8988            lookup,
8989        } => {
8990            let pi = row * iw + col;
8991            if pi >= sample_data.len() {
8992                return None;
8993            }
8994            let idx = (sample_data[pi] as usize).min(*hival as usize);
8995            let base_ncomp = base.num_components() as usize;
8996            let li = idx * base_ncomp;
8997            if li + base_ncomp > lookup.len() {
8998                return None;
8999            }
9000            match base.as_ref() {
9001                ImageColorSpace::Separation {
9002                    alt_space,
9003                    tint_table,
9004                    ..
9005                } => {
9006                    let tint = lookup[li] as f32 / 255.0;
9007                    let no = tint_table.num_outputs as usize;
9008                    let mut comps = vec![0.0f32; no];
9009                    tint_table.lookup_1d(tint, &mut comps);
9010                    Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
9011                }
9012                ImageColorSpace::DeviceN {
9013                    alt_space,
9014                    tint_table,
9015                    ..
9016                } => {
9017                    let ni = tint_table.num_inputs as usize;
9018                    let mut inputs = vec![0.0f32; ni];
9019                    for (c, inp) in inputs.iter_mut().enumerate() {
9020                        if c < base_ncomp {
9021                            *inp = lookup[li + c] as f32 / 255.0;
9022                        }
9023                    }
9024                    let no = tint_table.num_outputs as usize;
9025                    let mut comps = vec![0.0f32; no];
9026                    tint_table.lookup_nd(&inputs, &mut comps);
9027                    Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
9028                }
9029                _ => None,
9030            }
9031        }
9032        _ => None,
9033    }
9034}
9035
9036/// Extract CMYK values from DeviceN colorant inputs by mapping each named
9037/// process colorant directly to its CMYK channel. Spot colorants and `/None`
9038/// don't contribute. Used when the DeviceN's alt is non-CMYK so the tint
9039/// transform can't produce CMYK; the named-colorant inputs are themselves the
9040/// per-pixel ink amounts for the named process plates.
9041fn devicen_named_cmyk(names: &[Vec<u8>], inputs: &[u8]) -> (f64, f64, f64, f64) {
9042    use stet_graphics::device::{CMYK_C, CMYK_K, CMYK_M, CMYK_Y, cmyk_channel_for_name};
9043    let mut c = 0.0;
9044    let mut m = 0.0;
9045    let mut y = 0.0;
9046    let mut k = 0.0;
9047    for (i, name) in names.iter().enumerate() {
9048        let bit = cmyk_channel_for_name(name);
9049        if bit == 0 {
9050            continue;
9051        }
9052        let v = inputs.get(i).copied().unwrap_or(0) as f64 / 255.0;
9053        if bit & CMYK_C != 0 {
9054            c = v;
9055        }
9056        if bit & CMYK_M != 0 {
9057            m = v;
9058        }
9059        if bit & CMYK_Y != 0 {
9060            y = v;
9061        }
9062        if bit & CMYK_K != 0 {
9063            k = v;
9064        }
9065    }
9066    (c, m, y, k)
9067}
9068
9069/// Sample a single pixel's CMYK values from image data, handling DeviceCMYK,
9070/// ICCBased(4), Separation/DeviceN (CMYK alt via tint table, or non-CMYK alt
9071/// via named-colorant extraction), and Indexed color spaces. Returns None for
9072/// non-CMYK images.
9073fn sample_pixel_cmyk(
9074    sample_data: &[u8],
9075    cs: &ImageColorSpace,
9076    iw: usize,
9077    row: usize,
9078    col: usize,
9079) -> Option<(f64, f64, f64, f64)> {
9080    use stet_graphics::device::cmyk_channel_for_name;
9081    let is_cmyk_alt = |alt: &ImageColorSpace| {
9082        matches!(
9083            alt,
9084            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
9085        )
9086    };
9087    match cs {
9088        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => {
9089            let si = (row * iw + col) * 4;
9090            if si + 3 < sample_data.len() {
9091                Some((
9092                    sample_data[si] as f64 / 255.0,
9093                    sample_data[si + 1] as f64 / 255.0,
9094                    sample_data[si + 2] as f64 / 255.0,
9095                    sample_data[si + 3] as f64 / 255.0,
9096                ))
9097            } else {
9098                None
9099            }
9100        }
9101        ImageColorSpace::Separation {
9102            alt_space,
9103            tint_table,
9104            name,
9105        } => {
9106            let si = row * iw + col;
9107            if si >= sample_data.len() {
9108                return None;
9109            }
9110            let tint = sample_data[si] as f32 / 255.0;
9111            if is_cmyk_alt(alt_space.as_ref()) {
9112                let mut alt = [0.0f32; 4];
9113                tint_table.lookup_1d(tint, &mut alt);
9114                return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
9115            }
9116            // Non-CMYK alt: only a named process colorant is recoverable.
9117            let bit = cmyk_channel_for_name(name);
9118            if bit == 0 {
9119                return None;
9120            }
9121            let names = vec![name.clone()];
9122            let inputs = [(tint * 255.0).round() as u8];
9123            Some(devicen_named_cmyk(&names, &inputs))
9124        }
9125        ImageColorSpace::DeviceN {
9126            alt_space,
9127            tint_table,
9128            names,
9129        } => {
9130            let ni = tint_table.num_inputs as usize;
9131            let si = (row * iw + col) * ni;
9132            if si + ni > sample_data.len() {
9133                return None;
9134            }
9135            if is_cmyk_alt(alt_space.as_ref()) {
9136                let mut inputs = vec![0.0f32; ni];
9137                for (c, inp) in inputs.iter_mut().enumerate() {
9138                    *inp = sample_data[si + c] as f32 / 255.0;
9139                }
9140                let mut alt = [0.0f32; 4];
9141                tint_table.lookup_nd(&inputs, &mut alt);
9142                return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
9143            }
9144            // Non-CMYK alt: extract from named process colorants directly.
9145            if !names.iter().any(|n| cmyk_channel_for_name(n) != 0) {
9146                return None;
9147            }
9148            Some(devicen_named_cmyk(names, &sample_data[si..si + ni]))
9149        }
9150        ImageColorSpace::Indexed {
9151            base,
9152            hival,
9153            lookup,
9154        } => {
9155            let pi = row * iw + col;
9156            if pi >= sample_data.len() {
9157                return None;
9158            }
9159            let idx = sample_data[pi] as usize;
9160            let idx = idx.min(*hival as usize);
9161            let base_ncomp = base.num_components() as usize;
9162            let li = idx * base_ncomp;
9163            // For direct CMYK base (4 components): read CMYK from lookup table
9164            if is_cmyk_color_space(base) && base_ncomp == 4 && li + 3 < lookup.len() {
9165                return Some((
9166                    lookup[li] as f64 / 255.0,
9167                    lookup[li + 1] as f64 / 255.0,
9168                    lookup[li + 2] as f64 / 255.0,
9169                    lookup[li + 3] as f64 / 255.0,
9170                ));
9171            }
9172            // For Separation/DeviceN base: extract base components from lookup, then tint
9173            if li + base_ncomp <= lookup.len() {
9174                match base.as_ref() {
9175                    ImageColorSpace::Separation {
9176                        alt_space,
9177                        tint_table,
9178                        name,
9179                    } => {
9180                        let tint = lookup[li] as f32 / 255.0;
9181                        if is_cmyk_alt(alt_space.as_ref()) {
9182                            let mut alt = [0.0f32; 4];
9183                            tint_table.lookup_1d(tint, &mut alt);
9184                            return Some((
9185                                alt[0] as f64,
9186                                alt[1] as f64,
9187                                alt[2] as f64,
9188                                alt[3] as f64,
9189                            ));
9190                        }
9191                        // Non-CMYK alt: only named process colorants extractable.
9192                        let bit = cmyk_channel_for_name(name);
9193                        if bit == 0 {
9194                            return None;
9195                        }
9196                        let names = vec![name.clone()];
9197                        let inputs = [(tint * 255.0).round() as u8];
9198                        return Some(devicen_named_cmyk(&names, &inputs));
9199                    }
9200                    ImageColorSpace::DeviceN {
9201                        alt_space,
9202                        tint_table,
9203                        names,
9204                    } => {
9205                        let ni = tint_table.num_inputs as usize;
9206                        if is_cmyk_alt(alt_space.as_ref()) {
9207                            let mut inputs = vec![0.0f32; ni];
9208                            for (c, inp) in inputs.iter_mut().enumerate() {
9209                                if c < base_ncomp {
9210                                    *inp = lookup[li + c] as f32 / 255.0;
9211                                }
9212                            }
9213                            let mut alt = [0.0f32; 4];
9214                            tint_table.lookup_nd(&inputs, &mut alt);
9215                            return Some((
9216                                alt[0] as f64,
9217                                alt[1] as f64,
9218                                alt[2] as f64,
9219                                alt[3] as f64,
9220                            ));
9221                        }
9222                        // Non-CMYK alt: extract from named process colorants directly.
9223                        if !names.iter().any(|n| cmyk_channel_for_name(n) != 0) {
9224                            return None;
9225                        }
9226                        let take = ni.min(base_ncomp);
9227                        return Some(devicen_named_cmyk(names, &lookup[li..li + take]));
9228                    }
9229                    _ => {}
9230                }
9231            }
9232            None
9233        }
9234        _ => None,
9235    }
9236}
9237/// Banded rendering as a free function — runs on a background thread.
9238///
9239/// Renders the display list in horizontal bands and streams the output
9240/// to a `PageSink`. This function is self-contained: it creates its own
9241/// band pixmaps, clip state, and streams rows to the sink.
9242#[allow(clippy::too_many_arguments)]
9243fn render_banded_to_sink(
9244    page_w: u32,
9245    page_h: u32,
9246    band_h: u32,
9247    dpi: f64,
9248    list: &DisplayList,
9249    sink: &mut dyn stet_graphics::device::PageSink,
9250    icc_cache: &IccCache,
9251    no_aa: bool,
9252    layer_set: &LayerSet,
9253) -> Result<(), String> {
9254    // Precompute Y bounding boxes for culling
9255    let bboxes = precompute_bboxes(list, dpi);
9256
9257    // Build clip epochs — groups of elements between InitClip boundaries.
9258    // Epochs whose paint elements don't overlap a band can be skipped entirely,
9259    // avoiding both the per-element iteration AND clip mask rasterization.
9260    let epochs = build_clip_epochs(list, &bboxes);
9261
9262    // Pre-populate clip_mask_seen so repeated clip paths get cached from first band
9263    let clip_seen = precompute_clip_seen(list);
9264
9265    // Allocate a CMYK buffer at the page level when CMYK math is needed:
9266    // overprint simulation, an explicit DeviceCMYK page-level transparency
9267    // group (PDF spec §11.6.7), or any descendant group that declares its own
9268    // DeviceCMYK transparency CS.
9269    use stet_graphics::display_list::GroupColorSpace;
9270    let needs_cmyk_buffer = has_overprint_elements(list)
9271        || list.page_group_color_space() == GroupColorSpace::DeviceCMYK
9272        || has_cmyk_group(list);
9273
9274    // Pre-convert and prescale images once (instead of per-band)
9275    let preprocessed_images = preprocess_images_for_bands(list, Some(icc_cache));
9276
9277    // Extra rows rendered above and below each band to provide anti-aliasing
9278    // context at band seams. Without this, tiny-skia clips geometry at the
9279    // pixmap edge, producing visible discontinuities in thin diagonal strokes.
9280    const BAND_OVERLAP: u32 = 6;
9281
9282    let render_h = band_h + 2 * BAND_OVERLAP;
9283
9284    // Initialize the sink for this page
9285    sink.begin_page(page_w, page_h)?;
9286
9287    let num_bands = page_h.div_ceil(band_h);
9288    let elements = list.elements();
9289    let row_bytes = page_w as usize * 4;
9290    let icc_ref = Some(icc_cache);
9291
9292    // Closure that renders a single band and returns its RGBA pixels.
9293    let render_band = |band_idx: u32| -> Vec<u8> {
9294        let y_start = band_idx * band_h;
9295        let actual_h = (page_h - y_start).min(band_h);
9296
9297        let render_y_start = y_start.saturating_sub(BAND_OVERLAP);
9298        let render_y_end_f = ((y_start + actual_h + BAND_OVERLAP).min(page_h)) as f64;
9299        let band_offset = y_start - render_y_start;
9300
9301        let mut band_pixmap = Pixmap::new(page_w, render_h).expect("Failed to create band pixmap");
9302        // Start transparent — white background composited after content rendering
9303        band_pixmap.as_mut().data_mut().fill(0x00);
9304
9305        let cmyk_buf = if needs_cmyk_buffer {
9306            // CMYK buffer for the render region (including overlap)
9307            Some(vec![0.0f32; page_w as usize * render_h as usize * 4])
9308        } else {
9309            None
9310        };
9311
9312        let mut band_state = BandState {
9313            clip_region: None,
9314            spare_mask: None,
9315            clip_mask_cache: HashMap::new(),
9316            clip_mask_seen: clip_seen.clone(),
9317            mask_pool: Vec::new(),
9318            cmyk_buffer: cmyk_buf,
9319            op_bg_snapshot: None,
9320            op_touched: None,
9321            spot_mask: None,
9322        };
9323
9324        // Epoch-based replay
9325        for epoch in &epochs {
9326            if !epoch.has_erase_page {
9327                match epoch.paint_bbox {
9328                    Some(ref pb)
9329                        if pb.y_max <= render_y_start as f64 || pb.y_min >= render_y_end_f =>
9330                    {
9331                        continue;
9332                    }
9333                    None => continue,
9334                    _ => {}
9335                }
9336            }
9337
9338            for i in epoch.start_idx..epoch.end_idx {
9339                // OcgGroups containing Clip/InitClip must always be
9340                // processed so their clip-state changes apply for every
9341                // band — per-element Y culling would strand clip mutations
9342                // inside a group whose paint content doesn't touch the
9343                // current band.
9344                let force_process = matches!(
9345                    &elements[i],
9346                    DisplayElement::OcgGroup { elements: inner, .. }
9347                        if contains_clip_op(inner)
9348                );
9349                if !force_process
9350                    && let Some(ref bbox) = bboxes[i]
9351                    && (bbox.y_max <= render_y_start as f64 || bbox.y_min >= render_y_end_f)
9352                {
9353                    continue;
9354                }
9355                let ctx = RenderContext {
9356                    vp_x: 0.0,
9357                    vp_y: render_y_start as f32,
9358                    scale_x: 1.0,
9359                    scale_y: 1.0,
9360                    out_w: page_w,
9361                    out_h: render_h,
9362                    effective_dpi: dpi,
9363                    icc: icc_ref,
9364                    image_cache: None,
9365                    preprocessed: Some(&preprocessed_images),
9366                    elem_idx: i,
9367                    no_aa,
9368                    opm_zero_transparent: false,
9369                    knockout_painter_pass: KnockoutPainterPass::None,
9370                    parent_group_isolated: false,
9371                    alpha_extraction_pass: false,
9372                    layer_set,
9373                };
9374                render_element(&mut band_pixmap, &mut band_state, &elements[i], &ctx);
9375            }
9376        }
9377
9378        // Composite content onto white background (premultiplied alpha)
9379        composite_onto_white(band_pixmap.data_mut());
9380
9381        // Extract only the actual band rows (skip overlap)
9382        let start_byte = band_offset as usize * row_bytes;
9383        let total_bytes = actual_h as usize * row_bytes;
9384        band_pixmap.data()[start_byte..start_byte + total_bytes].to_vec()
9385    };
9386
9387    // Render bands in parallel (when available), write to sink in order.
9388    #[cfg(feature = "parallel")]
9389    {
9390        // Process in chunks of `chunk_size` bands to limit peak memory
9391        // (each rendered band is ~band_h * page_w * 4 bytes).
9392        // Cap at 8 threads — sequential sink writing bottleneck means
9393        // additional cores yield no speedup (benchmarked: 8→7.8s plateau).
9394        let chunk_size = rayon::current_num_threads().max(1);
9395
9396        for chunk_start in (0..num_bands).step_by(chunk_size) {
9397            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
9398
9399            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
9400                .into_par_iter()
9401                .map(&render_band)
9402                .collect();
9403
9404            for (i, band_data) in rendered.iter().enumerate() {
9405                let band_idx = chunk_start + i as u32;
9406                let y_start = band_idx * band_h;
9407                let actual_h = (page_h - y_start).min(band_h);
9408                sink.write_rows(band_data, actual_h)?;
9409            }
9410        }
9411    }
9412    #[cfg(not(feature = "parallel"))]
9413    {
9414        // Sequential single-threaded rendering
9415        for band_idx in 0..num_bands {
9416            let band_data = render_band(band_idx);
9417            let y_start = band_idx * band_h;
9418            let actual_h = (page_h - y_start).min(band_h);
9419            sink.write_rows(&band_data, actual_h)?;
9420        }
9421    }
9422
9423    sink.end_page()
9424}
9425
9426/// 2D bounding box in device pixels.
9427#[derive(Clone, Copy)]
9428struct BBox2D {
9429    x_min: f64,
9430    y_min: f64,
9431    x_max: f64,
9432    y_max: f64,
9433}
9434
9435/// Compute full 2D bounding boxes for display list elements (for viewport culling).
9436fn precompute_full_bboxes(list: &DisplayList, dpi: f64) -> Vec<Option<BBox2D>> {
9437    list.elements()
9438        .iter()
9439        .map(|elem| match elem {
9440            DisplayElement::Fill { path, params } => fill_device_full_bbox(path, &params.ctm),
9441            DisplayElement::Stroke { path, params } => {
9442                path_full_bbox(path).map(|mut bbox| {
9443                    // Use effective line width: actual width or hairline minimum
9444                    let effective_lw = params.line_width.max(hairline_min_width(&params.ctm, dpi));
9445                    let expand = effective_lw * params.miter_limit * 0.5;
9446                    let m = &params.ctm;
9447                    let is_identity = m.a == 1.0
9448                        && m.b == 0.0
9449                        && m.c == 0.0
9450                        && m.d == 1.0
9451                        && m.tx == 0.0
9452                        && m.ty == 0.0;
9453                    if is_identity {
9454                        bbox.x_min -= expand;
9455                        bbox.x_max += expand;
9456                        bbox.y_min -= expand;
9457                        bbox.y_max += expand;
9458                    } else {
9459                        // Path is in user space — expand for stroke, then
9460                        // transform bbox corners through CTM to device space.
9461                        let col_x_len = (m.a * m.a + m.b * m.b).sqrt().max(1.0);
9462                        let col_y_len = (m.c * m.c + m.d * m.d).sqrt().max(1.0);
9463                        let expand_x = effective_lw * col_x_len * params.miter_limit * 0.5;
9464                        let expand_y = effective_lw * col_y_len * params.miter_limit * 0.5;
9465                        bbox.x_min -= expand_x;
9466                        bbox.x_max += expand_x;
9467                        bbox.y_min -= expand_y;
9468                        bbox.y_max += expand_y;
9469                        // Transform all 4 corners to device space
9470                        let corners = [
9471                            (
9472                                m.a * bbox.x_min + m.c * bbox.y_min + m.tx,
9473                                m.b * bbox.x_min + m.d * bbox.y_min + m.ty,
9474                            ),
9475                            (
9476                                m.a * bbox.x_max + m.c * bbox.y_min + m.tx,
9477                                m.b * bbox.x_max + m.d * bbox.y_min + m.ty,
9478                            ),
9479                            (
9480                                m.a * bbox.x_min + m.c * bbox.y_max + m.tx,
9481                                m.b * bbox.x_min + m.d * bbox.y_max + m.ty,
9482                            ),
9483                            (
9484                                m.a * bbox.x_max + m.c * bbox.y_max + m.tx,
9485                                m.b * bbox.x_max + m.d * bbox.y_max + m.ty,
9486                            ),
9487                        ];
9488                        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9489                        bbox.x_max = corners
9490                            .iter()
9491                            .map(|c| c.0)
9492                            .fold(f64::NEG_INFINITY, f64::max);
9493                        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9494                        bbox.y_max = corners
9495                            .iter()
9496                            .map(|c| c.1)
9497                            .fold(f64::NEG_INFINITY, f64::max);
9498                    }
9499                    bbox
9500                })
9501            }
9502            DisplayElement::Image { params, .. } => image_full_bbox(params),
9503            DisplayElement::AxialShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9504            DisplayElement::RadialShading { params } => {
9505                shading_full_bbox(&params.bbox, &params.ctm)
9506            }
9507            DisplayElement::MeshShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9508            DisplayElement::PatchShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9509            DisplayElement::PatternFill { params } => pattern_fill_full_bbox(params),
9510            DisplayElement::Group { params, .. } => Some(BBox2D {
9511                x_min: params.bbox[0],
9512                y_min: params.bbox[1],
9513                x_max: params.bbox[2],
9514                y_max: params.bbox[3],
9515            }),
9516            DisplayElement::SoftMasked { params, .. } => Some(BBox2D {
9517                x_min: params.bbox[0],
9518                y_min: params.bbox[1],
9519                x_max: params.bbox[2],
9520                y_max: params.bbox[3],
9521            }),
9522            DisplayElement::OcgGroup {
9523                elements,
9524                visibility,
9525            } => {
9526                // Hidden groups without clip ops contribute nothing. Hidden
9527                // + has clip ops is force-processed at the render-loop layer
9528                // (see the viewport render_region_prepared loop) so we still
9529                // return the paint bounds here for correct epoch bbox.
9530                if !visibility.default_visible() && !contains_clip_op(elements) {
9531                    return None;
9532                }
9533                let child_bboxes = precompute_full_bboxes(elements, dpi);
9534                let mut x_min = f64::INFINITY;
9535                let mut y_min = f64::INFINITY;
9536                let mut x_max = f64::NEG_INFINITY;
9537                let mut y_max = f64::NEG_INFINITY;
9538                for cb in child_bboxes.into_iter().flatten() {
9539                    x_min = x_min.min(cb.x_min);
9540                    y_min = y_min.min(cb.y_min);
9541                    x_max = x_max.max(cb.x_max);
9542                    y_max = y_max.max(cb.y_max);
9543                }
9544                if x_min <= x_max && y_min <= y_max {
9545                    Some(BBox2D {
9546                        x_min,
9547                        y_min,
9548                        x_max,
9549                        y_max,
9550                    })
9551                } else {
9552                    None
9553                }
9554            }
9555            _ => None, // Clip, InitClip, ErasePage: always process
9556        })
9557        .collect()
9558}
9559
9560/// Compute the device-space bounding box of a Clip element's path.
9561///
9562/// Clip paths emitted by the PDF reader use `ctm = identity`, so the path
9563/// segments are already in device space. For Clips that come from other
9564/// sources (PostScript, the pattern transform path), the `ctm` field may
9565/// be non-identity and the path is in user space — transform the path's
9566/// bbox corners through the CTM in that case. Stroke-clips are expanded
9567/// by half the line width.
9568fn clip_path_bbox(path: &PsPath, params: &ClipParams) -> Option<BBox2D> {
9569    let mut bbox = path_full_bbox(path)?;
9570    let ctm = &params.ctm;
9571    let is_identity = ctm.a == 1.0
9572        && ctm.b == 0.0
9573        && ctm.c == 0.0
9574        && ctm.d == 1.0
9575        && ctm.tx == 0.0
9576        && ctm.ty == 0.0;
9577    if !is_identity {
9578        let corners = [
9579            ctm.transform_point(bbox.x_min, bbox.y_min),
9580            ctm.transform_point(bbox.x_max, bbox.y_min),
9581            ctm.transform_point(bbox.x_min, bbox.y_max),
9582            ctm.transform_point(bbox.x_max, bbox.y_max),
9583        ];
9584        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9585        bbox.x_max = corners
9586            .iter()
9587            .map(|c| c.0)
9588            .fold(f64::NEG_INFINITY, f64::max);
9589        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9590        bbox.y_max = corners
9591            .iter()
9592            .map(|c| c.1)
9593            .fold(f64::NEG_INFINITY, f64::max);
9594    }
9595    if let Some(sp) = &params.stroke_params {
9596        let scale = (ctm.a * ctm.a + ctm.b * ctm.b)
9597            .sqrt()
9598            .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt())
9599            .max(1.0);
9600        let expand = sp.line_width * 0.5 * scale;
9601        bbox.x_min -= expand;
9602        bbox.x_max += expand;
9603        bbox.y_min -= expand;
9604        bbox.y_max += expand;
9605    }
9606    Some(bbox)
9607}
9608
9609/// Intersect two bboxes; returns `None` if they don't overlap.
9610fn intersect_bbox(a: &BBox2D, b: &BBox2D) -> Option<BBox2D> {
9611    let x_min = a.x_min.max(b.x_min);
9612    let y_min = a.y_min.max(b.y_min);
9613    let x_max = a.x_max.min(b.x_max);
9614    let y_max = a.y_max.min(b.y_max);
9615    if x_min < x_max && y_min < y_max {
9616        Some(BBox2D {
9617            x_min,
9618            y_min,
9619            x_max,
9620            y_max,
9621        })
9622    } else {
9623        None
9624    }
9625}
9626
9627/// Compute the union of all paint elements' device-space bounds in
9628/// `list`, with awareness of the active clip stack.
9629///
9630/// Used by the soft-mask rasterization path: a SoftMasked element's
9631/// `params.bbox` is derived from the form's `/BBox` transformed by the
9632/// gs-time CTM, but the form's internal `cm` operators may translate
9633/// individual paint elements outside that bbox. The mask raster needs to
9634/// be sized against the actual paint bounds, not the form bbox.
9635///
9636/// **Why clip-awareness matters**: a mask form may contain a shading
9637/// without an explicit `/BBox`, in which case `precompute_full_bboxes`
9638/// returns a sentinel "infinite" bbox (`shading_full_bbox` falls back to
9639/// `0..1e9`) so band rendering doesn't cull it. If `compute_paint_bounds`
9640/// just unioned that, the result would exceed the mask raster size cap
9641/// and `rasterize_mask` would return `None`, making the entire SoftMasked
9642/// element invisible. Tracking the active clip stack lets us bound those
9643/// shadings to their effective paint area.
9644///
9645/// Returns `None` when the list contains no paintable elements or when
9646/// no element survives clip culling.
9647fn compute_paint_bounds(list: &DisplayList, _dpi: f64) -> Option<BBox2D> {
9648    // Active clip stack: each entry is the intersection so far. The
9649    // current clip is `clip_stack.last()`; an empty stack means
9650    // "unbounded" (no clip established yet, or just after InitClip).
9651    let mut clip_stack: Vec<BBox2D> = Vec::new();
9652    let mut union: Option<BBox2D> = None;
9653
9654    let push_paint = |union: &mut Option<BBox2D>, clip_stack: &[BBox2D], bbox: BBox2D| {
9655        // Intersect against the active clip if any. If the clip is
9656        // tighter than the bbox, the visible region is the intersection;
9657        // if the bbox is fully clipped away, skip it.
9658        let visible = match clip_stack.last() {
9659            Some(clip) => match intersect_bbox(clip, &bbox) {
9660                Some(b) => b,
9661                None => return,
9662            },
9663            None => bbox,
9664        };
9665        *union = Some(match union.take() {
9666            None => visible,
9667            Some(u) => BBox2D {
9668                x_min: u.x_min.min(visible.x_min),
9669                y_min: u.y_min.min(visible.y_min),
9670                x_max: u.x_max.max(visible.x_max),
9671                y_max: u.y_max.max(visible.y_max),
9672            },
9673        });
9674    };
9675
9676    for elem in list.elements() {
9677        match elem {
9678            DisplayElement::Clip { path, params } => {
9679                if let Some(cb) = clip_path_bbox(path, params) {
9680                    let new_top = match clip_stack.last() {
9681                        Some(prev) => match intersect_bbox(prev, &cb) {
9682                            Some(b) => b,
9683                            // Clip cleared the visible region; push an
9684                            // empty bbox so subsequent paints are
9685                            // clipped away.
9686                            None => BBox2D {
9687                                x_min: 0.0,
9688                                y_min: 0.0,
9689                                x_max: 0.0,
9690                                y_max: 0.0,
9691                            },
9692                        },
9693                        None => cb,
9694                    };
9695                    clip_stack.push(new_top);
9696                }
9697            }
9698            DisplayElement::InitClip | DisplayElement::ErasePage => {
9699                clip_stack.clear();
9700            }
9701            DisplayElement::Fill { path, .. } => {
9702                if let Some(b) = path_full_bbox(path) {
9703                    push_paint(&mut union, &clip_stack, b);
9704                }
9705            }
9706            DisplayElement::Stroke { path, params } => {
9707                if let Some(mut b) = path_full_bbox(path) {
9708                    let expand = params.line_width * params.miter_limit * 0.5;
9709                    b.x_min -= expand;
9710                    b.x_max += expand;
9711                    b.y_min -= expand;
9712                    b.y_max += expand;
9713                    push_paint(&mut union, &clip_stack, b);
9714                }
9715            }
9716            DisplayElement::Image { params, .. } => {
9717                if let Some(b) = image_full_bbox(params) {
9718                    push_paint(&mut union, &clip_stack, b);
9719                }
9720            }
9721            DisplayElement::AxialShading { params } => {
9722                let b = match &params.bbox {
9723                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9724                    None => clip_stack.last().copied(),
9725                };
9726                if let Some(b) = b {
9727                    push_paint(&mut union, &clip_stack, b);
9728                }
9729            }
9730            DisplayElement::RadialShading { params } => {
9731                let b = match &params.bbox {
9732                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9733                    None => clip_stack.last().copied(),
9734                };
9735                if let Some(b) = b {
9736                    push_paint(&mut union, &clip_stack, b);
9737                }
9738            }
9739            DisplayElement::MeshShading { params } => {
9740                let b = match &params.bbox {
9741                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9742                    None => clip_stack.last().copied(),
9743                };
9744                if let Some(b) = b {
9745                    push_paint(&mut union, &clip_stack, b);
9746                }
9747            }
9748            DisplayElement::PatchShading { params } => {
9749                let b = match &params.bbox {
9750                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9751                    None => clip_stack.last().copied(),
9752                };
9753                if let Some(b) = b {
9754                    push_paint(&mut union, &clip_stack, b);
9755                }
9756            }
9757            DisplayElement::PatternFill { params } => {
9758                if let Some(b) = pattern_fill_full_bbox(params) {
9759                    push_paint(&mut union, &clip_stack, b);
9760                }
9761            }
9762            DisplayElement::Group { params, .. } => {
9763                push_paint(
9764                    &mut union,
9765                    &clip_stack,
9766                    BBox2D {
9767                        x_min: params.bbox[0],
9768                        y_min: params.bbox[1],
9769                        x_max: params.bbox[2],
9770                        y_max: params.bbox[3],
9771                    },
9772                );
9773            }
9774            DisplayElement::SoftMasked { params, .. } => {
9775                push_paint(
9776                    &mut union,
9777                    &clip_stack,
9778                    BBox2D {
9779                        x_min: params.bbox[0],
9780                        y_min: params.bbox[1],
9781                        x_max: params.bbox[2],
9782                        y_max: params.bbox[3],
9783                    },
9784                );
9785            }
9786            DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
9787            DisplayElement::OcgGroup { .. } => {
9788                // OCG groups have no inherent bbox; their children's bounds
9789                // are unknown without recursion. Conservative: skip here —
9790                // if the mask form contains OCG layers, the parent bbox cap
9791                // provides a sufficient upper bound.
9792            }
9793            _ => {}
9794        }
9795    }
9796    union
9797}
9798
9799/// Compute full 2D bounds from path segments.
9800/// Compute device-space 2D bounds for a Fill element, accounting for CTM.
9801/// Paths may be stored in device space (identity CTM) or user space
9802/// (non-identity CTM, e.g. synthesized annotation appearances).
9803fn fill_device_full_bbox(path: &PsPath, ctm: &Matrix) -> Option<BBox2D> {
9804    let bbox = path_full_bbox(path)?;
9805    let is_identity = ctm.a == 1.0
9806        && ctm.b == 0.0
9807        && ctm.c == 0.0
9808        && ctm.d == 1.0
9809        && ctm.tx == 0.0
9810        && ctm.ty == 0.0;
9811    if is_identity {
9812        return Some(bbox);
9813    }
9814    let corners = [
9815        (bbox.x_min, bbox.y_min),
9816        (bbox.x_max, bbox.y_min),
9817        (bbox.x_min, bbox.y_max),
9818        (bbox.x_max, bbox.y_max),
9819    ];
9820    let mut x_min = f64::INFINITY;
9821    let mut x_max = f64::NEG_INFINITY;
9822    let mut y_min = f64::INFINITY;
9823    let mut y_max = f64::NEG_INFINITY;
9824    for (x, y) in &corners {
9825        let dx = ctm.a * x + ctm.c * y + ctm.tx;
9826        let dy = ctm.b * x + ctm.d * y + ctm.ty;
9827        x_min = x_min.min(dx);
9828        x_max = x_max.max(dx);
9829        y_min = y_min.min(dy);
9830        y_max = y_max.max(dy);
9831    }
9832    Some(BBox2D {
9833        x_min,
9834        y_min,
9835        x_max,
9836        y_max,
9837    })
9838}
9839
9840fn path_full_bbox(path: &PsPath) -> Option<BBox2D> {
9841    let mut x_min = f64::INFINITY;
9842    let mut x_max = f64::NEG_INFINITY;
9843    let mut y_min = f64::INFINITY;
9844    let mut y_max = f64::NEG_INFINITY;
9845    for seg in &path.segments {
9846        match seg {
9847            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => {
9848                x_min = x_min.min(*x);
9849                x_max = x_max.max(*x);
9850                y_min = y_min.min(*y);
9851                y_max = y_max.max(*y);
9852            }
9853            PathSegment::CurveTo {
9854                x1,
9855                y1,
9856                x2,
9857                y2,
9858                x3,
9859                y3,
9860            } => {
9861                x_min = x_min.min(*x1).min(*x2).min(*x3);
9862                x_max = x_max.max(*x1).max(*x2).max(*x3);
9863                y_min = y_min.min(*y1).min(*y2).min(*y3);
9864                y_max = y_max.max(*y1).max(*y2).max(*y3);
9865            }
9866            PathSegment::ClosePath => {}
9867        }
9868    }
9869    if x_min <= x_max {
9870        Some(BBox2D {
9871            x_min,
9872            y_min,
9873            x_max,
9874            y_max,
9875        })
9876    } else {
9877        None
9878    }
9879}
9880
9881/// Compute full 2D bounds for a PatternFill element.
9882/// For stroke patterns, the path is in user space and must be transformed
9883/// through the CTM to get device-space bounds, then expanded by half
9884/// the stroke width.
9885fn pattern_fill_full_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<BBox2D> {
9886    if let Some(ref sp) = params.stroke_params {
9887        let bbox = path_full_bbox(&params.path)?;
9888        let ctm = &sp.ctm;
9889        let corners = [
9890            ctm.transform_point(bbox.x_min, bbox.y_min),
9891            ctm.transform_point(bbox.x_max, bbox.y_min),
9892            ctm.transform_point(bbox.x_min, bbox.y_max),
9893            ctm.transform_point(bbox.x_max, bbox.y_max),
9894        ];
9895        let mut dev_bbox = BBox2D {
9896            x_min: f64::INFINITY,
9897            y_min: f64::INFINITY,
9898            x_max: f64::NEG_INFINITY,
9899            y_max: f64::NEG_INFINITY,
9900        };
9901        for (x, y) in &corners {
9902            dev_bbox.x_min = dev_bbox.x_min.min(*x);
9903            dev_bbox.y_min = dev_bbox.y_min.min(*y);
9904            dev_bbox.x_max = dev_bbox.x_max.max(*x);
9905            dev_bbox.y_max = dev_bbox.y_max.max(*y);
9906        }
9907        let half_w = sp.line_width
9908            * 0.5
9909            * (ctm.a * ctm.a + ctm.b * ctm.b)
9910                .sqrt()
9911                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
9912        dev_bbox.x_min -= half_w;
9913        dev_bbox.y_min -= half_w;
9914        dev_bbox.x_max += half_w;
9915        dev_bbox.y_max += half_w;
9916        Some(dev_bbox)
9917    } else {
9918        path_full_bbox(&params.path)
9919    }
9920}
9921
9922/// Compute Y-axis bounds for a PatternFill element (banded rendering).
9923fn pattern_fill_y_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<YBBox> {
9924    let bbox = pattern_fill_full_bbox(params)?;
9925    Some(YBBox {
9926        y_min: bbox.y_min,
9927        y_max: bbox.y_max,
9928    })
9929}
9930
9931/// Compute full 2D bounds for an image from its transform.
9932fn image_full_bbox(params: &ImageParams) -> Option<BBox2D> {
9933    let m = &params.ctm;
9934    let im = &params.image_matrix;
9935    let im_inv = im.invert()?;
9936    let combined = m.concat(&im_inv);
9937    // Image occupies [0, width] × [0, height] in image space
9938    let w = params.width as f64;
9939    let h = params.height as f64;
9940    let corners = [
9941        combined.transform_point(0.0, 0.0),
9942        combined.transform_point(w, 0.0),
9943        combined.transform_point(0.0, h),
9944        combined.transform_point(w, h),
9945    ];
9946    let mut x_min = f64::INFINITY;
9947    let mut x_max = f64::NEG_INFINITY;
9948    let mut y_min = f64::INFINITY;
9949    let mut y_max = f64::NEG_INFINITY;
9950    for (x, y) in &corners {
9951        x_min = x_min.min(*x);
9952        x_max = x_max.max(*x);
9953        y_min = y_min.min(*y);
9954        y_max = y_max.max(*y);
9955    }
9956    Some(BBox2D {
9957        x_min,
9958        y_min,
9959        x_max,
9960        y_max,
9961    })
9962}
9963
9964/// Compute full 2D bounds for a shading element from its BBox.
9965fn shading_full_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<BBox2D> {
9966    if let Some(bbox) = bbox {
9967        let corners = [
9968            ctm.transform_point(bbox[0], bbox[1]),
9969            ctm.transform_point(bbox[2], bbox[1]),
9970            ctm.transform_point(bbox[0], bbox[3]),
9971            ctm.transform_point(bbox[2], bbox[3]),
9972        ];
9973        let mut x_min = f64::INFINITY;
9974        let mut x_max = f64::NEG_INFINITY;
9975        let mut y_min = f64::INFINITY;
9976        let mut y_max = f64::NEG_INFINITY;
9977        for (x, y) in &corners {
9978            x_min = x_min.min(*x);
9979            x_max = x_max.max(*x);
9980            y_min = y_min.min(*y);
9981            y_max = y_max.max(*y);
9982        }
9983        Some(BBox2D {
9984            x_min,
9985            y_min,
9986            x_max,
9987            y_max,
9988        })
9989    } else {
9990        Some(BBox2D {
9991            x_min: 0.0,
9992            y_min: 0.0,
9993            x_max: 1e9,
9994            y_max: 1e9,
9995        })
9996    }
9997}
9998
9999/// Build 2D clip epochs for viewport culling.
10000fn build_viewport_epochs(list: &DisplayList, bboxes: &[Option<BBox2D>]) -> Vec<ViewportEpoch> {
10001    let elements = list.elements();
10002    let mut epochs = Vec::new();
10003    let mut epoch_start = 0;
10004    let mut x_min = f64::INFINITY;
10005    let mut x_max = f64::NEG_INFINITY;
10006    let mut y_min = f64::INFINITY;
10007    let mut y_max = f64::NEG_INFINITY;
10008    let mut has_erase = false;
10009
10010    for (i, element) in elements.iter().enumerate() {
10011        if matches!(element, DisplayElement::InitClip) && i > epoch_start {
10012            epochs.push(ViewportEpoch {
10013                start_idx: epoch_start,
10014                end_idx: i,
10015                paint_bbox: if x_min <= x_max {
10016                    Some(BBox2D {
10017                        x_min,
10018                        y_min,
10019                        x_max,
10020                        y_max,
10021                    })
10022                } else {
10023                    None
10024                },
10025                has_erase_page: has_erase,
10026            });
10027            epoch_start = i;
10028            x_min = f64::INFINITY;
10029            x_max = f64::NEG_INFINITY;
10030            y_min = f64::INFINITY;
10031            y_max = f64::NEG_INFINITY;
10032            has_erase = false;
10033        }
10034        if matches!(element, DisplayElement::ErasePage) {
10035            has_erase = true;
10036        }
10037        if let Some(ref bbox) = bboxes[i] {
10038            x_min = x_min.min(bbox.x_min);
10039            x_max = x_max.max(bbox.x_max);
10040            y_min = y_min.min(bbox.y_min);
10041            y_max = y_max.max(bbox.y_max);
10042        }
10043    }
10044    if epoch_start < elements.len() {
10045        epochs.push(ViewportEpoch {
10046            start_idx: epoch_start,
10047            end_idx: elements.len(),
10048            paint_bbox: if x_min <= x_max {
10049                Some(BBox2D {
10050                    x_min,
10051                    y_min,
10052                    x_max,
10053                    y_max,
10054                })
10055            } else {
10056                None
10057            },
10058            has_erase_page: has_erase,
10059        });
10060    }
10061    epochs
10062}
10063
10064/// Clip epoch with full 2D bounding box for viewport culling.
10065struct ViewportEpoch {
10066    start_idx: usize,
10067    end_idx: usize,
10068    paint_bbox: Option<BBox2D>,
10069    has_erase_page: bool,
10070}
10071
10072/// Pre-computed metadata for fast viewport rendering.
10073///
10074/// Compute once per display list via [`prepare_display_list()`],
10075/// reuse across all [`render_region_prepared()`] calls. This avoids
10076/// three expensive traversals (bboxes, epochs, clip_seen) on every pan.
10077pub struct PreparedDisplayList {
10078    bboxes: Vec<Option<BBox2D>>,
10079    epochs: Vec<ViewportEpoch>,
10080    clip_seen: HashSet<u64>,
10081}
10082
10083/// Precompute display list metadata for fast viewport rendering.
10084///
10085/// Uses a conservative DPI (72.0) for hairline expansion in bounding boxes,
10086/// producing safe overestimates that work at any zoom level without recomputation.
10087pub fn prepare_display_list(list: &DisplayList) -> PreparedDisplayList {
10088    let bboxes = precompute_full_bboxes(list, 72.0);
10089    let epochs = build_viewport_epochs(list, &bboxes);
10090    let clip_seen = precompute_clip_seen(list);
10091    PreparedDisplayList {
10092        bboxes,
10093        epochs,
10094        clip_seen,
10095    }
10096}
10097
10098/// Pre-converted and prescaled image for banded rendering.
10099///
10100/// Built once per page before the band loop so that expensive RGBA conversion
10101/// and box-filter prescaling run once instead of once-per-band.
10102struct PreprocessedImage {
10103    /// RGBA pixel data (prescaled if applicable).
10104    data: Vec<u8>,
10105    /// Dimensions after prescaling.
10106    width: u32,
10107    height: u32,
10108    /// Scale/rotation part of the adjusted transform.
10109    /// Per-band rendering reconstructs the full transform by combining these
10110    /// with the band-specific translation (tx, ty).
10111    adj_sx: f32,
10112    adj_ky: f32,
10113    adj_kx: f32,
10114    adj_sy: f32,
10115    /// Filter quality for draw_pixmap.
10116    quality: stet_tiny_skia::FilterQuality,
10117}
10118
10119/// Pre-converted RGBA image data cache, indexed by display list element index.
10120///
10121/// Built once per page after display list capture. Reused across all viewport
10122/// renders so that ICC color conversion (especially CMYK→sRGB) is not repeated
10123/// on every pan/zoom.
10124pub struct ImageCache {
10125    /// RGBA data per element index. `None` for non-image elements.
10126    entries: Vec<Option<Vec<u8>>>,
10127}
10128
10129impl ImageCache {
10130    /// Build cache by pre-converting all images in the display list.
10131    pub fn build(list: &DisplayList, icc: Option<&IccCache>) -> Self {
10132        let entries = list
10133            .elements()
10134            .iter()
10135            .map(|elem| {
10136                if let DisplayElement::Image {
10137                    sample_data,
10138                    params,
10139                } = elem
10140                {
10141                    if params.width == 0 || params.height == 0 {
10142                        return None;
10143                    }
10144                    let mut rgba = samples_to_rgba(sample_data, params, icc, false);
10145                    if params.mask_color.is_some() {
10146                        apply_mask_color_rgba(&mut rgba, sample_data, params);
10147                    }
10148                    Some(rgba)
10149                } else {
10150                    None
10151                }
10152            })
10153            .collect();
10154        Self { entries }
10155    }
10156
10157    /// Get pre-converted RGBA for the element at the given index.
10158    pub fn get(&self, index: usize) -> Option<&[u8]> {
10159        self.entries.get(index).and_then(|e| e.as_deref())
10160    }
10161}
10162
10163/// Build preprocessed image cache for banded rendering.
10164///
10165/// For each Image element, converts to RGBA and prescales once.
10166/// Banded rendering then only needs `draw_pixmap` per band.
10167fn preprocess_images_for_bands(
10168    list: &DisplayList,
10169    icc: Option<&IccCache>,
10170) -> Vec<Option<PreprocessedImage>> {
10171    list.elements()
10172        .iter()
10173        .map(|elem| {
10174            let DisplayElement::Image {
10175                sample_data,
10176                params,
10177            } = elem
10178            else {
10179                return None;
10180            };
10181            let iw = params.width;
10182            let ih = params.height;
10183            if iw == 0 || ih == 0 {
10184                return None;
10185            }
10186            // Skip overprint images — they use a separate rendering path
10187            if params.overprint {
10188                return None;
10189            }
10190
10191            // Convert to RGBA
10192            let mut rgba = samples_to_rgba(sample_data, params, icc, false);
10193            if params.mask_color.is_some() {
10194                apply_mask_color_rgba(&mut rgba, sample_data, params);
10195            }
10196
10197            // Compute the device-space transform (vp_y=0, scale=1.0)
10198            let image_inv = params.image_matrix.invert()?;
10199            let combined = params.ctm.concat(&image_inv);
10200            let base_transform = enforce_min_image_size(to_transform(&combined), iw, ih);
10201
10202            // Prescale
10203            let (data, width, height, adj_t) =
10204                match prescale_image(&rgba, iw, ih, base_transform, params.interpolate) {
10205                    Some((d, w, h, t)) => {
10206                        drop(rgba); // free the full-size RGBA
10207                        (d, w, h, t)
10208                    }
10209                    None => (rgba, iw, ih, base_transform),
10210                };
10211
10212            let quality = image_filter_quality(adj_t, params.interpolate);
10213
10214            Some(PreprocessedImage {
10215                data,
10216                width,
10217                height,
10218                adj_sx: adj_t.sx,
10219                adj_ky: adj_t.ky,
10220                adj_kx: adj_t.kx,
10221                adj_sy: adj_t.sy,
10222                quality,
10223            })
10224        })
10225        .collect()
10226}
10227
10228/// Render a rectangular viewport region using precomputed metadata.
10229///
10230/// Like [`render_region()`] but skips the three precomputation passes,
10231/// using the [`PreparedDisplayList`] instead. Significantly faster for
10232/// repeated renders of the same display list (e.g., panning at a fixed zoom).
10233#[allow(clippy::too_many_arguments)]
10234pub fn render_region_prepared(
10235    list: &DisplayList,
10236    prepared: &PreparedDisplayList,
10237    vp_x: f64,
10238    vp_y: f64,
10239    vp_w: f64,
10240    vp_h: f64,
10241    pixel_w: u32,
10242    pixel_h: u32,
10243    dpi: f64,
10244    icc: Option<&IccCache>,
10245    image_cache: Option<&ImageCache>,
10246    no_aa: bool,
10247) -> Vec<u8> {
10248    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
10249        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10250    }
10251
10252    let layer_set = LayerSet::new();
10253    let scale_x = pixel_w as f64 / vp_w;
10254    let scale_y = pixel_h as f64 / vp_h;
10255    let effective_dpi = dpi * scale_x;
10256
10257    // Allocate a pixmap with the same OVERLAP padding as the banded page
10258    // renderer. This is essential for matching the banded baseline: the page
10259    // pipeline always allocates `band_h + 2*BAND_OVERLAP` rows, even for a
10260    // single-band render. tiny-skia's `Mask::fill_path` chooses between
10261    // edge-clipped and unclipped rasterization based on whether the path
10262    // bounds fit within the mask, and the two paths produce subtly different
10263    // winding counts at some pixels. Without the OVERLAP padding here, the
10264    // viewport pipeline rasterizes clip paths into a tighter mask than the
10265    // banded pipeline does, producing 39 (and other counts) of edge-pixel
10266    // divergences on samples like 1915_1.pdf.
10267    const OVERLAP: u32 = 6;
10268    let render_h = pixel_h + 2 * OVERLAP;
10269    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
10270    // Start transparent — white background composited after content rendering
10271    pixmap.fill(Color::TRANSPARENT);
10272
10273    let cmyk_buf = if has_overprint_elements(list)
10274        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10275        || has_cmyk_group(list)
10276    {
10277        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10278    } else {
10279        None
10280    };
10281
10282    let mut state = BandState {
10283        clip_region: None,
10284        spare_mask: None,
10285        clip_mask_cache: HashMap::new(),
10286        clip_mask_seen: prepared.clip_seen.clone(),
10287        mask_pool: Vec::new(),
10288        cmyk_buffer: cmyk_buf,
10289        op_bg_snapshot: None,
10290        op_touched: None,
10291        spot_mask: None,
10292    };
10293
10294    let elements = list.elements();
10295    let vp_x_f = vp_x as f32;
10296    let vp_y_f = vp_y as f32;
10297    let sx = scale_x as f32;
10298    let sy = scale_y as f32;
10299    let vp_x_max = vp_x + vp_w;
10300    let vp_y_max = vp_y + vp_h;
10301
10302    for epoch in &prepared.epochs {
10303        if !epoch.has_erase_page {
10304            match epoch.paint_bbox {
10305                Some(ref pb)
10306                    if pb.x_max <= vp_x
10307                        || pb.x_min >= vp_x_max
10308                        || pb.y_max <= vp_y
10309                        || pb.y_min >= vp_y_max =>
10310                {
10311                    continue;
10312                }
10313                None => continue,
10314                _ => {}
10315            }
10316        }
10317
10318        #[allow(clippy::needless_range_loop)]
10319        for i in epoch.start_idx..epoch.end_idx {
10320            // OcgGroups with Clip/InitClip must always be processed — see
10321            // the banded renderer for the rationale.
10322            let force_process = matches!(
10323                &elements[i],
10324                DisplayElement::OcgGroup { elements: inner, .. }
10325                    if contains_clip_op(inner)
10326            );
10327            if !force_process
10328                && let Some(ref bbox) = prepared.bboxes[i]
10329                && (bbox.x_max <= vp_x
10330                    || bbox.x_min >= vp_x_max
10331                    || bbox.y_max <= vp_y
10332                    || bbox.y_min >= vp_y_max)
10333            {
10334                continue;
10335            }
10336            let ctx = RenderContext {
10337                vp_x: vp_x_f,
10338                vp_y: vp_y_f,
10339                scale_x: sx,
10340                scale_y: sy,
10341                out_w: pixel_w,
10342                out_h: render_h,
10343                effective_dpi,
10344                icc,
10345                image_cache,
10346                preprocessed: None,
10347                elem_idx: i,
10348                no_aa,
10349                opm_zero_transparent: false,
10350                knockout_painter_pass: KnockoutPainterPass::None,
10351                parent_group_isolated: false,
10352                alpha_extraction_pass: false,
10353                layer_set: &layer_set,
10354            };
10355            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10356        }
10357    }
10358
10359    // Composite onto white background
10360    composite_onto_white(pixmap.data_mut());
10361    // Extract only the requested pixel_h rows (skip the OVERLAP padding at the bottom).
10362    let row_bytes = pixel_w as usize * 4;
10363    let end = pixel_h as usize * row_bytes;
10364    pixmap.data()[..end].to_vec()
10365}
10366
10367/// Compute the number of bands and band height for viewport banding.
10368///
10369/// Returns `(num_bands, band_height)` using the same L2-cache-budget logic
10370/// as the full-page banded renderer.
10371pub fn viewport_band_count(pixel_w: u32, pixel_h: u32) -> (u32, u32) {
10372    let band_h = select_band_height(pixel_w, pixel_h);
10373    let num_bands = if band_h >= pixel_h {
10374        1
10375    } else {
10376        pixel_h.div_ceil(band_h)
10377    };
10378    (num_bands, band_h)
10379}
10380
10381/// Render a single horizontal band of a viewport region.
10382///
10383/// This is the per-band counterpart to [`render_region_prepared()`]. The caller
10384/// loops over `band_idx` in `0..num_bands`, collecting RGBA strips that tile
10385/// vertically to form the full viewport image.
10386///
10387/// Returns RGBA pixel data for `actual_h` rows (may be less than `band_h` for
10388/// the last band).
10389#[allow(clippy::too_many_arguments)]
10390pub fn render_region_single_band(
10391    list: &DisplayList,
10392    prepared: &PreparedDisplayList,
10393    vp_x: f64,
10394    vp_y: f64,
10395    vp_w: f64,
10396    vp_h: f64,
10397    pixel_w: u32,
10398    pixel_h: u32,
10399    band_idx: u32,
10400    band_h: u32,
10401    num_bands: u32,
10402    dpi: f64,
10403    icc: Option<&IccCache>,
10404    image_cache: Option<&ImageCache>,
10405    no_aa: bool,
10406) -> Vec<u8> {
10407    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
10408        let actual_h = if band_idx < num_bands - 1 {
10409            band_h
10410        } else {
10411            pixel_h - band_idx * band_h
10412        };
10413        return vec![0xFF; pixel_w as usize * actual_h as usize * 4];
10414    }
10415
10416    let layer_set = LayerSet::new();
10417    let scale_x = pixel_w as f64 / vp_w;
10418    let scale_y = pixel_h as f64 / vp_h;
10419    let effective_dpi = dpi * scale_x;
10420
10421    // Output Y range for this band
10422    let out_y_start = band_idx * band_h;
10423    let actual_h = if band_idx < num_bands - 1 {
10424        band_h
10425    } else {
10426        pixel_h - out_y_start
10427    };
10428
10429    // Add overlap above/below for anti-aliasing at seams.
10430    //
10431    // The pixmap is always `band_h + 2*OVERLAP` rows — matching the page
10432    // renderer (`render_banded_to_sink`) — even at the bottom band, where
10433    // content rendering stops at `pixel_h`. Without this, the bottom band's
10434    // pixmap is shorter than the page renderer's, and tiny-skia's
10435    // `Mask::fill_path` rasterizes clip paths into a tighter mask, producing
10436    // edge-pixel divergences from the banded baseline (39 pixels on
10437    // 1915_1.pdf, etc.). The extra rows below `pixel_h` are unused for output
10438    // but ensure mask-size-independent rasterization.
10439    const OVERLAP: u32 = 6;
10440    let render_y_start = out_y_start.saturating_sub(OVERLAP);
10441    let render_y_end = (out_y_start + actual_h + OVERLAP).min(pixel_h);
10442    let render_h = band_h + 2 * OVERLAP;
10443    let overlap_top = out_y_start - render_y_start;
10444
10445    // Source-space Y range for culling
10446    let src_y_min = vp_y + render_y_start as f64 / scale_y;
10447    let src_y_max = vp_y + render_y_end as f64 / scale_y;
10448
10449    // Adjusted viewport offset for this band's pixmap
10450    let band_vp_y = vp_y + render_y_start as f64 / scale_y;
10451
10452    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create band pixmap");
10453    pixmap.fill(Color::TRANSPARENT);
10454
10455    let cmyk_buf = if has_overprint_elements(list)
10456        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10457        || has_cmyk_group(list)
10458    {
10459        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10460    } else {
10461        None
10462    };
10463
10464    let mut state = BandState {
10465        clip_region: None,
10466        spare_mask: None,
10467        clip_mask_cache: HashMap::new(),
10468        clip_mask_seen: prepared.clip_seen.clone(),
10469        mask_pool: Vec::new(),
10470        cmyk_buffer: cmyk_buf,
10471        op_bg_snapshot: None,
10472        op_touched: None,
10473        spot_mask: None,
10474    };
10475
10476    let elements = list.elements();
10477    let vp_x_f = vp_x as f32;
10478    let band_vp_y_f = band_vp_y as f32;
10479    let sx = scale_x as f32;
10480    let sy = scale_y as f32;
10481    let vp_x_max = vp_x + vp_w;
10482
10483    for epoch in &prepared.epochs {
10484        if !epoch.has_erase_page {
10485            match epoch.paint_bbox {
10486                Some(ref pb)
10487                    if pb.x_max <= vp_x
10488                        || pb.x_min >= vp_x_max
10489                        || pb.y_max <= src_y_min
10490                        || pb.y_min >= src_y_max =>
10491                {
10492                    continue;
10493                }
10494                None => continue,
10495                _ => {}
10496            }
10497        }
10498
10499        #[allow(clippy::needless_range_loop)]
10500        for i in epoch.start_idx..epoch.end_idx {
10501            // OcgGroups containing Clip/InitClip must always be processed
10502            // regardless of this band's bbox — see the full-page banded
10503            // renderer for the rationale.
10504            let force_process = matches!(
10505                &elements[i],
10506                DisplayElement::OcgGroup { elements: inner, .. }
10507                    if contains_clip_op(inner)
10508            );
10509            if !force_process
10510                && let Some(ref bbox) = prepared.bboxes[i]
10511                && (bbox.x_max <= vp_x
10512                    || bbox.x_min >= vp_x_max
10513                    || bbox.y_max <= src_y_min
10514                    || bbox.y_min >= src_y_max)
10515            {
10516                continue;
10517            }
10518            let ctx = RenderContext {
10519                vp_x: vp_x_f,
10520                vp_y: band_vp_y_f,
10521                scale_x: sx,
10522                scale_y: sy,
10523                out_w: pixel_w,
10524                out_h: render_h,
10525                effective_dpi,
10526                icc,
10527                image_cache,
10528                preprocessed: None,
10529                elem_idx: i,
10530                no_aa,
10531                opm_zero_transparent: false,
10532                knockout_painter_pass: KnockoutPainterPass::None,
10533                parent_group_isolated: false,
10534                alpha_extraction_pass: false,
10535                layer_set: &layer_set,
10536            };
10537            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10538        }
10539    }
10540
10541    // Composite onto white background
10542    composite_onto_white(pixmap.data_mut());
10543
10544    // Extract only the non-overlap rows
10545    let row_bytes = pixel_w as usize * 4;
10546    let start = overlap_top as usize * row_bytes;
10547    let end = start + actual_h as usize * row_bytes;
10548    pixmap.data()[start..end].to_vec()
10549}
10550
10551/// Render a viewport region using parallel banded rendering via rayon.
10552///
10553/// This is the WASM counterpart to the parallel path in `render_banded_to_sink`.
10554/// All bands are rendered in parallel using `par_iter`, then assembled into the
10555/// final RGBA buffer in order.
10556///
10557/// Requires the `parallel` feature (rayon). Falls back to sequential rendering
10558/// if `parallel` is not enabled.
10559#[allow(clippy::too_many_arguments)]
10560pub fn render_region_prepared_parallel(
10561    list: &DisplayList,
10562    prepared: &PreparedDisplayList,
10563    vp_x: f64,
10564    vp_y: f64,
10565    vp_w: f64,
10566    vp_h: f64,
10567    pixel_w: u32,
10568    pixel_h: u32,
10569    dpi: f64,
10570    icc: Option<&IccCache>,
10571    image_cache: Option<&ImageCache>,
10572    no_aa: bool,
10573) -> Vec<u8> {
10574    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10575
10576    if num_bands <= 1 {
10577        // Single band — no parallelism needed
10578        return render_region_prepared(
10579            list,
10580            prepared,
10581            vp_x,
10582            vp_y,
10583            vp_w,
10584            vp_h,
10585            pixel_w,
10586            pixel_h,
10587            dpi,
10588            icc,
10589            image_cache,
10590            no_aa,
10591        );
10592    }
10593
10594    let render_band = |band_idx: u32| -> Vec<u8> {
10595        render_region_single_band(
10596            list,
10597            prepared,
10598            vp_x,
10599            vp_y,
10600            vp_w,
10601            vp_h,
10602            pixel_w,
10603            pixel_h,
10604            band_idx,
10605            band_h,
10606            num_bands,
10607            dpi,
10608            icc,
10609            image_cache,
10610            no_aa,
10611        )
10612    };
10613
10614    let row_bytes = pixel_w as usize * 4;
10615    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10616
10617    #[cfg(feature = "parallel")]
10618    {
10619        let chunk_size = rayon::current_num_threads().max(1);
10620
10621        for chunk_start in (0..num_bands).step_by(chunk_size) {
10622            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10623
10624            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10625                .into_par_iter()
10626                .map(&render_band)
10627                .collect();
10628
10629            for (i, band_data) in rendered.iter().enumerate() {
10630                let band_idx = chunk_start + i as u32;
10631                let y_start = (band_idx * band_h) as usize;
10632                let dest_start = y_start * row_bytes;
10633                let len = band_data.len();
10634                result[dest_start..dest_start + len].copy_from_slice(band_data);
10635            }
10636        }
10637    }
10638    #[cfg(not(feature = "parallel"))]
10639    {
10640        for band_idx in 0..num_bands {
10641            let band_data = render_band(band_idx);
10642            let y_start = (band_idx * band_h) as usize;
10643            let dest_start = y_start * row_bytes;
10644            let len = band_data.len();
10645            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10646        }
10647    }
10648
10649    result
10650}
10651
10652/// Like [`render_region_prepared_parallel()`] but with an atomic progress counter.
10653///
10654/// The counter is incremented after each chunk of bands completes. The total
10655/// number of bands is returned alongside the counter via [`viewport_band_count()`].
10656#[allow(clippy::too_many_arguments)]
10657pub fn render_region_prepared_parallel_with_progress(
10658    list: &DisplayList,
10659    prepared: &PreparedDisplayList,
10660    vp_x: f64,
10661    vp_y: f64,
10662    vp_w: f64,
10663    vp_h: f64,
10664    pixel_w: u32,
10665    pixel_h: u32,
10666    dpi: f64,
10667    icc: Option<&IccCache>,
10668    image_cache: Option<&ImageCache>,
10669    no_aa: bool,
10670    progress: &std::sync::atomic::AtomicU32,
10671) -> Vec<u8> {
10672    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10673
10674    if num_bands <= 1 {
10675        let result = render_region_prepared(
10676            list,
10677            prepared,
10678            vp_x,
10679            vp_y,
10680            vp_w,
10681            vp_h,
10682            pixel_w,
10683            pixel_h,
10684            dpi,
10685            icc,
10686            image_cache,
10687            no_aa,
10688        );
10689        progress.store(1, std::sync::atomic::Ordering::Relaxed);
10690        return result;
10691    }
10692
10693    let render_band = |band_idx: u32| -> Vec<u8> {
10694        render_region_single_band(
10695            list,
10696            prepared,
10697            vp_x,
10698            vp_y,
10699            vp_w,
10700            vp_h,
10701            pixel_w,
10702            pixel_h,
10703            band_idx,
10704            band_h,
10705            num_bands,
10706            dpi,
10707            icc,
10708            image_cache,
10709            no_aa,
10710        )
10711    };
10712
10713    let row_bytes = pixel_w as usize * 4;
10714    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10715
10716    #[cfg(feature = "parallel")]
10717    {
10718        let chunk_size = rayon::current_num_threads().max(1);
10719
10720        for chunk_start in (0..num_bands).step_by(chunk_size) {
10721            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10722
10723            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10724                .into_par_iter()
10725                .map(&render_band)
10726                .collect();
10727
10728            for (i, band_data) in rendered.iter().enumerate() {
10729                let band_idx = chunk_start + i as u32;
10730                let y_start = (band_idx * band_h) as usize;
10731                let dest_start = y_start * row_bytes;
10732                let len = band_data.len();
10733                result[dest_start..dest_start + len].copy_from_slice(band_data);
10734            }
10735            progress.store(chunk_end, std::sync::atomic::Ordering::Relaxed);
10736        }
10737    }
10738    #[cfg(not(feature = "parallel"))]
10739    {
10740        for band_idx in 0..num_bands {
10741            let band_data = render_band(band_idx);
10742            let y_start = (band_idx * band_h) as usize;
10743            let dest_start = y_start * row_bytes;
10744            let len = band_data.len();
10745            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10746            progress.store(band_idx + 1, std::sync::atomic::Ordering::Relaxed);
10747        }
10748    }
10749
10750    result
10751}
10752
10753/// Like [`render_region_prepared_parallel()`] but checks a cancellation flag
10754/// between band chunks. Returns `None` if cancelled.
10755#[allow(clippy::too_many_arguments)]
10756pub fn render_region_prepared_parallel_cancellable(
10757    list: &DisplayList,
10758    prepared: &PreparedDisplayList,
10759    vp_x: f64,
10760    vp_y: f64,
10761    vp_w: f64,
10762    vp_h: f64,
10763    pixel_w: u32,
10764    pixel_h: u32,
10765    dpi: f64,
10766    icc: Option<&IccCache>,
10767    image_cache: Option<&ImageCache>,
10768    no_aa: bool,
10769    cancelled: &std::sync::atomic::AtomicBool,
10770) -> Option<Vec<u8>> {
10771    if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10772        return None;
10773    }
10774
10775    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10776
10777    if num_bands <= 1 {
10778        return Some(render_region_prepared(
10779            list,
10780            prepared,
10781            vp_x,
10782            vp_y,
10783            vp_w,
10784            vp_h,
10785            pixel_w,
10786            pixel_h,
10787            dpi,
10788            icc,
10789            image_cache,
10790            no_aa,
10791        ));
10792    }
10793
10794    let render_band = |band_idx: u32| -> Vec<u8> {
10795        render_region_single_band(
10796            list,
10797            prepared,
10798            vp_x,
10799            vp_y,
10800            vp_w,
10801            vp_h,
10802            pixel_w,
10803            pixel_h,
10804            band_idx,
10805            band_h,
10806            num_bands,
10807            dpi,
10808            icc,
10809            image_cache,
10810            no_aa,
10811        )
10812    };
10813
10814    let row_bytes = pixel_w as usize * 4;
10815    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10816
10817    #[cfg(feature = "parallel")]
10818    {
10819        let chunk_size = rayon::current_num_threads().max(1);
10820
10821        for chunk_start in (0..num_bands).step_by(chunk_size) {
10822            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10823                return None;
10824            }
10825            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10826
10827            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10828                .into_par_iter()
10829                .map(&render_band)
10830                .collect();
10831
10832            for (i, band_data) in rendered.iter().enumerate() {
10833                let band_idx = chunk_start + i as u32;
10834                let y_start = (band_idx * band_h) as usize;
10835                let dest_start = y_start * row_bytes;
10836                let len = band_data.len();
10837                result[dest_start..dest_start + len].copy_from_slice(band_data);
10838            }
10839        }
10840    }
10841    #[cfg(not(feature = "parallel"))]
10842    {
10843        for band_idx in 0..num_bands {
10844            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10845                return None;
10846            }
10847            let band_data = render_band(band_idx);
10848            let y_start = (band_idx * band_h) as usize;
10849            let dest_start = y_start * row_bytes;
10850            let len = band_data.len();
10851            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10852        }
10853    }
10854
10855    Some(result)
10856}
10857
10858/// Render a full-page display list to RGBA pixels using the banded parallel renderer.
10859///
10860/// This is the preferred way to render a complete page — it uses rayon parallelism
10861/// (when the `parallel` feature is enabled) and L2-cache-friendly band sizing.
10862/// For sub-region / zoomed viewport rendering, use `render_region` instead.
10863///
10864/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`, composited onto white.
10865pub fn render_to_rgba(
10866    list: &DisplayList,
10867    pixel_w: u32,
10868    pixel_h: u32,
10869    dpi: f64,
10870    icc: Option<&IccCache>,
10871    no_aa: bool,
10872) -> Vec<u8> {
10873    render_to_rgba_with_layers(list, pixel_w, pixel_h, dpi, icc, no_aa, &LayerSet::new())
10874}
10875
10876/// Like [`render_to_rgba`] but consults the supplied [`LayerSet`] when
10877/// evaluating each `OcgGroup`'s visibility.
10878///
10879/// Pass `&LayerSet::new()` (or use [`render_to_rgba`]) to fall back to
10880/// each OCG's `default_visible` baked from the document's default
10881/// configuration.
10882#[allow(clippy::too_many_arguments)]
10883pub fn render_to_rgba_with_layers(
10884    list: &DisplayList,
10885    pixel_w: u32,
10886    pixel_h: u32,
10887    dpi: f64,
10888    icc: Option<&IccCache>,
10889    no_aa: bool,
10890    layer_set: &LayerSet,
10891) -> Vec<u8> {
10892    if pixel_w == 0 || pixel_h == 0 {
10893        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10894    }
10895
10896    let mut icc_cache = match icc {
10897        Some(c) => c.clone(),
10898        None => IccCache::new(),
10899    };
10900    // Register any ICC profiles from shadings in the display list
10901    // (the caller's cache only has image profiles)
10902    register_shading_icc_profiles(list, &mut icc_cache);
10903
10904    let mut sink = MemorySink {
10905        data: Vec::new(),
10906        width: 0,
10907    };
10908
10909    let band_h = select_band_height(pixel_w, pixel_h);
10910    if let Err(e) = render_banded_to_sink(
10911        pixel_w, pixel_h, band_h, dpi, list, &mut sink, &icc_cache, no_aa, layer_set,
10912    ) {
10913        eprintln!("render_to_rgba: banded render failed: {e}");
10914        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10915    }
10916
10917    sink.data
10918}
10919
10920/// Render a display list to RGBA using the **viewport** code path, with
10921/// the viewport set to the full page at 1:1 scale.
10922///
10923/// This exists to audit the viewport pipeline (`render_region_prepared_*`)
10924/// against the same baselines the banded PNG path uses. The two paths share
10925/// `render_element` and the same display list, so their output should be
10926/// pixel-identical on a correctly implemented display list. Differences
10927/// indicate a bug in one of the two culling / epoch / bbox pipelines.
10928///
10929/// The CLI exposes this as `--device viewport-png`; the visual test runner
10930/// uses it to double-cover each sample without maintaining a second
10931/// baseline.
10932pub fn render_to_rgba_viewport(
10933    list: &DisplayList,
10934    pixel_w: u32,
10935    pixel_h: u32,
10936    dpi: f64,
10937    icc: Option<&IccCache>,
10938    no_aa: bool,
10939) -> Vec<u8> {
10940    if pixel_w == 0 || pixel_h == 0 {
10941        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10942    }
10943
10944    let mut icc_cache = match icc {
10945        Some(c) => c.clone(),
10946        None => IccCache::new(),
10947    };
10948    register_shading_icc_profiles(list, &mut icc_cache);
10949
10950    let prepared = prepare_display_list(list);
10951    render_region_prepared_parallel(
10952        list,
10953        &prepared,
10954        0.0,
10955        0.0,
10956        pixel_w as f64,
10957        pixel_h as f64,
10958        pixel_w,
10959        pixel_h,
10960        dpi,
10961        Some(&icc_cache),
10962        None,
10963        no_aa,
10964    )
10965}
10966
10967/// Debug helper: format both bbox precomputations side-by-side.
10968///
10969/// Returns one line per element describing its Y-only bbox (used by the
10970/// banded page pipeline) and its 2D bbox (used by the viewport pipeline).
10971/// Elements that disagree on presence, or whose 2D bbox's Y extent differs
10972/// from the Y-only bbox, are marked with `DIFF`.
10973fn debug_bbox_lines(list: &DisplayList, dpi: f64, depth: usize, out: &mut Vec<String>) {
10974    let y_bboxes = precompute_bboxes(list, dpi);
10975    let full_bboxes = precompute_full_bboxes(list, dpi);
10976    let elements = list.elements();
10977    let indent = "  ".repeat(depth);
10978    for (i, elem) in elements.iter().enumerate() {
10979        let kind = match elem {
10980            DisplayElement::Fill { .. } => "Fill",
10981            DisplayElement::Stroke { .. } => "Stroke",
10982            DisplayElement::Image { .. } => "Image",
10983            DisplayElement::AxialShading { .. } => "AxialShading",
10984            DisplayElement::RadialShading { .. } => "RadialShading",
10985            DisplayElement::MeshShading { .. } => "MeshShading",
10986            DisplayElement::PatchShading { .. } => "PatchShading",
10987            DisplayElement::PatternFill { .. } => "PatternFill",
10988            DisplayElement::Group { .. } => "Group",
10989            DisplayElement::SoftMasked { .. } => "SoftMasked",
10990            DisplayElement::OcgGroup { .. } => "OcgGroup",
10991            DisplayElement::Clip { .. } => "Clip",
10992            DisplayElement::InitClip => "InitClip",
10993            DisplayElement::ErasePage => "ErasePage",
10994            DisplayElement::Text { .. } => "Text",
10995            _ => "Unknown",
10996        };
10997        let yb = &y_bboxes[i];
10998        let fb = &full_bboxes[i];
10999        let mut diff = false;
11000        if yb.is_some() != fb.is_some() {
11001            diff = true;
11002        }
11003        if let (Some(yb), Some(fb)) = (yb, fb)
11004            && ((yb.y_min - fb.y_min).abs() > 1e-9 || (yb.y_max - fb.y_max).abs() > 1e-9)
11005        {
11006            diff = true;
11007        }
11008        let yb_s = match yb {
11009            Some(b) => format!("Y[{:8.3}..{:8.3}]", b.y_min, b.y_max),
11010            None => "Y[None]".to_string(),
11011        };
11012        let fb_s = match fb {
11013            Some(b) => format!(
11014                "2D[x {:8.3}..{:8.3} y {:8.3}..{:8.3}]",
11015                b.x_min, b.x_max, b.y_min, b.y_max
11016            ),
11017            None => "2D[None]".to_string(),
11018        };
11019        out.push(format!(
11020            "{}{:4} {:15} {:30} {:55} {}",
11021            indent,
11022            i,
11023            kind,
11024            yb_s,
11025            fb_s,
11026            if diff { "DIFF" } else { "" }
11027        ));
11028        if let DisplayElement::Stroke { path, params } = elem {
11029            let rp = path_full_bbox(path);
11030            let m = &params.ctm;
11031            out.push(format!(
11032                "{}        ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] lw={:.4} miter={:.4} raw={}",
11033                indent,
11034                m.a,
11035                m.b,
11036                m.c,
11037                m.d,
11038                m.tx,
11039                m.ty,
11040                params.line_width,
11041                params.miter_limit,
11042                match rp {
11043                    Some(b) => format!(
11044                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11045                        b.x_min, b.x_max, b.y_min, b.y_max
11046                    ),
11047                    None => "None".to_string(),
11048                }
11049            ));
11050        }
11051        if let DisplayElement::Clip { path, params } = elem {
11052            let rp = path_full_bbox(path);
11053            let m = &params.ctm;
11054            out.push(format!(
11055                "{}        clip ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] rule={:?} raw={}",
11056                indent,
11057                m.a,
11058                m.b,
11059                m.c,
11060                m.d,
11061                m.tx,
11062                m.ty,
11063                params.fill_rule,
11064                match rp {
11065                    Some(b) => format!(
11066                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11067                        b.x_min, b.x_max, b.y_min, b.y_max
11068                    ),
11069                    None => "None".to_string(),
11070                }
11071            ));
11072        }
11073        if let DisplayElement::PatchShading { params } = elem {
11074            out.push(format!(
11075                "{}        patch ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] bbox={:?} patches={}",
11076                indent,
11077                params.ctm.a,
11078                params.ctm.b,
11079                params.ctm.c,
11080                params.ctm.d,
11081                params.ctm.tx,
11082                params.ctm.ty,
11083                params.bbox,
11084                params.patches.len()
11085            ));
11086            if !params.patches.is_empty() {
11087                let patch = &params.patches[0];
11088                // Compute device-space bbox of patch points
11089                let mut x_min = f64::INFINITY;
11090                let mut y_min = f64::INFINITY;
11091                let mut x_max = f64::NEG_INFINITY;
11092                let mut y_max = f64::NEG_INFINITY;
11093                for &(px, py) in &patch.points {
11094                    let (dx, dy) = params.ctm.transform_point(px, py);
11095                    x_min = x_min.min(dx);
11096                    y_min = y_min.min(dy);
11097                    x_max = x_max.max(dx);
11098                    y_max = y_max.max(dy);
11099                }
11100                out.push(format!(
11101                    "{}        patch[0] pts={} dev x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11102                    indent,
11103                    patch.points.len(),
11104                    x_min,
11105                    x_max,
11106                    y_min,
11107                    y_max
11108                ));
11109            }
11110        }
11111        if let DisplayElement::Group {
11112            elements: inner,
11113            params,
11114        } = elem
11115        {
11116            out.push(format!(
11117                "{}        group bbox={:?} iso={} ko={} alpha={} bm={} cs={:?}",
11118                indent,
11119                params.bbox,
11120                params.isolated,
11121                params.knockout,
11122                params.alpha,
11123                params.blend_mode,
11124                params.color_space
11125            ));
11126            debug_bbox_lines(inner, dpi, depth + 1, out);
11127        }
11128        if let DisplayElement::SoftMasked {
11129            content, params, ..
11130        } = elem
11131        {
11132            out.push(format!(
11133                "{}        softmasked bbox={:?}",
11134                indent, params.bbox
11135            ));
11136            debug_bbox_lines(content, dpi, depth + 1, out);
11137        }
11138        if let DisplayElement::OcgGroup {
11139            elements: inner,
11140            visibility,
11141        } = elem
11142        {
11143            out.push(format!(
11144                "{}        ocg default_visible={}",
11145                indent,
11146                visibility.default_visible()
11147            ));
11148            debug_bbox_lines(inner, dpi, depth + 1, out);
11149        }
11150    }
11151}
11152
11153pub fn debug_bbox_comparison(list: &DisplayList, dpi: f64) -> Vec<String> {
11154    let mut out = Vec::new();
11155    debug_bbox_lines(list, dpi, 0, &mut out);
11156    out
11157}
11158
11159/// In-memory page sink that collects RGBA rows into a Vec.
11160struct MemorySink {
11161    data: Vec<u8>,
11162    width: u32,
11163}
11164
11165impl stet_graphics::device::PageSink for MemorySink {
11166    fn begin_page(&mut self, width: u32, height: u32) -> Result<(), String> {
11167        self.width = width;
11168        self.data.reserve(width as usize * height as usize * 4);
11169        Ok(())
11170    }
11171
11172    fn write_rows(&mut self, rgba_rows: &[u8], _num_rows: u32) -> Result<(), String> {
11173        self.data.extend_from_slice(rgba_rows);
11174        Ok(())
11175    }
11176
11177    fn end_page(&mut self) -> Result<(), String> {
11178        Ok(())
11179    }
11180}
11181
11182/// Render a rectangular viewport region of a display list to RGBA pixels.
11183///
11184/// - `list`: The display list to render (in device-space coordinates at the reference DPI)
11185/// - `vp_x, vp_y, vp_w, vp_h`: Viewport rectangle in device-space pixels
11186/// - `pixel_w, pixel_h`: Output pixel dimensions
11187/// - `dpi`: Reference DPI (for hairline width decisions)
11188///
11189/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`.
11190#[allow(clippy::too_many_arguments)]
11191pub fn render_region(
11192    list: &DisplayList,
11193    vp_x: f64,
11194    vp_y: f64,
11195    vp_w: f64,
11196    vp_h: f64,
11197    pixel_w: u32,
11198    pixel_h: u32,
11199    dpi: f64,
11200    icc: Option<&IccCache>,
11201    image_cache: Option<&ImageCache>,
11202    no_aa: bool,
11203) -> Vec<u8> {
11204    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
11205        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
11206    }
11207
11208    let layer_set = LayerSet::new();
11209    let scale_x = pixel_w as f64 / vp_w;
11210    let scale_y = pixel_h as f64 / vp_h;
11211    // Effective DPI for hairline decisions — reference DPI scaled by zoom
11212    let effective_dpi = dpi * scale_x;
11213
11214    let bboxes = precompute_full_bboxes(list, effective_dpi);
11215    let epochs = build_viewport_epochs(list, &bboxes);
11216    let clip_seen = precompute_clip_seen(list);
11217
11218    // OVERLAP padding to match `render_banded_to_sink`. See the comment in
11219    // `render_region_prepared` for why this is required for tiny-skia
11220    // mask-rasterization parity with the page renderer.
11221    const OVERLAP: u32 = 6;
11222    let render_h = pixel_h + 2 * OVERLAP;
11223
11224    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
11225    pixmap.fill(Color::TRANSPARENT);
11226
11227    let cmyk_buf = if has_overprint_elements(list)
11228        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
11229        || has_cmyk_group(list)
11230    {
11231        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
11232    } else {
11233        None
11234    };
11235
11236    let mut state = BandState {
11237        clip_region: None,
11238        spare_mask: None,
11239        clip_mask_cache: HashMap::new(),
11240        clip_mask_seen: clip_seen,
11241        mask_pool: Vec::new(),
11242        cmyk_buffer: cmyk_buf,
11243        op_bg_snapshot: None,
11244        op_touched: None,
11245        spot_mask: None,
11246    };
11247
11248    let elements = list.elements();
11249    let vp_x_f = vp_x as f32;
11250    let vp_y_f = vp_y as f32;
11251    let sx = scale_x as f32;
11252    let sy = scale_y as f32;
11253    let vp_x_max = vp_x + vp_w;
11254    let vp_y_max = vp_y + vp_h;
11255
11256    for epoch in &epochs {
11257        // Epoch-level culling
11258        if !epoch.has_erase_page {
11259            match epoch.paint_bbox {
11260                Some(ref pb)
11261                    if pb.x_max <= vp_x
11262                        || pb.x_min >= vp_x_max
11263                        || pb.y_max <= vp_y
11264                        || pb.y_min >= vp_y_max =>
11265                {
11266                    continue;
11267                }
11268                None => continue,
11269                _ => {}
11270            }
11271        }
11272
11273        for i in epoch.start_idx..epoch.end_idx {
11274            // OcgGroups with Clip/InitClip must always be processed — see
11275            // render_region_prepared for the rationale.
11276            let force_process = matches!(
11277                &elements[i],
11278                DisplayElement::OcgGroup { elements: inner, .. }
11279                    if contains_clip_op(inner)
11280            );
11281            // Element-level culling
11282            if !force_process
11283                && let Some(ref bbox) = bboxes[i]
11284                && (bbox.x_max <= vp_x
11285                    || bbox.x_min >= vp_x_max
11286                    || bbox.y_max <= vp_y
11287                    || bbox.y_min >= vp_y_max)
11288            {
11289                continue;
11290            }
11291            let ctx = RenderContext {
11292                vp_x: vp_x_f,
11293                vp_y: vp_y_f,
11294                scale_x: sx,
11295                scale_y: sy,
11296                out_w: pixel_w,
11297                out_h: render_h,
11298                effective_dpi,
11299                icc,
11300                image_cache,
11301                preprocessed: None,
11302                elem_idx: i,
11303                no_aa,
11304                opm_zero_transparent: false,
11305                knockout_painter_pass: KnockoutPainterPass::None,
11306                parent_group_isolated: false,
11307                alpha_extraction_pass: false,
11308                layer_set: &layer_set,
11309            };
11310            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
11311        }
11312    }
11313
11314    composite_onto_white(pixmap.data_mut());
11315    // Extract only the requested pixel_h rows (skip OVERLAP padding).
11316    let row_bytes = pixel_w as usize * 4;
11317    let end = pixel_h as usize * row_bytes;
11318    pixmap.data()[..end].to_vec()
11319}
11320/// Copy a rectangular region from parent pixmap into a smaller crop pixmap.
11321fn copy_backdrop_crop(
11322    parent: &Pixmap,
11323    crop_x: i32,
11324    crop_y: i32,
11325    crop_w: u32,
11326    crop_h: u32,
11327) -> Vec<u8> {
11328    let pw = parent.width() as usize;
11329    let src = parent.data();
11330    let cw = crop_w as usize;
11331    let ch = crop_h as usize;
11332    let cx = crop_x as usize;
11333    let cy = crop_y as usize;
11334    let mut backdrop = vec![0u8; cw * ch * 4];
11335    for row in 0..ch {
11336        let src_off = ((cy + row) * pw + cx) * 4;
11337        let dst_off = row * cw * 4;
11338        backdrop[dst_off..dst_off + cw * 4].copy_from_slice(&src[src_off..src_off + cw * 4]);
11339    }
11340    backdrop
11341}
11342// ---- Shading rendering ----
11343
11344/// Sutherland-Hodgman polygon clipping against a half-plane.
11345/// Keeps the side where `nx*(x-px) + ny*(y-py) >= 0`.
11346fn clip_polygon_halfplane(
11347    poly: &[(f32, f32)],
11348    nx: f32,
11349    ny: f32,
11350    px: f32,
11351    py: f32,
11352) -> Vec<(f32, f32)> {
11353    if poly.is_empty() {
11354        return vec![];
11355    }
11356    let dot = |x: f32, y: f32| nx * (x - px) + ny * (y - py);
11357    let mut out = Vec::with_capacity(poly.len() + 1);
11358    let n = poly.len();
11359    for i in 0..n {
11360        let (ax, ay) = poly[i];
11361        let (bx, by) = poly[(i + 1) % n];
11362        let da = dot(ax, ay);
11363        let db = dot(bx, by);
11364        if da >= 0.0 {
11365            out.push((ax, ay));
11366        }
11367        if (da >= 0.0) != (db >= 0.0) {
11368            // Edge crosses the clipping line — compute intersection
11369            let t = da / (da - db);
11370            out.push((ax + t * (bx - ax), ay + t * (by - ay)));
11371        }
11372    }
11373    out
11374}
11375
11376/// Render an axial (linear) gradient shading.
11377#[allow(clippy::too_many_arguments)]
11378fn render_axial_shading(
11379    pixmap: &mut Pixmap,
11380    params: &AxialShadingParams,
11381    vp_x: f32,
11382    vp_y: f32,
11383    scale_x: f32,
11384    scale_y: f32,
11385    clip_mask: Option<&Mask>,
11386    no_aa: bool,
11387    cmyk_buf: Option<&mut [f32]>,
11388    icc: Option<&IccCache>,
11389) {
11390    let pw = pixmap.width();
11391    let ph = pixmap.height();
11392    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
11393        return;
11394    }
11395
11396    let (mut rx_min, mut ry_min, mut rx_max, mut ry_max) = if let Some(bbox) = &params.bbox {
11397        let corners = [
11398            params.ctm.transform_point(bbox[0], bbox[1]),
11399            params.ctm.transform_point(bbox[2], bbox[1]),
11400            params.ctm.transform_point(bbox[0], bbox[3]),
11401            params.ctm.transform_point(bbox[2], bbox[3]),
11402        ];
11403        let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
11404        let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
11405        let x_max = corners
11406            .iter()
11407            .map(|c| c.0)
11408            .fold(f64::NEG_INFINITY, f64::max);
11409        let y_max = corners
11410            .iter()
11411            .map(|c| c.1)
11412            .fold(f64::NEG_INFINITY, f64::max);
11413        (
11414            ((x_min as f32 - vp_x) * scale_x).max(0.0),
11415            ((y_min as f32 - vp_y) * scale_y).max(0.0),
11416            ((x_max as f32 - vp_x) * scale_x).min(pw as f32),
11417            ((y_max as f32 - vp_y) * scale_y).min(ph as f32),
11418        )
11419    } else {
11420        (0.0, 0.0, pw as f32, ph as f32)
11421    };
11422
11423    if rx_max <= rx_min || ry_max <= ry_min {
11424        return;
11425    }
11426
11427    // Transform endpoints to device space for perpendicular clipping
11428    let (dx0, dy0) = params.ctm.transform_point(params.x0, params.y0);
11429    let (dx1, dy1) = params.ctm.transform_point(params.x1, params.y1);
11430
11431    // When extend is false on a side, clip the fill area along a line
11432    // perpendicular to the gradient axis through that endpoint. For diagonal
11433    // gradients this produces a diagonal cutoff (not axis-aligned).
11434    let needs_perpendicular_clip = (!params.extend_start || !params.extend_end) && {
11435        let axis_x = dx1 - dx0;
11436        let axis_y = dy1 - dy0;
11437        axis_x.abs() > 1e-6 && axis_y.abs() > 1e-6
11438    };
11439
11440    // Detect rotated BBox: if CTM has rotation components (b or c non-zero),
11441    // the BBox is not axis-aligned in device space and needs proper polygon clipping.
11442    let bbox_is_rotated =
11443        params.bbox.is_some() && (params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10);
11444
11445    if needs_perpendicular_clip {
11446        // Diagonal gradient with non-extended side — fall back to tiny-skia
11447        // for Sutherland-Hodgman polygon clipping.
11448        let stops = build_gradient_stops(&params.color_stops);
11449        if stops.is_empty() {
11450            return;
11451        }
11452        let start = stet_tiny_skia::Point::from_xy(params.x0 as f32, params.y0 as f32);
11453        let end = stet_tiny_skia::Point::from_xy(params.x1 as f32, params.y1 as f32);
11454        let gradient_transform =
11455            viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
11456        let Some(gradient) = stet_tiny_skia::LinearGradient::new(
11457            start,
11458            end,
11459            stops,
11460            stet_tiny_skia::SpreadMode::Pad,
11461            gradient_transform,
11462        ) else {
11463            return;
11464        };
11465        let paint = Paint {
11466            shader: gradient,
11467            anti_alias: !no_aa,
11468            ..Paint::default()
11469        };
11470
11471        // Use rotated BBox polygon when CTM has rotation, otherwise axis-aligned rect
11472        let mut poly: Vec<(f32, f32)> = if bbox_is_rotated {
11473            let bbox = params.bbox.as_ref().unwrap();
11474            let corners = [
11475                params.ctm.transform_point(bbox[0], bbox[1]),
11476                params.ctm.transform_point(bbox[2], bbox[1]),
11477                params.ctm.transform_point(bbox[2], bbox[3]),
11478                params.ctm.transform_point(bbox[0], bbox[3]),
11479            ];
11480            corners
11481                .iter()
11482                .map(|(x, y)| ((*x as f32 - vp_x) * scale_x, (*y as f32 - vp_y) * scale_y))
11483                .collect()
11484        } else {
11485            vec![
11486                (rx_min, ry_min),
11487                (rx_max, ry_min),
11488                (rx_max, ry_max),
11489                (rx_min, ry_max),
11490            ]
11491        };
11492        let ax = (dx1 - dx0) as f32 * scale_x;
11493        let ay = (dy1 - dy0) as f32 * scale_y;
11494        if !params.extend_start {
11495            let px = (dx0 as f32 - vp_x) * scale_x;
11496            let py = (dy0 as f32 - vp_y) * scale_y;
11497            poly = clip_polygon_halfplane(&poly, ax, ay, px, py);
11498        }
11499        if !params.extend_end {
11500            let px = (dx1 as f32 - vp_x) * scale_x;
11501            let py = (dy1 as f32 - vp_y) * scale_y;
11502            poly = clip_polygon_halfplane(&poly, -ax, -ay, px, py);
11503        }
11504        if poly.len() >= 3 {
11505            let mut pb = PathBuilder::new();
11506            pb.move_to(poly[0].0, poly[0].1);
11507            for &(x, y) in &poly[1..] {
11508                pb.line_to(x, y);
11509            }
11510            pb.close();
11511            if let Some(path) = pb.finish() {
11512                pixmap.fill_path(
11513                    &path,
11514                    &paint,
11515                    SkiaFillRule::Winding,
11516                    Transform::identity(),
11517                    clip_mask,
11518                );
11519            }
11520        }
11521    } else {
11522        // Common case: axis-aligned or both sides extended — direct rasterization.
11523        // Clip fill rect to gradient extent when sides aren't extended.
11524        if !params.extend_start || !params.extend_end {
11525            let axis_x = dx1 - dx0;
11526            let axis_y = dy1 - dy0;
11527            let gx0 = (dx0 as f32 - vp_x) * scale_x;
11528            let gy0 = (dy0 as f32 - vp_y) * scale_y;
11529            let gx1 = (dx1 as f32 - vp_x) * scale_x;
11530            let gy1 = (dy1 as f32 - vp_y) * scale_y;
11531
11532            if axis_x.abs() >= axis_y.abs() {
11533                if !params.extend_start {
11534                    if axis_x >= 0.0 {
11535                        rx_min = rx_min.max(gx0);
11536                    } else {
11537                        rx_max = rx_max.min(gx0);
11538                    }
11539                }
11540                if !params.extend_end {
11541                    if axis_x >= 0.0 {
11542                        rx_max = rx_max.min(gx1);
11543                    } else {
11544                        rx_min = rx_min.max(gx1);
11545                    }
11546                }
11547            } else {
11548                if !params.extend_start {
11549                    if axis_y >= 0.0 {
11550                        ry_min = ry_min.max(gy0);
11551                    } else {
11552                        ry_max = ry_max.min(gy0);
11553                    }
11554                }
11555                if !params.extend_end {
11556                    if axis_y >= 0.0 {
11557                        ry_max = ry_max.min(gy1);
11558                    } else {
11559                        ry_min = ry_min.max(gy1);
11560                    }
11561                }
11562            }
11563            if rx_max <= rx_min || ry_max <= ry_min {
11564                return;
11565            }
11566        }
11567
11568        // Compute gradient axis in shading space.
11569        let ax = params.x1 - params.x0;
11570        let ay = params.y1 - params.y0;
11571        let axis_sq = ax * ax + ay * ay;
11572        if axis_sq < 1e-20 {
11573            return;
11574        }
11575
11576        // Size the LUT to the gradient's pixel span so each entry covers ≤1 pixel.
11577        // This ensures nearest-neighbor lookup produces pixel-perfect sharp edges
11578        // at stitching function discontinuities without banding in smooth gradients.
11579        let pixel_dx = (dx1 - dx0) * scale_x as f64;
11580        let pixel_dy = (dy1 - dy0) * scale_y as f64;
11581        let pixel_axis_len = (pixel_dx * pixel_dx + pixel_dy * pixel_dy).sqrt();
11582        let lut_size = (pixel_axis_len as usize)
11583            .max(params.color_stops.len())
11584            .max(256)
11585            .min(16384);
11586        let lut = build_gradient_lut(&params.color_stops, lut_size);
11587
11588        let Some(inv) = params.ctm.invert() else {
11589            return;
11590        };
11591        let inv_sx = 1.0 / scale_x as f64;
11592        let inv_sy = 1.0 / scale_y as f64;
11593        let dev_origin_x = vp_x as f64;
11594        let dev_origin_y = vp_y as f64;
11595
11596        // Shading-space coords as linear function of pixel coords:
11597        //   sx = sx_base + dsx_dx * px + dsx_dy * py
11598        //   sy = sy_base + dsy_dx * px + dsy_dy * py
11599        let sx_base = inv.a * dev_origin_x + inv.c * dev_origin_y + inv.tx;
11600        let sy_base = inv.b * dev_origin_x + inv.d * dev_origin_y + inv.ty;
11601        let dsx_dx = inv.a * inv_sx;
11602        let dsx_dy = inv.c * inv_sy;
11603        let dsy_dx = inv.b * inv_sx;
11604        let dsy_dy = inv.d * inv_sy;
11605
11606        // t = dot(P_shading - P0, axis) / dot(axis, axis)
11607        let inv_axis_sq = 1.0 / axis_sq;
11608        let t_origin = ((sx_base - params.x0) * ax + (sy_base - params.y0) * ay) * inv_axis_sq;
11609        let dt_dx = (dsx_dx * ax + dsy_dx * ay) * inv_axis_sq;
11610        let dt_dy = (dsx_dy * ax + dsy_dy * ay) * inv_axis_sq;
11611
11612        // Per-pixel rotated BBox clipping: reuse inverse CTM to map each pixel
11613        // back to shading space and check against the original BBox.
11614        let bbox_pixel_clip = if bbox_is_rotated {
11615            let bbox = params.bbox.as_ref().unwrap();
11616            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11617            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11618            Some((
11619                dsx_dx, dsx_dy, sx_base, dsy_dx, dsy_dy, sy_base, bx0, by0, bx1, by1,
11620            ))
11621        } else {
11622            None
11623        };
11624
11625        let ix_min = rx_min.floor() as u32;
11626        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11627        let iy_min = ry_min.floor() as u32;
11628        let iy_max = ry_max.ceil().min(ph as f32) as u32;
11629
11630        let stride = pw as usize * 4;
11631        let data = pixmap.data_mut();
11632        let mask_data = clip_mask.map(|m| m.data());
11633        let alpha = (params.alpha.clamp(0.0, 1.0) * 255.0 + 0.5) as u16;
11634
11635        for py in iy_min..iy_max {
11636            let t_row = t_origin + dt_dy * py as f64;
11637            let row_offset = py as usize * stride;
11638
11639            // Precompute row-base values for rotated BBox check
11640            let (ux_row, uy_row) =
11641                if let Some((_, dux_dy, ux_base, _, duy_dy, uy_base, ..)) = &bbox_pixel_clip {
11642                    (ux_base + dux_dy * py as f64, uy_base + duy_dy * py as f64)
11643                } else {
11644                    (0.0, 0.0)
11645                };
11646
11647            for px in ix_min..ix_max {
11648                // Check clip mask
11649                if let Some(md) = mask_data {
11650                    if md[py as usize * pw as usize + px as usize] == 0 {
11651                        continue;
11652                    }
11653                }
11654
11655                // Per-pixel rotated BBox clip
11656                if let Some((dux_dx, _, _, duy_dx, _, _, bx0, by0, bx1, by1)) = &bbox_pixel_clip {
11657                    let ux = ux_row + dux_dx * px as f64;
11658                    let uy = uy_row + duy_dx * px as f64;
11659                    if ux < *bx0 || ux > *bx1 || uy < *by0 || uy > *by1 {
11660                        continue;
11661                    }
11662                }
11663
11664                let t = t_row + dt_dx * px as f64;
11665                let t_clamped = t.clamp(0.0, 1.0);
11666                let idx = (t_clamped * (lut_size - 1) as f64 + 0.5) as usize;
11667                let [r, g, b, _] = lut[idx.min(lut_size - 1)];
11668
11669                let offset = row_offset + px as usize * 4;
11670                if alpha >= 255 {
11671                    data[offset] = r;
11672                    data[offset + 1] = g;
11673                    data[offset + 2] = b;
11674                    data[offset + 3] = 255;
11675                } else {
11676                    // Alpha blend: premultiply and composite over existing pixel
11677                    let a = alpha as u16;
11678                    let inv_a = 255 - a;
11679                    data[offset] = ((r as u16 * a + data[offset] as u16 * inv_a + 127) / 255) as u8;
11680                    data[offset + 1] =
11681                        ((g as u16 * a + data[offset + 1] as u16 * inv_a + 127) / 255) as u8;
11682                    data[offset + 2] =
11683                        ((b as u16 * a + data[offset + 2] as u16 * inv_a + 127) / 255) as u8;
11684                    data[offset + 3] = ((a + data[offset + 3] as u16 * inv_a / 255).min(255)) as u8;
11685                }
11686            }
11687        }
11688    }
11689
11690    // Update CMYK tracking buffer for axial shading
11691    if let Some(buf) = cmyk_buf {
11692        let pw = pixmap.width();
11693        let inv_sx = 1.0 / scale_x as f64;
11694        let inv_sy = 1.0 / scale_y as f64;
11695        let axis_x = params.x1 - params.x0;
11696        let axis_y = params.y1 - params.y0;
11697        let axis_len_sq = axis_x * axis_x + axis_y * axis_y;
11698        let Some(inv_ctm) = params.ctm.invert() else {
11699            return;
11700        };
11701
11702        let iy_min = ry_min.floor() as u32;
11703        let iy_max = ry_max.ceil().min(pixmap.height() as f32) as u32;
11704        let ix_min = rx_min.floor() as u32;
11705        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11706
11707        for py in iy_min..iy_max {
11708            let dev_y = py as f64 * inv_sy + vp_y as f64;
11709            for px in ix_min..ix_max {
11710                let dev_x = px as f64 * inv_sx + vp_x as f64;
11711                let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11712                let t = if axis_len_sq > 1e-10 {
11713                    ((ux - params.x0) * axis_x + (uy - params.y0) * axis_y) / axis_len_sq
11714                } else {
11715                    0.0
11716                };
11717                if t < 0.0 && !params.extend_start {
11718                    continue;
11719                }
11720                if t > 1.0 && !params.extend_end {
11721                    continue;
11722                }
11723                let clamped = t.clamp(0.0, 1.0);
11724
11725                if let Some(mask) = clip_mask {
11726                    let mi = py as usize * pw as usize + px as usize;
11727                    if mask.data()[mi] == 0 {
11728                        continue;
11729                    }
11730                }
11731
11732                let color = interpolate_color_stops(&params.color_stops, clamped);
11733                let cmyk = interpolate_cmyk_from_stops(
11734                    &params.color_stops,
11735                    &params.color_space,
11736                    clamped,
11737                    &color,
11738                    icc,
11739                );
11740                let ci = (py as usize * pw as usize + px as usize) * 4;
11741                if ci + 3 < buf.len() {
11742                    if params.spot_tint_blend && params.overprint {
11743                        // Per PDF spec 11.7.4.5 a Separation/DeviceN gradient
11744                        // only affects the device colorants identified by its
11745                        // color space: plates for NAMED PROCESS colorants are
11746                        // REPLACED with the gradient's CMYK value at this
11747                        // pixel, plates not tied to a named process colorant
11748                        // are PRESERVED.  The LUT-painted pixmap already
11749                        // carries the spot's full ICC-converted color, so:
11750                        //
11751                        // Gated on `overprint` because the LUT pass for
11752                        // non-overprint shadings carries the author-intended
11753                        // blend mode (e.g. 2265.pdf draws each circle wedge
11754                        // twice — Normal then Multiply — and the multiplied
11755                        // pixmap is the wedge's final color).  Recomposing
11756                        // here would overwrite the multiply-darkened result
11757                        // with a single ICC sample of the source CMYK.
11758                        //   * Where the CMYK buffer is empty (fresh paper),
11759                        //     leave the pixmap alone — re-running CMYK→RGB
11760                        //     here would round-trip through the system
11761                        //     profile and produce a perceptibly different
11762                        //     gradient curve (the snowman shading regression
11763                        //     guarded against in the original recompose
11764                        //     branch).  Just record the named-process
11765                        //     contribution to the buffer for later overprint
11766                        //     tracking.
11767                        //   * Where the CMYK buffer has prior values (a
11768                        //     CMYK fill underneath, e.g. a `1 0 1 0.5 k`
11769                        //     checkmark under the strip), the LUT-paint had
11770                        //     wiped that underlying paint from the pixmap.
11771                        //     Recompose the pixmap from the merged CMYK
11772                        //     (REPLACE named, preserve non-named) to restore
11773                        //     the checkmark with the gradient's named-plate
11774                        //     contribution layered on top.
11775                        let cur_c = buf[ci] as f64;
11776                        let cur_m = buf[ci + 1] as f64;
11777                        let cur_y = buf[ci + 2] as f64;
11778                        let cur_k = buf[ci + 3] as f64;
11779                        let cur_is_zero =
11780                            cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
11781                        let named = params.painted_channels;
11782                        if cur_is_zero {
11783                            if named & stet_graphics::device::CMYK_C != 0 {
11784                                buf[ci] = cmyk.0 as f32;
11785                            }
11786                            if named & stet_graphics::device::CMYK_M != 0 {
11787                                buf[ci + 1] = cmyk.1 as f32;
11788                            }
11789                            if named & stet_graphics::device::CMYK_Y != 0 {
11790                                buf[ci + 2] = cmyk.2 as f32;
11791                            }
11792                            if named & stet_graphics::device::CMYK_K != 0 {
11793                                buf[ci + 3] = cmyk.3 as f32;
11794                            }
11795                        } else {
11796                            let new_c = if named & stet_graphics::device::CMYK_C != 0 {
11797                                cmyk.0
11798                            } else {
11799                                cur_c
11800                            };
11801                            let new_m = if named & stet_graphics::device::CMYK_M != 0 {
11802                                cmyk.1
11803                            } else {
11804                                cur_m
11805                            };
11806                            let new_y = if named & stet_graphics::device::CMYK_Y != 0 {
11807                                cmyk.2
11808                            } else {
11809                                cur_y
11810                            };
11811                            let new_k = if named & stet_graphics::device::CMYK_K != 0 {
11812                                cmyk.3
11813                            } else {
11814                                cur_k
11815                            };
11816                            buf[ci] = new_c as f32;
11817                            buf[ci + 1] = new_m as f32;
11818                            buf[ci + 2] = new_y as f32;
11819                            buf[ci + 3] = new_k as f32;
11820                            let (rv, gv, bv) = if let Some(icc_cache) = icc {
11821                                icc_cache
11822                                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
11823                                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
11824                            } else {
11825                                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
11826                            };
11827                            let stride = pixmap.data().len() / pixmap.height() as usize;
11828                            let offset = py as usize * stride + px as usize * 4;
11829                            let data = pixmap.data_mut();
11830                            data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11831                            data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11832                            data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11833                        }
11834                    } else if params.overprint
11835                        && params.painted_channels != stet_graphics::device::CMYK_ALL
11836                    {
11837                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11838                            buf[ci] = cmyk.0 as f32;
11839                        }
11840                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11841                            buf[ci + 1] = cmyk.1 as f32;
11842                        }
11843                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11844                            buf[ci + 2] = cmyk.2 as f32;
11845                        }
11846                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
11847                            buf[ci + 3] = cmyk.3 as f32;
11848                        }
11849                        // Recomposite RGB from merged CMYK via ICC
11850                        let c = buf[ci] as f64;
11851                        let m = buf[ci + 1] as f64;
11852                        let y = buf[ci + 2] as f64;
11853                        let k = buf[ci + 3] as f64;
11854                        let (rv, gv, bv) = if let Some(icc_cache) = icc {
11855                            icc_cache
11856                                .convert_cmyk_readonly(c, m, y, k)
11857                                .unwrap_or_else(|| cmyk_to_rgb_plrm(c, m, y, k))
11858                        } else {
11859                            cmyk_to_rgb_plrm(c, m, y, k)
11860                        };
11861                        let stride = pixmap.data().len() / pixmap.height() as usize;
11862                        let offset = py as usize * stride + px as usize * 4;
11863                        let data = pixmap.data_mut();
11864                        data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11865                        data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11866                        data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11867                    } else {
11868                        // Non-overprint axial shading: write the source CMYK
11869                        // to the buffer for any consumer that needs it (e.g.
11870                        // overprint sibling tracking) but leave the pixmap
11871                        // alone — `build_gradient_lut` already painted the
11872                        // pixel with linearly-interpolated source RGB, and
11873                        // round-tripping CMYK→RGB through the ICC profile
11874                        // produces a different gradient curve (linear in
11875                        // CMYK rather than linear in RGB) that diverges
11876                        // visibly from the LUT result. The CMYK buffer is
11877                        // only consumed by `composite_non_isolated_cmyk`,
11878                        // which excludes shading-containing groups via
11879                        // `group_content_is_native_cmyk`, so the
11880                        // buffer/pixmap mismatch never reaches a consumer
11881                        // that would notice. Reintroducing the round-trip
11882                        // here was the 3000_9 / 3000_10 snowman shading
11883                        // regression in the silly-weaving-bird plan.
11884                        buf[ci] = cmyk.0 as f32;
11885                        buf[ci + 1] = cmyk.1 as f32;
11886                        buf[ci + 2] = cmyk.2 as f32;
11887                        buf[ci + 3] = cmyk.3 as f32;
11888                    }
11889                }
11890            }
11891        }
11892    }
11893}
11894
11895/// Render a radial gradient shading.
11896#[allow(clippy::too_many_arguments)]
11897fn render_radial_shading(
11898    pixmap: &mut Pixmap,
11899    params: &RadialShadingParams,
11900    vp_x: f32,
11901    vp_y: f32,
11902    scale_x: f32,
11903    scale_y: f32,
11904    clip_mask: Option<&Mask>,
11905    _no_aa: bool,
11906    mut cmyk_buf: Option<&mut [f32]>,
11907    icc: Option<&IccCache>,
11908) {
11909    let pw = pixmap.width();
11910    let ph = pixmap.height();
11911    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
11912        return;
11913    }
11914
11915    let Some(inv_ctm) = params.ctm.invert() else {
11916        return;
11917    };
11918
11919    let (px_min, py_min, px_max, py_max) = if let Some(bbox) = &params.bbox {
11920        let corners = [
11921            params.ctm.transform_point(bbox[0], bbox[1]),
11922            params.ctm.transform_point(bbox[2], bbox[1]),
11923            params.ctm.transform_point(bbox[0], bbox[3]),
11924            params.ctm.transform_point(bbox[2], bbox[3]),
11925        ];
11926        let x_min = corners
11927            .iter()
11928            .map(|c| c.0 as f32)
11929            .fold(f32::INFINITY, f32::min);
11930        let y_min = corners
11931            .iter()
11932            .map(|c| c.1 as f32)
11933            .fold(f32::INFINITY, f32::min);
11934        let x_max = corners
11935            .iter()
11936            .map(|c| c.0 as f32)
11937            .fold(f32::NEG_INFINITY, f32::max);
11938        let y_max = corners
11939            .iter()
11940            .map(|c| c.1 as f32)
11941            .fold(f32::NEG_INFINITY, f32::max);
11942        (
11943            ((x_min - vp_x) * scale_x).max(0.0) as u32,
11944            ((y_min - vp_y) * scale_y).max(0.0) as u32,
11945            (((x_max - vp_x) * scale_x).ceil() as u32).min(pw),
11946            (((y_max - vp_y) * scale_y).ceil() as u32).min(ph),
11947        )
11948    } else {
11949        (0, 0, pw, ph)
11950    };
11951
11952    let inv_sx = 1.0 / scale_x as f64;
11953    let inv_sy = 1.0 / scale_y as f64;
11954
11955    // Rotated BBox: check per-pixel user-space containment
11956    let rotated_bbox = if let Some(bbox) = &params.bbox {
11957        if params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10 {
11958            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11959            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11960            Some((bx0, by0, bx1, by1))
11961        } else {
11962            None
11963        }
11964    } else {
11965        None
11966    };
11967
11968    let data = pixmap.data_mut();
11969    let stride = pw as usize * 4;
11970
11971    for py in py_min..py_max {
11972        let dev_y = py as f64 * inv_sy + vp_y as f64;
11973        for px in px_min..px_max {
11974            let dev_x = px as f64 * inv_sx + vp_x as f64;
11975            let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11976
11977            // Per-pixel rotated BBox clip
11978            if let Some((bx0, by0, bx1, by1)) = rotated_bbox {
11979                if ux < bx0 || ux > bx1 || uy < by0 || uy > by1 {
11980                    continue;
11981                }
11982            }
11983
11984            let t = solve_radial_t(
11985                ux,
11986                uy,
11987                params.x0,
11988                params.y0,
11989                params.r0,
11990                params.x1,
11991                params.y1,
11992                params.r1,
11993                params.extend_start,
11994                params.extend_end,
11995            );
11996            if let Some(t) = t {
11997                let clamped = t.clamp(0.0, 1.0);
11998                let color = interpolate_color_stops(&params.color_stops, clamped);
11999
12000                let clipped = clip_mask
12001                    .is_some_and(|mask| mask.data()[py as usize * pw as usize + px as usize] == 0);
12002
12003                if clipped {
12004                    continue;
12005                }
12006
12007                // Decide whether this pixel should use the multiplicative
12008                // ink-stacking blend to preserve a spot backdrop. We mirror
12009                // the rule in `render_overprint_fill`: overprint + subset
12010                // painted channels + buffer effectively empty at this pixel
12011                // means the pixmap carries a non-CMYK contribution (or the
12012                // pixel is fresh), so per-channel ink-stacking gives the
12013                // correct result whether the backdrop was spot-painted or
12014                // plain.
12015                let cmyk = interpolate_cmyk_from_stops(
12016                    &params.color_stops,
12017                    &params.color_space,
12018                    clamped,
12019                    &color,
12020                    icc,
12021                );
12022                let ci = (py as usize * pw as usize + px as usize) * 4;
12023                let buffer_clean = if let Some(ref buf) = cmyk_buf {
12024                    if ci + 3 < buf.len() {
12025                        buf[ci] == 0.0
12026                            && buf[ci + 1] == 0.0
12027                            && buf[ci + 2] == 0.0
12028                            && buf[ci + 3] == 0.0
12029                    } else {
12030                        false
12031                    }
12032                } else {
12033                    false
12034                };
12035                let offset_for_check = py as usize * stride + px as usize * 4;
12036                let pixmap_has_colour = data[offset_for_check + 3] > 0
12037                    && (data[offset_for_check] < 250
12038                        || data[offset_for_check + 1] < 250
12039                        || data[offset_for_check + 2] < 250);
12040                let use_multiplicative = params.overprint
12041                    && params.painted_channels != stet_graphics::device::CMYK_ALL
12042                    && buffer_clean
12043                    && pixmap_has_colour;
12044
12045                // Write CMYK buffer at non-clipped pixels
12046                if let Some(ref mut buf) = cmyk_buf
12047                    && ci + 3 < buf.len()
12048                {
12049                    if params.overprint
12050                        && params.painted_channels != stet_graphics::device::CMYK_ALL
12051                    {
12052                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12053                            buf[ci] = cmyk.0 as f32;
12054                        }
12055                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12056                            buf[ci + 1] = cmyk.1 as f32;
12057                        }
12058                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12059                            buf[ci + 2] = cmyk.2 as f32;
12060                        }
12061                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12062                            buf[ci + 3] = cmyk.3 as f32;
12063                        }
12064                    } else {
12065                        buf[ci] = cmyk.0 as f32;
12066                        buf[ci + 1] = cmyk.1 as f32;
12067                        buf[ci + 2] = cmyk.2 as f32;
12068                        buf[ci + 3] = cmyk.3 as f32;
12069                    }
12070                }
12071
12072                let offset = py as usize * stride + px as usize * 4;
12073                if use_multiplicative {
12074                    // Ink-stack the per-stop CMYK onto the pixmap RGB. Only
12075                    // channels named by painted_channels contribute; others
12076                    // leave the pixmap untouched, so a spot-painted backdrop
12077                    // survives with just the named inks darkening it.
12078                    let bg_r = data[offset] as f64 / 255.0;
12079                    let bg_g = data[offset + 1] as f64 / 255.0;
12080                    let bg_b = data[offset + 2] as f64 / 255.0;
12081                    let over_r = if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12082                        1.0 - cmyk.0
12083                    } else {
12084                        1.0
12085                    };
12086                    let over_g = if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12087                        1.0 - cmyk.1
12088                    } else {
12089                        1.0
12090                    };
12091                    let over_b = if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12092                        1.0 - cmyk.2
12093                    } else {
12094                        1.0
12095                    };
12096                    let k_fac = if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12097                        1.0 - cmyk.3
12098                    } else {
12099                        1.0
12100                    };
12101                    data[offset] = ((bg_r * over_r * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12102                    data[offset + 1] =
12103                        ((bg_g * over_g * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12104                    data[offset + 2] =
12105                        ((bg_b * over_b * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12106                    data[offset + 3] = 255;
12107                } else {
12108                    data[offset] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
12109                    data[offset + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
12110                    data[offset + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
12111                    data[offset + 3] = 255;
12112
12113                    // Recomposite RGB from the CMYK buffer via ICC only for
12114                    // overprint DeviceCMYK shadings on a CMYK-only backdrop,
12115                    // where the per-channel merge in the buffer means the
12116                    // displayed pixel must reflect the merged CMYK rather
12117                    // than the source's RGB. For non-overprint shadings the
12118                    // LUT-rendered pixmap (above) is already correct, and
12119                    // round-tripping CMYK→RGB through the ICC profile
12120                    // produces a different gradient curve (linear in CMYK
12121                    // rather than linear in RGB) — that drift was the
12122                    // 3000_9 / 3000_10 snowman shading regression. The CMYK
12123                    // buffer is only consumed by `composite_non_isolated_cmyk`,
12124                    // which excludes shading-containing groups via
12125                    // `group_content_is_native_cmyk`, so the buffer/pixmap
12126                    // mismatch never reaches a consumer that would notice.
12127                    if params.overprint
12128                        && params.painted_channels != stet_graphics::device::CMYK_ALL
12129                        && matches!(
12130                            params.color_space,
12131                            ShadingColorSpace::DeviceCMYK
12132                                | ShadingColorSpace::Separation { .. }
12133                                | ShadingColorSpace::DeviceN { .. }
12134                        )
12135                        && let Some(ref mut buf) = cmyk_buf
12136                        && ci + 3 < buf.len()
12137                        && let Some(icc_cache) = icc
12138                    {
12139                        let c = buf[ci] as f64;
12140                        let m = buf[ci + 1] as f64;
12141                        let y = buf[ci + 2] as f64;
12142                        let k = buf[ci + 3] as f64;
12143                        if let Some((r, g, b)) = icc_cache.convert_cmyk_readonly(c, m, y, k) {
12144                            data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
12145                            data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
12146                            data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
12147                        }
12148                    }
12149                }
12150            }
12151        }
12152    }
12153}
12154/// Solve for the parameter t of a two-circle radial gradient at point (px, py).
12155///
12156/// Returns the largest root of the circle equation that falls within the valid
12157/// domain and has R(t) >= 0. The valid domain is [0,1], extended by extend flags.
12158#[allow(clippy::too_many_arguments)]
12159fn solve_radial_t(
12160    px: f64,
12161    py: f64,
12162    x0: f64,
12163    y0: f64,
12164    r0: f64,
12165    x1: f64,
12166    y1: f64,
12167    r1: f64,
12168    extend_start: bool,
12169    extend_end: bool,
12170) -> Option<f64> {
12171    // Parametric: C(t) = (1-t)*C0 + t*C1, R(t) = (1-t)*r0 + t*r1
12172    // Solve: (px - Cx(t))^2 + (py - Cy(t))^2 = R(t)^2
12173    let cdx = x1 - x0;
12174    let cdy = y1 - y0;
12175    let dr = r1 - r0;
12176
12177    let a = cdx * cdx + cdy * cdy - dr * dr;
12178    let dpx = px - x0;
12179    let dpy = py - y0;
12180    let b = -2.0 * (dpx * cdx + dpy * cdy + r0 * dr);
12181    let c = dpx * dpx + dpy * dpy - r0 * r0;
12182
12183    // Helper: check if a root is in the valid domain
12184    let in_domain = |t: f64| -> bool {
12185        (0.0..=1.0).contains(&t) || (t < 0.0 && extend_start) || (t > 1.0 && extend_end)
12186    };
12187
12188    if a.abs() < 1e-10 {
12189        // Linear case
12190        if b.abs() < 1e-10 {
12191            return None;
12192        }
12193        let t = -c / b;
12194        let radius = r0 + t * dr;
12195        if radius >= 0.0 && in_domain(t) {
12196            return Some(t);
12197        }
12198        return None;
12199    }
12200
12201    let discriminant = b * b - 4.0 * a * c;
12202    if discriminant < 0.0 {
12203        return None;
12204    }
12205    let sqrt_d = discriminant.sqrt();
12206    let t1 = (-b + sqrt_d) / (2.0 * a);
12207    let t2 = (-b - sqrt_d) / (2.0 * a);
12208
12209    // Pick the largest root that is in the valid domain and has R(t) >= 0
12210    let mut best: Option<f64> = None;
12211    for t in [t1, t2] {
12212        let radius = r0 + t * dr;
12213        if radius >= 0.0 && in_domain(t) {
12214            best = Some(match best {
12215                Some(prev) => prev.max(t),
12216                None => t,
12217            });
12218        }
12219    }
12220    best
12221}
12222
12223/// Render a Gouraud-shaded triangle mesh.
12224#[allow(clippy::too_many_arguments)]
12225fn render_mesh_shading(
12226    pixmap: &mut Pixmap,
12227    params: &MeshShadingParams,
12228    vp_x: f32,
12229    vp_y: f32,
12230    scale_x: f32,
12231    scale_y: f32,
12232    clip_mask: Option<&Mask>,
12233    mut cmyk_buf: Option<&mut [f32]>,
12234    icc: Option<&IccCache>,
12235) {
12236    let pw = pixmap.width() as usize;
12237    let ph = pixmap.height() as usize;
12238    if pw == 0 || ph == 0 {
12239        return;
12240    }
12241    let data = pixmap.data_mut();
12242    let stride = pw * 4;
12243
12244    let lut = params.color_lut.as_deref();
12245
12246    for tri in &params.triangles {
12247        let (dx0, dy0) = params.ctm.transform_point(tri.v0.x, tri.v0.y);
12248        let (dx1, dy1) = params.ctm.transform_point(tri.v1.x, tri.v1.y);
12249        let (dx2, dy2) = params.ctm.transform_point(tri.v2.x, tri.v2.y);
12250
12251        let x0 = (dx0 as f32 - vp_x) * scale_x;
12252        let y0 = (dy0 as f32 - vp_y) * scale_y;
12253        let x1 = (dx1 as f32 - vp_x) * scale_x;
12254        let y1 = (dy1 as f32 - vp_y) * scale_y;
12255        let x2 = (dx2 as f32 - vp_x) * scale_x;
12256        let y2 = (dy2 as f32 - vp_y) * scale_y;
12257
12258        let min_x = (x0.min(x1).min(x2).floor().max(0.0)) as usize;
12259        let max_x = (x0.max(x1).max(x2).ceil() as usize).min(pw);
12260        let min_y = (y0.min(y1).min(y2).floor().max(0.0)) as usize;
12261        let max_y = (y0.max(y1).max(y2).ceil() as usize).min(ph);
12262
12263        if min_x >= max_x || min_y >= max_y {
12264            continue;
12265        }
12266
12267        let x0 = x0 as f64;
12268        let y0 = y0 as f64;
12269        let x1 = x1 as f64;
12270        let y1 = y1 as f64;
12271        let x2 = x2 as f64;
12272        let y2 = y2 as f64;
12273        // Swap vertices 1 and 2 when the triangle has reversed winding
12274        // (from a CTM with negative determinant, e.g. X- or Y-flip).
12275        // This ensures barycentric coordinates stay positive for interior
12276        // points regardless of the CTM orientation.
12277        let denom = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2);
12278        if denom.abs() < 1e-10 {
12279            continue;
12280        }
12281        let (x1, y1, x2, y2) = if denom < 0.0 {
12282            (x2, y2, x1, y1)
12283        } else {
12284            (x1, y1, x2, y2)
12285        };
12286        let (v1_ref, v2_ref) = if denom < 0.0 {
12287            (&tri.v2, &tri.v1)
12288        } else {
12289            (&tri.v1, &tri.v2)
12290        };
12291        let denom = denom.abs();
12292        let inv_denom = 1.0 / denom;
12293
12294        for py in min_y..max_y {
12295            for px in min_x..max_x {
12296                let pxf = px as f64 + 0.5;
12297                let pyf = py as f64 + 0.5;
12298
12299                let w0 = ((y1 - y2) * (pxf - x2) + (x2 - x1) * (pyf - y2)) * inv_denom;
12300                let w1 = ((y2 - y0) * (pxf - x2) + (x0 - x2) * (pyf - y2)) * inv_denom;
12301                let w2 = 1.0 - w0 - w1;
12302
12303                if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
12304                    continue;
12305                }
12306
12307                let clipped = clip_mask.is_some_and(|mask| mask.data()[py * pw + px] == 0);
12308
12309                let w0c = w0.max(0.0);
12310                let w1c = w1.max(0.0);
12311                let w2c = w2.max(0.0);
12312                let wsum = w0c + w1c + w2c;
12313                let w0n = w0c / wsum;
12314                let w1n = w1c / wsum;
12315                let w2n = w2c / wsum;
12316
12317                // Per-pixel color: either LUT lookup (for function-based meshes)
12318                // or direct Gouraud interpolation of vertex DeviceColors.
12319                let (r, g, b) = if let Some(lut) = lut {
12320                    // Interpolate raw function input values per-pixel
12321                    let raw = w0n * tri.v0.raw_components[0]
12322                        + w1n * v1_ref.raw_components[0]
12323                        + w2n * v2_ref.raw_components[0];
12324                    let raw = raw.clamp(0.0, 1.0);
12325                    // Linear interpolation in the LUT
12326                    let fi = raw * (lut.len() - 1) as f64;
12327                    let i0 = (fi as usize).min(lut.len().saturating_sub(2));
12328                    let frac = fi - i0 as f64;
12329                    let c0 = &lut[i0];
12330                    let c1 = &lut[i0 + 1];
12331                    (
12332                        c0.r + frac * (c1.r - c0.r),
12333                        c0.g + frac * (c1.g - c0.g),
12334                        c0.b + frac * (c1.b - c0.b),
12335                    )
12336                } else {
12337                    (
12338                        w0n * tri.v0.color.r + w1n * v1_ref.color.r + w2n * v2_ref.color.r,
12339                        w0n * tri.v0.color.g + w1n * v1_ref.color.g + w2n * v2_ref.color.g,
12340                        w0n * tri.v0.color.b + w1n * v1_ref.color.b + w2n * v2_ref.color.b,
12341                    )
12342                };
12343
12344                // Write CMYK buffer
12345                if let Some(ref mut buf) = cmyk_buf {
12346                    let ci = (py * pw + px) * 4;
12347                    if ci + 3 < buf.len() {
12348                        let cmyk = interpolate_cmyk_from_vertices(
12349                            &tri.v0,
12350                            v1_ref,
12351                            v2_ref,
12352                            w0n,
12353                            w1n,
12354                            w2n,
12355                            &params.color_space,
12356                            r,
12357                            g,
12358                            b,
12359                            icc,
12360                        );
12361                        if params.overprint
12362                            && params.painted_channels != stet_graphics::device::CMYK_ALL
12363                        {
12364                            if !clipped {
12365                                if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12366                                    buf[ci] = cmyk.0 as f32;
12367                                }
12368                                if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12369                                    buf[ci + 1] = cmyk.1 as f32;
12370                                }
12371                                if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12372                                    buf[ci + 2] = cmyk.2 as f32;
12373                                }
12374                                if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12375                                    buf[ci + 3] = cmyk.3 as f32;
12376                                }
12377                            }
12378                        } else {
12379                            buf[ci] = cmyk.0 as f32;
12380                            buf[ci + 1] = cmyk.1 as f32;
12381                            buf[ci + 2] = cmyk.2 as f32;
12382                            buf[ci + 3] = cmyk.3 as f32;
12383                        }
12384                    }
12385                }
12386
12387                if clipped {
12388                    continue;
12389                }
12390
12391                let offset = py * stride + px * 4;
12392                data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
12393                data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
12394                data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
12395                data[offset + 3] = 255;
12396            }
12397        }
12398    }
12399}
12400
12401/// Render a Coons/tensor-product patch mesh by subdividing into triangles.
12402#[allow(clippy::too_many_arguments)]
12403fn render_patch_shading(
12404    pixmap: &mut Pixmap,
12405    params: &PatchShadingParams,
12406    vp_x: f32,
12407    vp_y: f32,
12408    scale_x: f32,
12409    scale_y: f32,
12410    clip_mask: Option<&Mask>,
12411    cmyk_buf: Option<&mut [f32]>,
12412    icc: Option<&IccCache>,
12413) {
12414    let mut triangles = Vec::new();
12415    let scale = scale_x.max(scale_y) as f64;
12416    for patch in &params.patches {
12417        if patch.points.len() >= 12 {
12418            // Compute device-space extent to choose subdivision level
12419            let mut x_min = f64::INFINITY;
12420            let mut y_min = f64::INFINITY;
12421            let mut x_max = f64::NEG_INFINITY;
12422            let mut y_max = f64::NEG_INFINITY;
12423            for &(px, py) in &patch.points {
12424                let (dx, dy) = params.ctm.transform_point(px, py);
12425                x_min = x_min.min(dx);
12426                y_min = y_min.min(dy);
12427                x_max = x_max.max(dx);
12428                y_max = y_max.max(dy);
12429            }
12430            let extent = (x_max - x_min).max(y_max - y_min).abs() * scale;
12431            // Target ~2 device pixels per boundary segment
12432            let n = (extent / 2.0).ceil().clamp(8.0, 64.0) as usize;
12433            // Extract ICC profile hash for per-grid-point color conversion
12434            let icc_profile_hash = match &params.color_space {
12435                stet_graphics::device::ShadingColorSpace::ICCBased { profile_hash, .. } => {
12436                    Some(profile_hash)
12437                }
12438                _ => None,
12439            };
12440            subdivide_patch_to_triangles(patch, &mut triangles, n, icc_profile_hash, icc);
12441        }
12442    }
12443    if !triangles.is_empty() {
12444        let mesh_params = MeshShadingParams {
12445            triangles,
12446            ctm: params.ctm,
12447            bbox: params.bbox,
12448            color_space: params.color_space.clone(),
12449            overprint: params.overprint,
12450            overprint_mode: params.overprint_mode,
12451            painted_channels: params.painted_channels,
12452            color_lut: params.color_lut.clone(),
12453            alpha: params.alpha,
12454            blend_mode: params.blend_mode,
12455            alpha_is_shape: params.alpha_is_shape,
12456        };
12457        render_mesh_shading(
12458            pixmap,
12459            &mesh_params,
12460            vp_x,
12461            vp_y,
12462            scale_x,
12463            scale_y,
12464            clip_mask,
12465            cmyk_buf,
12466            icc,
12467        );
12468    }
12469}
12470/// Subdivide a Coons/tensor patch into triangles via grid subdivision.
12471/// Evaluates the patch at NxN points and triangulates the resulting grid.
12472/// When an ICC profile hash and cache are provided, interpolates colors in the
12473/// source ICC color space and converts per-grid-point for accurate rendering.
12474fn subdivide_patch_to_triangles(
12475    patch: &stet_graphics::device::ShadingPatch,
12476    triangles: &mut Vec<stet_graphics::device::ShadingTriangle>,
12477    n: usize,
12478    icc_profile_hash: Option<&stet_graphics::icc::ProfileHash>,
12479    icc_cache: Option<&IccCache>,
12480) {
12481    // Evaluate patch at grid points.
12482    // Use tensor-product evaluation when 16 control points are available (Type 7),
12483    // otherwise fall back to Coons blending (Type 6, 12 points).
12484    let mut grid: Vec<(f64, f64, DeviceColor, Vec<f64>)> = Vec::with_capacity((n + 1) * (n + 1));
12485    let use_tensor = patch.points.len() >= 16;
12486    let has_raw = !patch.raw_colors[0].is_empty();
12487    // Use per-grid-point ICC conversion when profile info is available
12488    let use_icc_interp = has_raw && icc_profile_hash.is_some() && icc_cache.is_some();
12489
12490    for row in 0..=n {
12491        let v = row as f64 / n as f64;
12492        for col in 0..=n {
12493            let u = col as f64 / n as f64;
12494            let (x, y) = if use_tensor {
12495                eval_tensor_patch(patch, u, v)
12496            } else {
12497                eval_coons_patch(patch, u, v)
12498            };
12499            let raw = if has_raw {
12500                bilinear_raw(&patch.raw_colors, u, v)
12501            } else {
12502                vec![]
12503            };
12504            // When ICC profile is available, convert the interpolated raw
12505            // components at each grid point for accurate color rendering.
12506            // This interpolates in the source color space (e.g. ProPhoto RGB)
12507            // and converts per-grid-point, rather than interpolating pre-converted
12508            // sRGB values from only the 4 corners.
12509            let color = if use_icc_interp {
12510                if let Some((r, g, b)) = icc_cache
12511                    .unwrap()
12512                    .convert_color_readonly(icc_profile_hash.unwrap(), &raw)
12513                {
12514                    DeviceColor::from_rgb(r, g, b)
12515                } else {
12516                    bilinear_color(&patch.colors, u, v)
12517                }
12518            } else {
12519                bilinear_color(&patch.colors, u, v)
12520            };
12521            grid.push((x, y, color, raw));
12522        }
12523    }
12524
12525    // Triangulate grid
12526    let cols = n + 1;
12527    for row in 0..n {
12528        for col in 0..n {
12529            let i00 = row * cols + col;
12530            let i10 = i00 + 1;
12531            let i01 = i00 + cols;
12532            let i11 = i01 + 1;
12533
12534            let (x00, y00, c00, r00) = &grid[i00];
12535            let (x10, y10, c10, r10) = &grid[i10];
12536            let (x01, y01, c01, r01) = &grid[i01];
12537            let (x11, y11, c11, r11) = &grid[i11];
12538
12539            use stet_graphics::device::ShadingVertex;
12540            triangles.push(stet_graphics::device::ShadingTriangle {
12541                v0: ShadingVertex {
12542                    x: *x00,
12543                    y: *y00,
12544                    color: c00.clone(),
12545                    raw_components: r00.clone(),
12546                },
12547                v1: ShadingVertex {
12548                    x: *x10,
12549                    y: *y10,
12550                    color: c10.clone(),
12551                    raw_components: r10.clone(),
12552                },
12553                v2: ShadingVertex {
12554                    x: *x01,
12555                    y: *y01,
12556                    color: c01.clone(),
12557                    raw_components: r01.clone(),
12558                },
12559            });
12560            triangles.push(stet_graphics::device::ShadingTriangle {
12561                v0: ShadingVertex {
12562                    x: *x10,
12563                    y: *y10,
12564                    color: c10.clone(),
12565                    raw_components: r10.clone(),
12566                },
12567                v1: ShadingVertex {
12568                    x: *x11,
12569                    y: *y11,
12570                    color: c11.clone(),
12571                    raw_components: r11.clone(),
12572                },
12573                v2: ShadingVertex {
12574                    x: *x01,
12575                    y: *y01,
12576                    color: c01.clone(),
12577                    raw_components: r01.clone(),
12578                },
12579            });
12580        }
12581    }
12582}
12583
12584/// Evaluate a Coons patch at parameter (u, v).
12585/// The 12 control points define 4 cubic Bezier boundary curves.
12586fn eval_coons_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12587    let pts = &patch.points;
12588    if pts.len() < 12 {
12589        return (0.0, 0.0);
12590    }
12591
12592    // Side 0 (bottom): pts[0..4], u goes 0→1
12593    // Side 1 (right): pts[3..7], v goes 0→1
12594    // Side 2 (top): pts[6..10], u goes 1→0 (reversed)
12595    // Side 3 (left): pts[9..12] + pts[0], v goes 1→0 (reversed)
12596    let c0 = eval_cubic_bezier(pts[0], pts[1], pts[2], pts[3], u);
12597    let c2 = eval_cubic_bezier(pts[6], pts[7], pts[8], pts[9], 1.0 - u);
12598    let d0 = eval_cubic_bezier(pts[0], pts[11], pts[10], pts[9], v);
12599    let d1 = eval_cubic_bezier(pts[3], pts[4], pts[5], pts[6], v);
12600
12601    // Bilinear blending of corners
12602    let p00 = pts[0];
12603    let p10 = pts[3];
12604    let p01 = pts[9];
12605    let p11 = pts[6];
12606    let bx = (1.0 - u) * (1.0 - v) * p00.0
12607        + u * (1.0 - v) * p10.0
12608        + (1.0 - u) * v * p01.0
12609        + u * v * p11.0;
12610    let by = (1.0 - u) * (1.0 - v) * p00.1
12611        + u * (1.0 - v) * p10.1
12612        + (1.0 - u) * v * p01.1
12613        + u * v * p11.1;
12614
12615    // Coons blending: S(u,v) = c(u,v) + d(u,v) - B(u,v)
12616    let x = (1.0 - v) * c0.0 + v * c2.0 + (1.0 - u) * d0.0 + u * d1.0 - bx;
12617    let y = (1.0 - v) * c0.1 + v * c2.1 + (1.0 - u) * d0.1 + u * d1.1 - by;
12618
12619    (x, y)
12620}
12621
12622/// Evaluate a Type 7 tensor-product patch at parameter (u, v).
12623///
12624/// Uses 16 control points arranged in a 4×4 grid, evaluated as a bicubic
12625/// Bernstein surface: S(u,v) = ΣΣ B_i(u) * B_j(v) * P_ij
12626///
12627/// PDF spec (ISO 32000, Table 85) data ordering for flag=0:
12628///   p₁₁ p₁₂ p₁₃ p₁₄  p₂₁ p₂₂ p₂₃ p₂₄  p₃₁ p₃₂ p₃₃ p₃₄  p₄₁ p₄₂ p₄₃ p₄₄
12629///
12630/// In the grid (Figure 86), column index = u direction, row index = v direction:
12631///   grid[v=0][u] = p₁₁, p₂₁, p₃₁, p₄₁  = pts[0], pts[4], pts[8],  pts[12]
12632///   grid[v=⅓][u] = p₁₂, p₂₂, p₃₂, p₄₂  = pts[1], pts[5], pts[9],  pts[13]
12633///   grid[v=⅔][u] = p₁₃, p₂₃, p₃₃, p₄₃  = pts[2], pts[6], pts[10], pts[14]
12634///   grid[v=1][u] = p₁₄, p₂₄, p₃₄, p₄₄  = pts[3], pts[7], pts[11], pts[15]
12635fn eval_tensor_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12636    let pts = &patch.points;
12637
12638    // Map data indices to 4×4 grid [row][col].
12639    // pts[0..12] are boundary points around the perimeter (same as Type 6).
12640    // pts[12..16] are the 4 interior control points.
12641    let grid: [[usize; 4]; 4] = [[0, 1, 2, 3], [11, 12, 13, 4], [10, 15, 14, 5], [9, 8, 7, 6]];
12642
12643    // Cubic Bernstein basis values
12644    let su = 1.0 - u;
12645    let bu = [su * su * su, 3.0 * su * su * u, 3.0 * su * u * u, u * u * u];
12646    let sv = 1.0 - v;
12647    let bv = [sv * sv * sv, 3.0 * sv * sv * v, 3.0 * sv * v * v, v * v * v];
12648
12649    let mut x = 0.0;
12650    let mut y = 0.0;
12651    for j in 0..4 {
12652        for i in 0..4 {
12653            let w = bu[i] * bv[j];
12654            let p = pts[grid[j][i]];
12655            x += w * p.0;
12656            y += w * p.1;
12657        }
12658    }
12659    (x, y)
12660}
12661
12662/// Evaluate a cubic Bezier curve at parameter t.
12663fn eval_cubic_bezier(
12664    p0: (f64, f64),
12665    p1: (f64, f64),
12666    p2: (f64, f64),
12667    p3: (f64, f64),
12668    t: f64,
12669) -> (f64, f64) {
12670    let s = 1.0 - t;
12671    let s2 = s * s;
12672    let t2 = t * t;
12673    let b0 = s2 * s;
12674    let b1 = 3.0 * s2 * t;
12675    let b2 = 3.0 * s * t2;
12676    let b3 = t2 * t;
12677    (
12678        b0 * p0.0 + b1 * p1.0 + b2 * p2.0 + b3 * p3.0,
12679        b0 * p0.1 + b1 * p1.1 + b2 * p2.1 + b3 * p3.1,
12680    )
12681}
12682
12683/// Bilinear color interpolation across patch corners.
12684fn bilinear_color(colors: &[DeviceColor; 4], u: f64, v: f64) -> DeviceColor {
12685    let r = (1.0 - u) * (1.0 - v) * colors[0].r
12686        + u * (1.0 - v) * colors[1].r
12687        + (1.0 - u) * v * colors[3].r
12688        + u * v * colors[2].r;
12689    let g = (1.0 - u) * (1.0 - v) * colors[0].g
12690        + u * (1.0 - v) * colors[1].g
12691        + (1.0 - u) * v * colors[3].g
12692        + u * v * colors[2].g;
12693    let b = (1.0 - u) * (1.0 - v) * colors[0].b
12694        + u * (1.0 - v) * colors[1].b
12695        + (1.0 - u) * v * colors[3].b
12696        + u * v * colors[2].b;
12697    DeviceColor::from_rgb(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
12698}
12699
12700/// Bilinear interpolation of raw color components across patch corners.
12701fn bilinear_raw(raw_colors: &[Vec<f64>; 4], u: f64, v: f64) -> Vec<f64> {
12702    let n = raw_colors[0].len();
12703    let mut result = vec![0.0; n];
12704    for i in 0..n {
12705        result[i] = (1.0 - u) * (1.0 - v) * raw_colors[0][i]
12706            + u * (1.0 - v) * raw_colors[1][i]
12707            + (1.0 - u) * v * raw_colors[3][i]
12708            + u * v * raw_colors[2][i];
12709    }
12710    result
12711}
12712
12713/// Pre-rasterize color stops into a 256-entry RGBA lookup table.
12714///
12715/// Each entry is linearly interpolated from the color stops. Used by the
12716/// direct-rasterization axial shading path to replace per-pixel stop search
12717/// with a single array lookup.
12718fn build_gradient_lut(stops: &[stet_graphics::device::ColorStop], size: usize) -> Vec<[u8; 4]> {
12719    let size = size.max(2);
12720    let mut lut = vec![[0u8; 4]; size];
12721    if stops.is_empty() {
12722        return lut;
12723    }
12724    let mut si = 0usize; // current stop index
12725    let last = (size - 1) as f64;
12726    for i in 0..size {
12727        let t = i as f64 / last;
12728        // Advance stop index
12729        while si + 1 < stops.len() && stops[si + 1].position < t {
12730            si += 1;
12731        }
12732        let (r, g, b) = if si + 1 >= stops.len() {
12733            let c = &stops[stops.len() - 1].color;
12734            (c.r, c.g, c.b)
12735        } else if t <= stops[si].position {
12736            let c = &stops[si].color;
12737            (c.r, c.g, c.b)
12738        } else {
12739            let t0 = stops[si].position;
12740            let t1 = stops[si + 1].position;
12741            let frac = if (t1 - t0).abs() < 1e-10 {
12742                0.0
12743            } else {
12744                (t - t0) / (t1 - t0)
12745            };
12746            let c0 = &stops[si].color;
12747            let c1 = &stops[si + 1].color;
12748            (
12749                c0.r + frac * (c1.r - c0.r),
12750                c0.g + frac * (c1.g - c0.g),
12751                c0.b + frac * (c1.b - c0.b),
12752            )
12753        };
12754        lut[i] = [
12755            (r * 255.0).round().clamp(0.0, 255.0) as u8,
12756            (g * 255.0).round().clamp(0.0, 255.0) as u8,
12757            (b * 255.0).round().clamp(0.0, 255.0) as u8,
12758            255,
12759        ];
12760    }
12761    lut
12762}
12763
12764/// Build tiny-skia gradient stops from color stops.
12765fn build_gradient_stops(
12766    stops: &[stet_graphics::device::ColorStop],
12767) -> Vec<stet_tiny_skia::GradientStop> {
12768    let mut result = Vec::with_capacity(stops.len());
12769    for stop in stops {
12770        let r = (stop.color.r * 255.0).round().clamp(0.0, 255.0) as u8;
12771        let g = (stop.color.g * 255.0).round().clamp(0.0, 255.0) as u8;
12772        let b = (stop.color.b * 255.0).round().clamp(0.0, 255.0) as u8;
12773        result.push(stet_tiny_skia::GradientStop::new(
12774            stop.position as f32,
12775            Color::from_rgba8(r, g, b, 255),
12776        ));
12777    }
12778    result
12779}
12780
12781/// Interpolate between color stops at a given position (0.0..=1.0).
12782fn interpolate_color_stops(
12783    stops: &[stet_graphics::device::ColorStop],
12784    position: f64,
12785) -> DeviceColor {
12786    if stops.is_empty() {
12787        return DeviceColor::from_gray(0.0);
12788    }
12789    if stops.len() == 1 || position <= stops[0].position {
12790        return stops[0].color.clone();
12791    }
12792    if position >= stops.last().unwrap().position {
12793        return stops.last().unwrap().color.clone();
12794    }
12795
12796    // Find the two stops bracketing this position
12797    for i in 1..stops.len() {
12798        if position <= stops[i].position {
12799            let t0 = stops[i - 1].position;
12800            let t1 = stops[i].position;
12801            let frac = if (t1 - t0).abs() < 1e-10 {
12802                0.0
12803            } else {
12804                (position - t0) / (t1 - t0)
12805            };
12806            let c0 = &stops[i - 1].color;
12807            let c1 = &stops[i].color;
12808            return DeviceColor::from_rgb(
12809                (c0.r + frac * (c1.r - c0.r)).clamp(0.0, 1.0),
12810                (c0.g + frac * (c1.g - c0.g)).clamp(0.0, 1.0),
12811                (c0.b + frac * (c1.b - c0.b)).clamp(0.0, 1.0),
12812            );
12813        }
12814    }
12815
12816    stops.last().unwrap().color.clone()
12817}
12818
12819/// Derive CMYK values from color stops at parameter t.
12820///
12821/// For DeviceCMYK shading color spaces the per-stop `raw_components` carry the
12822/// authoritative 4-channel CMYK values (already tint-transformed for
12823/// Separation/DeviceN with a CMYK alt) — those are interpolated directly.
12824///
12825/// For non-CMYK source color spaces (DeviceRGB, DeviceGray, CalRGB, CalGray,
12826/// ICCBased non-4) the interpolated sRGB color is round-tripped to CMYK via
12827/// the system CMYK ICC profile so the parallel CMYK buffer holds an accurate
12828/// representation. Falls back to PLRM `(1−r, 1−g, 1−b, 0)` when no system
12829/// profile is registered (e.g. `--no-icc`).
12830fn interpolate_cmyk_from_stops(
12831    stops: &[stet_graphics::device::ColorStop],
12832    cs: &ShadingColorSpace,
12833    t: f64,
12834    color: &DeviceColor,
12835    icc: Option<&IccCache>,
12836) -> (f64, f64, f64, f64) {
12837    let rgb_to_cmyk = |c: &DeviceColor| -> (f64, f64, f64, f64) {
12838        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(c.r, c.g, c.b)) {
12839            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
12840        } else {
12841            (
12842                (1.0 - c.r).clamp(0.0, 1.0),
12843                (1.0 - c.g).clamp(0.0, 1.0),
12844                (1.0 - c.b).clamp(0.0, 1.0),
12845                0.0,
12846            )
12847        }
12848    };
12849
12850    match cs {
12851        // Separation/DeviceN with a CMYK alternate carry tint-transformed CMYK
12852        // in `raw_components`, the same shape as DeviceCMYK — handle them on
12853        // the same path so spot-shading round-trips render identically.
12854        ShadingColorSpace::DeviceCMYK
12855        | ShadingColorSpace::Separation { .. }
12856        | ShadingColorSpace::DeviceN { .. } => {
12857            // Interpolate raw CMYK components from stops
12858            if stops.len() == 1 {
12859                let rc = &stops[0].raw_components;
12860                if rc.len() >= 4 {
12861                    return (rc[0], rc[1], rc[2], rc[3]);
12862                }
12863            }
12864            // Find surrounding stops and interpolate
12865            let mut lo = &stops[0];
12866            let mut hi = stops.last().unwrap();
12867            for i in 0..stops.len() - 1 {
12868                if stops[i + 1].position >= t {
12869                    lo = &stops[i];
12870                    hi = &stops[i + 1];
12871                    break;
12872                }
12873            }
12874            let span = hi.position - lo.position;
12875            let frac = if span > 1e-10 {
12876                (t - lo.position) / span
12877            } else {
12878                0.0
12879            };
12880            let frac = frac.clamp(0.0, 1.0);
12881            if lo.raw_components.len() >= 4 && hi.raw_components.len() >= 4 {
12882                (
12883                    lo.raw_components[0] + frac * (hi.raw_components[0] - lo.raw_components[0]),
12884                    lo.raw_components[1] + frac * (hi.raw_components[1] - lo.raw_components[1]),
12885                    lo.raw_components[2] + frac * (hi.raw_components[2] - lo.raw_components[2]),
12886                    lo.raw_components[3] + frac * (hi.raw_components[3] - lo.raw_components[3]),
12887                )
12888            } else {
12889                rgb_to_cmyk(color)
12890            }
12891        }
12892        _ => rgb_to_cmyk(color),
12893    }
12894}
12895
12896/// Derive CMYK values from triangle mesh vertices using barycentric weights.
12897///
12898/// Mirrors [`interpolate_cmyk_from_stops`]: DeviceCMYK source spaces use the
12899/// per-vertex `raw_components`, non-CMYK spaces ICC-reverse the interpolated
12900/// sRGB color, and PLRM is the last-resort fallback.
12901#[allow(clippy::too_many_arguments)]
12902fn interpolate_cmyk_from_vertices(
12903    v0: &ShadingVertex,
12904    v1: &ShadingVertex,
12905    v2: &ShadingVertex,
12906    w0: f64,
12907    w1: f64,
12908    w2: f64,
12909    cs: &ShadingColorSpace,
12910    r: f64,
12911    g: f64,
12912    b: f64,
12913    icc: Option<&IccCache>,
12914) -> (f64, f64, f64, f64) {
12915    let rgb_to_cmyk = |r: f64, g: f64, b: f64| -> (f64, f64, f64, f64) {
12916        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
12917            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
12918        } else {
12919            (
12920                (1.0 - r).clamp(0.0, 1.0),
12921                (1.0 - g).clamp(0.0, 1.0),
12922                (1.0 - b).clamp(0.0, 1.0),
12923                0.0,
12924            )
12925        }
12926    };
12927
12928    match cs {
12929        ShadingColorSpace::DeviceCMYK
12930        | ShadingColorSpace::Separation { .. }
12931        | ShadingColorSpace::DeviceN { .. } => {
12932            if v0.raw_components.len() >= 4
12933                && v1.raw_components.len() >= 4
12934                && v2.raw_components.len() >= 4
12935            {
12936                (
12937                    w0 * v0.raw_components[0]
12938                        + w1 * v1.raw_components[0]
12939                        + w2 * v2.raw_components[0],
12940                    w0 * v0.raw_components[1]
12941                        + w1 * v1.raw_components[1]
12942                        + w2 * v2.raw_components[1],
12943                    w0 * v0.raw_components[2]
12944                        + w1 * v1.raw_components[2]
12945                        + w2 * v2.raw_components[2],
12946                    w0 * v0.raw_components[3]
12947                        + w1 * v1.raw_components[3]
12948                        + w2 * v2.raw_components[3],
12949                )
12950            } else {
12951                rgb_to_cmyk(r, g, b)
12952            }
12953        }
12954        _ => rgb_to_cmyk(r, g, b),
12955    }
12956}
12957
12958#[cfg(test)]
12959mod tests {
12960    use super::*;
12961    use stet_graphics::color::DashPattern;
12962    use stet_graphics::device::{BgUcrState, HalftoneState, TransferState};
12963
12964    #[test]
12965    fn test_create_device() {
12966        let dev = SkiaDevice::new(100, 100);
12967        assert_eq!(dev.page_size(), (100, 100));
12968    }
12969
12970    #[test]
12971    fn test_fill_rect() {
12972        let mut dev = SkiaDevice::new(100, 100);
12973        let mut path = PsPath::new();
12974        path.segments.push(PathSegment::MoveTo(10.0, 10.0));
12975        path.segments.push(PathSegment::LineTo(90.0, 10.0));
12976        path.segments.push(PathSegment::LineTo(90.0, 90.0));
12977        path.segments.push(PathSegment::LineTo(10.0, 90.0));
12978        path.segments.push(PathSegment::ClosePath);
12979
12980        let params = FillParams {
12981            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
12982            fill_rule: FillRule::NonZeroWinding,
12983            ctm: Matrix::identity(),
12984            is_text_glyph: false,
12985            overprint: false,
12986            overprint_mode: 0,
12987            opm_paired: false,
12988            painted_channels: 0,
12989            is_device_cmyk: false,
12990            spot_color: None,
12991            icc_color: None,
12992            rendering_intent: 0,
12993            transfer: TransferState::default(),
12994            halftone: HalftoneState::default(),
12995            bg_ucr: BgUcrState::default(),
12996            alpha: 1.0,
12997            blend_mode: 0,
12998            alpha_is_shape: false,
12999        };
13000        dev.fill_path(&path, &params);
13001
13002        // Check that pixel at center is red
13003        let pixel = dev.pixmap().pixel(50, 50).unwrap();
13004        assert_eq!(pixel.red(), 255);
13005        assert_eq!(pixel.green(), 0);
13006        assert_eq!(pixel.blue(), 0);
13007    }
13008
13009    #[test]
13010    fn test_stroke_line() {
13011        let mut dev = SkiaDevice::new(100, 100);
13012        let mut path = PsPath::new();
13013        path.segments.push(PathSegment::MoveTo(10.0, 50.0));
13014        path.segments.push(PathSegment::LineTo(90.0, 50.0));
13015
13016        let params = StrokeParams {
13017            color: DeviceColor::from_rgb(0.0, 0.0, 1.0),
13018            line_width: 4.0,
13019            line_cap: LineCap::Butt,
13020            line_join: LineJoin::Miter,
13021            miter_limit: 10.0,
13022            dash_pattern: DashPattern::solid(),
13023            ctm: Matrix::identity(),
13024            stroke_adjust: false,
13025            is_text_glyph: false,
13026            overprint: false,
13027            overprint_mode: 0,
13028            opm_paired: false,
13029            painted_channels: 0,
13030            is_device_cmyk: false,
13031            spot_color: None,
13032            icc_color: None,
13033            rendering_intent: 0,
13034            transfer: TransferState::default(),
13035            halftone: HalftoneState::default(),
13036            bg_ucr: BgUcrState::default(),
13037            alpha: 1.0,
13038            blend_mode: 0,
13039            alpha_is_shape: false,
13040        };
13041        dev.stroke_path(&path, &params);
13042
13043        // Check that pixel on the line is blue
13044        let pixel = dev.pixmap().pixel(50, 50).unwrap();
13045        assert_eq!(pixel.blue(), 255);
13046    }
13047
13048    #[test]
13049    fn test_clip() {
13050        let mut dev = SkiaDevice::new(100, 100);
13051
13052        // Set clip to left half
13053        let mut clip_path = PsPath::new();
13054        clip_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13055        clip_path.segments.push(PathSegment::LineTo(50.0, 0.0));
13056        clip_path.segments.push(PathSegment::LineTo(50.0, 100.0));
13057        clip_path.segments.push(PathSegment::LineTo(0.0, 100.0));
13058        clip_path.segments.push(PathSegment::ClosePath);
13059
13060        let clip_params = ClipParams {
13061            fill_rule: FillRule::NonZeroWinding,
13062            ctm: Matrix::identity(),
13063            stroke_params: None,
13064        };
13065        dev.clip_path(&clip_path, &clip_params);
13066
13067        // Fill entire page with red
13068        let mut fill_path = PsPath::new();
13069        fill_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13070        fill_path.segments.push(PathSegment::LineTo(100.0, 0.0));
13071        fill_path.segments.push(PathSegment::LineTo(100.0, 100.0));
13072        fill_path.segments.push(PathSegment::LineTo(0.0, 100.0));
13073        fill_path.segments.push(PathSegment::ClosePath);
13074
13075        let fill_params = FillParams {
13076            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
13077            fill_rule: FillRule::NonZeroWinding,
13078            ctm: Matrix::identity(),
13079            is_text_glyph: false,
13080            overprint: false,
13081            overprint_mode: 0,
13082            opm_paired: false,
13083            painted_channels: 0,
13084            is_device_cmyk: false,
13085            spot_color: None,
13086            icc_color: None,
13087            rendering_intent: 0,
13088            transfer: TransferState::default(),
13089            halftone: HalftoneState::default(),
13090            bg_ucr: BgUcrState::default(),
13091            alpha: 1.0,
13092            blend_mode: 0,
13093            alpha_is_shape: false,
13094        };
13095        dev.fill_path(&fill_path, &fill_params);
13096
13097        // Left half should be red
13098        let left_pixel = dev.pixmap().pixel(25, 50).unwrap();
13099        assert_eq!(left_pixel.red(), 255);
13100
13101        // Right half should still be white
13102        let right_pixel = dev.pixmap().pixel(75, 50).unwrap();
13103        assert_eq!(right_pixel.red(), 255);
13104        assert_eq!(right_pixel.green(), 255); // white
13105    }
13106
13107    #[test]
13108    fn test_erase_page() {
13109        let mut dev = SkiaDevice::new(100, 100);
13110        // Fill with red
13111        let mut path = PsPath::new();
13112        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13113        path.segments.push(PathSegment::LineTo(100.0, 0.0));
13114        path.segments.push(PathSegment::LineTo(100.0, 100.0));
13115        path.segments.push(PathSegment::LineTo(0.0, 100.0));
13116        path.segments.push(PathSegment::ClosePath);
13117        let params = FillParams {
13118            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
13119            fill_rule: FillRule::NonZeroWinding,
13120            ctm: Matrix::identity(),
13121            is_text_glyph: false,
13122            overprint: false,
13123            overprint_mode: 0,
13124            opm_paired: false,
13125            painted_channels: 0,
13126            is_device_cmyk: false,
13127            spot_color: None,
13128            icc_color: None,
13129            rendering_intent: 0,
13130            transfer: TransferState::default(),
13131            halftone: HalftoneState::default(),
13132            bg_ucr: BgUcrState::default(),
13133            alpha: 1.0,
13134            blend_mode: 0,
13135            alpha_is_shape: false,
13136        };
13137        dev.fill_path(&path, &params);
13138
13139        dev.erase_page();
13140
13141        // Should be white again
13142        let pixel = dev.pixmap().pixel(50, 50).unwrap();
13143        assert_eq!(pixel.red(), 255);
13144        assert_eq!(pixel.green(), 255);
13145        assert_eq!(pixel.blue(), 255);
13146    }
13147
13148    #[test]
13149    fn test_show_page() {
13150        let mut dev = SkiaDevice::new(10, 10);
13151        let path = std::env::temp_dir().join("stet_test_output.png");
13152        let path_str = path.to_string_lossy();
13153        let result = dev.show_page(&path_str);
13154        assert!(result.is_ok());
13155        assert!(path.exists());
13156        std::fs::remove_file(&path).ok();
13157    }
13158
13159    #[test]
13160    fn test_transform() {
13161        let mut dev = SkiaDevice::new(200, 200);
13162        // Draw at origin with a translate transform
13163        let mut path = PsPath::new();
13164        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13165        path.segments.push(PathSegment::LineTo(10.0, 0.0));
13166        path.segments.push(PathSegment::LineTo(10.0, 10.0));
13167        path.segments.push(PathSegment::LineTo(0.0, 10.0));
13168        path.segments.push(PathSegment::ClosePath);
13169
13170        let params = FillParams {
13171            color: DeviceColor::from_rgb(0.0, 1.0, 0.0),
13172            fill_rule: FillRule::NonZeroWinding,
13173            ctm: Matrix::translate(100.0, 100.0),
13174            is_text_glyph: false,
13175            overprint: false,
13176            overprint_mode: 0,
13177            opm_paired: false,
13178            painted_channels: 0,
13179            is_device_cmyk: false,
13180            spot_color: None,
13181            icc_color: None,
13182            rendering_intent: 0,
13183            transfer: TransferState::default(),
13184            halftone: HalftoneState::default(),
13185            bg_ucr: BgUcrState::default(),
13186            alpha: 1.0,
13187            blend_mode: 0,
13188            alpha_is_shape: false,
13189        };
13190        dev.fill_path(&path, &params);
13191
13192        // Pixel at translated location should be green
13193        let pixel = dev.pixmap().pixel(105, 105).unwrap();
13194        assert_eq!(pixel.green(), 255);
13195        assert_eq!(pixel.red(), 0);
13196    }
13197
13198    fn make_test_fill_at(x: f64, y: f64, w: f64, h: f64) -> DisplayElement {
13199        let mut path = PsPath::new();
13200        path.segments.push(PathSegment::MoveTo(x, y));
13201        path.segments.push(PathSegment::LineTo(x + w, y));
13202        path.segments.push(PathSegment::LineTo(x + w, y + h));
13203        path.segments.push(PathSegment::LineTo(x, y + h));
13204        path.segments.push(PathSegment::ClosePath);
13205        DisplayElement::Fill {
13206            path,
13207            params: FillParams {
13208                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
13209                fill_rule: FillRule::NonZeroWinding,
13210                ctm: Matrix::identity(),
13211                is_text_glyph: false,
13212                overprint: false,
13213                overprint_mode: 0,
13214                opm_paired: false,
13215                painted_channels: 0,
13216                is_device_cmyk: false,
13217                spot_color: None,
13218                icc_color: None,
13219                rendering_intent: 0,
13220                transfer: TransferState::default(),
13221                halftone: HalftoneState::default(),
13222                bg_ucr: BgUcrState::default(),
13223                alpha: 1.0,
13224                blend_mode: 0,
13225                alpha_is_shape: false,
13226            },
13227        }
13228    }
13229
13230    #[test]
13231    fn test_compute_paint_bounds_two_fills() {
13232        let mut list = DisplayList::new();
13233        list.push(make_test_fill_at(10.0, 20.0, 30.0, 40.0)); // [10..40, 20..60]
13234        list.push(make_test_fill_at(100.0, 50.0, 50.0, 25.0)); // [100..150, 50..75]
13235
13236        let bounds = compute_paint_bounds(&list, 72.0).expect("expected union bounds");
13237        assert!(
13238            (bounds.x_min - 10.0).abs() < 1e-9,
13239            "x_min was {}",
13240            bounds.x_min
13241        );
13242        assert!(
13243            (bounds.y_min - 20.0).abs() < 1e-9,
13244            "y_min was {}",
13245            bounds.y_min
13246        );
13247        assert!(
13248            (bounds.x_max - 150.0).abs() < 1e-9,
13249            "x_max was {}",
13250            bounds.x_max
13251        );
13252        assert!(
13253            (bounds.y_max - 75.0).abs() < 1e-9,
13254            "y_max was {}",
13255            bounds.y_max
13256        );
13257    }
13258
13259    #[test]
13260    fn test_compute_paint_bounds_empty_list() {
13261        let list = DisplayList::new();
13262        assert!(compute_paint_bounds(&list, 72.0).is_none());
13263    }
13264
13265    #[test]
13266    fn test_compute_paint_bounds_only_clip_returns_none() {
13267        let mut list = DisplayList::new();
13268        list.push(DisplayElement::InitClip);
13269        // Clip / InitClip / ErasePage are skipped (return None from
13270        // precompute_full_bboxes), so a list of only clip ops yields no bounds.
13271        assert!(compute_paint_bounds(&list, 72.0).is_none());
13272    }
13273
13274    #[test]
13275    fn test_rasterize_mask_anchors_to_paint_bounds() {
13276        use stet_graphics::display_list::{SoftMaskParams, SoftMaskSubtype};
13277
13278        // A 50×40 white fill at page coords (200, 300)..(250, 340).
13279        // Mask paint bounds in device units: x [200..250], y [300..340].
13280        let mut mask = DisplayList::new();
13281        let mut path = PsPath::new();
13282        path.segments.push(PathSegment::MoveTo(200.0, 300.0));
13283        path.segments.push(PathSegment::LineTo(250.0, 300.0));
13284        path.segments.push(PathSegment::LineTo(250.0, 340.0));
13285        path.segments.push(PathSegment::LineTo(200.0, 340.0));
13286        path.segments.push(PathSegment::ClosePath);
13287        mask.push(DisplayElement::Fill {
13288            path,
13289            params: FillParams {
13290                color: DeviceColor::from_rgb(1.0, 1.0, 1.0),
13291                fill_rule: FillRule::NonZeroWinding,
13292                ctm: Matrix::identity(),
13293                is_text_glyph: false,
13294                overprint: false,
13295                overprint_mode: 0,
13296                opm_paired: false,
13297                painted_channels: 0,
13298                is_device_cmyk: false,
13299                spot_color: None,
13300                icc_color: None,
13301                rendering_intent: 0,
13302                transfer: TransferState::default(),
13303                halftone: HalftoneState::default(),
13304                bg_ucr: BgUcrState::default(),
13305                alpha: 1.0,
13306                blend_mode: 0,
13307                alpha_is_shape: false,
13308            },
13309        });
13310
13311        let params = SoftMaskParams {
13312            subtype: SoftMaskSubtype::Luminosity,
13313            // Form bbox; intentionally tighter than paint bounds — the
13314            // raster should follow paint bounds, not this.
13315            bbox: [0.0, 0.0, 100.0, 100.0],
13316            backdrop_color: None, // black backdrop → out-of-bounds value = 0
13317            transfer_invert: false,
13318            has_nested_mask_scope: false,
13319            parent_clip_bbox: None,
13320        };
13321
13322        let raster = rasterize_mask(
13323            &mask,
13324            &params,
13325            None,
13326            false,
13327            72.0,
13328            1.0,
13329            1.0,
13330            &LayerSet::new(),
13331        )
13332        .expect("expected raster");
13333
13334        // Origin must be at (or just before) the paint bounds, with the
13335        // 1-pixel AA pad.
13336        assert_eq!(raster.origin_x, 199);
13337        assert_eq!(raster.origin_y, 299);
13338        // Width / height = paint bounds + 2 pixels of pad (1 each side).
13339        assert_eq!(raster.width, 52);
13340        assert_eq!(raster.height, 42);
13341        assert_eq!(raster.scale_x, 1.0);
13342        assert_eq!(raster.scale_y, 1.0);
13343
13344        // The raster should be non-zero somewhere inside the painted region.
13345        // Sample the center of the painted area: page (225, 320) → mask
13346        // index (225 - 199, 320 - 299) = (26, 21).
13347        let mx = 225 - raster.origin_x;
13348        let my = 320 - raster.origin_y;
13349        assert!(mx >= 0 && (mx as u32) < raster.width);
13350        assert!(my >= 0 && (my as u32) < raster.height);
13351        let center_value = raster.data[(my as usize) * raster.width as usize + mx as usize];
13352        assert_eq!(
13353            center_value, 255,
13354            "center of painted mask should be opaque white (lum=255)"
13355        );
13356
13357        // A point outside the paint bounds (page (300, 320)) maps to mask
13358        // index (101, 21) which is outside the raster width — sampling
13359        // there should fall back to out_of_bounds_mask_value(params) = 0.
13360        let mx_out = 300 - raster.origin_x;
13361        let in_bounds = mx_out >= 0 && (mx_out as u32) < raster.width;
13362        assert!(!in_bounds, "page x=300 should be outside the mask raster");
13363        assert_eq!(
13364            out_of_bounds_mask_value(&params),
13365            0,
13366            "black backdrop → out-of-bounds = 0"
13367        );
13368    }
13369
13370    #[test]
13371    fn test_band_local_to_mask_formula() {
13372        // Verify the band-local → page-pixel → mask-index arithmetic for
13373        // several band offsets. This is the highest-risk part of Step 4
13374        // because it bridges three coordinate systems:
13375        //
13376        //   band-local pixel (x, y)
13377        //     + (crop_x, crop_y)            → soft-mask offset within band
13378        //     + (vp_x_pixels, vp_y_pixels)  → page-pixel position
13379        //     - (origin_x, origin_y)        → mask raster index
13380
13381        // Mask raster anchored at page-pixel (200, 300).
13382        let raster_origin_x = 200i32;
13383        let raster_origin_y = 300i32;
13384
13385        // Helper that runs the formula from render_soft_masked.
13386        let sample = |vp_x_dev: f32,
13387                      vp_y_dev: f32,
13388                      scale: f32,
13389                      crop_x: i32,
13390                      crop_y: i32,
13391                      x: i32,
13392                      y: i32|
13393         -> (i32, i32) {
13394            let vp_x_pixels = (vp_x_dev * scale).round() as i32;
13395            let vp_y_pixels = (vp_y_dev * scale).round() as i32;
13396            let page_x = vp_x_pixels + crop_x + x;
13397            let page_y = vp_y_pixels + crop_y + y;
13398            let mx = page_x - raster_origin_x;
13399            let my = page_y - raster_origin_y;
13400            (mx, my)
13401        };
13402
13403        // Case 1: band starts at page Y=0 (top band of page).
13404        // vp_y=0, scale=1. The soft-mask top-left page (220, 310) must
13405        // map to mask index (20, 10).
13406        // crop_x = floor((220 - 0) * 1) = 220, crop_y = floor((310 - 0) * 1) = 310
13407        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 0, 0);
13408        assert_eq!((mx, my), (20, 10), "top band: smask top-left");
13409
13410        // 5 pixels into the smask region (band-local): page (225, 315)
13411        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 5, 5);
13412        assert_eq!((mx, my), (25, 15), "top band: 5px into smask");
13413
13414        // Case 2: band starts at page Y=400. The smask region [310..340]
13415        // doesn't intersect this band — covered by the early-return path.
13416        // But test a band that DOES intersect the smask, e.g. starting at
13417        // Y=305. Then page-Y 310 is band-local Y=5.
13418        // vp_y_pixels = round(305 * 1) = 305
13419        // crop_y = floor((310 - 305) * 1) = 5  (band-local)
13420        // For content y=0 (band-local), page_y = 305 + 5 + 0 = 310 ✓
13421        let (mx, my) = sample(0.0, 305.0, 1.0, 220, 5, 0, 0);
13422        assert_eq!((mx, my), (20, 10), "mid band: smask top-left");
13423
13424        // Case 3: viewport rendering at scale 2. vp_x=100.0, vp_y=150.0,
13425        // scale=2. Page pixel offset = (200, 300). The smask region
13426        // [220..270] in device units = [440..540] in page-pixels at scale 2.
13427        // But the mask raster was built at scale 1, so this is a
13428        // SCALE-MISMATCH case — the cache would invalidate and rebuild.
13429        // We're not testing the rebuild, just that the formula computes
13430        // the right page-pixel coords:
13431        //   vp_x_pixels = round(100 * 2) = 200
13432        //   smask in band: page (440..540), band-local (240..340)
13433        //   crop_x = max(0, floor((220 - 100) * 2)) = 240
13434        //   For x=0 (band-local), page_x = 200 + 240 + 0 = 440 ✓
13435        let vp_x_pixels = (100.0_f32 * 2.0).round() as i32;
13436        let crop_x = ((220.0_f32 - 100.0) * 2.0).floor() as i32;
13437        let page_x_for_x_zero = vp_x_pixels + crop_x;
13438        assert_eq!(page_x_for_x_zero, 440, "viewport scale-2: page-x at x=0");
13439    }
13440
13441    // --- obscured-fill skip (§ GWG reference-under-test pattern) ---
13442
13443    fn x_path() -> PsPath {
13444        let mut p = PsPath::new();
13445        p.segments.push(PathSegment::MoveTo(10.0, 10.0));
13446        p.segments.push(PathSegment::LineTo(20.0, 20.0));
13447        p.segments.push(PathSegment::LineTo(30.0, 10.0));
13448        p.segments.push(PathSegment::LineTo(20.0, 0.0));
13449        p.segments.push(PathSegment::ClosePath);
13450        p
13451    }
13452
13453    fn x_path_perturbed() -> PsPath {
13454        // Same shape, sub-unit rounding — stand-in for GWG's 0.001-unit
13455        // coordinate drift between duplicated path emissions.
13456        let mut p = PsPath::new();
13457        p.segments.push(PathSegment::MoveTo(10.001, 10.0));
13458        p.segments.push(PathSegment::LineTo(20.0, 19.999));
13459        p.segments.push(PathSegment::LineTo(30.002, 10.001));
13460        p.segments.push(PathSegment::LineTo(19.999, 0.0));
13461        p.segments.push(PathSegment::ClosePath);
13462        p
13463    }
13464
13465    fn fill(path: PsPath, alpha: f64, blend: u8) -> DisplayElement {
13466        DisplayElement::Fill {
13467            path,
13468            params: FillParams {
13469                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
13470                fill_rule: FillRule::NonZeroWinding,
13471                ctm: Matrix::identity(),
13472                is_text_glyph: false,
13473                overprint: false,
13474                overprint_mode: 0,
13475                opm_paired: false,
13476                painted_channels: 0,
13477                is_device_cmyk: false,
13478                spot_color: None,
13479                icc_color: None,
13480                rendering_intent: 0,
13481                transfer: TransferState::default(),
13482                halftone: HalftoneState::default(),
13483                bg_ucr: BgUcrState::default(),
13484                alpha,
13485                blend_mode: blend,
13486                alpha_is_shape: false,
13487            },
13488        }
13489    }
13490
13491    fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> PsPath {
13492        let mut p = PsPath::new();
13493        p.segments.push(PathSegment::MoveTo(x0, y0));
13494        p.segments.push(PathSegment::LineTo(x1, y0));
13495        p.segments.push(PathSegment::LineTo(x1, y1));
13496        p.segments.push(PathSegment::LineTo(x0, y1));
13497        p.segments.push(PathSegment::ClosePath);
13498        p
13499    }
13500
13501    fn clip_elem(path: PsPath) -> DisplayElement {
13502        DisplayElement::Clip {
13503            path,
13504            params: ClipParams {
13505                fill_rule: FillRule::NonZeroWinding,
13506                ctm: Matrix::identity(),
13507                stroke_params: None,
13508            },
13509        }
13510    }
13511
13512    fn group_elem(
13513        inner: Vec<DisplayElement>,
13514        bbox: [f64; 4],
13515        isolated: bool,
13516        alpha: f64,
13517        blend: u8,
13518    ) -> DisplayElement {
13519        let mut dl = DisplayList::new();
13520        for e in inner {
13521            dl.push(e);
13522        }
13523        DisplayElement::Group {
13524            elements: dl,
13525            params: stet_graphics::display_list::GroupParams {
13526                bbox,
13527                isolated,
13528                knockout: false,
13529                blend_mode: blend,
13530                alpha,
13531                color_space: stet_graphics::display_list::GroupColorSpace::Inherited,
13532            },
13533        }
13534    }
13535
13536    fn dl(elements: Vec<DisplayElement>) -> DisplayList {
13537        let mut d = DisplayList::new();
13538        for e in elements {
13539            d.push(e);
13540        }
13541        d
13542    }
13543
13544    #[test]
13545    fn obscured_skip_fires_on_matching_fill_plus_iso_group() {
13546        // Classic GWG pattern: parent Fill, then a clip, then an isolated
13547        // alpha-1 Group whose first paint is a matching Fill.
13548        let parent = fill(x_path(), 1.0, 0);
13549        let inner = vec![fill(x_path_perturbed(), 1.0, 0)];
13550        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13551        let d = dl(vec![
13552            parent,
13553            clip_elem(rect_path(0.0, -5.0, 40.0, 30.0)),
13554            grp,
13555        ]);
13556        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
13557    }
13558
13559    #[test]
13560    fn obscured_skip_does_not_fire_on_non_isolated_group() {
13561        let parent = fill(x_path(), 1.0, 0);
13562        let inner = vec![fill(x_path(), 1.0, 0)];
13563        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], false, 1.0, 0);
13564        let d = dl(vec![parent, grp]);
13565        assert!(compute_obscured_fill_skips(&d).is_empty());
13566    }
13567
13568    #[test]
13569    fn obscured_skip_does_not_fire_on_partial_alpha_group() {
13570        let parent = fill(x_path(), 1.0, 0);
13571        let inner = vec![fill(x_path(), 1.0, 0)];
13572        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 0.5, 0);
13573        let d = dl(vec![parent, grp]);
13574        assert!(compute_obscured_fill_skips(&d).is_empty());
13575    }
13576
13577    #[test]
13578    fn obscured_skip_does_not_fire_on_non_normal_blend() {
13579        let parent = fill(x_path(), 1.0, 0);
13580        let inner = vec![fill(x_path(), 1.0, 0)];
13581        // blend_mode = 10 (Difference) on the group — composite-back
13582        // semantics differ from Normal, so skipping parent is unsafe.
13583        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 10);
13584        let d = dl(vec![parent, grp]);
13585        assert!(compute_obscured_fill_skips(&d).is_empty());
13586    }
13587
13588    #[test]
13589    fn obscured_skip_does_not_fire_when_paths_differ() {
13590        let parent = fill(rect_path(0.0, 0.0, 5.0, 5.0), 1.0, 0);
13591        let inner = vec![fill(x_path(), 1.0, 0)];
13592        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13593        let d = dl(vec![parent, grp]);
13594        assert!(compute_obscured_fill_skips(&d).is_empty());
13595    }
13596
13597    #[test]
13598    fn obscured_skip_does_not_fire_when_group_bbox_too_small() {
13599        // Parent fills a rectangle larger than the group's declared
13600        // bbox — the form's BBox would clip the inner fill to a subset
13601        // of the parent's extent, so the parent cannot be dropped.
13602        let big = rect_path(0.0, 0.0, 100.0, 100.0);
13603        let parent = fill(big.clone(), 1.0, 0);
13604        let inner = vec![fill(big, 1.0, 0)];
13605        // Group bbox only covers [0..10, 0..10], much smaller than parent.
13606        let grp = group_elem(inner, [0.0, 0.0, 10.0, 10.0], true, 1.0, 0);
13607        let d = dl(vec![parent, grp]);
13608        assert!(compute_obscured_fill_skips(&d).is_empty());
13609    }
13610
13611    #[test]
13612    fn obscured_skip_does_not_fire_when_intervening_clip_too_small() {
13613        // A clip between the parent fill and the group is narrower than
13614        // the parent's extent — dropping the parent's fill would reveal
13615        // backdrop where the group couldn't paint.
13616        let parent = fill(x_path(), 1.0, 0);
13617        let narrow_clip = clip_elem(rect_path(12.0, 5.0, 18.0, 15.0));
13618        let inner = vec![fill(x_path(), 1.0, 0)];
13619        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13620        let d = dl(vec![parent, narrow_clip, grp]);
13621        assert!(compute_obscured_fill_skips(&d).is_empty());
13622    }
13623
13624    #[test]
13625    fn obscured_skip_does_not_fire_when_inner_clip_too_small() {
13626        // Clip *inside* the group is narrower than the parent's extent.
13627        let parent = fill(x_path(), 1.0, 0);
13628        let inner = vec![
13629            clip_elem(rect_path(12.0, 5.0, 18.0, 15.0)),
13630            fill(x_path(), 1.0, 0),
13631        ];
13632        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13633        let d = dl(vec![parent, grp]);
13634        assert!(compute_obscured_fill_skips(&d).is_empty());
13635    }
13636
13637    #[test]
13638    fn obscured_skip_fires_when_inner_clip_is_wider_than_parent_path() {
13639        // A clip inside the group that's larger than the parent's fill
13640        // doesn't threaten coverage; still safe to skip the parent.
13641        let parent = fill(x_path(), 1.0, 0);
13642        let inner = vec![
13643            clip_elem(rect_path(-10.0, -10.0, 40.0, 30.0)),
13644            fill(x_path_perturbed(), 1.0, 0),
13645        ];
13646        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13647        let d = dl(vec![parent, grp]);
13648        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
13649    }
13650
13651    #[test]
13652    fn obscured_skip_does_not_fire_on_partial_alpha_parent() {
13653        // A parent fill at alpha < 1 might blend with backdrop; dropping
13654        // it changes the visual even when the group overpaints.
13655        let parent = fill(x_path(), 0.5, 0);
13656        let inner = vec![fill(x_path(), 1.0, 0)];
13657        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13658        let d = dl(vec![parent, grp]);
13659        assert!(compute_obscured_fill_skips(&d).is_empty());
13660    }
13661}