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        // Estimate DPI from page height (assumes ~792pt US Letter as reference).
137        // Close enough for hairline width threshold decisions.
138        let dpi = height as f64 * 72.0 / 792.0;
139
140        // Start with a tiny placeholder. The full-page pixmap is allocated
141        // lazily only when the non-banded path is used (small pages / low DPI).
142        // For banded rendering, band-sized pixmaps are created in replay_and_show.
143        let pixmap = Pixmap::new(1, 1).expect("Failed to create placeholder pixmap");
144        Self {
145            pixmap,
146            page_w: width,
147            page_h: height,
148            dpi,
149            clip_region: None,
150            clip_mask_cache: HashMap::new(),
151            clip_mask_seen: HashSet::new(),
152            spare_mask: None,
153            pending_render: None,
154            sink_factory,
155            system_cmyk_bytes: None,
156            render_icc_cache: None,
157            no_aa: false,
158            use_viewport_path: false,
159            layer_set: LayerSet::new(),
160        }
161    }
162
163    /// Route rendering through the viewport pipeline. Used by the visual
164    /// test runner's `--device viewport-png` mode.
165    pub fn set_use_viewport_path(&mut self, on: bool) {
166        self.use_viewport_path = on;
167    }
168
169    /// Replace the device's OCG visibility overrides.
170    ///
171    /// The empty default has every layer fall back to its
172    /// `default_visible` baked into the display list. Callers building
173    /// a layer panel hand in a populated [`LayerSet`] each render
174    /// pass.
175    pub fn set_layer_set(&mut self, layer_set: LayerSet) {
176        self.layer_set = layer_set;
177    }
178
179    /// Read-only view of the device's current OCG visibility overrides.
180    pub fn layer_set(&self) -> &LayerSet {
181        &self.layer_set
182    }
183
184    /// Ensure `self.pixmap` is allocated at full page dimensions.
185    /// Called before non-banded rendering which operates on the full pixmap.
186    fn ensure_full_pixmap(&mut self) {
187        if self.pixmap.width() != self.page_w || self.pixmap.height() != self.page_h {
188            self.pixmap =
189                Pixmap::new(self.page_w, self.page_h).expect("Failed to create page pixmap");
190            self.pixmap.fill(Color::WHITE);
191        }
192    }
193
194    /// Get the underlying pixmap (for testing).
195    pub fn pixmap(&self) -> &Pixmap {
196        &self.pixmap
197    }
198
199    /// Set the system CMYK ICC profile bytes for ICC-aware rendering.
200    pub fn set_system_cmyk_bytes(&mut self, bytes: std::sync::Arc<Vec<u8>>) {
201        self.system_cmyk_bytes = Some(bytes);
202    }
203
204    /// Disable anti-aliasing for all fill/stroke operations.
205    pub fn set_no_aa(&mut self, no_aa: bool) {
206        self.no_aa = no_aa;
207    }
208}
209
210/// Convert a PostScript `Matrix` to tiny-skia `Transform` (f32).
211fn to_transform(m: &Matrix) -> Transform {
212    Transform::from_row(
213        m.a as f32,
214        m.b as f32,
215        m.c as f32,
216        m.d as f32,
217        m.tx as f32,
218        m.ty as f32,
219    )
220}
221
222/// Convert a `DeviceColor` to tiny-skia `Paint`.
223fn to_paint(color: &DeviceColor) -> Paint<'static> {
224    to_paint_alpha(color, 1.0, 0, false)
225}
226
227/// Convert a `DeviceColor` to tiny-skia `Paint` with the given opacity and blend mode.
228fn to_paint_alpha(color: &DeviceColor, alpha: f64, blend_mode: u8, no_aa: bool) -> Paint<'static> {
229    let mut paint = Paint::default();
230    let a = (alpha * 255.0).round().clamp(0.0, 255.0) as u8;
231    paint.set_color_rgba8(
232        (color.r * 255.0).round().clamp(0.0, 255.0) as u8,
233        (color.g * 255.0).round().clamp(0.0, 255.0) as u8,
234        (color.b * 255.0).round().clamp(0.0, 255.0) as u8,
235        a,
236    );
237    paint.anti_alias = !no_aa;
238    paint.blend_mode = u8_to_blend_mode(blend_mode);
239    paint
240}
241
242/// Map a blend mode byte (0–15) to the corresponding tiny-skia `BlendMode`.
243fn u8_to_blend_mode(mode: u8) -> BlendMode {
244    match mode {
245        1 => BlendMode::Multiply,
246        2 => BlendMode::Screen,
247        3 => BlendMode::Overlay,
248        4 => BlendMode::Darken,
249        5 => BlendMode::Lighten,
250        6 => BlendMode::ColorDodge,
251        7 => BlendMode::ColorBurn,
252        8 => BlendMode::HardLight,
253        9 => BlendMode::SoftLight,
254        10 => BlendMode::Difference,
255        11 => BlendMode::Exclusion,
256        12 => BlendMode::Hue,
257        13 => BlendMode::Saturation,
258        14 => BlendMode::Color,
259        15 => BlendMode::Luminosity,
260        _ => BlendMode::SourceOver,
261    }
262}
263
264/// Convert a `PsPath` to tiny-skia `Path`.
265/// Maximum coordinate magnitude for path rasterization.
266/// Coordinates beyond this cause integer overflow in the scanline rasterizer.
267/// 1e6 is well beyond any real page (e.g. 612×792 pt at 600 DPI = ~5100×6600 px)
268/// but safely within f32 precision and fixed-point limits.
269const MAX_PATH_COORD: f32 = 1e6;
270
271fn build_skia_path(path: &PsPath) -> Option<stet_tiny_skia::Path> {
272    let mut pb = PathBuilder::new();
273
274    for seg in &path.segments {
275        match seg {
276            PathSegment::MoveTo(x, y) => {
277                pb.move_to(*x as f32, *y as f32);
278            }
279            PathSegment::LineTo(x, y) => {
280                pb.line_to(*x as f32, *y as f32);
281            }
282            PathSegment::CurveTo {
283                x1,
284                y1,
285                x2,
286                y2,
287                x3,
288                y3,
289            } => {
290                pb.cubic_to(
291                    *x1 as f32, *y1 as f32, *x2 as f32, *y2 as f32, *x3 as f32, *y3 as f32,
292                );
293            }
294            PathSegment::ClosePath => {
295                pb.close();
296            }
297        }
298    }
299
300    let result = pb.finish()?;
301
302    // Reject paths with extreme coordinates that would overflow the scanline
303    // rasterizer's integer math. This handles corrupted PDF content streams
304    // with garbled coordinates.
305    let b = result.bounds();
306    if b.left().abs() > MAX_PATH_COORD
307        || b.top().abs() > MAX_PATH_COORD
308        || b.right().abs() > MAX_PATH_COORD
309        || b.bottom().abs() > MAX_PATH_COORD
310    {
311        return None;
312    }
313
314    Some(result)
315}
316
317/// Detect degenerate fill paths that have zero extent in one dimension.
318///
319/// PDFs commonly draw table grid lines as zero-width or zero-height filled
320/// rectangles (e.g., `8 0 1031 0 re f`). Since these have no area, the
321/// fill rasterizer produces zero pixels. This function detects such paths
322/// so they can be rendered as hairline strokes instead.
323///
324/// The check is performed in the path's own coordinate space (pre-transform)
325/// using a very tight epsilon, so only paths with *exactly* zero extent in
326/// one dimension are detected. Paths containing curves are never degenerate
327/// — only MoveTo/LineTo/ClosePath segments qualify.
328fn is_degenerate_fill(path: &PsPath) -> bool {
329    let mut x_min = f64::INFINITY;
330    let mut x_max = f64::NEG_INFINITY;
331    let mut y_min = f64::INFINITY;
332    let mut y_max = f64::NEG_INFINITY;
333
334    for seg in &path.segments {
335        let (x, y) = match seg {
336            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => (*x, *y),
337            // Paths with curves are real shapes, not degenerate lines
338            PathSegment::CurveTo { .. } => return false,
339            PathSegment::ClosePath => continue,
340        };
341        x_min = x_min.min(x);
342        x_max = x_max.max(x);
343        y_min = y_min.min(y);
344        y_max = y_max.max(y);
345    }
346
347    if x_min > x_max {
348        return false; // empty path
349    }
350
351    let w = x_max - x_min;
352    let h = y_max - y_min;
353
354    // Degenerate if one dimension is exactly zero (within f64 epsilon)
355    // while the other has real extent. This catches `re` rects with
356    // zero width or height but not legitimate small shapes.
357    let eps = 1e-6;
358    (w < eps && h > eps) || (h < eps && w > eps)
359}
360
361/// Convert a tiny-skia Path back to a PsPath.
362/// Used for overprint stroke handling where we convert a stroked outline to a fill.
363
364/// Convert PostScript FillRule to tiny-skia FillRule.
365fn to_fill_rule(rule: &FillRule) -> SkiaFillRule {
366    match rule {
367        FillRule::NonZeroWinding => SkiaFillRule::Winding,
368        FillRule::EvenOdd => SkiaFillRule::EvenOdd,
369        _ => SkiaFillRule::Winding,
370    }
371}
372
373/// Convert PostScript LineCap to tiny-skia LineCap.
374fn to_line_cap(cap: LineCap) -> SkiaLineCap {
375    match cap {
376        LineCap::Butt => SkiaLineCap::Butt,
377        LineCap::Round => SkiaLineCap::Round,
378        LineCap::Square => SkiaLineCap::Square,
379        _ => SkiaLineCap::Butt,
380    }
381}
382
383/// Convert PostScript LineJoin to tiny-skia LineJoin.
384fn to_line_join(join: LineJoin) -> SkiaLineJoin {
385    match join {
386        LineJoin::Miter => SkiaLineJoin::Miter,
387        LineJoin::Round => SkiaLineJoin::Round,
388        LineJoin::Bevel => SkiaLineJoin::Bevel,
389        _ => SkiaLineJoin::Miter,
390    }
391}
392
393/// Detect if a path is an axis-aligned rectangle. Returns pixel-coordinate ClipRect if so.
394/// Handles both CW and CCW winding, with optional trailing ClosePath.
395fn detect_rect(path: &PsPath, page_w: u32, page_h: u32) -> Option<ClipRect> {
396    let segs = &path.segments;
397    // Expect: MoveTo + 3 LineTo + ClosePath (5 segments)
398    // or MoveTo + 3 LineTo + LineTo(back to start) + ClosePath (6 segments)
399    // or MoveTo + 3 LineTo (4 segments, implicitly closed)
400    let (move_to, lines, _has_close) = match segs.len() {
401        5 => {
402            // MoveTo + 3 LineTo + ClosePath
403            if !matches!(segs[4], PathSegment::ClosePath) {
404                return None;
405            }
406            (&segs[0], &segs[1..4], true)
407        }
408        6 => {
409            // MoveTo + 4 LineTo + ClosePath (4th LineTo returns to start)
410            if !matches!(segs[5], PathSegment::ClosePath) {
411                return None;
412            }
413            (&segs[0], &segs[1..5], true)
414        }
415        4 => {
416            // MoveTo + 3 LineTo (no explicit close)
417            (&segs[0], &segs[1..4], false)
418        }
419        _ => return None,
420    };
421
422    let PathSegment::MoveTo(mx, my) = move_to else {
423        return None;
424    };
425
426    // Collect all corner points
427    let mut pts = vec![(*mx, *my)];
428    for seg in lines {
429        match seg {
430            PathSegment::LineTo(x, y) => pts.push((*x, *y)),
431            _ => return None,
432        }
433    }
434
435    // If 5 points (4 LineTos), last must return to start
436    if pts.len() == 5 {
437        let (fx, fy) = pts[0];
438        let (lx, ly) = pts[4];
439        if (fx - lx).abs() > 0.01 || (fy - ly).abs() > 0.01 {
440            return None;
441        }
442        pts.truncate(4);
443    }
444
445    // Check axis-aligned: each edge must be horizontal or vertical
446    for i in 0..4 {
447        let (x1, y1) = pts[i];
448        let (x2, y2) = pts[(i + 1) % 4];
449        let dx = (x2 - x1).abs();
450        let dy = (y2 - y1).abs();
451        if dx > 0.01 && dy > 0.01 {
452            return None; // diagonal edge
453        }
454    }
455
456    // Compute bounding box
457    let min_x = pts.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
458    let min_y = pts.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
459    let max_x = pts.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max);
460    let max_y = pts.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
461
462    // Convert to pixel coords: floor for top-left, ceil for bottom-right, clamp to page
463    let x0 = (min_x.floor().max(0.0) as u32).min(page_w);
464    let y0 = (min_y.floor().max(0.0) as u32).min(page_h);
465    let x1 = (max_x.ceil().max(0.0) as u32).min(page_w);
466    let y1 = (max_y.ceil().max(0.0) as u32).min(page_h);
467
468    Some(ClipRect { x0, y0, x1, y1 })
469}
470
471/// Zero out mask pixels outside the given rectangle bounds.
472fn intersect_mask_with_rect(mask: &mut Mask, rect: &ClipRect, w: u32, h: u32) {
473    let data = mask.data_mut();
474    let stride = w as usize;
475
476    // Zero rows above rect
477    if rect.y0 > 0 {
478        let end = (rect.y0 as usize * stride).min(data.len());
479        data[..end].fill(0);
480    }
481
482    // Zero rows below rect
483    if rect.y1 < h {
484        let start = (rect.y1 as usize * stride).min(data.len());
485        data[start..].fill(0);
486    }
487
488    // Zero left and right margins within rect rows
489    for y in rect.y0..rect.y1.min(h) {
490        let row_start = y as usize * stride;
491        // Left margin
492        if rect.x0 > 0 {
493            let end = row_start + rect.x0 as usize;
494            data[row_start..end].fill(0);
495        }
496        // Right margin
497        if rect.x1 < w {
498            let start = row_start + rect.x1 as usize;
499            let end = row_start + stride;
500            data[start..end].fill(0);
501        }
502    }
503}
504
505/// Resolve a ClipRegion to an Option<&Mask> for paint operations.
506/// Returns `None` if the clip is empty (caller should skip painting).
507/// Returns `Some(None)` if no mask is needed (full page or no clip).
508/// Returns `Some(Some(&Mask))` if a mask should be applied.
509fn resolve_clip_mask<'a>(
510    clip_region: &'a Option<ClipRegion>,
511    temp_mask: &'a mut Option<Mask>,
512    w: u32,
513    h: u32,
514) -> Option<Option<&'a Mask>> {
515    match clip_region {
516        None => Some(None),
517        Some(ClipRegion::Mask(m)) => Some(Some(m)),
518        Some(ClipRegion::Rect(rect)) => {
519            if rect.is_empty() {
520                return None; // empty clip → skip painting
521            }
522            if rect.is_full_page(w, h) {
523                return Some(None); // full page → no mask needed
524            }
525            *temp_mask = rect.make_mask(w, h);
526            Some(temp_mask.as_ref())
527        }
528    }
529}
530
531/// Hash a PsPath's segments for clip mask caching. Uses bit-exact f64 comparison
532/// since paths are already in device space.
533fn hash_clip_path(path: &PsPath, fill_rule: &FillRule) -> u64 {
534    let mut hasher = std::collections::hash_map::DefaultHasher::new();
535    std::mem::discriminant(fill_rule).hash(&mut hasher);
536    for seg in &path.segments {
537        match seg {
538            PathSegment::MoveTo(x, y) => {
539                0u8.hash(&mut hasher);
540                x.to_bits().hash(&mut hasher);
541                y.to_bits().hash(&mut hasher);
542            }
543            PathSegment::LineTo(x, y) => {
544                1u8.hash(&mut hasher);
545                x.to_bits().hash(&mut hasher);
546                y.to_bits().hash(&mut hasher);
547            }
548            PathSegment::CurveTo {
549                x1,
550                y1,
551                x2,
552                y2,
553                x3,
554                y3,
555            } => {
556                2u8.hash(&mut hasher);
557                x1.to_bits().hash(&mut hasher);
558                y1.to_bits().hash(&mut hasher);
559                x2.to_bits().hash(&mut hasher);
560                y2.to_bits().hash(&mut hasher);
561                x3.to_bits().hash(&mut hasher);
562                y3.to_bits().hash(&mut hasher);
563            }
564            PathSegment::ClosePath => {
565                3u8.hash(&mut hasher);
566            }
567        }
568    }
569    hasher.finish()
570}
571
572/// Pixel-multiply two masks: dst[i] = dst[i] * src[i] / 255.
573fn intersect_masks(dst: &mut Mask, src: &Mask) {
574    let dst_data = dst.data_mut();
575    let src_data = src.data();
576    for (d, s) in dst_data.iter_mut().zip(src_data.iter()) {
577        *d = ((*d as u16 * *s as u16 + 127) / 255) as u8;
578    }
579}
580
581// ---- Banded rendering support ----
582
583use stet_graphics::display_list::{DisplayElement, DisplayList};
584
585/// Band-local clip state, rebuilt for each band.
586struct BandState {
587    clip_region: Option<ClipRegion>,
588    spare_mask: Option<Mask>,
589    /// Per-band cache (cleared each band since masks are band-sized).
590    clip_mask_cache: HashMap<u64, Mask>,
591    /// Persists across bands for cache-on-second-sight.
592    clip_mask_seen: HashSet<u64>,
593    /// Pool of recycled masks to avoid alloc/dealloc (mmap/munmap) per band.
594    mask_pool: Vec<Mask>,
595    /// Per-pixel CMYK tracking buffer for overprint simulation.
596    /// Only allocated when the display list contains overprint elements.
597    /// Layout: [C, M, Y, K] as f32 per pixel, band_w * band_h * 4 entries.
598    cmyk_buffer: Option<Vec<f32>>,
599    /// Per-pixel snapshot of pixmap RGBA *before* the first overprint paint
600    /// touched that pixel in this band. Subsequent overprint paints at the
601    /// same pixel blend their result against this snapshot instead of the
602    /// current (already-overprinted) pixmap, so AA edges of stacked overprints
603    /// do not leak earlier colour through later paints.
604    /// Lazily allocated on first overprint paint. 4 bytes per pixel.
605    op_bg_snapshot: Option<Vec<u8>>,
606    /// Parallel to `op_bg_snapshot`: 1 byte per pixel, non-zero iff the
607    /// snapshot for that pixel has been captured. Reset to zero over the
608    /// paint bbox on non-overprint writes so a later non-overprint fill
609    /// establishes a fresh backdrop for subsequent overprints.
610    op_touched: Option<Vec<u8>>,
611    /// Per-pixel marker for "this pixel's pixmap colour includes spot-
612    /// colorant contribution not reflected in `cmyk_buffer`". Set by
613    /// DeviceN/Separation paints that include at least one spot colorant
614    /// (i.e. `process_cmyk != native_cmyk`). Consulted by CMYK overprint
615    /// rendering so the no-op-delta skip only fires on pixels where
616    /// preserving the pixmap actually preserves spot colour — other pixels
617    /// still go through the ICC(new_cmyk) replace path.
618    spot_mask: Option<Vec<u8>>,
619}
620
621/// Maximum masks to keep in the recycling pool. Enough to avoid alloc churn
622/// without accumulating unbounded memory across bands.
623const MAX_POOL_MASKS: usize = 8;
624
625impl BandState {
626    /// Recycle all cached masks into the pool, clearing the cache for the next band.
627    #[allow(dead_code)]
628    fn recycle_cache(&mut self) {
629        for (_, mask) in self.clip_mask_cache.drain() {
630            if self.mask_pool.len() < MAX_POOL_MASKS {
631                self.mask_pool.push(mask);
632            }
633            // else: drop mask, returning memory to OS
634        }
635    }
636
637    /// Return a mask to the pool if under capacity, otherwise drop it.
638    fn recycle_mask(&mut self, mask: Mask) {
639        if self.mask_pool.len() < MAX_POOL_MASKS {
640            self.mask_pool.push(mask);
641        }
642    }
643
644    /// Get a recycled mask or allocate a new one.
645    fn take_mask(&mut self, w: u32, h: u32) -> Mask {
646        self.spare_mask
647            .take()
648            .or_else(|| self.mask_pool.pop())
649            .unwrap_or_else(|| Mask::new(w, h).expect("Failed to create mask"))
650    }
651
652    /// Take (or lazily allocate) the overprint background snapshot and
653    /// touched-flag buffers. Caller must pass them back via
654    /// `restore_op_buffers`. Layout: snapshot is 4 bytes/pixel (RGBA),
655    /// touched is 1 byte/pixel.
656    fn take_op_buffers(&mut self, w: u32, h: u32) -> (Vec<u8>, Vec<u8>) {
657        let n = w as usize * h as usize;
658        let bg = self
659            .op_bg_snapshot
660            .take()
661            .unwrap_or_else(|| vec![0u8; n * 4]);
662        let touched = self.op_touched.take().unwrap_or_else(|| vec![0u8; n]);
663        (bg, touched)
664    }
665
666    /// Put the overprint buffers back after an overprint render pass.
667    fn restore_op_buffers(&mut self, bg: Vec<u8>, touched: Vec<u8>) {
668        self.op_bg_snapshot = Some(bg);
669        self.op_touched = Some(touched);
670    }
671
672    /// Take (or lazily allocate) the spot-contribution mask (1 byte/pixel).
673    fn take_spot_mask(&mut self, w: u32, h: u32) -> Vec<u8> {
674        let n = w as usize * h as usize;
675        self.spot_mask.take().unwrap_or_else(|| vec![0u8; n])
676    }
677
678    /// Put the spot-contribution mask back after a paint.
679    fn restore_spot_mask(&mut self, mask: Vec<u8>) {
680        self.spot_mask = Some(mask);
681    }
682
683    /// Clear the overprint touched flag for pixels in the given bbox. Called
684    /// by non-overprint paints so a subsequent overprint at those pixels
685    /// captures a fresh backdrop snapshot instead of reusing a stale one.
686    #[allow(dead_code)]
687    fn invalidate_op_snapshot(
688        &mut self,
689        bbox_x0: usize,
690        bbox_y0: usize,
691        bbox_x1: usize,
692        bbox_y1: usize,
693        stride: usize,
694    ) {
695        if let Some(touched) = self.op_touched.as_mut() {
696            for y in bbox_y0..bbox_y1 {
697                let row = y * stride;
698                for x in bbox_x0..bbox_x1 {
699                    touched[row + x] = 0;
700                }
701            }
702        }
703    }
704}
705
706/// Unified rendering context that parameterizes both band and viewport rendering.
707///
708/// Band rendering is viewport rendering with `scale_x = scale_y = 1.0`.
709/// `viewport_transform(t, vp_x, vp_y, 1.0, 1.0)` == `offset_transform_xy(t, vp_x, vp_y)`.
710struct RenderContext<'a> {
711    /// Viewport/band origin X in device space.
712    vp_x: f32,
713    /// Viewport/band origin Y in device space.
714    vp_y: f32,
715    /// Horizontal scale (1.0 for band rendering, zoom for viewport).
716    scale_x: f32,
717    /// Vertical scale (1.0 for band rendering, zoom for viewport).
718    scale_y: f32,
719    /// Output pixmap width in pixels.
720    out_w: u32,
721    /// Output pixmap height in pixels.
722    out_h: u32,
723    /// Effective DPI at output scale.
724    effective_dpi: f64,
725    /// ICC color profile cache (for CMYK conversions).
726    icc: Option<&'a IccCache>,
727    /// Pre-converted image data cache (for viewport rendering).
728    image_cache: Option<&'a ImageCache>,
729    /// Pre-converted and prescaled images (for banded rendering).
730    preprocessed: Option<&'a [Option<PreprocessedImage>]>,
731    /// Element index in parent display list (for image cache lookup).
732    elem_idx: usize,
733    /// Disable anti-aliasing for all fill/stroke operations.
734    no_aa: bool,
735    /// When true, CMYK(0,0,0,0) pixels in images produce alpha=0 (OPM=1).
736    opm_zero_transparent: bool,
737    /// Knockout group painter rendering pass override. The knockout group
738    /// renders each Group painter twice — once for the blended-color result
739    /// (`ColorPass`), once for the painter's coverage mask (`CoveragePass`).
740    /// Both passes need to override `render_group`'s usual decisions:
741    ///   * `ColorPass` expands the per-pixel CMYK composite-back gate to all
742    ///     non-Normal blend modes so painters with separable blends like
743    ///     Screen / ColorDodge / Overlay / SoftLight blend in DeviceCMYK
744    ///     (matching the spec for `/CS DeviceCMYK` knockout groups) instead
745    ///     of in tiny-skia's sRGB blend.
746    ///   * `CoveragePass` disables the CMYK composite-back (its
747    ///     "source==backdrop" guard would discard white-CMYK painters
748    ///     against the transparent coverage backdrop) and forces the
749    ///     painter's alpha to 1.0 with Normal blend so the coverage offscreen
750    ///     captures the painter's *shape* even when the original alpha was 0
751    ///     (Opacity 0% test) or its blend mode would erase the source.
752    knockout_painter_pass: KnockoutPainterPass,
753    /// True when the immediately enclosing transparency group was isolated.
754    /// GWG 16.2's nested CMYK painter pattern (Painter B → Sub A/B) only
755    /// requires CMYK math at the inner non-isolated layer when Painter B
756    /// itself is isolated; for non-isolated parents (the 907 p28 financial
757    /// chart pattern) the existing sRGB compositing path produces the right
758    /// result and the new CMYK math would over-darken anti-aliased gray
759    /// strokes.
760    parent_group_isolated: bool,
761    /// True when rendering an alpha-extraction pass for a non-isolated group
762    /// with non-Normal blend mode.  Nested groups must render as isolated
763    /// (no backdrop preload, no two-pass) so the alpha channel reflects
764    /// pure element coverage rather than backdrop-blended results.
765    alpha_extraction_pass: bool,
766    /// OCG visibility overrides. Empty (every layer at its
767    /// `default_visible`) when the caller didn't supply one.
768    layer_set: &'a LayerSet,
769}
770
771/// Override mode applied to `render_group` while the knockout group renders
772/// one of its painters; see [`RenderContext::knockout_painter_pass`].
773#[derive(Clone, Copy, PartialEq, Eq)]
774enum KnockoutPainterPass {
775    /// Default rendering — no knockout overrides.
776    None,
777    /// Pass 1 (color): widen `plan_cmyk_compose` to any non-Normal blend mode.
778    ColorPass,
779    /// Pass 2 (coverage): disable CMYK composite-back, force full alpha and
780    /// Normal blend so the coverage offscreen captures the painter's shape.
781    CoveragePass,
782}
783
784impl RenderContext<'_> {
785    /// Apply viewport transform to a PostScript matrix.
786    fn transform(&self, m: &Matrix) -> Transform {
787        viewport_transform(
788            to_transform(m),
789            self.vp_x,
790            self.vp_y,
791            self.scale_x,
792            self.scale_y,
793        )
794    }
795}
796
797/// Y-axis bounding box in device pixels.
798struct YBBox {
799    y_min: f64,
800    y_max: f64,
801}
802
803/// A group of display list elements between consecutive InitClip boundaries.
804/// Each epoch starts with an InitClip (except possibly the first) and contains
805/// all elements up to the next InitClip. Epochs whose paint elements don't
806/// overlap a band can be skipped entirely.
807struct ClipEpoch {
808    /// Index of the first element in this epoch (the InitClip, or 0).
809    start_idx: usize,
810    /// One past the last element in this epoch.
811    end_idx: usize,
812    /// Y bounding box of all paint elements (Fill/Stroke/Image) in this epoch.
813    /// None if the epoch has no paint elements (pure clip setup).
814    paint_bbox: Option<YBBox>,
815    /// True if this epoch contains an ErasePage element (must process for all bands).
816    has_erase_page: bool,
817}
818
819/// Choose band height so that band pixmap + 2 clip masks fit in ~2 MB (L2 cache).
820/// Returns `page_h` when banding is not worthwhile (≤2 bands).
821fn select_band_height(w: u32, h: u32) -> u32 {
822    if w == 0 || h == 0 {
823        return h;
824    }
825    // Per-row cost: w*4 (RGBA) + w*1 (clip mask) + w*1 (spare mask) = w*6
826    let per_row = w as u64 * 6;
827    let budget = 2 * 1024 * 1024u64; // 2 MB (L2)
828    let max_rows = budget / per_row;
829
830    // Floor to power of 2, clamp to [16, h]
831    let band = if max_rows >= h as u64 {
832        h
833    } else {
834        let mut p = 1u32;
835        while (p as u64) * 2 <= max_rows {
836            p *= 2;
837        }
838        // Minimum 128 rows per band. At very high DPI the L2 budget yields
839        // tiny bands (16 rows at 2400 DPI = 1650 bands) where display list
840        // replay overhead dominates. 128-row minimum balances L3 cache fit
841        // (~15 MB working set at 2400 DPI) against per-band overhead (207 bands).
842        // Benchmarked: 16→31.3s, 64→22.5s, 128→21.8s, 256→22.1s.
843        p.clamp(128, h)
844    };
845
846    // Skip banding if ≤2 bands
847    if h.div_ceil(band) <= 2 {
848        return h;
849    }
850    band
851}
852
853/// True if this display list contains any `Clip`/`InitClip` op, recursively
854/// descending into `OcgGroup` / `Group` / `SoftMasked` children. When an
855/// `OcgGroup` wraps clip ops, Y-bbox culling would skip the whole group for
856/// bands its paint content doesn't overlap, but the clip state changes inside
857/// must still be applied — otherwise subsequent top-level elements inherit a
858/// stale clip. Use this to force such `OcgGroup`s to always be processed.
859fn contains_clip_op(list: &DisplayList) -> bool {
860    list.elements().iter().any(|e| match e {
861        DisplayElement::Clip { .. } | DisplayElement::InitClip => true,
862        DisplayElement::OcgGroup { elements, .. } => contains_clip_op(elements),
863        DisplayElement::Group { elements, .. } => contains_clip_op(elements),
864        DisplayElement::SoftMasked { content, .. } => contains_clip_op(content),
865        _ => false,
866    })
867}
868
869/// Compute conservative Y bounding boxes for display list elements.
870/// Returns `None` for elements that must always be processed (Clip, InitClip, ErasePage).
871///
872/// All returned Y values are in **device space** (pixel coordinates) so they can be
873/// compared directly against band boundaries.
874fn precompute_bboxes(list: &DisplayList, dpi: f64) -> Vec<Option<YBBox>> {
875    list.elements()
876        .iter()
877        .map(|elem| match elem {
878            DisplayElement::Fill { path, params } => fill_device_y_bbox(path, &params.ctm),
879            DisplayElement::Stroke { path, params } => stroke_device_y_bbox(path, params, dpi),
880            DisplayElement::Image { params, .. } => image_y_bbox(params),
881            DisplayElement::AxialShading { params } => {
882                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
883            }
884            DisplayElement::RadialShading { params } => {
885                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
886            }
887            DisplayElement::MeshShading { params } => {
888                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
889            }
890            DisplayElement::PatchShading { params } => {
891                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
892            }
893            DisplayElement::PatternFill { params } => pattern_fill_y_bbox(params),
894            DisplayElement::Group { params, .. } => Some(YBBox {
895                y_min: params.bbox[1],
896                y_max: params.bbox[3],
897            }),
898            DisplayElement::SoftMasked { params, .. } => Some(YBBox {
899                y_min: params.bbox[1],
900                y_max: params.bbox[3],
901            }),
902            DisplayElement::OcgGroup {
903                elements,
904                visibility,
905            } => {
906                // Hidden groups without clip ops contribute nothing — cull.
907                // (Hidden + has clip ops is handled below: we return paint
908                // bounds so the epoch has correct extent, and the band loop
909                // skips per-element culling for OcgGroups so the clip ops
910                // always execute.)
911                if !visibility.default_visible() && !contains_clip_op(elements) {
912                    return None;
913                }
914                let child_bboxes = precompute_bboxes(elements, dpi);
915                let mut y_min = f64::INFINITY;
916                let mut y_max = f64::NEG_INFINITY;
917                for cb in child_bboxes.into_iter().flatten() {
918                    y_min = y_min.min(cb.y_min);
919                    y_max = y_max.max(cb.y_max);
920                }
921                if y_min <= y_max {
922                    Some(YBBox { y_min, y_max })
923                } else {
924                    None
925                }
926            }
927            _ => None, // Clip, InitClip, ErasePage: always process
928        })
929        .collect()
930}
931
932/// Compute device-space Y bounding box for a shading element.
933/// Uses the BBox if present, otherwise returns a full-page sentinel
934/// (y_min=0, y_max=very large) so the element is never culled.
935fn shading_y_bbox_from_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<YBBox> {
936    if let Some(bbox) = bbox {
937        let corners = [
938            (bbox[0], bbox[1]),
939            (bbox[2], bbox[1]),
940            (bbox[0], bbox[3]),
941            (bbox[2], bbox[3]),
942        ];
943        let mut y_min = f64::INFINITY;
944        let mut y_max = f64::NEG_INFINITY;
945        for (x, y) in &corners {
946            let (_, dy) = ctm.transform_point(*x, *y);
947            y_min = y_min.min(dy);
948            y_max = y_max.max(dy);
949        }
950        Some(YBBox { y_min, y_max })
951    } else {
952        // No BBox — shading covers unbounded area; return sentinel so it's
953        // never culled by band processing.
954        Some(YBBox {
955            y_min: 0.0,
956            y_max: 1e9,
957        })
958    }
959}
960
961/// Compute device-space Y bounding box for a stroke element.
962///
963/// Isotropic strokes have paths already in device space (Identity CTM), so
964/// `path_y_bbox` gives device-space bounds directly. Anisotropic strokes have
965/// paths in user space with the full CTM — we must transform the bounding box
966/// through the CTM to get device-space bounds.
967fn stroke_device_y_bbox(path: &PsPath, params: &StrokeParams, dpi: f64) -> Option<YBBox> {
968    let m = &params.ctm;
969    let is_identity =
970        m.a == 1.0 && m.b == 0.0 && m.c == 0.0 && m.d == 1.0 && m.tx == 0.0 && m.ty == 0.0;
971
972    // Use effective line width: actual width or hairline minimum, whichever is larger
973    let effective_lw = params.line_width.max(hairline_min_width(&params.ctm, dpi));
974
975    if is_identity {
976        // Path in device space — just read Y coords and expand for stroke width.
977        return path_y_bbox(path).map(|mut bbox| {
978            let expand = effective_lw * params.miter_limit * 0.5;
979            bbox.y_min -= expand;
980            bbox.y_max += expand;
981            bbox
982        });
983    }
984
985    // Anisotropic: path in user space. Compute full XY bbox, transform corners
986    // through CTM to get device-space Y range.
987    let (mut x_min, mut x_max) = (f64::INFINITY, f64::NEG_INFINITY);
988    let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY);
989    for seg in &path.segments {
990        match seg {
991            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => {
992                x_min = x_min.min(*x);
993                x_max = x_max.max(*x);
994                y_min = y_min.min(*y);
995                y_max = y_max.max(*y);
996            }
997            PathSegment::CurveTo {
998                x1,
999                y1,
1000                x2,
1001                y2,
1002                x3,
1003                y3,
1004            } => {
1005                x_min = x_min.min(*x1).min(*x2).min(*x3);
1006                x_max = x_max.max(*x1).max(*x2).max(*x3);
1007                y_min = y_min.min(*y1).min(*y2).min(*y3);
1008                y_max = y_max.max(*y1).max(*y2).max(*y3);
1009            }
1010            PathSegment::ClosePath => {}
1011        }
1012    }
1013    if x_min > x_max {
1014        return None;
1015    }
1016
1017    // Transform all 4 corners of user-space bbox to device space
1018    let corners = [
1019        (x_min, y_min),
1020        (x_max, y_min),
1021        (x_min, y_max),
1022        (x_max, y_max),
1023    ];
1024    let mut dev_y_min = f64::INFINITY;
1025    let mut dev_y_max = f64::NEG_INFINITY;
1026    for (x, y) in &corners {
1027        let dy = m.b * x + m.d * y + m.ty;
1028        dev_y_min = dev_y_min.min(dy);
1029        dev_y_max = dev_y_max.max(dy);
1030    }
1031
1032    // Expand for stroke width + miter in device-space units.
1033    // ||[c,d]|| converts user-space line_width to device-space Y expansion.
1034    let col_y_len = (m.c * m.c + m.d * m.d).sqrt().max(1.0);
1035    let expand = effective_lw * col_y_len * params.miter_limit * 0.5;
1036    dev_y_min -= expand;
1037    dev_y_max += expand;
1038
1039    Some(YBBox {
1040        y_min: dev_y_min,
1041        y_max: dev_y_max,
1042    })
1043}
1044
1045/// Compute device-space Y bounds for a Fill element, accounting for CTM.
1046/// Mirrors `stroke_device_y_bbox` but without stroke-width expansion.
1047/// Paths may be stored either in device space (identity CTM, content streams)
1048/// or user space (non-identity CTM, synthesized annotation appearances).
1049fn fill_device_y_bbox(path: &PsPath, ctm: &Matrix) -> Option<YBBox> {
1050    let is_identity = ctm.a == 1.0
1051        && ctm.b == 0.0
1052        && ctm.c == 0.0
1053        && ctm.d == 1.0
1054        && ctm.tx == 0.0
1055        && ctm.ty == 0.0;
1056    if is_identity {
1057        return path_y_bbox(path);
1058    }
1059    let bbox = path_full_bbox(path)?;
1060    let corners = [
1061        (bbox.x_min, bbox.y_min),
1062        (bbox.x_max, bbox.y_min),
1063        (bbox.x_min, bbox.y_max),
1064        (bbox.x_max, bbox.y_max),
1065    ];
1066    let mut dev_y_min = f64::INFINITY;
1067    let mut dev_y_max = f64::NEG_INFINITY;
1068    for (x, y) in &corners {
1069        let dy = ctm.b * x + ctm.d * y + ctm.ty;
1070        dev_y_min = dev_y_min.min(dy);
1071        dev_y_max = dev_y_max.max(dy);
1072    }
1073    Some(YBBox {
1074        y_min: dev_y_min,
1075        y_max: dev_y_max,
1076    })
1077}
1078
1079/// Compute Y bounds from path segments (conservative: uses control points for curves).
1080fn path_y_bbox(path: &PsPath) -> Option<YBBox> {
1081    let mut y_min = f64::INFINITY;
1082    let mut y_max = f64::NEG_INFINITY;
1083    for seg in &path.segments {
1084        match seg {
1085            PathSegment::MoveTo(_, y) | PathSegment::LineTo(_, y) => {
1086                y_min = y_min.min(*y);
1087                y_max = y_max.max(*y);
1088            }
1089            PathSegment::CurveTo { y1, y2, y3, .. } => {
1090                y_min = y_min.min(*y1).min(*y2).min(*y3);
1091                y_max = y_max.max(*y1).max(*y2).max(*y3);
1092            }
1093            PathSegment::ClosePath => {}
1094        }
1095    }
1096    if y_min <= y_max {
1097        Some(YBBox { y_min, y_max })
1098    } else {
1099        None
1100    }
1101}
1102
1103/// Compute Y bounds for an image element from its transform.
1104fn image_y_bbox(params: &ImageParams) -> Option<YBBox> {
1105    let image_inv = params.image_matrix.invert()?;
1106    let combined = params.ctm.concat(&image_inv);
1107    let corners = [
1108        (0.0, 0.0),
1109        (params.width as f64, 0.0),
1110        (params.width as f64, params.height as f64),
1111        (0.0, params.height as f64),
1112    ];
1113    let mut y_min = f64::INFINITY;
1114    let mut y_max = f64::NEG_INFINITY;
1115    for (x, y) in &corners {
1116        let (_, dy) = combined.transform_point(*x, *y);
1117        y_min = y_min.min(dy);
1118        y_max = y_max.max(dy);
1119    }
1120    Some(YBBox { y_min, y_max })
1121}
1122
1123/// Pre-populate clip_mask_seen with hashes of clip paths that appear ≥2 times.
1124/// This lets the first band immediately cache repeated clip paths.
1125fn precompute_clip_seen(list: &DisplayList) -> HashSet<u64> {
1126    let mut counts: HashMap<u64, u32> = HashMap::new();
1127    for elem in list.elements() {
1128        if let DisplayElement::Clip { path, params } = elem {
1129            let hash = hash_clip_path(path, &params.fill_rule);
1130            *counts.entry(hash).or_insert(0) += 1;
1131        }
1132    }
1133    counts
1134        .into_iter()
1135        .filter(|(_, c)| *c > 1)
1136        .map(|(h, _)| h)
1137        .collect()
1138}
1139
1140/// Build clip epochs — groups of elements between InitClip boundaries.
1141/// Each epoch's paint_bbox is the union of Y ranges for all paint elements in it.
1142fn build_clip_epochs(list: &DisplayList, bboxes: &[Option<YBBox>]) -> Vec<ClipEpoch> {
1143    let elements = list.elements();
1144    let mut epochs = Vec::new();
1145    let mut epoch_start = 0;
1146    let mut y_min = f64::INFINITY;
1147    let mut y_max = f64::NEG_INFINITY;
1148    let mut has_erase = false;
1149
1150    for (i, element) in elements.iter().enumerate() {
1151        // InitClip starts a new epoch (close the previous one first)
1152        if matches!(element, DisplayElement::InitClip) && i > epoch_start {
1153            epochs.push(ClipEpoch {
1154                start_idx: epoch_start,
1155                end_idx: i,
1156                paint_bbox: if y_min <= y_max {
1157                    Some(YBBox { y_min, y_max })
1158                } else {
1159                    None
1160                },
1161                has_erase_page: has_erase,
1162            });
1163            epoch_start = i;
1164            y_min = f64::INFINITY;
1165            y_max = f64::NEG_INFINITY;
1166            has_erase = false;
1167        }
1168        if matches!(element, DisplayElement::ErasePage) {
1169            has_erase = true;
1170        }
1171        if let Some(ref bbox) = bboxes[i] {
1172            y_min = y_min.min(bbox.y_min);
1173            y_max = y_max.max(bbox.y_max);
1174        }
1175    }
1176    // Final epoch
1177    if epoch_start < elements.len() {
1178        epochs.push(ClipEpoch {
1179            start_idx: epoch_start,
1180            end_idx: elements.len(),
1181            paint_bbox: if y_min <= y_max {
1182                Some(YBBox { y_min, y_max })
1183            } else {
1184                None
1185            },
1186            has_erase_page: has_erase,
1187        });
1188    }
1189    epochs
1190}
1191
1192/// Apply a device-space Y offset to a tiny-skia Transform.
1193/// The original transform maps from path space to full-page device space;
1194/// we subtract `y_offset` from `ty` so band rows [y_start, y_start+band_h)
1195/// map to pixmap rows [0, band_h).
1196/// Composite premultiplied-alpha RGBA pixels onto a white background.
1197/// After this, all pixels are fully opaque (alpha=255).
1198fn composite_onto_white(data: &mut [u8]) {
1199    for pixel in data.chunks_exact_mut(4) {
1200        let a = pixel[3] as u16;
1201        if a == 255 {
1202            continue; // fully opaque — no compositing needed
1203        }
1204        let inv_a = 255 - a;
1205        pixel[0] = (pixel[0] as u16 + inv_a).min(255) as u8;
1206        pixel[1] = (pixel[1] as u16 + inv_a).min(255) as u8;
1207        pixel[2] = (pixel[2] as u16 + inv_a).min(255) as u8;
1208        pixel[3] = 255;
1209    }
1210}
1211
1212/// Extract the contribution of a non-isolated transparency group and composite
1213/// it onto the parent using the group's blend mode and alpha.
1214///
1215/// Composite a (possibly cropped) non-isolated group offscreen onto the parent pixmap.
1216///
1217/// Like `extract_and_composite_contribution`, but the offscreen and backdrop
1218/// are crop-sized (only covering the group's bounding box region), positioned
1219/// at `(crop_x, crop_y)` in the parent's coordinate system.
1220fn composite_non_isolated_group_cropped(
1221    target: &mut Pixmap,
1222    source: &Pixmap,
1223    backdrop: &[u8],
1224    params: &stet_graphics::display_list::GroupParams,
1225    clip_mask: Option<&stet_tiny_skia::Mask>,
1226    crop_x: i32,
1227    crop_y: i32,
1228) {
1229    let cw = source.width();
1230    let ch = source.height();
1231
1232    // Build a contribution pixmap: pixels that changed vs backdrop
1233    let Some(mut contribution) = Pixmap::new(cw, ch) else {
1234        return;
1235    };
1236    let src_data = source.data();
1237    let contrib_data = contribution.data_mut();
1238
1239    for (i, chunk) in contrib_data.chunks_exact_mut(4).enumerate() {
1240        let off = i * 4;
1241        if src_data[off] != backdrop[off]
1242            || src_data[off + 1] != backdrop[off + 1]
1243            || src_data[off + 2] != backdrop[off + 2]
1244            || src_data[off + 3] != backdrop[off + 3]
1245        {
1246            chunk.copy_from_slice(&src_data[off..off + 4]);
1247        }
1248    }
1249
1250    let paint = stet_tiny_skia::PixmapPaint {
1251        opacity: params.alpha as f32,
1252        blend_mode: u8_to_blend_mode(params.blend_mode),
1253        quality: stet_tiny_skia::FilterQuality::Nearest,
1254    };
1255    target.draw_pixmap(
1256        crop_x,
1257        crop_y,
1258        contribution.as_ref(),
1259        &paint,
1260        Transform::identity(),
1261        clip_mask,
1262    );
1263}
1264
1265/// Non-isolated group composite-back using the proper source-extraction
1266/// formula (ISO 32000-1 §11.4.8).
1267///
1268/// `source` was rendered against the `backdrop`; `isolated` was rendered
1269/// against transparent.  The isolated render's alpha channel gives the
1270/// group's shape, which lets us extract the source color:
1271///
1272///   C_g_premul = R - B · (1 - α_g)      (premultiplied source color)
1273///   α_g        = isolated alpha channel
1274///
1275/// The extracted contribution is then composited onto `target` with the
1276/// group's blend mode and opacity.
1277fn composite_non_isolated_extracted(
1278    target: &mut Pixmap,
1279    source: &Pixmap,
1280    isolated: &Pixmap,
1281    backdrop: &[u8],
1282    params: &stet_graphics::display_list::GroupParams,
1283    clip_mask: Option<&stet_tiny_skia::Mask>,
1284    crop_x: i32,
1285    crop_y: i32,
1286) {
1287    let cw = source.width();
1288    let ch = source.height();
1289
1290    let Some(mut contribution) = Pixmap::new(cw, ch) else {
1291        return;
1292    };
1293    let src_data = source.data();
1294    let iso_data = isolated.data();
1295    let contrib_data = contribution.data_mut();
1296
1297    for i in 0..(cw as usize * ch as usize) {
1298        let off = i * 4;
1299        let alpha_g = iso_data[off + 3];
1300        if alpha_g == 0 {
1301            continue; // no group contribution at this pixel
1302        }
1303
1304        // Extract premultiplied source: C_g_premul = R - B · (1 - α_g/255)
1305        let inv_alpha = 255 - alpha_g as i32;
1306        for c in 0..3 {
1307            let r = src_data[off + c] as i32;
1308            let b = backdrop[off + c] as i32;
1309            let raw = r - (b * inv_alpha + 127) / 255;
1310            contrib_data[off + c] = raw.clamp(0, 255) as u8;
1311        }
1312        contrib_data[off + 3] = alpha_g;
1313    }
1314
1315    let paint = stet_tiny_skia::PixmapPaint {
1316        opacity: params.alpha as f32,
1317        blend_mode: u8_to_blend_mode(params.blend_mode),
1318        quality: stet_tiny_skia::FilterQuality::Nearest,
1319    };
1320    target.draw_pixmap(
1321        crop_x,
1322        crop_y,
1323        contribution.as_ref(),
1324        &paint,
1325        Transform::identity(),
1326        clip_mask,
1327    );
1328}
1329
1330/// Apply a combined offset + scale to a tiny-skia Transform for viewport rendering.
1331/// Maps device-space coordinates into viewport-local pixel coordinates:
1332///   output_x = (device_x - vp_x) * scale_x
1333///   output_y = (device_y - vp_y) * scale_y
1334fn viewport_transform(t: Transform, vp_x: f32, vp_y: f32, scale_x: f32, scale_y: f32) -> Transform {
1335    // Post-compose: first apply `t` (path→device), then translate(-vp_x,-vp_y), then scale
1336    Transform::from_row(
1337        t.sx * scale_x,
1338        t.ky * scale_y,
1339        t.kx * scale_x,
1340        t.sy * scale_y,
1341        (t.tx - vp_x) * scale_x,
1342        (t.ty - vp_y) * scale_y,
1343    )
1344}
1345
1346/// Fast area-average box filter resample for downscaling.
1347///
1348/// Each output pixel averages all source pixels that fall within its footprint.
1349/// Two-pass separable (horizontal then vertical) for O(src) total work regardless
1350/// of scale ratio. Produces quality equivalent to Lanczos3 for downscaling at a
1351/// fraction of the cost.
1352fn box_resample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
1353    if dw == 0 || dh == 0 {
1354        return Vec::new();
1355    }
1356    let (sw, sh, dw, dh) = (sw as usize, sh as usize, dw as usize, dh as usize);
1357
1358    // Pass 1: horizontal (sw → dw) with fractional edge weights.
1359    // Each output pixel covers [left_f, right_f] in source space. Edge source
1360    // pixels get proportional weight; interior pixels get weight 1.0.
1361    let ratio_x = sw as f32 / dw as f32;
1362    let mut tmp = vec![0.0f32; dw * sh * 4];
1363    let tmp_stride = dw * 4;
1364
1365    for y in 0..sh {
1366        let row_off = y * sw * 4;
1367        let dst_row = y * tmp_stride;
1368        for dx in 0..dw {
1369            let left_f = dx as f32 * ratio_x;
1370            let right_f = (dx + 1) as f32 * ratio_x;
1371            let left = (left_f as usize).min(sw - 1);
1372            let right = (right_f.ceil() as usize).min(sw);
1373            let inv_area = 1.0 / (right_f - left_f);
1374            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1375            for sx in left..right {
1376                // Weight: fraction of this source pixel covered by the output pixel
1377                let pixel_left = sx as f32;
1378                let pixel_right = (sx + 1) as f32;
1379                let w = pixel_right.min(right_f) - pixel_left.max(left_f);
1380                let i = row_off + sx * 4;
1381                r += src[i] as f32 * w;
1382                g += src[i + 1] as f32 * w;
1383                b += src[i + 2] as f32 * w;
1384                a += src[i + 3] as f32 * w;
1385            }
1386            let di = dst_row + dx * 4;
1387            tmp[di] = r * inv_area;
1388            tmp[di + 1] = g * inv_area;
1389            tmp[di + 2] = b * inv_area;
1390            tmp[di + 3] = a * inv_area;
1391        }
1392    }
1393
1394    // Pass 2: vertical (sh → dh) with fractional edge weights, row-major order.
1395    let ratio_y = sh as f32 / dh as f32;
1396    let mut out = vec![0u8; dw * dh * 4];
1397    let out_stride = dw * 4;
1398
1399    for dy in 0..dh {
1400        let top_f = dy as f32 * ratio_y;
1401        let bottom_f = (dy + 1) as f32 * ratio_y;
1402        let top = (top_f as usize).min(sh - 1);
1403        let bottom = (bottom_f.ceil() as usize).min(sh);
1404        let inv_area = 1.0 / (bottom_f - top_f);
1405
1406        // Pre-compute row weights
1407        let n_rows = bottom - top;
1408        let mut row_weights_buf: [(usize, f32); 8] = [(0, 0.0); 8];
1409        let row_weights_vec: Vec<(usize, f32)>;
1410        let row_weights: &[(usize, f32)] = if n_rows <= 8 {
1411            for (i, sy) in (top..bottom).enumerate() {
1412                let pixel_top = sy as f32;
1413                let pixel_bottom = (sy + 1) as f32;
1414                let w = pixel_bottom.min(bottom_f) - pixel_top.max(top_f);
1415                row_weights_buf[i] = (sy, w);
1416            }
1417            &row_weights_buf[..n_rows]
1418        } else {
1419            row_weights_vec = (top..bottom)
1420                .map(|sy| {
1421                    let pixel_top = sy as f32;
1422                    let pixel_bottom = (sy + 1) as f32;
1423                    let w = pixel_bottom.min(bottom_f) - pixel_top.max(top_f);
1424                    (sy, w)
1425                })
1426                .collect();
1427            &row_weights_vec
1428        };
1429
1430        let dst_row = dy * out_stride;
1431        for dx in 0..dw {
1432            let col = dx * 4;
1433            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1434            for &(sy, w) in row_weights {
1435                let i = sy * tmp_stride + col;
1436                r += tmp[i] * w;
1437                g += tmp[i + 1] * w;
1438                b += tmp[i + 2] * w;
1439                a += tmp[i + 3] * w;
1440            }
1441            let di = dst_row + col;
1442            out[di] = (r * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1443            out[di + 1] = (g * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1444            out[di + 2] = (b * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1445            out[di + 3] = (a * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1446        }
1447    }
1448
1449    out
1450}
1451
1452/// Bicubic (Catmull-Rom) resample for upscaling — two-pass separable.
1453///
1454/// Pass 1: horizontal resample (sw → dw) at f32 precision.
1455/// Pass 2: vertical resample (sh → dh) and quantize to u8.
1456///
1457/// Separable approach: O(dw×sh + dw×dh) × 4 taps instead of O(dw×dh) × 16 taps.
1458fn bicubic_resample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
1459    if dw == 0 || dh == 0 {
1460        return Vec::new();
1461    }
1462
1463    let (sw, sh, dw, dh) = (sw as usize, sh as usize, dw as usize, dh as usize);
1464    let ratio_x = sw as f32 / dw as f32;
1465    let ratio_y = sh as f32 / dh as f32;
1466
1467    // Pass 1: horizontal (sw → dw), keep sh rows, store as f32.
1468    let mut tmp = vec![0.0f32; dw * sh * 4];
1469    for y in 0..sh {
1470        let src_row = y * sw * 4;
1471        let dst_row = y * dw * 4;
1472        for dx in 0..dw {
1473            let sx = (dx as f32 + 0.5) * ratio_x - 0.5;
1474            let sx_floor = sx.floor() as i32;
1475            let fx = sx - sx_floor as f32;
1476            let w0 = catmull_rom(fx + 1.0);
1477            let w1 = catmull_rom(fx);
1478            let w2 = catmull_rom(1.0 - fx);
1479            let w3 = catmull_rom(2.0 - fx);
1480            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1481            for (k, w) in [
1482                (sx_floor - 1, w0),
1483                (sx_floor, w1),
1484                (sx_floor + 1, w2),
1485                (sx_floor + 2, w3),
1486            ] {
1487                let px = k.clamp(0, sw as i32 - 1) as usize;
1488                let i = src_row + px * 4;
1489                r += src[i] as f32 * w;
1490                g += src[i + 1] as f32 * w;
1491                b += src[i + 2] as f32 * w;
1492                a += src[i + 3] as f32 * w;
1493            }
1494            let di = dst_row + dx * 4;
1495            tmp[di] = r;
1496            tmp[di + 1] = g;
1497            tmp[di + 2] = b;
1498            tmp[di + 3] = a;
1499        }
1500    }
1501
1502    // Pass 2: vertical (sh → dh) on the dw-wide tmp, quantize to u8.
1503    // Row-major order for cache-friendly access.
1504    let mut out = vec![0u8; dw * dh * 4];
1505    let tmp_stride = dw * 4;
1506    let out_stride = dw * 4;
1507    for dy in 0..dh {
1508        let sy = (dy as f32 + 0.5) * ratio_y - 0.5;
1509        let sy_floor = sy.floor() as i32;
1510        let fy = sy - sy_floor as f32;
1511        let w0 = catmull_rom(fy + 1.0);
1512        let w1 = catmull_rom(fy);
1513        let w2 = catmull_rom(1.0 - fy);
1514        let w3 = catmull_rom(2.0 - fy);
1515        let py0 = (sy_floor - 1).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1516        let py1 = sy_floor.clamp(0, sh as i32 - 1) as usize * tmp_stride;
1517        let py2 = (sy_floor + 1).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1518        let py3 = (sy_floor + 2).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1519        let dst_row = dy * out_stride;
1520        for dx in 0..dw {
1521            let col = dx * 4;
1522            let r = tmp[py0 + col] * w0
1523                + tmp[py1 + col] * w1
1524                + tmp[py2 + col] * w2
1525                + tmp[py3 + col] * w3;
1526            let g = tmp[py0 + col + 1] * w0
1527                + tmp[py1 + col + 1] * w1
1528                + tmp[py2 + col + 1] * w2
1529                + tmp[py3 + col + 1] * w3;
1530            let b = tmp[py0 + col + 2] * w0
1531                + tmp[py1 + col + 2] * w1
1532                + tmp[py2 + col + 2] * w2
1533                + tmp[py3 + col + 2] * w3;
1534            let a = tmp[py0 + col + 3] * w0
1535                + tmp[py1 + col + 3] * w1
1536                + tmp[py2 + col + 3] * w2
1537                + tmp[py3 + col + 3] * w3;
1538            let di = dst_row + col;
1539            out[di] = r.round().clamp(0.0, 255.0) as u8;
1540            out[di + 1] = g.round().clamp(0.0, 255.0) as u8;
1541            out[di + 2] = b.round().clamp(0.0, 255.0) as u8;
1542            out[di + 3] = a.round().clamp(0.0, 255.0) as u8;
1543        }
1544    }
1545
1546    out
1547}
1548
1549/// Catmull-Rom spline weight (a = -0.5).
1550#[inline]
1551fn catmull_rom(t: f32) -> f32 {
1552    let t = t.abs();
1553    if t < 1.0 {
1554        (1.5 * t - 2.5) * t * t + 1.0
1555    } else if t < 2.0 {
1556        ((-0.5 * t + 2.5) * t - 4.0) * t + 2.0
1557    } else {
1558        0.0
1559    }
1560}
1561
1562/// Pre-downsample an image when the transform indicates significant downscaling.
1563///
1564/// tiny-skia's bilinear filter only samples a 2×2 neighborhood — it has no mipmap
1565/// support, so large downscale ratios cause severe aliasing (e.g., 300 DPI bitmap
1566/// fonts rendered at screen resolution).
1567///
1568/// For axis-aligned transforms: box-filter resample to the exact target dimensions.
1569///
1570/// Build an `IccCache` from ICC profiles found in a display list.
1571///
1572/// Registers all unique ICCBased profiles and optionally the system CMYK
1573/// profile. When `proofing_enabled` is true, ICCBased profiles registered
1574/// while scanning the display list are color-managed *through* the system
1575/// CMYK (the PDF's OutputIntent), so a render-thread cache built from the
1576/// effective OutputIntent matches the bake-time cache that produced the
1577/// display list. PostScript callers should pass `false` (no
1578/// PDF/X OutputIntent semantics).
1579pub fn build_icc_cache_for_list(
1580    list: &DisplayList,
1581    system_cmyk_bytes: Option<&std::sync::Arc<Vec<u8>>>,
1582    proofing_enabled: bool,
1583) -> IccCache {
1584    let mut cache = IccCache::new();
1585    let mut seen = HashSet::new();
1586
1587    // Register system CMYK profile first. Proofing must stay off here: the
1588    // OutputIntent itself converts directly to sRGB, not through itself.
1589    if let Some(cmyk_bytes) = system_cmyk_bytes
1590        && let Some(hash) = cache.register_profile(cmyk_bytes)
1591    {
1592        seen.insert(hash);
1593        // Set the default CMYK hash so convert_image_8bit works for DeviceCMYK
1594        cache.set_default_cmyk_hash(hash);
1595        // Pre-warm the sRGB→CMYK reverse transform so band renderers, which
1596        // only hold an `&IccCache`, can use `convert_rgb_to_cmyk_readonly`
1597        // when populating the parallel CMYK buffer for non-CMYK painters.
1598        cache.prepare_reverse_cmyk();
1599        // Pre-build the per-intent Lab → OI CMYK samplers so Lab fills can
1600        // populate `native_cmyk` from `&IccCache` (mirrors the PNG path's
1601        // `apply_output_intent_as_default_cmyk`). Required for GWG 22.1.
1602        cache.prepare_lab_to_oi_cmyk();
1603    }
1604
1605    // Enable proofing AFTER the OutputIntent itself is registered so the
1606    // chain logic in `register_profile` sees `default_cmyk_hash` set when
1607    // subsequent ICCBased profiles arrive — those get chained through the
1608    // OutputIntent.
1609    cache.set_proofing_enabled(proofing_enabled);
1610
1611    // Scan display list for ICCBased images and shadings (recursing into Groups)
1612    fn scan_elements(
1613        elements: &[DisplayElement],
1614        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1615        cache: &mut IccCache,
1616    ) {
1617        for element in elements {
1618            // Recurse into groups
1619            if let DisplayElement::Group { elements: sub, .. } = element {
1620                scan_elements(sub.elements(), seen, cache);
1621            }
1622            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1623                scan_elements(content.elements(), seen, cache);
1624                scan_elements(mask.elements(), seen, cache);
1625            }
1626            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1627                scan_elements(sub.elements(), seen, cache);
1628            }
1629            // Shading color spaces
1630            let shading_cs = match element {
1631                DisplayElement::AxialShading { params } => Some(&params.color_space),
1632                DisplayElement::RadialShading { params } => Some(&params.color_space),
1633                DisplayElement::MeshShading { params } => Some(&params.color_space),
1634                DisplayElement::PatchShading { params } => Some(&params.color_space),
1635                _ => None,
1636            };
1637            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1638                n,
1639                profile_hash,
1640                profile_data,
1641            }) = shading_cs
1642            {
1643                if seen.insert(*profile_hash) {
1644                    cache.register_profile_with_n(profile_data, Some(*n));
1645                }
1646            }
1647            // Image color spaces
1648            if let DisplayElement::Image { params, .. } = element {
1649                match &params.color_space {
1650                    ImageColorSpace::ICCBased {
1651                        n,
1652                        profile_hash,
1653                        profile_data,
1654                    } if seen.insert(*profile_hash) => {
1655                        cache.register_profile_with_n(profile_data, Some(*n));
1656                    }
1657                    ImageColorSpace::Indexed { base, .. }
1658                        if matches!(base.as_ref(), ImageColorSpace::ICCBased { .. }) =>
1659                    {
1660                        if let ImageColorSpace::ICCBased {
1661                            n,
1662                            profile_hash,
1663                            profile_data,
1664                        } = base.as_ref()
1665                        {
1666                            if seen.insert(*profile_hash) {
1667                                cache.register_profile_with_n(profile_data, Some(*n));
1668                            }
1669                        }
1670                    }
1671                    _ => {}
1672                }
1673            }
1674        }
1675    }
1676    scan_elements(list.elements(), &mut seen, &mut cache);
1677
1678    cache
1679}
1680
1681/// Register ICC profiles from shading elements in a display list.
1682///
1683/// Recursively scans Groups and SoftMasks for ICCBased shading color spaces
1684/// and registers their profiles in the cache.
1685fn register_shading_icc_profiles(list: &DisplayList, cache: &mut IccCache) {
1686    fn register_image_iccs(
1687        cs: &ImageColorSpace,
1688        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1689        cache: &mut IccCache,
1690    ) {
1691        match cs {
1692            ImageColorSpace::ICCBased {
1693                n,
1694                profile_hash,
1695                profile_data,
1696            } => {
1697                if seen.insert(*profile_hash) {
1698                    cache.register_profile_with_n(profile_data, Some(*n));
1699                }
1700            }
1701            ImageColorSpace::Indexed { base, .. } => register_image_iccs(base, seen, cache),
1702            ImageColorSpace::Separation { alt_space, .. }
1703            | ImageColorSpace::DeviceN { alt_space, .. } => {
1704                register_image_iccs(alt_space, seen, cache)
1705            }
1706            _ => {}
1707        }
1708    }
1709    fn scan(
1710        elements: &[DisplayElement],
1711        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1712        cache: &mut IccCache,
1713    ) {
1714        for element in elements {
1715            if let DisplayElement::Group { elements: sub, .. } = element {
1716                scan(sub.elements(), seen, cache);
1717            }
1718            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1719                scan(content.elements(), seen, cache);
1720                scan(mask.elements(), seen, cache);
1721            }
1722            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1723                scan(sub.elements(), seen, cache);
1724            }
1725            let shading_cs = match element {
1726                DisplayElement::AxialShading { params } => Some(&params.color_space),
1727                DisplayElement::RadialShading { params } => Some(&params.color_space),
1728                DisplayElement::MeshShading { params } => Some(&params.color_space),
1729                DisplayElement::PatchShading { params } => Some(&params.color_space),
1730                _ => None,
1731            };
1732            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1733                n,
1734                profile_hash,
1735                profile_data,
1736            }) = shading_cs
1737                && seen.insert(*profile_hash)
1738            {
1739                cache.register_profile_with_n(profile_data, Some(*n));
1740            }
1741            if let DisplayElement::Image { params, .. } = element {
1742                register_image_iccs(&params.color_space, seen, cache);
1743            }
1744        }
1745    }
1746    let mut seen = HashSet::new();
1747    scan(list.elements(), &mut seen, cache);
1748}
1749
1750/// Convert raw image samples to RGBA for rasterization.
1751///
1752/// Handles all `ImageColorSpace` variants, producing width×height×4 RGBA bytes.
1753fn samples_to_rgba(
1754    data: &[u8],
1755    params: &ImageParams,
1756    icc: Option<&IccCache>,
1757    opm_zero_transparent: bool,
1758) -> Vec<u8> {
1759    let w = params.width as usize;
1760    let h = params.height as usize;
1761    let npixels = w * h;
1762    let bpc = params.bits_per_component;
1763    match &params.color_space {
1764        ImageColorSpace::PreconvertedRGBA => {
1765            // Already RGBA — just return as-is
1766            data.to_vec()
1767        }
1768        ImageColorSpace::DeviceGray => {
1769            let mut rgba = vec![255u8; npixels * 4];
1770            if bpc == 16 {
1771                for i in 0..npixels {
1772                    let g = data.get(i * 2).copied().unwrap_or(0);
1773                    let pi = i * 4;
1774                    rgba[pi] = g;
1775                    rgba[pi + 1] = g;
1776                    rgba[pi + 2] = g;
1777                }
1778            } else {
1779                for i in 0..npixels {
1780                    let g = data.get(i).copied().unwrap_or(0);
1781                    let pi = i * 4;
1782                    rgba[pi] = g;
1783                    rgba[pi + 1] = g;
1784                    rgba[pi + 2] = g;
1785                }
1786            }
1787            rgba
1788        }
1789        ImageColorSpace::DeviceRGB => {
1790            let mut rgba = vec![255u8; npixels * 4];
1791            if bpc == 16 {
1792                // 16 BPC: 6 bytes per pixel (R_hi R_lo G_hi G_lo B_hi B_lo)
1793                // Take high byte of each 16-bit sample
1794                for i in 0..npixels {
1795                    let si = i * 6;
1796                    let pi = i * 4;
1797                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1798                    rgba[pi + 1] = data.get(si + 2).copied().unwrap_or(0);
1799                    rgba[pi + 2] = data.get(si + 4).copied().unwrap_or(0);
1800                }
1801            } else {
1802                for i in 0..npixels {
1803                    let si = i * 3;
1804                    let pi = i * 4;
1805                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1806                    rgba[pi + 1] = data.get(si + 1).copied().unwrap_or(0);
1807                    rgba[pi + 2] = data.get(si + 2).copied().unwrap_or(0);
1808                }
1809            }
1810            rgba
1811        }
1812        ImageColorSpace::DeviceCMYK => {
1813            // Try ICC-based CMYK→RGB conversion via system CMYK profile.
1814            // Convert as many complete pixels as the data allows; PLRM-fallback
1815            // for any remaining pixels with insufficient data.
1816            if let Some(cache) = icc
1817                && let Some(cmyk_hash) = cache.default_cmyk_hash()
1818            {
1819                let avail_pixels = data.len() / 4;
1820                let icc_pixels = avail_pixels.min(npixels);
1821                if icc_pixels > 0
1822                    && let Some(rgb) = cache.convert_image_8bit(cmyk_hash, data, icc_pixels)
1823                {
1824                    let mut rgba = vec![255u8; npixels * 4];
1825                    for i in 0..icc_pixels {
1826                        rgba[i * 4] = rgb[i * 3];
1827                        rgba[i * 4 + 1] = rgb[i * 3 + 1];
1828                        rgba[i * 4 + 2] = rgb[i * 3 + 2];
1829                        // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1830                        if opm_zero_transparent {
1831                            let si = i * 4;
1832                            if data[si] == 0
1833                                && data[si + 1] == 0
1834                                && data[si + 2] == 0
1835                                && data[si + 3] == 0
1836                            {
1837                                rgba[i * 4 + 3] = 0;
1838                            }
1839                        }
1840                    }
1841                    // Remaining pixels (if data was short) stay white (0xFF)
1842                    return rgba;
1843                }
1844            }
1845            // Fallback: PLRM CMYK→RGB formula
1846            let mut rgba = vec![255u8; npixels * 4];
1847            for i in 0..npixels {
1848                let si = i * 4;
1849                let c = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1850                let m = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1851                let y = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1852                let k = data.get(si + 3).copied().unwrap_or(0) as f64 / 255.0;
1853                let r = (1.0 - c.min(1.0)) * (1.0 - k.min(1.0));
1854                let g = (1.0 - m.min(1.0)) * (1.0 - k.min(1.0));
1855                let b = (1.0 - y.min(1.0)) * (1.0 - k.min(1.0));
1856                let pi = i * 4;
1857                rgba[pi] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
1858                rgba[pi + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
1859                rgba[pi + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
1860                // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1861                if opm_zero_transparent
1862                    && data.get(si).copied().unwrap_or(0) == 0
1863                    && data.get(si + 1).copied().unwrap_or(0) == 0
1864                    && data.get(si + 2).copied().unwrap_or(0) == 0
1865                    && data.get(si + 3).copied().unwrap_or(0) == 0
1866                {
1867                    rgba[pi + 3] = 0;
1868                }
1869            }
1870            rgba
1871        }
1872        ImageColorSpace::ICCBased {
1873            n,
1874            profile_hash,
1875            profile_data,
1876        } => {
1877            // Try ICC-based conversion if cache is available. Routes through
1878            // the proofing chain (`chain_per_intent_8bit[intent]`) when the
1879            // chain has been populated for this intent — the proofing chain
1880            // is what `convert_color_with_intent` uses for vector paints,
1881            // so images need it too to match. Without this, an Adobe-RGB
1882            // image renders via the source profile's direct RGB→sRGB while
1883            // the surrounding CMYK paint goes through the OutputIntent
1884            // CMYK→sRGB; the two sRGB outputs diverge. GWG 17.2 calibrates
1885            // both so they match under correct CMS, and the test's "X"
1886            // appears whenever the image bypasses the OI roundtrip.
1887            let intent = stet_graphics::icc::intent_from_pdf_byte(params.rendering_intent);
1888            if let Some(cache) = icc
1889                && cache.has_profile(profile_hash)
1890                && let Some(rgb) =
1891                    cache.convert_image_8bit_with_intent(profile_hash, data, npixels, intent)
1892            {
1893                let mut rgba = vec![255u8; npixels * 4];
1894                for i in 0..npixels {
1895                    rgba[i * 4] = rgb[i * 3];
1896                    rgba[i * 4 + 1] = rgb[i * 3 + 1];
1897                    rgba[i * 4 + 2] = rgb[i * 3 + 2];
1898                    // OPM=1 on 4-component (CMYK) ICC profiles
1899                    if opm_zero_transparent && *n == 4 {
1900                        let si = i * *n as usize;
1901                        if si + 3 < data.len()
1902                            && data[si] == 0
1903                            && data[si + 1] == 0
1904                            && data[si + 2] == 0
1905                            && data[si + 3] == 0
1906                        {
1907                            rgba[i * 4 + 3] = 0;
1908                        }
1909                    }
1910                }
1911                return rgba;
1912            }
1913            // Fallback to device equivalent based on component count
1914            let _ = (profile_hash, profile_data);
1915            let fallback = match n {
1916                1 => ImageColorSpace::DeviceGray,
1917                4 => ImageColorSpace::DeviceCMYK,
1918                _ => ImageColorSpace::DeviceRGB,
1919            };
1920            let p = ImageParams {
1921                color_space: fallback,
1922                bits_per_component: 8,
1923                ..params.clone()
1924            };
1925            samples_to_rgba(data, &p, icc, opm_zero_transparent)
1926        }
1927        ImageColorSpace::Indexed {
1928            base,
1929            hival,
1930            lookup,
1931        } => {
1932            let base_ncomp = base.num_components() as usize;
1933            // Expand indexed samples to base color space, then convert
1934            let mut expanded = Vec::with_capacity(npixels * base_ncomp);
1935            for i in 0..npixels {
1936                let idx = data.get(i).copied().unwrap_or(0) as usize;
1937                let idx = idx.min(*hival as usize);
1938                let offset = idx * base_ncomp;
1939                for c in 0..base_ncomp {
1940                    expanded.push(lookup.get(offset + c).copied().unwrap_or(0));
1941                }
1942            }
1943            let p = ImageParams {
1944                color_space: *base.clone(),
1945                bits_per_component: 8,
1946                ..params.clone()
1947            };
1948            samples_to_rgba(&expanded, &p, icc, opm_zero_transparent)
1949        }
1950        ImageColorSpace::CIEBasedABC { params: cie_params } => {
1951            let mut rgba = vec![255u8; npixels * 4];
1952            for i in 0..npixels {
1953                let si = i * 3;
1954                let a = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1955                let b = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1956                let c = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1957                let color = DeviceColor::from_cie_abc(a, b, c, cie_params);
1958                let pi = i * 4;
1959                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1960                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1961                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1962            }
1963            rgba
1964        }
1965        ImageColorSpace::CIEBasedA { params: cie_params } => {
1966            let mut rgba = vec![255u8; npixels * 4];
1967            for i in 0..npixels {
1968                let val = data.get(i).copied().unwrap_or(0) as f64 / 255.0;
1969                let color = DeviceColor::from_cie_a(val, cie_params);
1970                let pi = i * 4;
1971                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1972                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1973                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1974            }
1975            rgba
1976        }
1977        ImageColorSpace::Lab { range, .. } => {
1978            let mut rgba = vec![255u8; npixels * 4];
1979            let a_span = range[1] - range[0];
1980            let b_span = range[3] - range[2];
1981            for i in 0..npixels {
1982                let si = i * 3;
1983                let l = data.get(si).copied().unwrap_or(0) as f64 / 255.0 * 100.0;
1984                let a = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0 * a_span + range[0];
1985                let b = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0 * b_span + range[2];
1986                let color = DeviceColor::from_lab(l, a, b, range);
1987                let pi = i * 4;
1988                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1989                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1990                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1991            }
1992            rgba
1993        }
1994        ImageColorSpace::Separation {
1995            alt_space,
1996            tint_table,
1997            ..
1998        } => {
1999            // 1 byte per pixel → lookup in tint table → convert alt space to RGB
2000            // For CMYK alt space with ICC, build bulk CMYK data and convert via ICC
2001            if matches!(alt_space.as_ref(), ImageColorSpace::DeviceCMYK)
2002                && let Some(rgba) = tint_separation_via_icc(data, npixels, tint_table, icc)
2003            {
2004                return rgba;
2005            }
2006            let mut rgba = vec![255u8; npixels * 4];
2007            let no = tint_table.num_outputs as usize;
2008            let mut alt_comps = vec![0.0f32; no];
2009            for i in 0..npixels {
2010                let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
2011                tint_table.lookup_1d(tint, &mut alt_comps);
2012                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
2013                let pi = i * 4;
2014                rgba[pi] = r;
2015                rgba[pi + 1] = g;
2016                rgba[pi + 2] = b;
2017            }
2018            rgba
2019        }
2020        ImageColorSpace::DeviceN {
2021            alt_space,
2022            tint_table,
2023            ..
2024        } => {
2025            let ni = tint_table.num_inputs as usize;
2026            let no = tint_table.num_outputs as usize;
2027            // For CMYK alt space with ICC, build bulk CMYK data and convert via ICC
2028            if matches!(alt_space.as_ref(), ImageColorSpace::DeviceCMYK)
2029                && let Some(rgba) = tint_devicen_via_icc(data, npixels, ni, tint_table, icc)
2030            {
2031                return rgba;
2032            }
2033            let mut rgba = vec![255u8; npixels * 4];
2034            let mut inputs = vec![0.0f32; ni];
2035            let mut alt_comps = vec![0.0f32; no];
2036            for i in 0..npixels {
2037                let si = i * ni;
2038                for (c, inp) in inputs.iter_mut().enumerate() {
2039                    *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
2040                }
2041                tint_table.lookup_nd(&inputs, &mut alt_comps);
2042                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
2043                let pi = i * 4;
2044                rgba[pi] = r;
2045                rgba[pi + 1] = g;
2046                rgba[pi + 2] = b;
2047            }
2048            rgba
2049        }
2050        ImageColorSpace::Mask {
2051            color, polarity, ..
2052        } => {
2053            let mut rgba = vec![0u8; npixels * 4];
2054            let r = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
2055            let g = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
2056            let b = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
2057            let bytes_per_row = (w).div_ceil(8);
2058            for row in 0..h {
2059                for col in 0..w {
2060                    let byte_idx = row * bytes_per_row + col / 8;
2061                    let bit_offset = 7 - (col % 8);
2062                    let bit = if byte_idx < data.len() {
2063                        (data[byte_idx] >> bit_offset) & 1
2064                    } else {
2065                        0
2066                    };
2067                    let paint = if *polarity { bit == 1 } else { bit == 0 };
2068                    if paint {
2069                        let pi = (row * w + col) * 4;
2070                        rgba[pi] = r;
2071                        rgba[pi + 1] = g;
2072                        rgba[pi + 2] = b;
2073                        rgba[pi + 3] = 255;
2074                    }
2075                }
2076            }
2077            rgba
2078        }
2079        _ => vec![0u8; npixels * 4],
2080    }
2081}
2082
2083/// Convert Separation (1-input) tint table output through ICC CMYK profile.
2084/// Builds 4-byte CMYK data from tint table, then bulk-converts via ICC 8-bit transform.
2085fn tint_separation_via_icc(
2086    data: &[u8],
2087    npixels: usize,
2088    tint_table: &TintLookupTable,
2089    icc: Option<&IccCache>,
2090) -> Option<Vec<u8>> {
2091    let cache = icc?;
2092    let cmyk_hash = cache.default_cmyk_hash()?;
2093    // Build CMYK byte buffer from tint table
2094    let mut cmyk_data = vec![0u8; npixels * 4];
2095    let mut alt_comps = [0.0f32; 4];
2096    for i in 0..npixels {
2097        let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
2098        tint_table.lookup_1d(tint, &mut alt_comps);
2099        let si = i * 4;
2100        cmyk_data[si] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2101        cmyk_data[si + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2102        cmyk_data[si + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2103        cmyk_data[si + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2104    }
2105    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2106    let mut rgba = vec![255u8; npixels * 4];
2107    for i in 0..npixels {
2108        rgba[i * 4] = rgb[i * 3];
2109        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2110        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2111    }
2112    Some(rgba)
2113}
2114
2115/// Convert DeviceN (N-input) tint table output through ICC CMYK profile.
2116fn tint_devicen_via_icc(
2117    data: &[u8],
2118    npixels: usize,
2119    ni: 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    let mut cmyk_data = vec![0u8; npixels * 4];
2126    let mut inputs = vec![0.0f32; ni];
2127    let mut alt_comps = [0.0f32; 4];
2128    for i in 0..npixels {
2129        let si = i * ni;
2130        for (c, inp) in inputs.iter_mut().enumerate() {
2131            *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
2132        }
2133        tint_table.lookup_nd(&inputs, &mut alt_comps);
2134        let di = i * 4;
2135        cmyk_data[di] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2136        cmyk_data[di + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2137        cmyk_data[di + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2138        cmyk_data[di + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2139    }
2140    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2141    let mut rgba = vec![255u8; npixels * 4];
2142    for i in 0..npixels {
2143        rgba[i * 4] = rgb[i * 3];
2144        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2145        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2146    }
2147    Some(rgba)
2148}
2149
2150/// Convert alt-space f32 component values to RGB bytes.
2151fn alt_comps_to_rgb(comps: &[f32], alt_space: &ImageColorSpace) -> (u8, u8, u8) {
2152    match alt_space {
2153        ImageColorSpace::DeviceGray => {
2154            let g = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2155            (g, g, g)
2156        }
2157        ImageColorSpace::DeviceRGB => {
2158            let r = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2159            let g = (comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2160            let b = (comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2161            (r, g, b)
2162        }
2163        ImageColorSpace::DeviceCMYK => {
2164            let c = comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0);
2165            let m = comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2166            let y = comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2167            let k = comps.get(3).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2168            let r = ((1.0 - (c + k).min(1.0)) * 255.0).round() as u8;
2169            let g = ((1.0 - (m + k).min(1.0)) * 255.0).round() as u8;
2170            let b = ((1.0 - (y + k).min(1.0)) * 255.0).round() as u8;
2171            (r, g, b)
2172        }
2173        _ => (0, 0, 0),
2174    }
2175}
2176
2177/// Apply ImageType 4 mask color transparency to RGBA data.
2178fn apply_mask_color_rgba(rgba: &mut [u8], sample_data: &[u8], params: &ImageParams) {
2179    let mask_color = match &params.mask_color {
2180        Some(mc) => mc,
2181        None => return,
2182    };
2183    let ncomp = params.color_space.num_components() as usize;
2184    let npixels = params.width as usize * params.height as usize;
2185    let is_range = mask_color.len() == 2 * ncomp;
2186
2187    for i in 0..npixels {
2188        let si = i * ncomp;
2189        let matched = if is_range {
2190            (0..ncomp).all(|c| {
2191                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2192                let min_val = mask_color.get(c * 2).copied().unwrap_or(0);
2193                let max_val = mask_color.get(c * 2 + 1).copied().unwrap_or(0);
2194                sample >= min_val && sample <= max_val
2195            })
2196        } else {
2197            (0..ncomp).all(|c| {
2198                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2199                let target = mask_color.get(c).copied().unwrap_or(0);
2200                sample == target
2201            })
2202        };
2203        if matched {
2204            let pi = i * 4;
2205            if pi + 3 < rgba.len() {
2206                rgba[pi] = 0;
2207                rgba[pi + 1] = 0;
2208                rgba[pi + 2] = 0;
2209                rgba[pi + 3] = 0;
2210            }
2211        }
2212    }
2213}
2214
2215/// Choose filter quality for image drawing.
2216///
2217/// When `interpolate` is false, use Nearest for upscaling (crisp pixel edges)
2218/// and Bilinear only for downscaling (proper area averaging). When `interpolate`
2219/// is true, use Bilinear for any scaling.
2220fn image_filter_quality(transform: Transform, interpolate: bool) -> stet_tiny_skia::FilterQuality {
2221    let eff_sx = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2222    let eff_sy = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2223    let min_scale = eff_sx.min(eff_sy);
2224    // Near-exact 1:1: Nearest is pixel-perfect and faster
2225    if (eff_sx - 1.0).abs() < 0.01 && (eff_sy - 1.0).abs() < 0.01 {
2226        stet_tiny_skia::FilterQuality::Nearest
2227    } else if !interpolate && min_scale >= 0.95 {
2228        // Non-interpolated upscaling: nearest-neighbor for crisp pixel edges
2229        stet_tiny_skia::FilterQuality::Nearest
2230    } else {
2231        stet_tiny_skia::FilterQuality::Bilinear
2232    }
2233}
2234
2235/// For rotated/sheared transforms: integer box-filter pre-downsample, leaving
2236/// the fractional remainder to tiny-skia's bilinear.
2237///
2238/// Returns `None` if no pre-scaling is needed.
2239fn prescale_image(
2240    rgba_data: &[u8],
2241    w: u32,
2242    h: u32,
2243    transform: Transform,
2244    interpolate: bool,
2245) -> Option<(Vec<u8>, u32, u32, Transform)> {
2246    // Compute effective scale factors from the 2×2 part of the transform.
2247    let scale_x = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2248    let scale_y = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2249    let min_scale = scale_x.min(scale_y);
2250
2251    // Upscaling: only apply bicubic resampling when Interpolate is true.
2252    // Per PLRM/PDF spec, non-interpolated images should use nearest-neighbor
2253    // for upscaling (crisp pixel boundaries, no smoothing).
2254    if min_scale > 1.05 {
2255        if interpolate {
2256            let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2257            if is_axis_aligned && w >= 2 && h >= 2 {
2258                let dw = (w as f32 * transform.sx.abs()).round().max(1.0) as u32;
2259                let dh = (h as f32 * transform.sy.abs()).round().max(1.0) as u32;
2260                if dw > w || dh > h {
2261                    let resampled = bicubic_resample(rgba_data, w, h, dw, dh);
2262                    let new_sx = transform.sx * w as f32 / dw as f32;
2263                    let new_sy = transform.sy * h as f32 / dh as f32;
2264                    let adjusted = Transform::from_row(
2265                        new_sx,
2266                        transform.ky,
2267                        transform.kx,
2268                        new_sy,
2269                        transform.tx,
2270                        transform.ty,
2271                    );
2272                    return Some((resampled, dw, dh, adjusted));
2273                }
2274            }
2275        }
2276        return None;
2277    }
2278
2279    // Near 1:1 — no prescaling needed.
2280    if min_scale >= 0.95 {
2281        return None;
2282    }
2283
2284    // Axis-aligned: use area-average box filter to target dimensions.
2285    // Much faster than Lanczos3 and produces equally good results for downscaling.
2286    let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2287    if is_axis_aligned && w >= 2 && h >= 2 {
2288        let dw = (w as f32 * transform.sx.abs()).ceil().max(1.0) as u32;
2289        let dh = (h as f32 * transform.sy.abs()).ceil().max(1.0) as u32;
2290        if dw < w || dh < h {
2291            let resampled = box_resample(rgba_data, w, h, dw, dh);
2292            // Adjust transform so scale ≈ ±1 (sign preserved), same translation.
2293            let new_sx = transform.sx * w as f32 / dw as f32;
2294            let new_sy = transform.sy * h as f32 / dh as f32;
2295            let adjusted = Transform::from_row(
2296                new_sx,
2297                transform.ky,
2298                transform.kx,
2299                new_sy,
2300                transform.tx,
2301                transform.ty,
2302            );
2303            return Some((resampled, dw, dh, adjusted));
2304        }
2305    }
2306
2307    // Fallback for rotated/sheared: integer box filter.
2308    let factor = (1.0 / min_scale) as u32;
2309    if factor < 2 || w < factor || h < factor {
2310        return None;
2311    }
2312    let nw = w / factor;
2313    let nh = h / factor;
2314    if nw == 0 || nh == 0 {
2315        return None;
2316    }
2317    let area = factor * factor;
2318    let half = area / 2;
2319    let stride = w as usize * 4;
2320    let mut out = vec![0u8; (nw * nh * 4) as usize];
2321    for dy in 0..nh {
2322        for dx in 0..nw {
2323            let (mut r, mut g, mut b, mut a) = (0u32, 0u32, 0u32, 0u32);
2324            let sy0 = (dy * factor) as usize;
2325            let sx0 = (dx * factor) as usize;
2326            for iy in 0..factor as usize {
2327                let row = (sy0 + iy) * stride + sx0 * 4;
2328                for ix in 0..factor as usize {
2329                    let i = row + ix * 4;
2330                    r += rgba_data[i] as u32;
2331                    g += rgba_data[i + 1] as u32;
2332                    b += rgba_data[i + 2] as u32;
2333                    a += rgba_data[i + 3] as u32;
2334                }
2335            }
2336            let di = (dy * nw + dx) as usize * 4;
2337            out[di] = ((r + half) / area) as u8;
2338            out[di + 1] = ((g + half) / area) as u8;
2339            out[di + 2] = ((b + half) / area) as u8;
2340            out[di + 3] = ((a + half) / area) as u8;
2341        }
2342    }
2343    let f = factor as f32;
2344    let adjusted = Transform::from_row(
2345        transform.sx * f,
2346        transform.ky * f,
2347        transform.kx * f,
2348        transform.sy * f,
2349        transform.tx,
2350        transform.ty,
2351    );
2352    Some((out, nw, nh, adjusted))
2353}
2354
2355/// Translate a device-space ClipRect into band-local coordinates.
2356fn translate_clip_rect(rect: &ClipRect, y_start: u32, band_h: u32) -> ClipRect {
2357    ClipRect {
2358        x0: rect.x0,
2359        y0: rect.y0.saturating_sub(y_start).min(band_h),
2360        x1: rect.x1,
2361        y1: rect.y1.saturating_sub(y_start).min(band_h),
2362    }
2363}
2364
2365/// Ensure an image transform maps to at least 1 device pixel in each dimension.
2366///
2367/// PDFs commonly draw rules and borders using tiny image masks (1×1 or 4×1 pixels)
2368/// scaled via the CTM to thin rectangles. At low DPI these can map to sub-pixel
2369/// device dimensions and vanish. This adjusts the transform's scale components
2370/// so the image covers at least 1 pixel in each direction.
2371fn enforce_min_image_size(transform: Transform, img_w: u32, img_h: u32) -> Transform {
2372    // Effective device-space dimensions
2373    let eff_w =
2374        ((transform.sx * img_w as f32).powi(2) + (transform.ky * img_w as f32).powi(2)).sqrt();
2375    let eff_h =
2376        ((transform.kx * img_h as f32).powi(2) + (transform.sy * img_h as f32).powi(2)).sqrt();
2377
2378    if eff_w >= 1.0 && eff_h >= 1.0 {
2379        return transform;
2380    }
2381
2382    // Only boost if the image is a thin rule (large aspect ratio).
2383    // Small images that are sub-pixel in both dimensions (e.g. tiny dots)
2384    // are left as-is — boosting them would create visible artifacts.
2385    let ratio = eff_w.max(eff_h) / eff_w.min(eff_h).max(0.001);
2386    if ratio < 3.0 {
2387        return transform;
2388    }
2389
2390    let mut t = transform;
2391    if eff_w < 1.0 && eff_w > 0.001 {
2392        let boost = 1.0 / eff_w;
2393        t.sx *= boost;
2394        t.ky *= boost;
2395    }
2396    if eff_h < 1.0 && eff_h > 0.001 {
2397        let boost = 1.0 / eff_h;
2398        t.kx *= boost;
2399        t.sy *= boost;
2400    }
2401    t
2402}
2403
2404/// Compute minimum line width for hairline strokes at a given DPI and CTM.
2405/// Returns the minimum width in user-space units that ensures at least
2406/// 0.5 device pixels at ≤150 DPI or 1.0 device pixel above 150 DPI.
2407fn hairline_min_width(ctm: &Matrix, dpi: f64) -> f64 {
2408    let (a, b, c, d) = (ctm.a, ctm.b, ctm.c, ctm.d);
2409    let sum_sq = a * a + b * b + c * c + d * d;
2410    let diff = ((a * a + b * b - c * c - d * d).powi(2) + 4.0 * (a * c + b * d).powi(2)).sqrt();
2411    let s_max = (0.5 * (sum_sq + diff)).max(0.0).sqrt();
2412    let min_px = if dpi <= 150.0 { 0.5 } else { 1.0 };
2413    if s_max > 1e-10 {
2414        min_px / s_max
2415    } else {
2416        min_px
2417    }
2418}
2419
2420/// True when the paint's source CMYK is K-only (C=M=Y=0, any K).
2421/// Used to route OPM 0 DeviceCMYK paints that encode "K-only" — like
2422/// `0 0 0 0.5 k` — through the per-pixel overprint path, so the no-op delta
2423/// skip can preserve a spot-painted backdrop at pixels where K already equals
2424/// the source value.
2425fn is_k_only_src(color: &DeviceColor) -> bool {
2426    if let Some((c, m, y, _k)) = color.native_cmyk {
2427        c == 0.0 && m == 0.0 && y == 0.0
2428    } else {
2429        false
2430    }
2431}
2432
2433/// Detect a DeviceGray paint that should be promoted to CMYK_K for overprint.
2434///
2435/// DeviceGray `g` sets `painted_channels = 0` and leaves `native_cmyk = None`,
2436/// so overprint dispatch can't see it as a K-ink paint. When overprint is
2437/// active we re-describe the paint as DeviceCMYK `(0, 0, 0, 1-g)` with
2438/// `painted_channels = CMYK_K`: it flows through the subset path, only the K
2439/// plate is touched, and the pixmap is updated multiplicatively so any
2440/// backdrop spot contribution survives.
2441fn needs_gray_promotion(
2442    overprint: bool,
2443    painted_channels: u8,
2444    is_device_cmyk: bool,
2445    color: &DeviceColor,
2446) -> Option<f64> {
2447    if !overprint
2448        || painted_channels != 0
2449        || is_device_cmyk
2450        || color.native_cmyk.is_some()
2451        || color.process_cmyk.is_some()
2452    {
2453        return None;
2454    }
2455    let r = color.r;
2456    if (r - color.g).abs() > f64::EPSILON || (r - color.b).abs() > f64::EPSILON {
2457        return None;
2458    }
2459    Some(r.clamp(0.0, 1.0))
2460}
2461
2462/// Promote a gray `FillParams` to a DeviceCMYK K-only overprint description if
2463/// the paint qualifies (see [`needs_gray_promotion`]).
2464fn maybe_promote_gray_fill<'a>(
2465    params: &'a FillParams,
2466    buf: &'a mut Option<FillParams>,
2467) -> &'a FillParams {
2468    if let Some(gray) = needs_gray_promotion(
2469        params.overprint,
2470        params.painted_channels,
2471        params.is_device_cmyk,
2472        &params.color,
2473    ) {
2474        let mut promoted = params.clone();
2475        promoted.is_device_cmyk = true;
2476        promoted.painted_channels = stet_graphics::device::CMYK_K;
2477        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2478        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2479        *buf = Some(promoted);
2480        return buf.as_ref().unwrap();
2481    }
2482    params
2483}
2484
2485/// Promote a gray `StrokeParams` to a DeviceCMYK K-only overprint description.
2486fn maybe_promote_gray_stroke<'a>(
2487    params: &'a StrokeParams,
2488    buf: &'a mut Option<StrokeParams>,
2489) -> &'a StrokeParams {
2490    if let Some(gray) = needs_gray_promotion(
2491        params.overprint,
2492        params.painted_channels,
2493        params.is_device_cmyk,
2494        &params.color,
2495    ) {
2496        let mut promoted = params.clone();
2497        promoted.is_device_cmyk = true;
2498        promoted.painted_channels = stet_graphics::device::CMYK_K;
2499        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2500        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2501        *buf = Some(promoted);
2502        return buf.as_ref().unwrap();
2503    }
2504    params
2505}
2506
2507/// Build a stroke with minimum line-width enforcement (shared by trait impl and band rendering).
2508/// `dpi` is the device resolution, used to select the hairline minimum width:
2509/// at ≤150 DPI use 0.6 device pixels; above 150 DPI use 1.0 device pixel.
2510fn build_stroke(params: &StrokeParams, dpi: f64) -> Stroke {
2511    let min_lw = hairline_min_width(&params.ctm, dpi);
2512    let mut stroke = Stroke {
2513        width: (params.line_width as f32).max(min_lw as f32),
2514        line_cap: to_line_cap(params.line_cap),
2515        line_join: to_line_join(params.line_join),
2516        miter_limit: params.miter_limit as f32,
2517        ..Stroke::default()
2518    };
2519    if !params.dash_pattern.array.is_empty() {
2520        let mut dash_array: Vec<f32> = params
2521            .dash_pattern
2522            .array
2523            .iter()
2524            .map(|&v| v as f32)
2525            .collect();
2526        // PostScript allows odd-length dash arrays (implicitly doubled),
2527        // but tiny-skia requires even length. Double odd arrays to match PS semantics.
2528        if dash_array.len() % 2 == 1 {
2529            let clone = dash_array.clone();
2530            dash_array.extend_from_slice(&clone);
2531        }
2532        if let Some(dash) = StrokeDash::new(dash_array, params.dash_pattern.offset as f32) {
2533            stroke.dash = Some(dash);
2534        }
2535    }
2536    stroke
2537}
2538
2539/// Apply stroke adjustment: snap axis-aligned path segments to device pixel
2540/// centers so thin strokes render with consistent weight.
2541///
2542/// For a stroke of width W in device pixels:
2543/// - Odd-integer width (1, 3, ...): snap to half-pixel (floor(x) + 0.5)
2544/// - Even-integer width or non-integer: snap to pixel edge (round(x))
2545/// - For hairlines (device width < 1.5): always snap to half-pixel
2546///
2547/// Only axis-aligned segments (horizontal/vertical lines) are snapped.
2548/// Diagonal/curved segments are left as-is since snapping would distort them.
2549///
2550/// Check whether a CTM indicates the path is already in device space (identity
2551/// or simple Y-flip/translation). Stroke adjustment snaps coordinates to pixel
2552/// boundaries, which only makes sense when path coordinates are device pixels.
2553/// PDF Form XObjects with large scale factors (e.g. [405, 0, 0, 283, ...]) would
2554/// cause catastrophic snapping if treated as device-space paths.
2555fn ctm_is_device_space(ctm: &Matrix) -> bool {
2556    (ctm.a.abs() - 1.0).abs() < 0.01
2557        && ctm.b.abs() < 0.01
2558        && ctm.c.abs() < 0.01
2559        && (ctm.d.abs() - 1.0).abs() < 0.01
2560}
2561
2562/// Apply stroke adjustment for viewport rendering.
2563///
2564/// Path coordinates are in reference-DPI device space. The viewport transform
2565/// maps them to output pixels: out = (ref - vp_origin) * scale.
2566/// We snap in output pixel space then map back to reference space.
2567fn stroke_adjust_path_viewport(
2568    path: &PsPath,
2569    device_width: f64,
2570    scale_x: f64,
2571    scale_y: f64,
2572    vp_x: f64,
2573    vp_y: f64,
2574) -> PsPath {
2575    let use_half_pixel = device_width < 1.5 || (device_width.round() as i32) % 2 == 1;
2576
2577    // Snap a reference-space coordinate to the output pixel grid, then map back
2578    let snap_x = |v: f64| -> f64 {
2579        let out = (v - vp_x) * scale_x;
2580        let snapped = if use_half_pixel {
2581            out.floor() + 0.5
2582        } else {
2583            out.round()
2584        };
2585        snapped / scale_x + vp_x
2586    };
2587    let snap_y = |v: f64| -> f64 {
2588        let out = (v - vp_y) * scale_y;
2589        let snapped = if use_half_pixel {
2590            out.floor() + 0.5
2591        } else {
2592            out.round()
2593        };
2594        snapped / scale_y + vp_y
2595    };
2596
2597    let mut result = PsPath::new();
2598    let mut prev_x = 0.0_f64;
2599    let mut prev_y = 0.0_f64;
2600
2601    for seg in &path.segments {
2602        match *seg {
2603            PathSegment::MoveTo(x, y) => {
2604                prev_x = x;
2605                prev_y = y;
2606                result.segments.push(PathSegment::MoveTo(x, y));
2607            }
2608            PathSegment::LineTo(x, y) => {
2609                let is_horizontal = (y - prev_y).abs() < 1e-6;
2610                let is_vertical = (x - prev_x).abs() < 1e-6;
2611
2612                if is_horizontal {
2613                    let snapped_y = snap_y(y);
2614                    if let Some(PathSegment::MoveTo(_, ly) | PathSegment::LineTo(_, ly)) =
2615                        result.segments.last_mut()
2616                    {
2617                        *ly = snapped_y;
2618                    }
2619                    result.segments.push(PathSegment::LineTo(x, snapped_y));
2620                    prev_x = x;
2621                    prev_y = snapped_y;
2622                } else if is_vertical {
2623                    let snapped_x = snap_x(x);
2624                    if let Some(PathSegment::MoveTo(lx, _) | PathSegment::LineTo(lx, _)) =
2625                        result.segments.last_mut()
2626                    {
2627                        *lx = snapped_x;
2628                    }
2629                    result.segments.push(PathSegment::LineTo(snapped_x, y));
2630                    prev_x = snapped_x;
2631                    prev_y = y;
2632                } else {
2633                    result.segments.push(PathSegment::LineTo(x, y));
2634                    prev_x = x;
2635                    prev_y = y;
2636                }
2637            }
2638            PathSegment::CurveTo {
2639                x1,
2640                y1,
2641                x2,
2642                y2,
2643                x3,
2644                y3,
2645            } => {
2646                result.segments.push(PathSegment::CurveTo {
2647                    x1,
2648                    y1,
2649                    x2,
2650                    y2,
2651                    x3,
2652                    y3,
2653                });
2654                prev_x = x3;
2655                prev_y = y3;
2656            }
2657            PathSegment::ClosePath => {
2658                result.segments.push(PathSegment::ClosePath);
2659            }
2660        }
2661    }
2662    result
2663}
2664
2665/// Process a single display list element into a pixmap using the given render context.
2666///
2667/// This unified function handles both band rendering (scale=1.0) and viewport
2668/// rendering (arbitrary scale). Band rendering is viewport rendering with
2669/// `scale_x = scale_y = 1.0`.
2670fn render_element(
2671    pixmap: &mut Pixmap,
2672    band_state: &mut BandState,
2673    element: &DisplayElement,
2674    ctx: &RenderContext<'_>,
2675) {
2676    match element {
2677        DisplayElement::Fill { path, params } => {
2678            // DeviceGray with overprint behaves as a K-only process paint —
2679            // promote it to DeviceCMYK (0, 0, 0, 1-gray) with painted_channels
2680            // set to CMYK_K so it flows through the overprint subset path,
2681            // preserving backdrop CMY plates and the spot-derived visual
2682            // instead of knocking the pixmap out with plain RGB gray.
2683            let mut promoted_fill: Option<FillParams> = None;
2684            let params = maybe_promote_gray_fill(params, &mut promoted_fill);
2685            // Use the overprint compositing path whenever the fill needs
2686            // per-channel CMYK rendering. Five cases trigger it:
2687            //   1. Subset painted_channels (Separation /Magenta, DeviceN, etc.)
2688            //      — only the named channels touch the buffer; the rest are
2689            //      preserved from the backdrop.
2690            //   2. DeviceCMYK + OPM 1 — zero-valued components don't paint, so
2691            //      a per-pixel filter is required.
2692            //   3. Custom spot (painted_channels=0, non-CMYK, with native_cmyk)
2693            //      under overprint — process plates must be preserved; the
2694            //      spot's alt-CMYK only contributes multiplicatively to RGB.
2695            //   4. DeviceCMYK + overprint (any OPM) with CMYK_ALL — the per-
2696            //      pixel path lets us recognise a "no-op" overprint (src CMYK
2697            //      == backdrop CMYK) and leave the pixmap untouched, which
2698            //      preserves any spot-derived colour already visible there.
2699            //   5. (Combinations of the above.)
2700            // Only fires for Normal blend; non-Normal blend modes handle zero
2701            // values through their blend math, not through overprint filtering.
2702            // Includes text glyphs: when overprint is meaningful (the test
2703            // suite's GWG 1.0 swatches f/a use Separation /Magenta + glyphs),
2704            // correctness wins over the slight AA difference vs tiny-skia.
2705            let painted = params.painted_channels;
2706            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2707            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2708            // Real Separation/DeviceN custom spots set `process_cmyk` (even pure
2709            // spots set it to `(0, 0, 0, 0)`); ICCBased RGB routed through the
2710            // proofing chain has `native_cmyk` populated but leaves
2711            // `process_cmyk == None`. Per PDF 1.7 §11.7.4.5 a non-process source
2712            // colour space (CalGray/CalRGB/Lab/ICCBased) must paint as if /OP
2713            // were false — gating on `process_cmyk.is_some()` keeps ICCBased RGB
2714            // out of the overprint path so GWG 13.3 (ICC RGB X over CMYK BG)
2715            // knocks out instead of preserving the backdrop's CMYK plates.
2716            let custom_spot = painted == 0
2717                && !params.is_device_cmyk
2718                && params.color.native_cmyk.is_some()
2719                && params.color.process_cmyk.is_some();
2720            // A "near-K-only" DeviceCMYK paint under OPM 0 — e.g. `0 0 0 0.5 k`
2721            // — matches the Black-component plate of a DeviceN [Black, spot]
2722            // backdrop exactly. Routing it through the per-pixel path lets the
2723            // no-op-delta skip preserve the spot-derived colour instead of
2724            // wiping it with plain grey (GWG 3.0 "50% K over spot").
2725            let is_k_only_cmyk =
2726                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2727            let needs_overprint = params.overprint
2728                && band_state.cmyk_buffer.is_some()
2729                && params.blend_mode == 0
2730                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2731
2732            if needs_overprint {
2733                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2734                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2735                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2736                render_overprint_fill(
2737                    pixmap,
2738                    &mut cmyk_buf,
2739                    &mut op_bg,
2740                    &mut op_touched,
2741                    &spot_mask,
2742                    band_state,
2743                    path,
2744                    params,
2745                    ctx.vp_x,
2746                    ctx.vp_y,
2747                    ctx.scale_x,
2748                    ctx.scale_y,
2749                    ctx.out_w,
2750                    ctx.out_h,
2751                    ctx.icc,
2752                    ctx.no_aa,
2753                );
2754                band_state.cmyk_buffer = Some(cmyk_buf);
2755                band_state.restore_op_buffers(op_bg, op_touched);
2756                band_state.restore_spot_mask(spot_mask);
2757            } else {
2758                let Some(skia_path) = build_skia_path(path) else {
2759                    return;
2760                };
2761                let mut temp_mask = None;
2762                let Some(mask_ref) = resolve_clip_mask(
2763                    &band_state.clip_region,
2764                    &mut temp_mask,
2765                    ctx.out_w,
2766                    ctx.out_h,
2767                ) else {
2768                    return;
2769                };
2770                let paint =
2771                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2772                let transform = ctx.transform(&params.ctm);
2773
2774                // Detect degenerate fill paths: rectangles/lines with zero extent
2775                // in one dimension. These are commonly used in PDFs to draw table
2776                // grid lines as zero-width or zero-height filled rectangles.
2777                // Since they have no area, fill_path produces nothing. Render them
2778                // as hairline strokes instead.
2779                if is_degenerate_fill(path) {
2780                    let stroke = Stroke {
2781                        width: 1.0,
2782                        ..Stroke::default()
2783                    };
2784                    pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2785                } else {
2786                    let fill_rule = to_fill_rule(&params.fill_rule);
2787                    pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
2788                }
2789
2790                // Update CMYK tracking buffer for non-overprint fills
2791                if band_state.cmyk_buffer.is_some() {
2792                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2793                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2794                    update_cmyk_buffer_for_fill(
2795                        &mut cmyk_buf,
2796                        &mut spot_mask,
2797                        path,
2798                        params,
2799                        ctx.vp_x,
2800                        ctx.vp_y,
2801                        ctx.scale_x,
2802                        ctx.scale_y,
2803                        ctx.out_w,
2804                        ctx.out_h,
2805                        &band_state.clip_region,
2806                        ctx.no_aa,
2807                        ctx.icc,
2808                    );
2809                    band_state.cmyk_buffer = Some(cmyk_buf);
2810                    band_state.restore_spot_mask(spot_mask);
2811                }
2812            }
2813        }
2814        DisplayElement::Stroke { path, params } => {
2815            let mut promoted_stroke: Option<StrokeParams> = None;
2816            let params = maybe_promote_gray_stroke(params, &mut promoted_stroke);
2817            let transform = ctx.transform(&params.ctm);
2818            // Build stroke using the composited transform so hairline width
2819            // calculations account for the actual output resolution.
2820            let vp_ctm = Matrix {
2821                a: transform.sx as f64,
2822                b: transform.ky as f64,
2823                c: transform.kx as f64,
2824                d: transform.sy as f64,
2825                tx: 0.0,
2826                ty: 0.0,
2827            };
2828            let vp_params = StrokeParams {
2829                ctm: vp_ctm,
2830                ..params.clone()
2831            };
2832            let stroke = build_stroke(&vp_params, ctx.effective_dpi);
2833
2834            // Apply stroke adjustment — snap in output device space
2835            let adjusted;
2836            let draw_path = if params.stroke_adjust
2837                && stroke.width <= 2.0
2838                && ctm_is_device_space(&params.ctm)
2839            {
2840                adjusted = stroke_adjust_path_viewport(
2841                    path,
2842                    stroke.width as f64,
2843                    ctx.scale_x as f64,
2844                    ctx.scale_y as f64,
2845                    ctx.vp_x as f64,
2846                    ctx.vp_y as f64,
2847                );
2848                &adjusted
2849            } else {
2850                path
2851            };
2852
2853            // Mirror the Fill gating: per-channel CMYK rendering kicks in for
2854            // subset painted_channels (Separation /Magenta, DeviceN, etc.), for
2855            // DeviceCMYK + OPM 1 (zero-valued source components don't paint),
2856            // or for a custom spot (painted=0, non-CMYK) under overprint — so
2857            // the spot applies multiplicatively to RGB without disturbing the
2858            // process plates. GWG 1.0 swatch a/b/f/g need this for the magenta
2859            // X stroke that overlays the same path the fill already drew.
2860            let painted = params.painted_channels;
2861            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2862            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2863            // Mirror the Fill custom-spot gate: ICCBased RGB (proofing-chain
2864            // `native_cmyk`, no `process_cmyk`) must not reach the overprint
2865            // path. PDF 1.7 §11.7.4.5: non-process source spaces paint as if
2866            // /OP were false.
2867            let custom_spot = painted == 0
2868                && !params.is_device_cmyk
2869                && params.color.native_cmyk.is_some()
2870                && params.color.process_cmyk.is_some();
2871            let is_k_only_cmyk =
2872                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2873            let needs_overprint = params.overprint
2874                && band_state.cmyk_buffer.is_some()
2875                && params.blend_mode == 0
2876                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2877
2878            let Some(skia_path) = build_skia_path(draw_path) else {
2879                return;
2880            };
2881            let mut temp_mask = None;
2882            let Some(mask_ref) = resolve_clip_mask(
2883                &band_state.clip_region,
2884                &mut temp_mask,
2885                ctx.out_w,
2886                ctx.out_h,
2887            ) else {
2888                return;
2889            };
2890
2891            if needs_overprint {
2892                // Convert the stroke outline to a fill path and route it
2893                // through the same per-channel CMYK compositing logic the
2894                // fill path uses, so the post-overprint result lands in the
2895                // pixmap (not the raw source colour).
2896                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2897                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2898                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2899                render_overprint_stroke(
2900                    pixmap,
2901                    &mut cmyk_buf,
2902                    &mut op_bg,
2903                    &mut op_touched,
2904                    &spot_mask,
2905                    band_state,
2906                    &skia_path,
2907                    &stroke,
2908                    transform,
2909                    params,
2910                    ctx.out_w,
2911                    ctx.out_h,
2912                    ctx.icc,
2913                    ctx.no_aa,
2914                );
2915                band_state.cmyk_buffer = Some(cmyk_buf);
2916                band_state.restore_op_buffers(op_bg, op_touched);
2917                band_state.restore_spot_mask(spot_mask);
2918            } else {
2919                let paint =
2920                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2921                pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2922
2923                if band_state.cmyk_buffer.is_some() {
2924                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2925                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2926                    update_cmyk_buffer_for_stroke(
2927                        &mut cmyk_buf,
2928                        &mut spot_mask,
2929                        draw_path,
2930                        params,
2931                        &stroke,
2932                        transform,
2933                        ctx.out_w,
2934                        ctx.out_h,
2935                        &band_state.clip_region,
2936                        ctx.no_aa,
2937                        ctx.icc,
2938                    );
2939                    band_state.cmyk_buffer = Some(cmyk_buf);
2940                    band_state.restore_spot_mask(spot_mask);
2941                }
2942            }
2943        }
2944        DisplayElement::Clip { path, params } => {
2945            clip_path_unified(band_state, path, params, ctx);
2946        }
2947        DisplayElement::InitClip => {
2948            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2949                band_state.recycle_mask(mask);
2950            }
2951            band_state.clip_region = None;
2952        }
2953        DisplayElement::ErasePage => {
2954            pixmap.fill(Color::TRANSPARENT);
2955            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2956                band_state.recycle_mask(mask);
2957            }
2958            band_state.clip_region = None;
2959        }
2960        DisplayElement::Image {
2961            sample_data,
2962            params,
2963        } => {
2964            let iw = params.width;
2965            let ih = params.height;
2966            if iw == 0 || ih == 0 {
2967                return;
2968            }
2969
2970            let needs_overprint = params.overprint
2971                && band_state.cmyk_buffer.is_some()
2972                && image_supports_overprint(&params.color_space);
2973
2974            if needs_overprint {
2975                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2976                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2977                render_overprint_image(
2978                    pixmap,
2979                    &mut cmyk_buf,
2980                    &mut op_bg,
2981                    &mut op_touched,
2982                    band_state,
2983                    sample_data,
2984                    params,
2985                    ctx.vp_x,
2986                    ctx.vp_y,
2987                    ctx.scale_x,
2988                    ctx.scale_y,
2989                    ctx.out_w,
2990                    ctx.out_h,
2991                    ctx.icc,
2992                );
2993                band_state.cmyk_buffer = Some(cmyk_buf);
2994                band_state.restore_op_buffers(op_bg, op_touched);
2995            } else if let Some(pp) = ctx
2996                .preprocessed
2997                .and_then(|pp| pp.get(ctx.elem_idx))
2998                .and_then(|e| e.as_ref())
2999            {
3000                // Fast path: use pre-converted and prescaled image data.
3001                // Only the per-band translation differs; scale factors are cached.
3002                let Some(image_inv) = params.image_matrix.invert() else {
3003                    return;
3004                };
3005                let combined = params.ctm.concat(&image_inv);
3006                let raw_transform = ctx.transform(&combined);
3007                let transform = Transform::from_row(
3008                    pp.adj_sx,
3009                    pp.adj_ky,
3010                    pp.adj_kx,
3011                    pp.adj_sy,
3012                    raw_transform.tx,
3013                    raw_transform.ty,
3014                );
3015
3016                let Some(img_pixmap) =
3017                    stet_tiny_skia::PixmapRef::from_bytes(&pp.data, pp.width, pp.height)
3018                else {
3019                    return;
3020                };
3021                #[allow(unused_assignments)]
3022                let mut temp_mask = None;
3023                let mask_ref = match &band_state.clip_region {
3024                    None => None,
3025                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
3026                    Some(ClipRegion::Rect(rect)) => {
3027                        if rect.is_empty() {
3028                            return;
3029                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
3030                            None
3031                        } else {
3032                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
3033                            temp_mask.as_ref()
3034                        }
3035                    }
3036                };
3037                let img_paint = stet_tiny_skia::PixmapPaint {
3038                    quality: pp.quality,
3039                    opacity: params.alpha as f32,
3040                    blend_mode: u8_to_blend_mode(params.blend_mode),
3041                };
3042                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
3043
3044                // Update CMYK tracking buffer for non-overprint images on the
3045                // fast path. Reading from the post-draw pixmap means the same
3046                // helper handles native-CMYK and non-CMYK source images, even
3047                // though `pp.data` is prescaled and we no longer have a
3048                // matching native RGBA buffer.
3049                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
3050                    update_cmyk_buffer_for_image(
3051                        cmyk_buf,
3052                        sample_data,
3053                        pixmap.data(),
3054                        params,
3055                        ctx.vp_x,
3056                        ctx.vp_y,
3057                        ctx.scale_x,
3058                        ctx.scale_y,
3059                        ctx.out_w,
3060                        ctx.out_h,
3061                        &band_state.clip_region,
3062                        ctx.icc,
3063                    );
3064                }
3065            } else {
3066                // Use pre-converted RGBA from image cache when available
3067                let owned_rgba;
3068                let rgba_data: &[u8] = if let Some(cached) =
3069                    ctx.image_cache.and_then(|c| c.get(ctx.elem_idx))
3070                {
3071                    cached
3072                } else {
3073                    owned_rgba = {
3074                        let mut rgba =
3075                            samples_to_rgba(sample_data, params, ctx.icc, ctx.opm_zero_transparent);
3076                        if params.mask_color.is_some() {
3077                            apply_mask_color_rgba(&mut rgba, sample_data, params);
3078                        }
3079                        rgba
3080                    };
3081                    &owned_rgba
3082                };
3083                let expected = (iw * ih * 4) as usize;
3084                if rgba_data.len() < expected {
3085                    return;
3086                }
3087                let Some(image_inv) = params.image_matrix.invert() else {
3088                    return;
3089                };
3090                let combined = params.ctm.concat(&image_inv);
3091                let raw_transform = enforce_min_image_size(ctx.transform(&combined), iw, ih);
3092
3093                // Pre-scale images that are being downscaled. Even non-interpolated
3094                // images need proper area averaging when shrinking — "no interpolation"
3095                // means don't smooth when *upscaling*, but downscaling without averaging
3096                // produces aliased garbage.
3097                let prescaled =
3098                    prescale_image(rgba_data, iw, ih, raw_transform, params.interpolate);
3099                let (img_data, img_w, img_h, transform) = match &prescaled {
3100                    Some((data, w, h, t)) => (data.as_slice(), *w, *h, *t),
3101                    None => (rgba_data, iw, ih, raw_transform),
3102                };
3103
3104                let Some(img_pixmap) =
3105                    stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h)
3106                else {
3107                    return;
3108                };
3109                #[allow(unused_assignments)]
3110                let mut temp_mask = None;
3111                let mask_ref = match &band_state.clip_region {
3112                    None => None,
3113                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
3114                    Some(ClipRegion::Rect(rect)) => {
3115                        if rect.is_empty() {
3116                            return;
3117                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
3118                            None
3119                        } else {
3120                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
3121                            temp_mask.as_ref()
3122                        }
3123                    }
3124                };
3125                let img_paint = stet_tiny_skia::PixmapPaint {
3126                    quality: image_filter_quality(transform, params.interpolate),
3127                    opacity: params.alpha as f32,
3128                    blend_mode: u8_to_blend_mode(params.blend_mode),
3129                };
3130                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
3131
3132                // Update CMYK tracking buffer for non-overprint images. Sample
3133                // the now-composited pixmap so non-CMYK source images can be
3134                // reverse-converted to CMYK via the system profile.
3135                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
3136                    update_cmyk_buffer_for_image(
3137                        cmyk_buf,
3138                        sample_data,
3139                        pixmap.data(),
3140                        params,
3141                        ctx.vp_x,
3142                        ctx.vp_y,
3143                        ctx.scale_x,
3144                        ctx.scale_y,
3145                        ctx.out_w,
3146                        ctx.out_h,
3147                        &band_state.clip_region,
3148                        ctx.icc,
3149                    );
3150                }
3151            }
3152        }
3153        DisplayElement::AxialShading { params } => {
3154            let mut temp_mask = None;
3155            let Some(mask_ref) = resolve_clip_mask(
3156                &band_state.clip_region,
3157                &mut temp_mask,
3158                ctx.out_w,
3159                ctx.out_h,
3160            ) else {
3161                return;
3162            };
3163            render_axial_shading(
3164                pixmap,
3165                params,
3166                ctx.vp_x,
3167                ctx.vp_y,
3168                ctx.scale_x,
3169                ctx.scale_y,
3170                mask_ref,
3171                ctx.no_aa,
3172                band_state.cmyk_buffer.as_deref_mut(),
3173                ctx.icc,
3174            );
3175        }
3176        DisplayElement::RadialShading { params } => {
3177            let mut temp_mask = None;
3178            let Some(mask_ref) = resolve_clip_mask(
3179                &band_state.clip_region,
3180                &mut temp_mask,
3181                ctx.out_w,
3182                ctx.out_h,
3183            ) else {
3184                return;
3185            };
3186            render_radial_shading(
3187                pixmap,
3188                params,
3189                ctx.vp_x,
3190                ctx.vp_y,
3191                ctx.scale_x,
3192                ctx.scale_y,
3193                mask_ref,
3194                ctx.no_aa,
3195                band_state.cmyk_buffer.as_deref_mut(),
3196                ctx.icc,
3197            );
3198        }
3199        DisplayElement::MeshShading { params } => {
3200            let mut temp_mask = None;
3201            let Some(mask_ref) = resolve_clip_mask(
3202                &band_state.clip_region,
3203                &mut temp_mask,
3204                ctx.out_w,
3205                ctx.out_h,
3206            ) else {
3207                return;
3208            };
3209            render_mesh_shading(
3210                pixmap,
3211                params,
3212                ctx.vp_x,
3213                ctx.vp_y,
3214                ctx.scale_x,
3215                ctx.scale_y,
3216                mask_ref,
3217                band_state.cmyk_buffer.as_deref_mut(),
3218                ctx.icc,
3219            );
3220        }
3221        DisplayElement::PatchShading { params } => {
3222            let mut temp_mask = None;
3223            let Some(mask_ref) = resolve_clip_mask(
3224                &band_state.clip_region,
3225                &mut temp_mask,
3226                ctx.out_w,
3227                ctx.out_h,
3228            ) else {
3229                return;
3230            };
3231            render_patch_shading(
3232                pixmap,
3233                params,
3234                ctx.vp_x,
3235                ctx.vp_y,
3236                ctx.scale_x,
3237                ctx.scale_y,
3238                mask_ref,
3239                band_state.cmyk_buffer.as_deref_mut(),
3240                ctx.icc,
3241            );
3242        }
3243        DisplayElement::PatternFill { params } => {
3244            render_pattern_fill(pixmap, band_state, params, ctx);
3245        }
3246        DisplayElement::Group { elements, params } => {
3247            render_group(pixmap, band_state, elements, params, ctx);
3248        }
3249        DisplayElement::SoftMasked {
3250            mask,
3251            content,
3252            params,
3253            mask_cache,
3254        } => {
3255            render_soft_masked(pixmap, band_state, mask, content, params, mask_cache, ctx);
3256        }
3257        DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
3258        DisplayElement::OcgGroup {
3259            elements,
3260            visibility,
3261        } => {
3262            // Visible groups render every child. OFF-by-default groups still
3263            // apply Clip/InitClip so the band's clip state stays in sync —
3264            // otherwise a transient clip from the previous group would leak
3265            // into the next visible one. Paint ops are skipped; that's what
3266            // "hidden layer" means.
3267            let visible = ctx.layer_set.evaluate(visibility);
3268            for (idx, elem) in elements.elements().iter().enumerate() {
3269                if !visible
3270                    && !matches!(elem, DisplayElement::Clip { .. } | DisplayElement::InitClip)
3271                {
3272                    continue;
3273                }
3274                let elem_ctx = RenderContext {
3275                    elem_idx: idx,
3276                    ..*ctx
3277                };
3278                render_element(pixmap, band_state, elem, &elem_ctx);
3279            }
3280        }
3281        _ => {}
3282    }
3283}
3284
3285/// Compute the cropped output-pixel region for a group's device-space bounding box.
3286///
3287/// Returns `(crop_x, crop_y, crop_w, crop_h)` in output pixels, or `None` if
3288/// the group is entirely outside the viewport or cropping isn't worthwhile.
3289fn compute_group_crop(bbox: &[f64; 4], ctx: &RenderContext<'_>) -> Option<(i32, i32, u32, u32)> {
3290    // Transform device-space bbox to output pixel coords
3291    let px_min = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
3292    let py_min = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
3293    let px_max = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
3294    let py_max = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
3295
3296    // Clip to output bounds
3297    let x0 = px_min.max(0);
3298    let y0 = py_min.max(0);
3299    let x1 = px_max.min(ctx.out_w as i32);
3300    let y1 = py_max.min(ctx.out_h as i32);
3301
3302    if x0 >= x1 || y0 >= y1 {
3303        return None;
3304    }
3305
3306    let crop_w = (x1 - x0) as u32;
3307    let crop_h = (y1 - y0) as u32;
3308
3309    // Only crop if it saves at least 25% of pixels
3310    let crop_pixels = crop_w as u64 * crop_h as u64;
3311    let full_pixels = ctx.out_w as u64 * ctx.out_h as u64;
3312    if crop_pixels * 4 >= full_pixels * 3 {
3313        return None;
3314    }
3315
3316    Some((x0, y0, crop_w, crop_h))
3317}
3318
3319/// Apply a separable PDF blend mode in DeviceCMYK using the spec's "effective"
3320/// inversion convention (PDF 1.7 §11.3.5.2): the inverse value `1−c` is used as
3321/// input to the RGB-style blend function, and the result is inverted back.
3322fn blend_cmyk_separable_channel(cb: f64, cs: f64, mode: u8) -> f64 {
3323    let cbi = 1.0 - cb;
3324    let csi = 1.0 - cs;
3325    let result_inv = match mode {
3326        1 => cbi * csi,             // Multiply
3327        2 => cbi + csi - cbi * csi, // Screen
3328        3 => {
3329            // Overlay(b, s) = HardLight(s, b)
3330            if cbi <= 0.5 {
3331                2.0 * cbi * csi
3332            } else {
3333                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3334            }
3335        }
3336        4 => cbi.min(csi), // Darken
3337        5 => cbi.max(csi), // Lighten
3338        6 => {
3339            // ColorDodge
3340            if csi >= 1.0 {
3341                1.0
3342            } else {
3343                (cbi / (1.0 - csi)).min(1.0)
3344            }
3345        }
3346        7 => {
3347            // ColorBurn
3348            if csi <= 0.0 {
3349                0.0
3350            } else {
3351                1.0 - ((1.0 - cbi) / csi).min(1.0)
3352            }
3353        }
3354        8 => {
3355            // HardLight
3356            if csi <= 0.5 {
3357                2.0 * cbi * csi
3358            } else {
3359                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3360            }
3361        }
3362        9 => {
3363            // SoftLight (Adobe formulation)
3364            let d = if cbi <= 0.25 {
3365                ((16.0 * cbi - 12.0) * cbi + 4.0) * cbi
3366            } else {
3367                cbi.sqrt()
3368            };
3369            if csi <= 0.5 {
3370                cbi - (1.0 - 2.0 * csi) * cbi * (1.0 - cbi)
3371            } else {
3372                cbi + (2.0 * csi - 1.0) * (d - cbi)
3373            }
3374        }
3375        10 => (cbi - csi).abs(),           // Difference
3376        11 => cbi + csi - 2.0 * cbi * csi, // Exclusion
3377        _ => csi,                          // Normal/fallback
3378    };
3379    1.0 - result_inv.clamp(0.0, 1.0)
3380}
3381
3382/// Apply a non-separable HSL-style PDF blend mode (Hue, Saturation, Color,
3383/// Luminosity) in DeviceCMYK. Per the spec, the inverted CMY components are
3384/// treated as "effective RGB" and the standard non-separable formulas are
3385/// applied; the K channel is taken from the source (it acts as the source's
3386/// luminosity contribution for the purposes of the blend).
3387fn blend_cmyk_nonseparable(cb: [f64; 4], cs: [f64; 4], mode: u8) -> [f64; 4] {
3388    fn lum(c: [f64; 3]) -> f64 {
3389        0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
3390    }
3391    fn clip_color(mut c: [f64; 3]) -> [f64; 3] {
3392        let l = lum(c);
3393        let n = c[0].min(c[1]).min(c[2]);
3394        let x = c[0].max(c[1]).max(c[2]);
3395        if n < 0.0 {
3396            for ci in c.iter_mut() {
3397                *ci = l + (*ci - l) * l / (l - n);
3398            }
3399        }
3400        if x > 1.0 {
3401            for ci in c.iter_mut() {
3402                *ci = l + (*ci - l) * (1.0 - l) / (x - l);
3403            }
3404        }
3405        c
3406    }
3407    fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
3408        let d = l - lum(c);
3409        clip_color([c[0] + d, c[1] + d, c[2] + d])
3410    }
3411    fn sat(c: [f64; 3]) -> f64 {
3412        c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
3413    }
3414    fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
3415        // Index components by rank: min, mid, max.
3416        let mut idx = [0usize, 1, 2];
3417        idx.sort_by(|a, b| {
3418            c[*a]
3419                .partial_cmp(&c[*b])
3420                .unwrap_or(std::cmp::Ordering::Equal)
3421        });
3422        let (i_min, i_mid, i_max) = (idx[0], idx[1], idx[2]);
3423        let mut out = c;
3424        if c[i_max] > c[i_min] {
3425            out[i_mid] = (c[i_mid] - c[i_min]) * s / (c[i_max] - c[i_min]);
3426            out[i_max] = s;
3427        } else {
3428            out[i_mid] = 0.0;
3429            out[i_max] = 0.0;
3430        }
3431        out[i_min] = 0.0;
3432        out
3433    }
3434
3435    let cb_rgb = [1.0 - cb[0], 1.0 - cb[1], 1.0 - cb[2]];
3436    let cs_rgb = [1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2]];
3437    let result_rgb = match mode {
3438        12 => set_lum(set_sat(cs_rgb, sat(cb_rgb)), lum(cb_rgb)), // Hue
3439        13 => set_lum(set_sat(cb_rgb, sat(cs_rgb)), lum(cb_rgb)), // Saturation
3440        14 => set_lum(cs_rgb, lum(cb_rgb)),                       // Color
3441        15 => set_lum(cb_rgb, lum(cs_rgb)),                       // Luminosity
3442        _ => cs_rgb,
3443    };
3444    // Hue/Saturation/Color preserve the backdrop's luminosity, which in CMYK
3445    // is carried primarily by the K channel. Luminosity transfers the source's
3446    // luminosity, so it takes K from the source.
3447    let result_k = if mode == 15 { cs[3] } else { cb[3] };
3448    [
3449        (1.0 - result_rgb[0]).clamp(0.0, 1.0),
3450        (1.0 - result_rgb[1]).clamp(0.0, 1.0),
3451        (1.0 - result_rgb[2]).clamp(0.0, 1.0),
3452        result_k,
3453    ]
3454}
3455
3456/// Render a transparency group into a pixmap.
3457/// Device-space axis-aligned bbox of a path, computed from its segment
3458/// endpoints and curve control points. Returned as (x0, y0, x1, y1) with
3459/// x0 ≤ x1, y0 ≤ y1. Returns `None` for an empty path.
3460fn ps_path_bbox(path: &PsPath) -> Option<(f64, f64, f64, f64)> {
3461    let mut it = path.segments.iter().filter_map(|seg| match *seg {
3462        PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => Some(vec![(x, y)]),
3463        PathSegment::CurveTo {
3464            x1,
3465            y1,
3466            x2,
3467            y2,
3468            x3,
3469            y3,
3470        } => Some(vec![(x1, y1), (x2, y2), (x3, y3)]),
3471        PathSegment::ClosePath => None,
3472    });
3473    let first = it.next()?.into_iter().next()?;
3474    let (mut x0, mut y0) = first;
3475    let (mut x1, mut y1) = first;
3476    for seg_points in std::iter::once(vec![first]).chain(it) {
3477        for (x, y) in seg_points {
3478            x0 = x0.min(x);
3479            y0 = y0.min(y);
3480            x1 = x1.max(x);
3481            y1 = y1.max(y);
3482        }
3483    }
3484    Some((x0, y0, x1, y1))
3485}
3486
3487/// True when rectangle `inner` fits inside `outer` with `tolerance` slack
3488/// (positive tolerance = inner may protrude by up to `tolerance` units).
3489fn bbox_contains(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64), tolerance: f64) -> bool {
3490    inner.0 >= outer.0 - tolerance
3491        && inner.1 >= outer.1 - tolerance
3492        && inner.2 <= outer.2 + tolerance
3493        && inner.3 <= outer.3 + tolerance
3494}
3495
3496/// Detect the GWG "reference-under-test" authoring pattern: a parent Fill
3497/// that will be fully covered by the first Fill of a following isolated
3498/// transparency group. When detected, the parent's Fill can be skipped —
3499/// its AA edges otherwise bleed into the dest under the group's partial-
3500/// alpha source during composite-back, producing a visible outline where
3501/// Acrobat shows none (see GWG 16.2 Opacity(0%) analysis in
3502/// `project_icc_profile_stability.md`).
3503///
3504/// Returns indices in `elements` that should be skipped. Safety conditions:
3505///   1. Parent fill is fully opaque, Normal blend.
3506///   2. Next paint (ignoring Clip/InitClip) is an isolated, alpha-1,
3507///      Normal-blend Group whose first paint is a Fill with matching
3508///      path (within tolerance) and the same opacity/blend conditions.
3509///   3. The group's declared bbox fully contains the parent path's bbox
3510///      — i.e. the form's own BBox clip won't carve the fill away.
3511///   4. Every Clip element between the parent fill and the group, and
3512///      every Clip between the group's start and its first fill, has a
3513///      bbox that also fully contains the parent path — so no additional
3514///      clip can cut the group's first fill to a subset of the parent's
3515///      extent.
3516///   5. PDF's isolated transparency semantics guarantee that once the
3517///      first fill establishes alpha=1 at the parent-path pixels, later
3518///      Normal-blend paints can only add colour there; alpha can't
3519///      decrease. So nothing in the group's tail can re-expose backdrop,
3520///      even without auditing those elements explicitly.
3521fn compute_obscured_fill_skips(elements: &DisplayList) -> Vec<usize> {
3522    let mut skips = Vec::new();
3523    let els = elements.elements();
3524    for i in 0..els.len() {
3525        let DisplayElement::Fill {
3526            path: parent_path,
3527            params: parent_params,
3528        } = &els[i]
3529        else {
3530            continue;
3531        };
3532        if (parent_params.alpha - 1.0).abs() > 1e-6 || parent_params.blend_mode != 0 {
3533            continue;
3534        }
3535        let Some(parent_bbox) = ps_path_bbox(parent_path) else {
3536            continue;
3537        };
3538        // Walk forward past Clip/InitClip between parent fill and the
3539        // group. Each such clip must contain the parent's extent; any
3540        // other element type ends the scan.
3541        let mut j = i + 1;
3542        let mut clips_ok = true;
3543        while j < els.len() {
3544            match &els[j] {
3545                DisplayElement::InitClip => {}
3546                DisplayElement::Clip {
3547                    path: clip_path, ..
3548                } => match ps_path_bbox(clip_path) {
3549                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3550                    _ => {
3551                        clips_ok = false;
3552                        break;
3553                    }
3554                },
3555                _ => break,
3556            }
3557            j += 1;
3558        }
3559        if !clips_ok {
3560            continue;
3561        }
3562        let Some(DisplayElement::Group {
3563            elements: group_elements,
3564            params: group_params,
3565        }) = els.get(j)
3566        else {
3567            continue;
3568        };
3569        if !group_params.isolated
3570            || (group_params.alpha - 1.0).abs() > 1e-6
3571            || group_params.blend_mode != 0
3572        {
3573            continue;
3574        }
3575        // The form's declared BBox acts as a clip inside the group; the
3576        // parent's fill must fit inside it or the group's output will be
3577        // carved away where we'd rely on coverage.
3578        let group_bbox = (
3579            group_params.bbox[0],
3580            group_params.bbox[1],
3581            group_params.bbox[2],
3582            group_params.bbox[3],
3583        );
3584        if !bbox_contains(group_bbox, parent_bbox, 0.5) {
3585            continue;
3586        }
3587        // Walk past Clip/InitClip inside the group to its first paint,
3588        // requiring each clip to contain the parent's extent.
3589        let inner_els = group_elements.elements();
3590        let mut k = 0;
3591        let mut inner_clips_ok = true;
3592        while k < inner_els.len() {
3593            match &inner_els[k] {
3594                DisplayElement::InitClip => {}
3595                DisplayElement::Clip {
3596                    path: clip_path, ..
3597                } => match ps_path_bbox(clip_path) {
3598                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3599                    _ => {
3600                        inner_clips_ok = false;
3601                        break;
3602                    }
3603                },
3604                _ => break,
3605            }
3606            k += 1;
3607        }
3608        if !inner_clips_ok {
3609            continue;
3610        }
3611        let Some(DisplayElement::Fill {
3612            path: group_path,
3613            params: group_fill_params,
3614        }) = inner_els.get(k)
3615        else {
3616            continue;
3617        };
3618        if (group_fill_params.alpha - 1.0).abs() > 1e-6 || group_fill_params.blend_mode != 0 {
3619            continue;
3620        }
3621        if paths_approximately_equal(parent_path, group_path, 0.5) {
3622            skips.push(i);
3623        }
3624    }
3625    skips
3626}
3627
3628/// True when two device-space paths have the same segment sequence and
3629/// matching endpoints within `tolerance` device pixels per coordinate.
3630/// Used by `compute_obscured_fill_skips` to recognise PDF-authored patterns
3631/// where the same logical X path is emitted twice with sub-unit rounding
3632/// differences (GWG test suite authoring style from InDesign CS6).
3633fn paths_approximately_equal(a: &PsPath, b: &PsPath, tolerance: f64) -> bool {
3634    if a.segments.len() != b.segments.len() {
3635        return false;
3636    }
3637    for (sa, sb) in a.segments.iter().zip(b.segments.iter()) {
3638        let close_pair = |(x1, y1): (f64, f64), (x2, y2): (f64, f64)| -> bool {
3639            (x1 - x2).abs() <= tolerance && (y1 - y2).abs() <= tolerance
3640        };
3641        match (sa, sb) {
3642            (PathSegment::MoveTo(x1, y1), PathSegment::MoveTo(x2, y2)) => {
3643                if !close_pair((*x1, *y1), (*x2, *y2)) {
3644                    return false;
3645                }
3646            }
3647            (PathSegment::LineTo(x1, y1), PathSegment::LineTo(x2, y2)) => {
3648                if !close_pair((*x1, *y1), (*x2, *y2)) {
3649                    return false;
3650                }
3651            }
3652            (
3653                PathSegment::CurveTo {
3654                    x1: ax1,
3655                    y1: ay1,
3656                    x2: ax2,
3657                    y2: ay2,
3658                    x3: ax3,
3659                    y3: ay3,
3660                },
3661                PathSegment::CurveTo {
3662                    x1: bx1,
3663                    y1: by1,
3664                    x2: bx2,
3665                    y2: by2,
3666                    x3: bx3,
3667                    y3: by3,
3668                },
3669            ) => {
3670                if !close_pair((*ax1, *ay1), (*bx1, *by1))
3671                    || !close_pair((*ax2, *ay2), (*bx2, *by2))
3672                    || !close_pair((*ax3, *ay3), (*bx3, *by3))
3673                {
3674                    return false;
3675                }
3676            }
3677            (PathSegment::ClosePath, PathSegment::ClosePath) => {}
3678            _ => return false,
3679        }
3680    }
3681    true
3682}
3683
3684///
3685/// Creates an offscreen pixmap, renders the group's child elements into it,
3686/// then composites back onto the parent with the group's blend mode and alpha.
3687fn render_group(
3688    pixmap: &mut Pixmap,
3689    band_state: &mut BandState,
3690    elements: &DisplayList,
3691    params: &stet_graphics::display_list::GroupParams,
3692    ctx: &RenderContext<'_>,
3693) {
3694    if params.knockout {
3695        render_knockout_group(pixmap, band_state, elements, params, ctx);
3696        return;
3697    }
3698
3699    let crop = compute_group_crop(&params.bbox, ctx);
3700
3701    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
3702        Some((cx, cy, cw, ch)) => (
3703            cw,
3704            ch,
3705            cx,
3706            cy,
3707            ctx.vp_x + cx as f32 / ctx.scale_x,
3708            ctx.vp_y + cy as f32 / ctx.scale_y,
3709        ),
3710        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
3711    };
3712
3713    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
3714        return;
3715    };
3716
3717    // Decide upfront whether the composite-back will run in CMYK. The CMYK
3718    // path needs the parent backdrop pre-loaded into the offscreen so that
3719    // per-element painting accumulates in the right starting state. The
3720    // sRGB contribution-extraction path renders against an empty offscreen
3721    // for non-Normal BMs to avoid anti-aliased clip artifacts at the BBox
3722    // edges (the diff-against-backdrop logic mishandles partially-blended
3723    // edge pixels otherwise).
3724    use stet_graphics::display_list::GroupColorSpace;
3725
3726    // Allocate a CMYK buffer for the group when:
3727    //   - it tracks overprint, OR
3728    //   - the parent already has one (CMYK context inheritance), OR
3729    //   - this group itself or one of its descendants declares an explicit
3730    //     `/CS DeviceCMYK`, meaning compositing within it needs CMYK math.
3731    let needs_group_cmyk = has_overprint_elements(elements)
3732        || band_state.cmyk_buffer.is_some()
3733        || params.color_space == GroupColorSpace::DeviceCMYK
3734        || has_cmyk_group(elements);
3735
3736    // Decide whether to run the per-pixel CMYK composite-back. The default
3737    // (gated) rule restricts it to the cases the prior rendering session
3738    // explicitly validated. The `STET_FORCE_CMYK_COMPOSITE_BACK=1` env var
3739    // bypasses both gates and switches to the principled rule that the rest
3740    // of this plan will adopt — useful for A/B-comparing the broader fix
3741    // before flipping the default in Step 9.
3742    let force_cmyk_compose =
3743        std::env::var_os("STET_FORCE_CMYK_COMPOSITE_BACK").as_deref() == Some("1".as_ref());
3744    // The knockout group's coverage pass disables CMYK composite-back so the
3745    // painter falls through to the simple sRGB draw_pixmap path. Without this,
3746    // a white-source painter (CMYK 0,0,0,0) would be skipped by the
3747    // composite-back's "source==backdrop" guard against the transparent
3748    // coverage backdrop, and pass 2 wouldn't capture the painter's coverage.
3749    //
3750    // The color pass widens the gate to all non-Normal blend modes so a
3751    // `/CS DeviceCMYK` knockout group's painters with separable blends like
3752    // Screen / ColorDodge / Overlay / SoftLight blend in CMYK math (matching
3753    // the spec) instead of in tiny-skia's sRGB blend.
3754    let plan_cmyk_compose = match ctx.knockout_painter_pass {
3755        KnockoutPainterPass::CoveragePass => false,
3756        KnockoutPainterPass::ColorPass => {
3757            !params.isolated
3758                && params.blend_mode != 0
3759                && needs_group_cmyk
3760                && band_state.cmyk_buffer.is_some()
3761                && group_content_is_native_cmyk(elements)
3762        }
3763        KnockoutPainterPass::None if force_cmyk_compose => {
3764            // Principled rule: non-isolated group with an inversion-sensitive
3765            // blend mode (Difference, Exclusion, Hue, Saturation, Color,
3766            // Luminosity) whose painters all supply native CMYK source colors.
3767            //
3768            // The blend-mode restriction is intentional: bm 10..=15 produce
3769            // visibly *wrong* results in sRGB (the GWG 16.0 transparency test
3770            // exists exactly to expose this), so CMYK math is unambiguously
3771            // correct there. The separable modes 1..=9 (Multiply, Screen, etc.)
3772            // are spec-defensible in either color space but look noticeably
3773            // different — most renderers blend them in sRGB, and PDFs authored
3774            // for that look "wrong" if we suddenly switch them to CMYK math.
3775            //
3776            // The painter-set restriction (no shadings, no non-CMYK content)
3777            // exists because the parallel CMYK buffer can only faithfully track
3778            // single-CMYK-value-per-pixel painters; gradients interpolate
3779            // differently in pixmap RGB vs buffer CMYK and the divergence makes
3780            // the composite-back read stale source values.
3781            !params.isolated
3782                && matches!(params.blend_mode, 10..=15)
3783                && needs_group_cmyk
3784                && band_state.cmyk_buffer.is_some()
3785                && group_content_is_native_cmyk(elements)
3786        }
3787        KnockoutPainterPass::None => {
3788            // Default rule: only the inversion-sensitive blend modes
3789            // (Difference, Exclusion, HSL non-separable) need CMYK math; the
3790            // separable modes 1..=9 are spec-defensible in either color space
3791            // and most sRGB-authored PDFs expect them to blend in sRGB.
3792            let inversion_sensitive = !params.isolated
3793                && matches!(params.blend_mode, 10..=15)
3794                && group_only_native_cmyk_fills(elements);
3795            // GWG 16.2 ("Transparency Basic Blend Modes — DeviceCMYK,
3796            // Isolated") nests non-isolated `/CS DeviceCMYK` painter sub-groups
3797            // inside an isolated `/CS DeviceCMYK` group, with the swatch's
3798            // blend mode applied at the inner Do. Per PDF spec §11.6.7 the
3799            // compositing for those inner groups must happen in DeviceCMYK,
3800            // not sRGB — otherwise their colored X-shape produces the wrong
3801            // color and fails to cover the painter-A black X. The explicit
3802            // `/CS DeviceCMYK` declaration plus the isolated parent are the
3803            // spec signal that the author wants CMYK-space compositing for
3804            // a fresh transparent backdrop. The `parent_group_isolated`
3805            // gate keeps the rule from firing for non-isolated parents like
3806            // 907 page 28's chart panels, where the existing sRGB
3807            // contribution-extraction path correctly preserves anti-aliased
3808            // gray strokes.
3809            //
3810            // GWG 16.1 ("Transparency Basic Blend Modes — ICCBasedRGB")
3811            // exercises the same DeviceCMYK page group but the parent is
3812            // *non-isolated*, so the `parent_group_isolated` gate refused
3813            // to fire and every separable blend swatch fell back to sRGB
3814            // blending (visible as the test's "X" markers). PDF/X
3815            // workflows already declare their target compositing space via
3816            // `/OutputIntents`, and the proofing chain in
3817            // `register_profile_with_n` flips `IccCache::proofing_enabled`
3818            // on once that's been honoured. Use that as the PDF/X-specific
3819            // signal for "blend in DeviceCMYK regardless of group
3820            // isolation"; non-proofing documents (907 p28 et al.) keep
3821            // the original `parent_group_isolated` requirement.
3822            let proofing_enabled = ctx.icc.is_some_and(|c| c.proofing_enabled());
3823            // Per PDF 1.7 §11.6.6, a transparency group with no `/CS` inherits
3824            // its color space from the enclosing group. When the parent has
3825            // already allocated a CMYK buffer (the only way `cmyk_buffer` is
3826            // `Some` on this band_state when we enter `render_group`), the
3827            // parent's effective compositing space is DeviceCMYK and an
3828            // `Inherited` child should join it. Without this, GWG 16.4 swatch
3829            // groups (no `/CS`) fell back to sRGB blending and the Multiply /
3830            // Color Burn blends produced visible X markers.
3831            let effective_cs_is_cmyk = params.color_space == GroupColorSpace::DeviceCMYK
3832                || (params.color_space == GroupColorSpace::Inherited
3833                    && band_state.cmyk_buffer.is_some());
3834            let cmyk_group_blend = !params.isolated
3835                && (ctx.parent_group_isolated || proofing_enabled)
3836                && params.blend_mode != 0
3837                && effective_cs_is_cmyk
3838                && needs_group_cmyk
3839                && band_state.cmyk_buffer.is_some()
3840                && group_content_is_native_cmyk(elements);
3841            inversion_sensitive || cmyk_group_blend
3842        }
3843    };
3844    // Non-isolated groups with non-Normal blend modes on the sRGB path
3845    // need a two-pass render: once against the backdrop (for correct
3846    // internal blending) and once against transparent (to extract the
3847    // group's shape/alpha for the proper source-contribution formula).
3848    let needs_alpha_extraction = !params.isolated
3849        && params.blend_mode != 0
3850        && !plan_cmyk_compose
3851        && !ctx.alpha_extraction_pass;
3852    let needs_backdrop_preload =
3853        !params.isolated && (params.blend_mode == 0 || plan_cmyk_compose || needs_alpha_extraction);
3854    let backdrop = if needs_backdrop_preload {
3855        let data = if crop.is_some() {
3856            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
3857        } else {
3858            pixmap.data().to_vec()
3859        };
3860        offscreen.data_mut().copy_from_slice(&data);
3861        Some(data)
3862    } else {
3863        None
3864    };
3865    let group_cmyk = if needs_group_cmyk {
3866        let buf_size = eff_w as usize * eff_h as usize * 4;
3867        let mut buf = vec![0.0f32; buf_size];
3868        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
3869            let parent_stride = ctx.out_w as usize * 4;
3870            let group_stride = eff_w as usize * 4;
3871            for gy in 0..eff_h as usize {
3872                let py = crop_y as usize + gy;
3873                if py < ctx.out_h as usize {
3874                    let p_start = py * parent_stride + crop_x as usize * 4;
3875                    let g_start = gy * group_stride;
3876                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
3877                    buf[g_start..g_start + copy_len]
3878                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
3879                }
3880            }
3881        }
3882        Some(buf)
3883    } else {
3884        None
3885    };
3886
3887    // Snapshot the pre-load CMYK so the composite-back can identify pixels
3888    // the group actually modified. Without a separate snapshot we'd have to
3889    // diff against the parent CMYK buffer, which would lose any in-place
3890    // updates to the parent across the group's lifetime.
3891    let backdrop_cmyk: Option<Vec<f32>> = if !params.isolated {
3892        group_cmyk.clone()
3893    } else {
3894        None
3895    };
3896
3897    let mut group_band = BandState {
3898        clip_region: None,
3899        spare_mask: None,
3900        clip_mask_cache: HashMap::new(),
3901        clip_mask_seen: HashSet::new(),
3902        mask_pool: Vec::new(),
3903        cmyk_buffer: group_cmyk,
3904        op_bg_snapshot: None,
3905        op_touched: None,
3906        spot_mask: None,
3907    };
3908
3909    let group_ctx = RenderContext {
3910        vp_x: eff_vp_x,
3911        vp_y: eff_vp_y,
3912        scale_x: ctx.scale_x,
3913        scale_y: ctx.scale_y,
3914        out_w: eff_w,
3915        out_h: eff_h,
3916        effective_dpi: ctx.effective_dpi,
3917        icc: ctx.icc,
3918        image_cache: None, // Group elements don't use parent image cache
3919        preprocessed: None,
3920        elem_idx: 0,
3921        no_aa: ctx.no_aa,
3922        opm_zero_transparent: ctx.opm_zero_transparent,
3923        knockout_painter_pass: ctx.knockout_painter_pass,
3924        // The children of this group see *this* group as their parent.
3925        parent_group_isolated: params.isolated,
3926        alpha_extraction_pass: ctx.alpha_extraction_pass,
3927        layer_set: ctx.layer_set,
3928    };
3929
3930    let skip_indices = compute_obscured_fill_skips(elements);
3931    for (idx, elem) in elements.elements().iter().enumerate() {
3932        if skip_indices.contains(&idx) {
3933            continue;
3934        }
3935        let elem_ctx = RenderContext {
3936            elem_idx: idx,
3937            ..group_ctx
3938        };
3939        render_element(&mut offscreen, &mut group_band, elem, &elem_ctx);
3940    }
3941
3942    // Second pass: render against transparent to extract the group's
3943    // shape/alpha.  Only needed for the sRGB two-pass composite-back
3944    // path (non-isolated, non-Normal blend, no CMYK compose).
3945    let alpha_offscreen = if needs_alpha_extraction {
3946        let mut iso = Pixmap::new(eff_w, eff_h);
3947        if let Some(ref mut iso_pm) = iso {
3948            let mut iso_band = BandState {
3949                clip_region: None,
3950                spare_mask: None,
3951                clip_mask_cache: HashMap::new(),
3952                clip_mask_seen: HashSet::new(),
3953                mask_pool: Vec::new(),
3954                cmyk_buffer: None,
3955                op_bg_snapshot: None,
3956                op_touched: None,
3957                spot_mask: None,
3958            };
3959            let iso_ctx = RenderContext {
3960                parent_group_isolated: true,
3961                alpha_extraction_pass: true,
3962                ..group_ctx
3963            };
3964            for (idx, elem) in elements.elements().iter().enumerate() {
3965                let elem_ctx = RenderContext {
3966                    elem_idx: idx,
3967                    ..iso_ctx
3968                };
3969                render_element(iso_pm, &mut iso_band, elem, &elem_ctx);
3970            }
3971        }
3972        iso
3973    } else {
3974        None
3975    };
3976
3977    let mut temp_mask = None;
3978    let mask_ref = match resolve_clip_mask(
3979        &band_state.clip_region,
3980        &mut temp_mask,
3981        ctx.out_w,
3982        ctx.out_h,
3983    ) {
3984        None => return, // empty clip → nothing visible
3985        Some(m) => m,
3986    };
3987
3988    // Coverage pass override: force opacity 1.0 + Normal blend so the
3989    // painter's shape reaches the coverage offscreen even when the
3990    // original alpha was 0 (Opacity 0% test) or the blend mode would
3991    // erase the source against the transparent coverage backdrop.
3992    let coverage_params;
3993    let effective_params: &stet_graphics::display_list::GroupParams =
3994        if ctx.knockout_painter_pass == KnockoutPainterPass::CoveragePass {
3995            coverage_params = stet_graphics::display_list::GroupParams {
3996                alpha: 1.0,
3997                blend_mode: 0,
3998                ..params.clone()
3999            };
4000            &coverage_params
4001        } else {
4002            params
4003        };
4004
4005    let mut cmyk_compose_done = false;
4006    if let Some(backdrop) = &backdrop {
4007        // Non-isolated group. For the inversion-sensitive blend modes
4008        // (Difference, Exclusion) and the HSL non-separable modes (Hue,
4009        // Saturation, Color, Luminosity), tiny-skia's sRGB blend math gives
4010        // visibly wrong results for the GWG 16.0 transparency test, where
4011        // the source colors are chosen so that, in CMYK, the blend produces
4012        // the backdrop color exactly. Run the composite-back per pixel in
4013        // CMYK for those modes when the inner content is exclusively
4014        // native-CMYK fills (so the inner CMYK buffer faithfully represents
4015        // the source). The other separable modes (Multiply / Lighten /
4016        // Darken / etc.) and non-CMYK content stay on the existing sRGB
4017        // contribution-extraction path because their CMYK pipeline currently
4018        // depends on `interpolate_cmyk_from_stops`, which derives CMYK from
4019        // sRGB via the lossy `(1−r,1−g,1−b,0)` inverse for shadings/images
4020        // and would shift their colors. Lifting that restriction requires
4021        // computing exact CMYK from each shading/image's source color space
4022        // (e.g. running the DeviceN tint transform), which is a larger
4023        // change than this fix attempts.
4024        let inner_cmyk = group_band.cmyk_buffer.as_deref();
4025        let pre_cmyk = backdrop_cmyk.as_deref();
4026        if plan_cmyk_compose && let (Some(inner), Some(pre)) = (inner_cmyk, pre_cmyk) {
4027            composite_non_isolated_cmyk(
4028                pixmap,
4029                band_state.cmyk_buffer.as_deref_mut(),
4030                &offscreen,
4031                inner,
4032                pre,
4033                backdrop,
4034                effective_params,
4035                mask_ref,
4036                crop_x,
4037                crop_y,
4038                ctx.icc,
4039            );
4040            cmyk_compose_done = true;
4041        } else if let Some(ref alpha_os) = alpha_offscreen {
4042            composite_non_isolated_extracted(
4043                pixmap,
4044                &offscreen,
4045                alpha_os,
4046                backdrop,
4047                effective_params,
4048                mask_ref,
4049                crop_x,
4050                crop_y,
4051            );
4052        } else {
4053            composite_non_isolated_group_cropped(
4054                pixmap,
4055                &offscreen,
4056                backdrop,
4057                effective_params,
4058                mask_ref,
4059                crop_x,
4060                crop_y,
4061            );
4062        }
4063    } else {
4064        let paint = stet_tiny_skia::PixmapPaint {
4065            opacity: effective_params.alpha as f32,
4066            blend_mode: u8_to_blend_mode(effective_params.blend_mode),
4067            quality: stet_tiny_skia::FilterQuality::Nearest,
4068        };
4069        pixmap.draw_pixmap(
4070            crop_x,
4071            crop_y,
4072            offscreen.as_ref(),
4073            &paint,
4074            Transform::identity(),
4075            mask_ref,
4076        );
4077    }
4078
4079    // Write group CMYK buffer back to parent. Skip when the CMYK composite-back
4080    // already wrote the blended values into the parent CMYK buffer — running
4081    // `copy_cmyk_buffer_to_parent` afterwards would overwrite those blended
4082    // values with the inner buffer's raw source colors, breaking subsequent
4083    // siblings that read the parent CMYK as their backdrop.
4084    if !cmyk_compose_done
4085        && let (Some(group_cmyk), Some(parent_cmyk)) =
4086            (&group_band.cmyk_buffer, &mut band_state.cmyk_buffer)
4087    {
4088        copy_cmyk_buffer_to_parent(
4089            parent_cmyk,
4090            group_cmyk,
4091            offscreen.data(),
4092            crop_x as usize,
4093            crop_y as usize,
4094            eff_w as usize,
4095            eff_h as usize,
4096            ctx.out_w as usize,
4097            ctx.out_h as usize,
4098        );
4099    }
4100}
4101
4102/// CMYK-aware composite-back for a non-isolated transparency group.
4103///
4104/// For each pixel in the group's region:
4105///   1. If the inner CMYK buffer matches the snapshot taken when the group
4106///      started, the group painted nothing there → leave the parent unchanged.
4107///   2. Otherwise apply the group blend mode in DeviceCMYK using the spec's
4108///      effective inversion formulas (`blend_cmyk_separable_channel` or
4109///      `blend_cmyk_nonseparable`), convert the result to sRGB through the
4110///      ICC system CMYK profile so it sits seamlessly next to the rest of the
4111///      page, and write the result to both the parent pixmap and (when
4112///      present) the parent CMYK buffer.
4113#[allow(clippy::too_many_arguments)]
4114fn composite_non_isolated_cmyk(
4115    target: &mut Pixmap,
4116    parent_cmyk: Option<&mut [f32]>,
4117    source: &Pixmap,
4118    source_cmyk: &[f32],
4119    backdrop_cmyk: &[f32],
4120    backdrop_pixels: &[u8],
4121    params: &stet_graphics::display_list::GroupParams,
4122    clip_mask: Option<&stet_tiny_skia::Mask>,
4123    crop_x: i32,
4124    crop_y: i32,
4125    icc: Option<&IccCache>,
4126) {
4127    let cw = source.width() as usize;
4128    let ch = source.height() as usize;
4129    let target_w = target.width() as usize;
4130    let target_h = target.height() as usize;
4131
4132    let opacity = params.alpha.clamp(0.0, 1.0);
4133    let blend_mode = params.blend_mode;
4134    let is_nonseparable = matches!(blend_mode, 12..=15);
4135
4136    let target_data = target.data_mut();
4137    let target_stride = target_w * 4;
4138    let group_stride = cw * 4;
4139
4140    let clip_data = clip_mask.map(|m| m.data());
4141
4142    for gy in 0..ch {
4143        let ty = crop_y + gy as i32;
4144        if ty < 0 || ty as usize >= target_h {
4145            continue;
4146        }
4147        let ty = ty as usize;
4148        let group_row = gy * group_stride;
4149        let target_row = ty * target_stride;
4150
4151        for gx in 0..cw {
4152            let tx = crop_x + gx as i32;
4153            if tx < 0 || tx as usize >= target_w {
4154                continue;
4155            }
4156            let tx = tx as usize;
4157            let gi = group_row + gx * 4;
4158            let ti = target_row + tx * 4;
4159
4160            // Did the group actually paint this pixel?
4161            let bc = backdrop_cmyk[gi] as f64;
4162            let bm = backdrop_cmyk[gi + 1] as f64;
4163            let by_ = backdrop_cmyk[gi + 2] as f64;
4164            let bk = backdrop_cmyk[gi + 3] as f64;
4165            let sc = source_cmyk[gi] as f64;
4166            let sm = source_cmyk[gi + 1] as f64;
4167            let sy_ = source_cmyk[gi + 2] as f64;
4168            let sk = source_cmyk[gi + 3] as f64;
4169            if (sc - bc).abs() < 1.0 / 255.0
4170                && (sm - bm).abs() < 1.0 / 255.0
4171                && (sy_ - by_).abs() < 1.0 / 255.0
4172                && (sk - bk).abs() < 1.0 / 255.0
4173            {
4174                continue;
4175            }
4176
4177            // Clip mask coverage in target coordinates.
4178            let cov = if let Some(cd) = clip_data {
4179                cd[ty * target_w + tx] as f64 / 255.0
4180            } else {
4181                1.0
4182            };
4183            if cov <= 0.0 {
4184                continue;
4185            }
4186
4187            // Transparent-backdrop fast path: when the backdrop pixmap's alpha
4188            // is 0 the parent group hasn't painted this pixel, so PDF spec
4189            // §11.4.6 says the blended result reduces to α_s · source — the
4190            // blend formula must NOT be applied. Without this check, formulas
4191            // like ColorBurn / ColorDodge / Lighten / Screen produce visibly
4192            // wrong colors (yellow instead of orange-yellow, white instead of
4193            // the source) because an all-zero CMYK backdrop is identical to
4194            // opaque white in CMYK terms. Using the pixmap alpha as the
4195            // sentinel correctly distinguishes "truly nothing painted"
4196            // (alpha 0) from "white painted" (alpha 1, CMYK 0,0,0,0).
4197            //
4198            // For this branch we composite the source pixmap directly via
4199            // SourceOver (rather than converting source CMYK→sRGB) so the
4200            // source's per-pixel alpha — including anti-aliased edges and
4201            // partially-transparent paint like 907 page 28's gray rules —
4202            // is preserved. The CMYK→sRGB direct path used the un-modulated
4203            // painter color and the group opacity, which forced antialiased
4204            // gray strokes to opaque black.
4205            let backdrop_alpha = backdrop_pixels[gi + 3];
4206            let backdrop_transparent = backdrop_alpha == 0;
4207
4208            let mix = cov * opacity;
4209            let dst_a = target_data[ti + 3] as f64 / 255.0;
4210
4211            if backdrop_transparent {
4212                // SourceOver of the source pixmap (already correctly rendered
4213                // for transparent-backdrop semantics) modulated by the group's
4214                // mix factor. To ensure inner-group AA edges don't leave
4215                // sliver gaps where the outer parent pixmap had previously
4216                // drawn a near-identical path (GWG 16.2 directly-drawn black
4217                // X covered by Painter B's slightly-offset colored X), we
4218                // promote any non-zero source alpha to the painter's full
4219                // unpremultiplied source CMYK converted to sRGB. This
4220                // produces fully-opaque coverage at edge pixels matching
4221                // what the inner painter would render at the path interior,
4222                // so the inner group can fully knock out the outer's AA
4223                // edge when composited back to its parent.
4224                let src_data = source.data();
4225                let src_a_pm = src_data[gi + 3] as f64 / 255.0;
4226                if src_a_pm <= 0.0 {
4227                    continue;
4228                }
4229                // Convert source CMYK directly to sRGB. The CMYK at this
4230                // pixel was written by the inner painter at its full
4231                // un-modulated value (the cmyk_buf doesn't track AA), so
4232                // this is the pure painter color regardless of AA cov.
4233                let (full_r, full_g, full_b) = icc
4234                    .and_then(|i| i.convert_cmyk_readonly(sc, sm, sy_, sk))
4235                    .unwrap_or_else(|| cmyk_to_rgb_plrm(sc, sm, sy_, sk));
4236                let alpha_s = mix;
4237                let inv_sa = 1.0 - alpha_s;
4238                let dst_r_pm = target_data[ti] as f64 / 255.0;
4239                let dst_g_pm = target_data[ti + 1] as f64 / 255.0;
4240                let dst_b_pm = target_data[ti + 2] as f64 / 255.0;
4241                let out_r = full_r * alpha_s + dst_r_pm * inv_sa;
4242                let out_g = full_g * alpha_s + dst_g_pm * inv_sa;
4243                let out_b = full_b * alpha_s + dst_b_pm * inv_sa;
4244                let out_a = alpha_s + dst_a * inv_sa;
4245                target_data[ti] = (out_r * 255.0).round().clamp(0.0, 255.0) as u8;
4246                target_data[ti + 1] = (out_g * 255.0).round().clamp(0.0, 255.0) as u8;
4247                target_data[ti + 2] = (out_b * 255.0).round().clamp(0.0, 255.0) as u8;
4248                target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4249                continue;
4250            }
4251
4252            // Apply the group's blend mode in CMYK.
4253            let (rc, rm, ry, rk) = if is_nonseparable {
4254                let r = blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4255                (r[0], r[1], r[2], r[3])
4256            } else {
4257                (
4258                    blend_cmyk_separable_channel(bc, sc, blend_mode),
4259                    blend_cmyk_separable_channel(bm, sm, blend_mode),
4260                    blend_cmyk_separable_channel(by_, sy_, blend_mode),
4261                    blend_cmyk_separable_channel(bk, sk, blend_mode),
4262                )
4263            };
4264
4265            let (new_r, new_g, new_b) = icc
4266                .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
4267                .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
4268
4269            // tiny-skia stores premultiplied sRGB. Apply the PDF
4270            // §11.4.6 result formula in straight-color form. We force the
4271            // source alpha to 1 (subject to clip + group opacity) at any
4272            // pixel where the source CMYK was written by the inner painter
4273            // — the cmyk_buf flags coverage at the path's full extent, even
4274            // at AA edges. Using full alpha here ensures the inner group
4275            // fully covers the outer parent's previously-drawn content
4276            // when both reference near-identical paths (GWG 16.2 directly-
4277            // drawn outer X path covered by Painter B's slightly-offset
4278            // colored X path). Without this, the formula's partial-cover
4279            // mix produces a 1-pixel sliver of darker color where the two
4280            // paths' rasterizations diverge sub-pixel-wise.
4281            let alpha_s = mix;
4282            let alpha_b = dst_a;
4283            let out_a = alpha_s + alpha_b * (1.0 - alpha_s);
4284            if out_a <= 0.0 {
4285                continue;
4286            }
4287            let (dst_r, dst_g, dst_b) = if alpha_b > 0.0 {
4288                let inv_a = 1.0 / alpha_b;
4289                (
4290                    (target_data[ti] as f64 / 255.0) * inv_a,
4291                    (target_data[ti + 1] as f64 / 255.0) * inv_a,
4292                    (target_data[ti + 2] as f64 / 255.0) * inv_a,
4293                )
4294            } else {
4295                (0.0, 0.0, 0.0)
4296            };
4297            // Spec §11.4.6 result computation:
4298            //   C_o = (α_s·(1−α_b)·C_s + α_s·α_b·B(C_b,C_s) + (1−α_s)·α_b·C_b) / α_o
4299            // Here we already have B(C_b,C_s) computed in CMYK and converted
4300            // to sRGB as (new_r, new_g, new_b). The "C_s" term — the source
4301            // color un-blended — uses the same value because the spec says
4302            // when α_b = 0 the formula reduces to source-as-is, which the
4303            // (1−α_b) coefficient already handles.
4304            let coef_b = alpha_s * alpha_b;
4305            let coef_s = alpha_s * (1.0 - alpha_b);
4306            let coef_d = (1.0 - alpha_s) * alpha_b;
4307            let out_r = (coef_s * new_r + coef_b * new_r + coef_d * dst_r) / out_a;
4308            let out_g = (coef_s * new_g + coef_b * new_g + coef_d * dst_g) / out_a;
4309            let out_b = (coef_s * new_b + coef_b * new_b + coef_d * dst_b) / out_a;
4310
4311            target_data[ti] = (out_r * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4312            target_data[ti + 1] = (out_g * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4313            target_data[ti + 2] = (out_b * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4314            target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4315        }
4316    }
4317
4318    // Write the blended CMYK back to the parent CMYK buffer so subsequent
4319    // sibling groups see consistent backdrop values. We re-walk the same
4320    // region — keeps the inner loop above tight (no double-borrow on the
4321    // parent buffer) and only touches pixels we actually modified.
4322    if let Some(parent_cmyk) = parent_cmyk {
4323        for gy in 0..ch {
4324            let ty = crop_y + gy as i32;
4325            if ty < 0 || ty as usize >= target_h {
4326                continue;
4327            }
4328            let ty = ty as usize;
4329            let group_row = gy * group_stride;
4330            let parent_row = ty * target_stride;
4331
4332            for gx in 0..cw {
4333                let tx = crop_x + gx as i32;
4334                if tx < 0 || tx as usize >= target_w {
4335                    continue;
4336                }
4337                let tx = tx as usize;
4338                let gi = group_row + gx * 4;
4339                let pi = parent_row + tx * 4;
4340
4341                let bc = backdrop_cmyk[gi] as f64;
4342                let bm = backdrop_cmyk[gi + 1] as f64;
4343                let by_ = backdrop_cmyk[gi + 2] as f64;
4344                let bk = backdrop_cmyk[gi + 3] as f64;
4345                let sc = source_cmyk[gi] as f64;
4346                let sm = source_cmyk[gi + 1] as f64;
4347                let sy_ = source_cmyk[gi + 2] as f64;
4348                let sk = source_cmyk[gi + 3] as f64;
4349                if (sc - bc).abs() < 1.0 / 255.0
4350                    && (sm - bm).abs() < 1.0 / 255.0
4351                    && (sy_ - by_).abs() < 1.0 / 255.0
4352                    && (sk - bk).abs() < 1.0 / 255.0
4353                {
4354                    continue;
4355                }
4356
4357                // Same transparent-backdrop fast path as above: use source
4358                // as-is. We read the original backdrop alpha from the saved
4359                // backdrop_pixels slice, NOT the live target — the live
4360                // target's alpha was already updated by the first loop's
4361                // composite-back writes.
4362                let backdrop_transparent = backdrop_pixels[gi + 3] == 0;
4363                let (rc, rm, ry, rk) = if backdrop_transparent {
4364                    (sc, sm, sy_, sk)
4365                } else if is_nonseparable {
4366                    let r =
4367                        blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4368                    (r[0], r[1], r[2], r[3])
4369                } else {
4370                    (
4371                        blend_cmyk_separable_channel(bc, sc, blend_mode),
4372                        blend_cmyk_separable_channel(bm, sm, blend_mode),
4373                        blend_cmyk_separable_channel(by_, sy_, blend_mode),
4374                        blend_cmyk_separable_channel(bk, sk, blend_mode),
4375                    )
4376                };
4377                parent_cmyk[pi] = rc as f32;
4378                parent_cmyk[pi + 1] = rm as f32;
4379                parent_cmyk[pi + 2] = ry as f32;
4380                parent_cmyk[pi + 3] = rk as f32;
4381            }
4382        }
4383    }
4384}
4385
4386/// Render a knockout transparency group into a pixmap.
4387///
4388/// In a knockout group, each element composites against the group's initial
4389/// backdrop (not the accumulated result of previous elements).
4390fn render_knockout_group(
4391    pixmap: &mut Pixmap,
4392    band_state: &mut BandState,
4393    elements: &DisplayList,
4394    params: &stet_graphics::display_list::GroupParams,
4395    ctx: &RenderContext<'_>,
4396) {
4397    let crop = compute_group_crop(&params.bbox, ctx);
4398
4399    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
4400        Some((cx, cy, cw, ch)) => (
4401            cw,
4402            ch,
4403            cx,
4404            cy,
4405            ctx.vp_x + cx as f32 / ctx.scale_x,
4406            ctx.vp_y + cy as f32 / ctx.scale_y,
4407        ),
4408        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
4409    };
4410
4411    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
4412        return;
4413    };
4414
4415    let initial_backdrop = if !params.isolated {
4416        if crop.is_some() {
4417            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
4418        } else {
4419            pixmap.data().to_vec()
4420        }
4421    } else {
4422        vec![0u8; (eff_w * eff_h * 4) as usize]
4423    };
4424
4425    let Some(mut accumulated) = Pixmap::new(eff_w, eff_h) else {
4426        return;
4427    };
4428    accumulated.data_mut().copy_from_slice(&initial_backdrop);
4429
4430    // Initial CMYK values for the knockout group
4431    let needs_cmyk = has_overprint_elements(elements) || band_state.cmyk_buffer.is_some();
4432    let initial_cmyk = if needs_cmyk {
4433        let buf_size = eff_w as usize * eff_h as usize * 4;
4434        let mut buf = vec![0.0f32; buf_size];
4435        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4436            let parent_stride = ctx.out_w as usize * 4;
4437            let group_stride = eff_w as usize * 4;
4438            for gy in 0..eff_h as usize {
4439                let py = crop_y as usize + gy;
4440                if py < ctx.out_h as usize {
4441                    let p_start = py * parent_stride + crop_x as usize * 4;
4442                    let g_start = gy * group_stride;
4443                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4444                    buf[g_start..g_start + copy_len]
4445                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
4446                }
4447            }
4448        }
4449        Some(buf)
4450    } else {
4451        None
4452    };
4453
4454    let mut accumulated_cmyk = initial_cmyk.clone();
4455
4456    // Disable anti-aliasing in knockout groups to prevent seam artifacts.
4457    // Each element composites independently against the backdrop, so adjacent
4458    // fills' AA edges don't mesh — both blend toward the backdrop color,
4459    // creating visible 1px white lines at shared boundaries.
4460    let group_ctx = RenderContext {
4461        vp_x: eff_vp_x,
4462        vp_y: eff_vp_y,
4463        scale_x: ctx.scale_x,
4464        scale_y: ctx.scale_y,
4465        out_w: eff_w,
4466        out_h: eff_h,
4467        effective_dpi: ctx.effective_dpi,
4468        icc: ctx.icc,
4469        image_cache: None,
4470        preprocessed: None,
4471        elem_idx: 0,
4472        no_aa: true,
4473        opm_zero_transparent: ctx.opm_zero_transparent,
4474        knockout_painter_pass: ctx.knockout_painter_pass,
4475        // Knockout groups composite each element against the initial backdrop;
4476        // children effectively see this group's "fresh" backdrop. Treat the
4477        // knockout group as isolated for the purposes of the inner CMYK rule.
4478        parent_group_isolated: true,
4479        alpha_extraction_pass: false,
4480        layer_set: ctx.layer_set,
4481    };
4482
4483    // Persistent band state for clip tracking — clips must accumulate across
4484    // elements in the knockout group (each paint element still composites
4485    // against the initial backdrop, but it must respect the current clip).
4486    let mut ko_band = BandState {
4487        clip_region: None,
4488        spare_mask: None,
4489        clip_mask_cache: HashMap::new(),
4490        clip_mask_seen: HashSet::new(),
4491        mask_pool: Vec::new(),
4492        cmyk_buffer: None,
4493        op_bg_snapshot: None,
4494        op_touched: None,
4495        spot_mask: None,
4496    };
4497
4498    // Coverage offscreen for two-pass painter rendering of nested transparency
4499    // groups. Reused (zeroed) across painters; allocated lazily on first need.
4500    let mut coverage_offscreen: Option<Pixmap> = None;
4501
4502    for elem in elements.elements() {
4503        match elem {
4504            // State-only elements: update persistent clip, no knockout compositing
4505            DisplayElement::Clip { .. } | DisplayElement::InitClip => {
4506                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4507            }
4508            // Group painters need two-pass rendering. Knockout semantics
4509            // require each painter to overwrite previous siblings within its
4510            // coverage area, even when the painter's blend mode happens to
4511            // produce a result that equals the initial backdrop (e.g.
4512            // Darken(red, white)=red, SoftLight(red, black)=red,
4513            // Multiply(red, magenta)=red — which is exactly what GWG 16.1
4514            // tests). The single-pass change-against-backdrop check used for
4515            // simpler painter types would miss those pixels, and earlier
4516            // siblings' contributions would bleed through.
4517            DisplayElement::Group { .. } => {
4518                // Pass 1: render painter against initial_backdrop to compute
4519                // the blended-color result (the painter's contribution).
4520                // Use ColorPass mode so any non-Normal blend mode goes through
4521                // the per-pixel CMYK composite-back — required for separable
4522                // blends like Screen / ColorDodge / Overlay / SoftLight whose
4523                // sRGB result drifts away from the CMYK-math result.
4524                let pass1_ctx = RenderContext {
4525                    knockout_painter_pass: KnockoutPainterPass::ColorPass,
4526                    ..group_ctx
4527                };
4528                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4529                ko_band.cmyk_buffer = initial_cmyk.clone();
4530                render_element(&mut offscreen, &mut ko_band, elem, &pass1_ctx);
4531                let pass1_cmyk = ko_band.cmyk_buffer.take();
4532
4533                // Pass 2: render painter into a fresh transparent offscreen so
4534                // the alpha channel captures the painter's coverage, which the
4535                // result-color comparison cannot recover when the blend mode
4536                // outputs the backdrop color exactly.
4537                let cov = match coverage_offscreen.as_mut() {
4538                    Some(p) => {
4539                        p.data_mut().fill(0);
4540                        p
4541                    }
4542                    None => {
4543                        let Some(p) = Pixmap::new(eff_w, eff_h) else {
4544                            // Out of memory for coverage buffer — fall back
4545                            // to the change-detection path so the painter
4546                            // still appears (just without proper knockout).
4547                            replace_changed_pixels(
4548                                accumulated.data_mut(),
4549                                offscreen.data(),
4550                                &initial_backdrop,
4551                            );
4552                            if let (Some(p1), Some(acc)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4553                                replace_changed_cmyk(acc, p1, offscreen.data(), &initial_backdrop);
4554                            }
4555                            continue;
4556                        };
4557                        coverage_offscreen = Some(p);
4558                        coverage_offscreen.as_mut().unwrap()
4559                    }
4560                };
4561                ko_band.cmyk_buffer = None;
4562                // Coverage pass: render through the simple sRGB path with
4563                // alpha forced to 1.0 and Normal blend so the painter's
4564                // shape reaches the coverage offscreen even for white-source
4565                // CMYK painters and zero-alpha painters (Opacity 0% test).
4566                let coverage_ctx = RenderContext {
4567                    knockout_painter_pass: KnockoutPainterPass::CoveragePass,
4568                    ..group_ctx
4569                };
4570                render_element(cov, &mut ko_band, elem, &coverage_ctx);
4571
4572                // Use the coverage offscreen's alpha as a knockout mask: the
4573                // painter's contribution from pass 1 source-overs onto
4574                // accumulated weighted by the coverage alpha.
4575                replace_with_coverage_mask(accumulated.data_mut(), offscreen.data(), cov.data());
4576
4577                if let (Some(p1_cmyk), Some(acc_cmyk)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4578                    replace_cmyk_with_coverage_mask(acc_cmyk, p1_cmyk, cov.data());
4579                }
4580                ko_band.cmyk_buffer = None;
4581            }
4582            // Other paint elements: single-pass with change-against-backdrop.
4583            // Direct path/image/shading paints always change pixels they cover,
4584            // so the simpler detection works and avoids the second-pass cost.
4585            _ => {
4586                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4587
4588                ko_band.cmyk_buffer = initial_cmyk.clone();
4589
4590                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4591
4592                if let (Some(elem_cmyk), Some(acc_cmyk)) =
4593                    (&ko_band.cmyk_buffer, &mut accumulated_cmyk)
4594                {
4595                    replace_changed_cmyk(acc_cmyk, elem_cmyk, offscreen.data(), &initial_backdrop);
4596                }
4597                ko_band.cmyk_buffer = None;
4598
4599                replace_changed_pixels(accumulated.data_mut(), offscreen.data(), &initial_backdrop);
4600            }
4601        }
4602    }
4603
4604    let mut temp_mask = None;
4605    let mask_ref = resolve_clip_mask(
4606        &band_state.clip_region,
4607        &mut temp_mask,
4608        ctx.out_w,
4609        ctx.out_h,
4610    );
4611    let mask_ref = match mask_ref {
4612        None => return,
4613        Some(m) => m,
4614    };
4615
4616    composite_non_isolated_group_cropped(
4617        pixmap,
4618        &accumulated,
4619        &initial_backdrop,
4620        params,
4621        mask_ref,
4622        crop_x,
4623        crop_y,
4624    );
4625
4626    if let (Some(acc_cmyk), Some(parent_cmyk)) = (&accumulated_cmyk, &mut band_state.cmyk_buffer) {
4627        copy_cmyk_buffer_to_parent(
4628            parent_cmyk,
4629            acc_cmyk,
4630            accumulated.data(),
4631            crop_x as usize,
4632            crop_y as usize,
4633            eff_w as usize,
4634            eff_h as usize,
4635            ctx.out_w as usize,
4636            ctx.out_h as usize,
4637        );
4638    }
4639}
4640/// Source-over `source` onto `target` weighted by `coverage`'s alpha channel.
4641/// Used for the two-pass knockout group rendering: `coverage` is rendered
4642/// into a transparent offscreen so its alpha records the painter's coverage
4643/// regardless of whether the painter's blend mode produced backdrop-equal
4644/// pixels in the color pass. Both `source` and `target` are assumed fully
4645/// opaque pixmaps (alpha=255 everywhere) since the knockout offscreens are
4646/// pre-loaded with the opaque initial backdrop.
4647fn replace_with_coverage_mask(target: &mut [u8], source: &[u8], coverage: &[u8]) {
4648    for i in (0..target.len()).step_by(4) {
4649        let cov_a = coverage[i + 3];
4650        if cov_a == 0 {
4651            continue;
4652        }
4653        if cov_a == 255 {
4654            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4655            continue;
4656        }
4657        let a = cov_a as u32;
4658        let inv = 255 - a;
4659        for c in 0..4 {
4660            let s = source[i + c] as u32;
4661            let t = target[i + c] as u32;
4662            target[i + c] = ((s * a + t * inv + 127) / 255) as u8;
4663        }
4664    }
4665}
4666
4667/// Source-over CMYK values from `source` onto `target` weighted by the
4668/// coverage offscreen's alpha channel. Companion to
4669/// `replace_with_coverage_mask` for the parallel CMYK buffer.
4670fn replace_cmyk_with_coverage_mask(target: &mut [f32], source: &[f32], coverage: &[u8]) {
4671    let pixel_count = target.len() / 4;
4672    for i in 0..pixel_count {
4673        let pi = i * 4;
4674        let cov_a = coverage[pi + 3];
4675        if cov_a == 0 {
4676            continue;
4677        }
4678        if cov_a == 255 {
4679            target[pi..pi + 4].copy_from_slice(&source[pi..pi + 4]);
4680            continue;
4681        }
4682        let a = cov_a as f32 / 255.0;
4683        let inv = 1.0 - a;
4684        for c in 0..4 {
4685            target[pi + c] = source[pi + c] * a + target[pi + c] * inv;
4686        }
4687    }
4688}
4689
4690/// Replace pixels in `target` with pixels from `source` wherever `source`
4691/// differs from `backdrop`. Used for knockout group per-element compositing
4692/// where each element replaces (not blends with) previous elements.
4693fn replace_changed_pixels(target: &mut [u8], source: &[u8], backdrop: &[u8]) {
4694    for i in (0..target.len()).step_by(4) {
4695        if source[i] != backdrop[i]
4696            || source[i + 1] != backdrop[i + 1]
4697            || source[i + 2] != backdrop[i + 2]
4698            || source[i + 3] != backdrop[i + 3]
4699        {
4700            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4701        }
4702    }
4703}
4704
4705/// Copy a group's CMYK buffer back to the parent's CMYK buffer after compositing.
4706/// Only copies values for pixels where the group offscreen has non-zero alpha,
4707/// indicating the group actually painted something at that position.
4708#[allow(clippy::too_many_arguments)]
4709fn copy_cmyk_buffer_to_parent(
4710    parent_cmyk: &mut [f32],
4711    group_cmyk: &[f32],
4712    group_pixels: &[u8],
4713    crop_x: usize,
4714    crop_y: usize,
4715    group_w: usize,
4716    group_h: usize,
4717    parent_w: usize,
4718    parent_h: usize,
4719) {
4720    let parent_stride = parent_w * 4;
4721    let group_stride = group_w * 4;
4722    for gy in 0..group_h {
4723        let py = crop_y + gy;
4724        if py >= parent_h {
4725            break;
4726        }
4727        for gx in 0..group_w {
4728            let px = crop_x + gx;
4729            if px >= parent_w {
4730                break;
4731            }
4732            // Only copy if the group pixel has non-zero alpha AND
4733            // the group's cmyk at that pixel is non-zero.
4734            // Zero cmyk means "not tracked by a CMYK fill in this group"
4735            // — writing it back would erase the parent's tracked values.
4736            let g_pixel_idx = (gy * group_w + gx) * 4;
4737            let g_cmyk_idx = gy * group_stride + gx * 4;
4738            if group_pixels[g_pixel_idx + 3] > 0
4739                && (group_cmyk[g_cmyk_idx] != 0.0
4740                    || group_cmyk[g_cmyk_idx + 1] != 0.0
4741                    || group_cmyk[g_cmyk_idx + 2] != 0.0
4742                    || group_cmyk[g_cmyk_idx + 3] != 0.0)
4743            {
4744                let p_cmyk_idx = py * parent_stride + px * 4;
4745                parent_cmyk[p_cmyk_idx..p_cmyk_idx + 4]
4746                    .copy_from_slice(&group_cmyk[g_cmyk_idx..g_cmyk_idx + 4]);
4747            }
4748        }
4749    }
4750}
4751
4752/// Copy CMYK values for pixels that changed in a knockout element.
4753/// Used alongside replace_changed_pixels to keep CMYK in sync with RGB.
4754fn replace_changed_cmyk(
4755    target_cmyk: &mut [f32],
4756    source_cmyk: &[f32],
4757    source_pixels: &[u8],
4758    backdrop_pixels: &[u8],
4759) {
4760    let pixel_count = target_cmyk.len() / 4;
4761    for i in 0..pixel_count {
4762        let pi = i * 4;
4763        if source_pixels[pi] != backdrop_pixels[pi]
4764            || source_pixels[pi + 1] != backdrop_pixels[pi + 1]
4765            || source_pixels[pi + 2] != backdrop_pixels[pi + 2]
4766            || source_pixels[pi + 3] != backdrop_pixels[pi + 3]
4767        {
4768            target_cmyk[pi..pi + 4].copy_from_slice(&source_cmyk[pi..pi + 4]);
4769        }
4770    }
4771}
4772
4773/// Render soft-masked content.
4774///
4775/// 1. Renders the mask display list to an offscreen pixmap.
4776/// 2. Extracts a grayscale mask (luminosity or alpha).
4777/// 3. Renders content into another offscreen pixmap.
4778/// 4. Multiplies content alpha by the mask values.
4779/// 5. Composites the masked content onto the parent.
4780#[allow(clippy::too_many_arguments)]
4781fn render_soft_masked(
4782    pixmap: &mut Pixmap,
4783    band_state: &mut BandState,
4784    mask_list: &DisplayList,
4785    content_list: &DisplayList,
4786    params: &stet_graphics::display_list::SoftMaskParams,
4787    mask_cache: &Arc<Mutex<Option<Option<stet_graphics::display_list::MaskRaster>>>>,
4788    ctx: &RenderContext<'_>,
4789) {
4790    // The SoftMask's display list elements are in absolute device space (page coords).
4791    // params.bbox is the SoftMasked element's compositing bounds, derived
4792    // from the form's /BBox transformed by the gs-time CTM. The mask raster
4793    // (built lazily by `rasterize_mask` and cached on the display-list
4794    // element) is anchored independently to the *actual* mask paint bounds,
4795    // which may differ from params.bbox when the form's internal `cm`
4796    // operators translated paint elements outside the form bbox.
4797    //
4798    // The cached-raster path can produce truncated output when the
4799    // SoftMasked is rendered inside an outer offscreen (a Group, an
4800    // outer SoftMasked, etc.) — the nested offscreen's coordinate
4801    // system clips the mask raster's right edge unexpectedly. Detect
4802    // "nested" via `ctx.vp_x != 0.0` (top-level banded rendering uses
4803    // vp_x = 0; nested rendering inherits the parent offscreen's vp).
4804    // For nested cases, fall back to the inline band-local mask
4805    // rendering that worked before Step 4 of cosmic-masking-bird.
4806    let use_inline_mask = ctx.vp_x != 0.0;
4807    let bbox = &params.bbox;
4808    let smask_px_x0 = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
4809    let smask_px_y0 = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
4810    let smask_px_x1 = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
4811    let smask_px_y1 = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
4812
4813    // Clip to parent output bounds
4814    let crop_x = smask_px_x0.max(0);
4815    let crop_y = smask_px_y0.max(0);
4816    let crop_x1 = smask_px_x1.min(ctx.out_w as i32);
4817    let crop_y1 = smask_px_y1.min(ctx.out_h as i32);
4818    if crop_x >= crop_x1 || crop_y >= crop_y1 {
4819        return;
4820    }
4821    let eff_w = (crop_x1 - crop_x) as u32;
4822    let eff_h = (crop_y1 - crop_y) as u32;
4823
4824    // Viewport for the content offscreen: derived from the SoftMask's bbox
4825    // position relative to the parent's viewport. The content offscreen
4826    // still uses params.bbox because params.bbox correctly bounds where
4827    // the content can paint.
4828    let eff_vp_x = ctx.vp_x + crop_x as f32 / ctx.scale_x;
4829    let eff_vp_y = ctx.vp_y + crop_y as f32 / ctx.scale_y;
4830
4831    let sub_ctx = RenderContext {
4832        vp_x: eff_vp_x,
4833        vp_y: eff_vp_y,
4834        scale_x: ctx.scale_x,
4835        scale_y: ctx.scale_y,
4836        out_w: eff_w,
4837        out_h: eff_h,
4838        effective_dpi: ctx.effective_dpi,
4839        icc: ctx.icc,
4840        image_cache: None,
4841        preprocessed: None,
4842        elem_idx: 0,
4843        no_aa: ctx.no_aa,
4844        opm_zero_transparent: ctx.opm_zero_transparent,
4845        knockout_painter_pass: ctx.knockout_painter_pass,
4846        parent_group_isolated: ctx.parent_group_isolated,
4847        // Soft masks render into their own independent offscreen and must
4848        // not inherit the alpha extraction pass — their groups need normal
4849        // backdrop preloading regardless of the outer extraction context.
4850        alpha_extraction_pass: false,
4851        layer_set: ctx.layer_set,
4852    };
4853
4854    // 1a. INLINE PATH: Mask form contains nested offscreens.
4855    // Render the mask form into a band-local offscreen sized to the
4856    // SoftMasked's bbox crop. This matches the pre-Step-4 behavior.
4857    let mut mask_values_inline: Vec<u8> = Vec::new();
4858    if use_inline_mask {
4859        let Some(mut mask_pixmap) = Pixmap::new(eff_w, eff_h) else {
4860            return;
4861        };
4862        let mut mask_band = BandState {
4863            clip_region: None,
4864            spare_mask: None,
4865            clip_mask_cache: HashMap::new(),
4866            clip_mask_seen: HashSet::new(),
4867            mask_pool: Vec::new(),
4868            cmyk_buffer: None,
4869            op_bg_snapshot: None,
4870            op_touched: None,
4871            spot_mask: None,
4872        };
4873        for (idx, elem) in mask_list.elements().iter().enumerate() {
4874            let elem_ctx = RenderContext {
4875                elem_idx: idx,
4876                ..sub_ctx
4877            };
4878            render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
4879        }
4880        if params.has_nested_mask_scope
4881            && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
4882        {
4883            let bc = params.backdrop_color.as_ref();
4884            let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4885            let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4886            let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4887            for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
4888                let a = chunk[3] as u16;
4889                if a == 255 {
4890                    continue;
4891                }
4892                let inv_a = 255 - a;
4893                chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
4894                chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
4895                chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
4896                chunk[3] = 255;
4897            }
4898        }
4899        mask_values_inline = vec![0u8; (eff_w * eff_h) as usize];
4900        extract_soft_mask_values(mask_pixmap.data(), &mut mask_values_inline, params);
4901    }
4902
4903    // 1b. CACHED RASTER PATH: simple masks (no nested offscreens).
4904    let raster_owned: Option<stet_graphics::display_list::MaskRaster> = if use_inline_mask {
4905        None
4906    } else {
4907        let mut guard = mask_cache.lock().unwrap();
4908        let needs_build = match guard.as_ref() {
4909            None => true,
4910            Some(None) => false, // memoized "no mask"
4911            Some(Some(r)) => {
4912                (r.scale_x - ctx.scale_x).abs() > 1e-4 || (r.scale_y - ctx.scale_y).abs() > 1e-4
4913            }
4914        };
4915        if needs_build {
4916            let built = rasterize_mask(
4917                mask_list,
4918                params,
4919                ctx.icc,
4920                ctx.no_aa,
4921                ctx.effective_dpi,
4922                ctx.scale_x,
4923                ctx.scale_y,
4924                ctx.layer_set,
4925            );
4926            *guard = Some(built);
4927        }
4928        guard.as_ref().and_then(|inner| inner.clone())
4929    };
4930
4931    // Default mask value for content pixels that fall outside the mask
4932    // raster (e.g. backdrop region for a Luminosity mask with non-black
4933    // /BC, or always 0 for Alpha masks).
4934    let fallback_mask = out_of_bounds_mask_value(params) as i32;
4935
4936    // 2. Render content into an offscreen, initialized with the parent's
4937    // backdrop so non-isolated groups with blend modes (e.g. Multiply) see
4938    // the correct background and produce the right composited result.
4939    let Some(mut content_pixmap) = Pixmap::new(eff_w, eff_h) else {
4940        return;
4941    };
4942    let backdrop = copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h);
4943    content_pixmap.data_mut().copy_from_slice(&backdrop);
4944
4945    let content_cmyk = if has_overprint_elements(content_list) || band_state.cmyk_buffer.is_some() {
4946        let buf_size = eff_w as usize * eff_h as usize * 4;
4947        let mut buf = vec![0.0f32; buf_size];
4948        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4949            let parent_stride = ctx.out_w as usize * 4;
4950            let group_stride = eff_w as usize * 4;
4951            for gy in 0..eff_h as usize {
4952                let py = crop_y as usize + gy;
4953                if py < ctx.out_h as usize {
4954                    let p_start = py * parent_stride + crop_x as usize * 4;
4955                    let g_start = gy * group_stride;
4956                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4957                    buf[g_start..g_start + copy_len]
4958                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
4959                }
4960            }
4961        }
4962        Some(buf)
4963    } else {
4964        None
4965    };
4966    // Snapshot the pre-content CMYK state so the mask blend can run in CMYK
4967    // space. Without this, the downstream sRGB blend interpolates between
4968    // CMYK backdrop and source after each has been ICC-converted separately,
4969    // which shifts the midtones away from the CMYK-interpolated result the
4970    // source was authored against (pink cast vs warm peach on GWG 16.10
4971    // inner-glow in PDFX-ready_Output-Test_X4.pdf).
4972    let backdrop_cmyk: Option<Vec<f32>> = content_cmyk.clone();
4973    let mut content_band = BandState {
4974        clip_region: None,
4975        spare_mask: None,
4976        clip_mask_cache: HashMap::new(),
4977        clip_mask_seen: HashSet::new(),
4978        mask_pool: Vec::new(),
4979        cmyk_buffer: content_cmyk,
4980        op_bg_snapshot: None,
4981        op_touched: None,
4982        spot_mask: None,
4983    };
4984    for (idx, elem) in content_list.elements().iter().enumerate() {
4985        let elem_ctx = RenderContext {
4986            elem_idx: idx,
4987            ..sub_ctx
4988        };
4989        render_element(&mut content_pixmap, &mut content_band, elem, &elem_ctx);
4990    }
4991
4992    // 3. Apply soft mask: compute per-pixel masked contribution and write
4993    // to parent. result[c] = parent[c] + m * (content_on_backdrop[c] - backdrop[c]) / 255
4994    //
4995    // Mask sampling: the mask raster is in page-pixel coordinates at the
4996    // current render scale, anchored at `(raster.origin_x, raster.origin_y)`.
4997    // The combine loop iterates over content pixel `(x, y)` band-local in
4998    // the content offscreen. To translate to a mask raster index:
4999    //
5000    //   page_x = vp_x_pixels + crop_x + x
5001    //   page_y = vp_y_pixels + crop_y + y
5002    //   mask_x = page_x - raster.origin_x
5003    //   mask_y = page_y - raster.origin_y
5004    //
5005    // where `vp_x_pixels = round(ctx.vp_x * ctx.scale_x)` is the page-pixel
5006    // offset of the band's top-left. For banded rendering this is exact
5007    // (vp = 0, scale = 1, so vp_x_pixels = 0). For viewport rendering with
5008    // a fractional `vp_x`, there is at most a 0.5-pixel sub-pixel offset
5009    // between the content render grid and the cached mask grid; this is
5010    // bounded and visually acceptable for nearest-neighbor sampling.
5011    let vp_x_pixels = (ctx.vp_x * ctx.scale_x).round() as i32;
5012    let vp_y_pixels = (ctx.vp_y * ctx.scale_y).round() as i32;
5013
5014    let mut temp_mask = None;
5015    let clip_ref = resolve_clip_mask(
5016        &band_state.clip_region,
5017        &mut temp_mask,
5018        ctx.out_w,
5019        ctx.out_h,
5020    );
5021    let clip_ref = match clip_ref {
5022        None => return,
5023        Some(m) => m,
5024    };
5025
5026    // Decide whether to interpolate the masked delta in CMYK (with ICC→sRGB
5027    // on the way out) instead of sRGB. The CMYK path matches Acrobat's
5028    // behaviour when the transparency group declares /CS DeviceCMYK and all
5029    // content is native CMYK — the blend color space is then CMYK, and
5030    // sRGB-space interpolation on ICC-converted endpoints loses the warm
5031    // midtone that M+Y mixing produces under a proper CMYK profile.
5032    //
5033    // Gate strictly: content_list must be a flat list of native-CMYK fills
5034    // or strokes with Normal blend and full opacity. Any nested Group,
5035    // SoftMasked, Image, or blend-mode-modulated paint means the parallel
5036    // cmyk_buffer can't be trusted to match the pixmap — running CMYK
5037    // interpolation against a mismatched CMYK snapshot produced wrong
5038    // colors on GWG 16.10 outer-glow C (Fm5 is a Screen-blend white rect
5039    // inside a Group; cmyk_buffer held raw white while pixmap held the
5040    // screen-blended light gray).
5041    let use_cmyk_blend = ctx.icc.is_some()
5042        && backdrop_cmyk.is_some()
5043        && content_band.cmyk_buffer.is_some()
5044        && content_list_is_simple_native_cmyk(content_list);
5045
5046    let content_data = content_pixmap.data();
5047    let parent_data = pixmap.data_mut();
5048    let parent_stride = ctx.out_w as usize * 4;
5049    let content_stride = eff_w as usize * 4;
5050
5051    for y in 0..eff_h as usize {
5052        let py = crop_y as usize + y;
5053        if py >= ctx.out_h as usize {
5054            break;
5055        }
5056        let ci_row = y * content_stride;
5057        let pi_row = py * parent_stride;
5058        let page_y = vp_y_pixels + crop_y + y as i32;
5059
5060        for x in 0..eff_w as usize {
5061            let px = crop_x as usize + x;
5062            if px >= ctx.out_w as usize {
5063                break;
5064            }
5065
5066            // Check clip mask (in parent coordinates)
5067            if let Some(clip) = clip_ref {
5068                if clip.data()[py * ctx.out_w as usize + px] == 0 {
5069                    continue;
5070                }
5071            }
5072
5073            // Sample the mask: inline-rendered values for masks with
5074            // nested offscreens, cached raster for simple masks.
5075            let m = if use_inline_mask {
5076                mask_values_inline[y * eff_w as usize + x] as i32
5077            } else if let Some(ref raster) = raster_owned {
5078                let page_x = vp_x_pixels + crop_x + x as i32;
5079                let mx = page_x - raster.origin_x;
5080                let my = page_y - raster.origin_y;
5081                if mx >= 0 && (mx as u32) < raster.width && my >= 0 && (my as u32) < raster.height {
5082                    raster.data[my as usize * raster.width as usize + mx as usize] as i32
5083                } else {
5084                    fallback_mask
5085                }
5086            } else {
5087                fallback_mask
5088            };
5089            if m == 0 {
5090                continue;
5091            }
5092
5093            let ci = ci_row + x * 4;
5094            let pi = pi_row + px * 4;
5095
5096            // Per-pixel gate: CMYK interpolation is only safe when both
5097            // endpoints are faithfully tracked. ICC-convert both cmyk
5098            // snapshots and compare with the sRGB endpoints; only take
5099            // the CMYK path if BOTH agree within tolerance. The backdrop
5100            // check catches image/RGB paints upstream (tile_clamp_bug.pdf
5101            // photo background) where cmyk_buffer is an approximate
5102            // reverse-transform. The content check catches cases where
5103            // non-CMYK paints inside content leave the cmyk_buffer stale
5104            // relative to the sRGB content pixmap.
5105            let ci_cmyk = (y * eff_w as usize + x) * 4;
5106            let cmyk_path_ok = use_cmyk_blend && {
5107                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5108                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5109                let icc_match = |cmyk: &[f32], rgb: &[u8]| -> bool {
5110                    let (r, g, b) = ctx
5111                        .icc
5112                        .and_then(|i| {
5113                            i.convert_cmyk_readonly(
5114                                cmyk[0] as f64,
5115                                cmyk[1] as f64,
5116                                cmyk[2] as f64,
5117                                cmyk[3] as f64,
5118                            )
5119                        })
5120                        .unwrap_or_else(|| {
5121                            cmyk_to_rgb_plrm(
5122                                cmyk[0] as f64,
5123                                cmyk[1] as f64,
5124                                cmyk[2] as f64,
5125                                cmyk[3] as f64,
5126                            )
5127                        });
5128                    let r = (r * 255.0).round() as i32;
5129                    let g = (g * 255.0).round() as i32;
5130                    let b = (b * 255.0).round() as i32;
5131                    (r - rgb[0] as i32).abs() <= 3
5132                        && (g - rgb[1] as i32).abs() <= 3
5133                        && (b - rgb[2] as i32).abs() <= 3
5134                };
5135                icc_match(bc_cmyk, &backdrop[ci..ci + 3])
5136                    && icc_match(cc_cmyk, &content_data[ci..ci + 3])
5137            };
5138
5139            if cmyk_path_ok {
5140                // CMYK-space mask blend: result_cmyk = backdrop + m*(content - backdrop)
5141                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5142                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5143                let mf = m as f64 / 255.0;
5144                let rc = bc_cmyk[0] as f64 + mf * (cc_cmyk[0] as f64 - bc_cmyk[0] as f64);
5145                let rm = bc_cmyk[1] as f64 + mf * (cc_cmyk[1] as f64 - bc_cmyk[1] as f64);
5146                let ry = bc_cmyk[2] as f64 + mf * (cc_cmyk[2] as f64 - bc_cmyk[2] as f64);
5147                let rk = bc_cmyk[3] as f64 + mf * (cc_cmyk[3] as f64 - bc_cmyk[3] as f64);
5148                let (fr, fg, fb) = ctx
5149                    .icc
5150                    .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
5151                    .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
5152                parent_data[pi] = (fr * 255.0).round().clamp(0.0, 255.0) as u8;
5153                parent_data[pi + 1] = (fg * 255.0).round().clamp(0.0, 255.0) as u8;
5154                parent_data[pi + 2] = (fb * 255.0).round().clamp(0.0, 255.0) as u8;
5155                // Alpha channel: keep sRGB delta blend.
5156                let content_a = content_data[ci + 3] as i32;
5157                let backdrop_a = backdrop[ci + 3] as i32;
5158                let delta = content_a - backdrop_a;
5159                if delta != 0 {
5160                    let masked_delta = if delta > 0 {
5161                        (delta * m + 128) / 255
5162                    } else {
5163                        (delta * m - 128) / 255
5164                    };
5165                    let result = (parent_data[pi + 3] as i32 + masked_delta).clamp(0, 255);
5166                    parent_data[pi + 3] = result as u8;
5167                }
5168                // The parent's cmyk_buffer is deliberately NOT written here.
5169                // Writing back mask-blended CMYK would overwrite backdrop
5170                // tracking that downstream CMYK consumers (outer groups,
5171                // subsequent masks) depend on and cause them to render
5172                // nearby pixels as pure CMYK channels (e.g. the outer-glow
5173                // C regression: adjacent gray pixels ICC-resolved to a
5174                // black K silhouette). The sRGB pixmap carries the mask-
5175                // blended color; parent_cmyk stays untouched.
5176            } else {
5177                for c in 0..4 {
5178                    let content_val = content_data[ci + c] as i32;
5179                    let backdrop_val = backdrop[ci + c] as i32;
5180                    let delta = content_val - backdrop_val;
5181                    if delta != 0 {
5182                        let masked_delta = if delta > 0 {
5183                            (delta * m + 128) / 255
5184                        } else {
5185                            (delta * m - 128) / 255
5186                        };
5187                        let result = (parent_data[pi + c] as i32 + masked_delta).clamp(0, 255);
5188                        parent_data[pi + c] = result as u8;
5189                    }
5190                }
5191            }
5192        }
5193    }
5194
5195    // Write content CMYK buffer back to parent. Skip when the CMYK blend
5196    // loop already updated band_state.cmyk_buffer with mask-blended values
5197    // — copying the unmodulated content CMYK here would overwrite them.
5198    if !use_cmyk_blend {
5199        if let (Some(content_cmyk), Some(parent_cmyk)) =
5200            (&content_band.cmyk_buffer, &mut band_state.cmyk_buffer)
5201        {
5202            copy_cmyk_buffer_to_parent(
5203                parent_cmyk,
5204                content_cmyk,
5205                content_pixmap.data(),
5206                crop_x as usize,
5207                crop_y as usize,
5208                eff_w as usize,
5209                eff_h as usize,
5210                ctx.out_w as usize,
5211                ctx.out_h as usize,
5212            );
5213        }
5214    }
5215}
5216/// Extract grayscale mask values from rendered RGBA pixels.
5217fn extract_soft_mask_values(
5218    rgba: &[u8],
5219    out: &mut [u8],
5220    params: &stet_graphics::display_list::SoftMaskParams,
5221) {
5222    use stet_graphics::display_list::SoftMaskSubtype;
5223    let pixel_count = out.len();
5224
5225    match params.subtype {
5226        SoftMaskSubtype::Alpha => {
5227            for i in 0..pixel_count {
5228                let a = rgba[i * 4 + 3]; // alpha channel
5229                out[i] = if params.transfer_invert { 255 - a } else { a };
5230            }
5231        }
5232        SoftMaskSubtype::Luminosity => {
5233            // Backdrop luminosity for transparent pixels
5234            let backdrop_lum = if let Some(bc) = &params.backdrop_color {
5235                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5236            } else {
5237                0.0 // black backdrop
5238            };
5239            let backdrop_byte = (backdrop_lum * 255.0 + 0.5) as u8;
5240
5241            #[allow(clippy::needless_range_loop)]
5242            for i in 0..pixel_count {
5243                let off = i * 4;
5244                let a = rgba[off + 3];
5245                let lum_byte = if a == 0 {
5246                    backdrop_byte
5247                } else if a < 255 {
5248                    // Composite premultiplied RGB onto backdrop before computing
5249                    // luminosity (PDF spec 11.6.5.3): premul_rgb + BC × (1 - α/255)
5250                    let af = a as f64;
5251                    let bd = backdrop_lum * 255.0;
5252                    let r = rgba[off] as f64 + bd * (255.0 - af) / 255.0;
5253                    let g = rgba[off + 1] as f64 + bd * (255.0 - af) / 255.0;
5254                    let b = rgba[off + 2] as f64 + bd * (255.0 - af) / 255.0;
5255                    let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
5256                    (lum + 0.5).clamp(0.0, 255.0) as u8
5257                } else {
5258                    // Fully opaque: premultiplied == straight RGB
5259                    let lum = 0.2126 * rgba[off] as f64
5260                        + 0.7152 * rgba[off + 1] as f64
5261                        + 0.0722 * rgba[off + 2] as f64;
5262                    (lum + 0.5).clamp(0.0, 255.0) as u8
5263                };
5264                // Apply transfer function inversion: {1 exch sub} → 255 - value
5265                out[i] = if params.transfer_invert {
5266                    255 - lum_byte
5267                } else {
5268                    lum_byte
5269                };
5270            }
5271        }
5272    }
5273}
5274
5275/// Compute the byte the mask sample loop should use for content pixels
5276/// that fall outside the rasterized mask raster.
5277///
5278/// For Luminosity masks, transparent pixels (no rendered mask paint)
5279/// composite onto the backdrop color, so the effective mask value is the
5280/// backdrop's luminosity. For Alpha masks, transparent = 0 = mask off.
5281/// Both subtypes apply the `/TR {1 exch sub}` transfer inversion.
5282fn out_of_bounds_mask_value(params: &stet_graphics::display_list::SoftMaskParams) -> u8 {
5283    use stet_graphics::display_list::SoftMaskSubtype;
5284    let raw = match params.subtype {
5285        SoftMaskSubtype::Alpha => 0u8,
5286        SoftMaskSubtype::Luminosity => {
5287            let lum = if let Some(bc) = &params.backdrop_color {
5288                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5289            } else {
5290                0.0
5291            };
5292            (lum * 255.0 + 0.5) as u8
5293        }
5294    };
5295    if params.transfer_invert {
5296        255 - raw
5297    } else {
5298        raw
5299    }
5300}
5301
5302/// Maximum mask raster area in pixels.  A malformed PDF that asks for a
5303/// gigantic mask form would otherwise OOM. 64 megapixels = 64 MB for
5304/// grayscale or 256 MB for RGBA — generous but bounded.  Using an area
5305/// limit instead of a per-dimension limit correctly handles narrow-but-tall
5306/// pages (e.g. infographics that exceed 8192 pixels in height while being
5307/// only ~1000 pixels wide).
5308const MAX_MASK_RASTER_PIXELS: u64 = 64 * 1024 * 1024;
5309
5310/// Rasterize a soft mask form's display list into a `MaskRaster`.
5311///
5312/// Walks the mask display list to compute its actual paint bounds (which
5313/// may differ from the SoftMasked element's `params.bbox` because the
5314/// form's internal `cm` operators may translate paint elements outside
5315/// the form's `/BBox`), allocates a pixmap that exactly covers those
5316/// bounds in device-space pixels, and renders the mask elements with the
5317/// viewport set to the bounds origin so each element rasterizes at
5318/// `(device_x - origin_x, device_y - origin_y)`.
5319///
5320/// Returns `None` when the mask paints nothing.
5321fn rasterize_mask(
5322    mask_list: &DisplayList,
5323    params: &stet_graphics::display_list::SoftMaskParams,
5324    icc: Option<&IccCache>,
5325    no_aa: bool,
5326    effective_dpi: f64,
5327    scale_x: f32,
5328    scale_y: f32,
5329    layer_set: &LayerSet,
5330) -> Option<stet_graphics::display_list::MaskRaster> {
5331    // 1. Find the actual paint bounds in device space, then cap them to
5332    // the parent gstate's clip path bbox if known. The cap is critical
5333    // for masks whose form contains an unbounded shading inside a
5334    // sentinel-sized internal clip — without it, the raster blows past
5335    // the size limit and produces no output. Pixels outside the parent
5336    // clip can never affect the final image, so the cap is safe.
5337    let mut bounds = compute_paint_bounds(mask_list, effective_dpi)?;
5338    if let Some(cap) = params.parent_clip_bbox {
5339        let cap_bbox = BBox2D {
5340            x_min: cap[0],
5341            y_min: cap[1],
5342            x_max: cap[2],
5343            y_max: cap[3],
5344        };
5345        bounds = intersect_bbox(&bounds, &cap_bbox)?;
5346    }
5347
5348    // 2. Snap to integer device pixels at the current render scale, with a
5349    // 1-pixel pad on each side to avoid antialiasing edge clipping.
5350    let px_x_min = (bounds.x_min as f32 * scale_x).floor() as i32 - 1;
5351    let px_y_min = (bounds.y_min as f32 * scale_y).floor() as i32 - 1;
5352    let px_x_max = (bounds.x_max as f32 * scale_x).ceil() as i32 + 1;
5353    let px_y_max = (bounds.y_max as f32 * scale_y).ceil() as i32 + 1;
5354    if px_x_min >= px_x_max || px_y_min >= px_y_max {
5355        return None;
5356    }
5357    let raster_w = (px_x_max - px_x_min) as u32;
5358    let raster_h = (px_y_max - px_y_min) as u32;
5359    if raster_w == 0 || raster_h == 0 {
5360        return None;
5361    }
5362    if (raster_w as u64) * (raster_h as u64) > MAX_MASK_RASTER_PIXELS {
5363        return None;
5364    }
5365
5366    // 3. Allocate the offscreen pixmap (transparent backdrop).
5367    let mut mask_pixmap = Pixmap::new(raster_w, raster_h)?;
5368
5369    // 4. Build a RenderContext that maps device pixel `(dx, dy)` to
5370    // raster pixel `(dx - px_x_min, dy - px_y_min)`. The viewport is in
5371    // device-space units (not pixels), so divide by scale.
5372    let sub_ctx = RenderContext {
5373        vp_x: px_x_min as f32 / scale_x,
5374        vp_y: px_y_min as f32 / scale_y,
5375        scale_x,
5376        scale_y,
5377        out_w: raster_w,
5378        out_h: raster_h,
5379        effective_dpi,
5380        icc,
5381        image_cache: None,
5382        preprocessed: None,
5383        elem_idx: 0,
5384        no_aa,
5385        opm_zero_transparent: false,
5386        knockout_painter_pass: KnockoutPainterPass::None,
5387        parent_group_isolated: false,
5388        alpha_extraction_pass: false,
5389        layer_set,
5390    };
5391
5392    // 5. Mask rendering doesn't participate in CMYK overprint compositing.
5393    let mut mask_band = BandState {
5394        clip_region: None,
5395        spare_mask: None,
5396        clip_mask_cache: HashMap::new(),
5397        clip_mask_seen: HashSet::new(),
5398        mask_pool: Vec::new(),
5399        cmyk_buffer: None,
5400        op_bg_snapshot: None,
5401        op_touched: None,
5402        spot_mask: None,
5403    };
5404
5405    // 6. Render every element of the mask display list into the offscreen.
5406    for (idx, elem) in mask_list.elements().iter().enumerate() {
5407        let elem_ctx = RenderContext {
5408            elem_idx: idx,
5409            ..sub_ctx
5410        };
5411        render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
5412    }
5413
5414    // 7. If the mask form contained nested gs-set SMask scopes, composite
5415    // the rendered mask onto the backdrop color before extracting
5416    // luminosity. Nested masks produce semi-transparent pixels where
5417    // alpha encodes the mask modulation; without compositing,
5418    // un-premultiplying would amplify the color and lose the modulation.
5419    // Only Luminosity: Alpha masks extract the alpha channel directly,
5420    // so forcing alpha=255 via compositing would destroy the mask info.
5421    if params.has_nested_mask_scope
5422        && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
5423    {
5424        let bc = params.backdrop_color.as_ref();
5425        let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5426        let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5427        let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5428        for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
5429            let a = chunk[3] as u16;
5430            if a == 255 {
5431                continue;
5432            }
5433            let inv_a = 255 - a;
5434            chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
5435            chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
5436            chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
5437            chunk[3] = 255;
5438        }
5439    }
5440
5441    // 8. Extract grayscale mask values into a flat single-channel buffer.
5442    let pixel_count = (raster_w * raster_h) as usize;
5443    let mut data = vec![0u8; pixel_count];
5444    extract_soft_mask_values(mask_pixmap.data(), &mut data, params);
5445
5446    Some(stet_graphics::display_list::MaskRaster {
5447        data,
5448        width: raster_w,
5449        height: raster_h,
5450        origin_x: px_x_min,
5451        origin_y: px_y_min,
5452        scale_x,
5453        scale_y,
5454    })
5455}
5456
5457/// Transform a display element's CTM through a matrix so that pattern-space
5458/// coordinates map to device space.  Recursively transforms children of
5459/// Group and SoftMasked elements, and adjusts their bboxes.
5460fn transform_element_ctm(elem: &DisplayElement, pm: &Matrix) -> DisplayElement {
5461    match elem {
5462        DisplayElement::Fill { path, params } => {
5463            let mut p = params.clone();
5464            p.ctm = pm.concat(&p.ctm);
5465            DisplayElement::Fill {
5466                path: path.clone(),
5467                params: p,
5468            }
5469        }
5470        DisplayElement::Stroke { path, params } => {
5471            let mut p = params.clone();
5472            p.ctm = pm.concat(&p.ctm);
5473            DisplayElement::Stroke {
5474                path: path.clone(),
5475                params: p,
5476            }
5477        }
5478        DisplayElement::Clip { path, params } => {
5479            let mut p = params.clone();
5480            p.ctm = pm.concat(&p.ctm);
5481            if let Some(ref mut sp) = p.stroke_params {
5482                sp.ctm = pm.concat(&sp.ctm);
5483            }
5484            DisplayElement::Clip {
5485                path: path.clone(),
5486                params: p,
5487            }
5488        }
5489        DisplayElement::Image {
5490            sample_data,
5491            params,
5492        } => {
5493            let mut p = params.clone();
5494            p.ctm = pm.concat(&p.ctm);
5495            DisplayElement::Image {
5496                sample_data: sample_data.clone(),
5497                params: p,
5498            }
5499        }
5500        DisplayElement::MeshShading { params } => {
5501            let mut p = params.clone();
5502            p.ctm = pm.concat(&p.ctm);
5503            DisplayElement::MeshShading { params: p }
5504        }
5505        DisplayElement::PatchShading { params } => {
5506            let mut p = params.clone();
5507            p.ctm = pm.concat(&p.ctm);
5508            DisplayElement::PatchShading { params: p }
5509        }
5510        DisplayElement::AxialShading { params } => {
5511            let mut p = params.clone();
5512            p.ctm = pm.concat(&p.ctm);
5513            DisplayElement::AxialShading { params: p }
5514        }
5515        DisplayElement::RadialShading { params } => {
5516            let mut p = params.clone();
5517            p.ctm = pm.concat(&p.ctm);
5518            DisplayElement::RadialShading { params: p }
5519        }
5520        DisplayElement::Group { elements, params } => {
5521            let mut t = DisplayList::new();
5522            for child in elements.elements() {
5523                t.push(transform_element_ctm(child, pm));
5524            }
5525            let mut p = params.clone();
5526            let corners = [
5527                pm.transform_point(p.bbox[0], p.bbox[1]),
5528                pm.transform_point(p.bbox[2], p.bbox[1]),
5529                pm.transform_point(p.bbox[0], p.bbox[3]),
5530                pm.transform_point(p.bbox[2], p.bbox[3]),
5531            ];
5532            p.bbox = [
5533                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5534                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5535                corners
5536                    .iter()
5537                    .map(|c| c.0)
5538                    .fold(f64::NEG_INFINITY, f64::max),
5539                corners
5540                    .iter()
5541                    .map(|c| c.1)
5542                    .fold(f64::NEG_INFINITY, f64::max),
5543            ];
5544            DisplayElement::Group {
5545                elements: t,
5546                params: p,
5547            }
5548        }
5549        DisplayElement::SoftMasked {
5550            mask,
5551            content,
5552            params,
5553            ..
5554        } => {
5555            let mut t_mask = DisplayList::new();
5556            for child in mask.elements() {
5557                t_mask.push(transform_element_ctm(child, pm));
5558            }
5559            let mut t_content = DisplayList::new();
5560            for child in content.elements() {
5561                t_content.push(transform_element_ctm(child, pm));
5562            }
5563            let mut p = params.clone();
5564            let corners = [
5565                pm.transform_point(p.bbox[0], p.bbox[1]),
5566                pm.transform_point(p.bbox[2], p.bbox[1]),
5567                pm.transform_point(p.bbox[0], p.bbox[3]),
5568                pm.transform_point(p.bbox[2], p.bbox[3]),
5569            ];
5570            p.bbox = [
5571                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5572                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5573                corners
5574                    .iter()
5575                    .map(|c| c.0)
5576                    .fold(f64::NEG_INFINITY, f64::max),
5577                corners
5578                    .iter()
5579                    .map(|c| c.1)
5580                    .fold(f64::NEG_INFINITY, f64::max),
5581            ];
5582            // parent_clip_bbox was captured in the original (pattern)
5583            // coordinate system. Transform it through pm to match the
5584            // device-space coords that mask/content elements were just
5585            // moved into; otherwise the renderer would intersect a
5586            // device-space mask bbox with a pattern-space clip and get
5587            // an empty raster.
5588            if let Some(pcb) = p.parent_clip_bbox {
5589                let pcb_corners = [
5590                    pm.transform_point(pcb[0], pcb[1]),
5591                    pm.transform_point(pcb[2], pcb[1]),
5592                    pm.transform_point(pcb[0], pcb[3]),
5593                    pm.transform_point(pcb[2], pcb[3]),
5594                ];
5595                p.parent_clip_bbox = Some([
5596                    pcb_corners
5597                        .iter()
5598                        .map(|c| c.0)
5599                        .fold(f64::INFINITY, f64::min),
5600                    pcb_corners
5601                        .iter()
5602                        .map(|c| c.1)
5603                        .fold(f64::INFINITY, f64::min),
5604                    pcb_corners
5605                        .iter()
5606                        .map(|c| c.0)
5607                        .fold(f64::NEG_INFINITY, f64::max),
5608                    pcb_corners
5609                        .iter()
5610                        .map(|c| c.1)
5611                        .fold(f64::NEG_INFINITY, f64::max),
5612                ]);
5613            }
5614            // The transformed element's coordinate system is different
5615            // from the original; the original cache (if any) is invalid.
5616            // Allocate a fresh cache cell.
5617            DisplayElement::SoftMasked {
5618                mask: t_mask,
5619                content: t_content,
5620                params: p,
5621                mask_cache: Arc::new(Mutex::new(None)),
5622            }
5623        }
5624        DisplayElement::PatternFill { params } => {
5625            let mut p = params.clone();
5626            p.pattern_matrix = pm.concat(&p.pattern_matrix);
5627            // Transform the fill path (device-space coordinates)
5628            p.path = transform_path_by_matrix(&p.path, pm);
5629            if let Some(ref mut sp) = p.stroke_params {
5630                sp.ctm = pm.concat(&sp.ctm);
5631            }
5632            DisplayElement::PatternFill { params: p }
5633        }
5634        DisplayElement::OcgGroup {
5635            elements,
5636            visibility,
5637        } => {
5638            let mut t = DisplayList::new();
5639            for child in elements.elements() {
5640                t.push(transform_element_ctm(child, pm));
5641            }
5642            DisplayElement::OcgGroup {
5643                elements: t,
5644                visibility: visibility.clone(),
5645            }
5646        }
5647        other => other.clone(),
5648    }
5649}
5650
5651/// Transform all points in a path through a matrix.
5652fn transform_path_by_matrix(path: &PsPath, m: &Matrix) -> PsPath {
5653    use stet_fonts::geometry::PathSegment;
5654    let mut out = PsPath::new();
5655    for seg in &path.segments {
5656        out.segments.push(match *seg {
5657            PathSegment::MoveTo(x, y) => {
5658                let (nx, ny) = m.transform_point(x, y);
5659                PathSegment::MoveTo(nx, ny)
5660            }
5661            PathSegment::LineTo(x, y) => {
5662                let (nx, ny) = m.transform_point(x, y);
5663                PathSegment::LineTo(nx, ny)
5664            }
5665            PathSegment::CurveTo {
5666                x1,
5667                y1,
5668                x2,
5669                y2,
5670                x3,
5671                y3,
5672            } => {
5673                let (nx1, ny1) = m.transform_point(x1, y1);
5674                let (nx2, ny2) = m.transform_point(x2, y2);
5675                let (nx3, ny3) = m.transform_point(x3, y3);
5676                PathSegment::CurveTo {
5677                    x1: nx1,
5678                    y1: ny1,
5679                    x2: nx2,
5680                    y2: ny2,
5681                    x3: nx3,
5682                    y3: ny3,
5683                }
5684            }
5685            PathSegment::ClosePath => PathSegment::ClosePath,
5686        });
5687    }
5688    out
5689}
5690
5691/// Render a tiled pattern fill.
5692/// Bilinear downscale of premultiplied RGBA image data.
5693///
5694/// Used to pre-scale pattern tile images when the device-space tile is smaller
5695/// than the image resolution, since tiny-skia's `draw_pixmap` doesn't handle
5696/// sub-1.0 scale transforms.
5697fn bilinear_prescale(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
5698    let mut dst = vec![0u8; (dw * dh * 4) as usize];
5699    for dy in 0..dh {
5700        let sy_f = (dy as f64 + 0.5) * sh as f64 / dh as f64 - 0.5;
5701        let sy0 = sy_f.floor().max(0.0) as u32;
5702        let sy1 = (sy0 + 1).min(sh - 1);
5703        let fy = (sy_f - sy0 as f64) as f32;
5704        let ify = 1.0 - fy;
5705        for dx in 0..dw {
5706            let sx_f = (dx as f64 + 0.5) * sw as f64 / dw as f64 - 0.5;
5707            let sx0 = sx_f.floor().max(0.0) as u32;
5708            let sx1 = (sx0 + 1).min(sw - 1);
5709            let fx = (sx_f - sx0 as f64) as f32;
5710            let ifx = 1.0 - fx;
5711
5712            let i00 = (sy0 * sw + sx0) as usize * 4;
5713            let i10 = (sy0 * sw + sx1) as usize * 4;
5714            let i01 = (sy1 * sw + sx0) as usize * 4;
5715            let i11 = (sy1 * sw + sx1) as usize * 4;
5716            let di = (dy * dw + dx) as usize * 4;
5717            for c in 0..4 {
5718                dst[di + c] = (src[i00 + c] as f32 * ifx * ify
5719                    + src[i10 + c] as f32 * fx * ify
5720                    + src[i01 + c] as f32 * ifx * fy
5721                    + src[i11 + c] as f32 * fx * fy)
5722                    .round() as u8;
5723            }
5724        }
5725    }
5726    dst
5727}
5728
5729fn render_pattern_fill(
5730    pixmap: &mut Pixmap,
5731    band_state: &mut BandState,
5732    params: &stet_graphics::device::PatternFillParams,
5733    ctx: &RenderContext<'_>,
5734) {
5735    let mut temp_mask = None;
5736    let Some(mask_ref) = resolve_clip_mask(
5737        &band_state.clip_region,
5738        &mut temp_mask,
5739        ctx.out_w,
5740        ctx.out_h,
5741    ) else {
5742        return;
5743    };
5744
5745    let pm = &params.pattern_matrix;
5746
5747    // Tile step vectors in device space (handles rotation/shear)
5748    let (step_ux, step_uy) = pm.transform_delta(params.xstep, 0.0);
5749    let (step_vx, step_vy) = pm.transform_delta(0.0, params.ystep);
5750
5751    let step_u_len = (step_ux * step_ux + step_uy * step_uy).sqrt();
5752    let step_v_len = (step_vx * step_vx + step_vy * step_vy).sqrt();
5753    if step_u_len < 0.01 || step_v_len < 0.01 {
5754        return;
5755    }
5756
5757    let origin_x = pm.tx;
5758    let origin_y = pm.ty;
5759
5760    // Viewport bounds in device space
5761    let dev_vp_x = ctx.vp_x as f64;
5762    let dev_vp_y = ctx.vp_y as f64;
5763    let dev_vp_w = ctx.out_w as f64 / ctx.scale_x as f64;
5764    let dev_vp_h = ctx.out_h as f64 / ctx.scale_y as f64;
5765
5766    let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
5767    for seg in &params.path.segments {
5768        let (x, y) = match seg {
5769            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => (*x, *y),
5770            PathSegment::CurveTo { x3, y3, .. } => (*x3, *y3),
5771            PathSegment::ClosePath => continue,
5772        };
5773        min_x = min_x.min(x);
5774        min_y = min_y.min(y);
5775        max_x = max_x.max(x);
5776        max_y = max_y.max(y);
5777    }
5778
5779    // For stroke patterns, the path extends beyond the centerline by half
5780    // the stroke width.  The path is in user space; transform the bbox
5781    // corners through the CTM to get device-space bounds.
5782    if let Some(ref sp) = params.stroke_params {
5783        // Transform user-space bbox corners through CTM to device space
5784        let ctm = &sp.ctm;
5785        let corners = [
5786            ctm.transform_point(min_x, min_y),
5787            ctm.transform_point(max_x, min_y),
5788            ctm.transform_point(min_x, max_y),
5789            ctm.transform_point(max_x, max_y),
5790        ];
5791        min_x = f64::MAX;
5792        min_y = f64::MAX;
5793        max_x = f64::MIN;
5794        max_y = f64::MIN;
5795        for (cx, cy) in &corners {
5796            min_x = min_x.min(*cx);
5797            min_y = min_y.min(*cy);
5798            max_x = max_x.max(*cx);
5799            max_y = max_y.max(*cy);
5800        }
5801        // Expand by half stroke width in device space
5802        let half_w = sp.line_width
5803            * 0.5
5804            * (ctm.a * ctm.a + ctm.b * ctm.b)
5805                .sqrt()
5806                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
5807        min_x -= half_w;
5808        min_y -= half_w;
5809        max_x += half_w;
5810        max_y += half_w;
5811    }
5812
5813    // Clamp to viewport bounds in device space
5814    min_x = min_x.max(dev_vp_x);
5815    min_y = min_y.max(dev_vp_y);
5816    max_x = max_x.min(dev_vp_x + dev_vp_w);
5817    max_y = max_y.min(dev_vp_y + dev_vp_h);
5818    if min_x >= max_x || min_y >= max_y {
5819        return;
5820    }
5821
5822    let det = step_ux * step_vy - step_uy * step_vx;
5823    if det.abs() < 1e-10 {
5824        return;
5825    }
5826    let inv_det = 1.0 / det;
5827
5828    let mut tu_min = f64::MAX;
5829    let mut tu_max = f64::MIN;
5830    let mut tv_min = f64::MAX;
5831    let mut tv_max = f64::MIN;
5832    for &(cx, cy) in &[
5833        (min_x, min_y),
5834        (max_x, min_y),
5835        (min_x, max_y),
5836        (max_x, max_y),
5837    ] {
5838        let dx = cx - origin_x;
5839        let dy = cy - origin_y;
5840        let tu = (dx * step_vy - dy * step_vx) * inv_det;
5841        let tv = (-dx * step_uy + dy * step_ux) * inv_det;
5842        tu_min = tu_min.min(tu);
5843        tu_max = tu_max.max(tu);
5844        tv_min = tv_min.min(tv);
5845        tv_max = tv_max.max(tv);
5846    }
5847
5848    let tile_x_start = tu_min.floor() as i32 - 1;
5849    let tile_x_end = tu_max.ceil() as i32 + 1;
5850    let tile_y_start = tv_min.floor() as i32 - 1;
5851    let tile_y_end = tv_max.ceil() as i32 + 1;
5852
5853    let tile_count = (tile_x_end - tile_x_start) as i64 * (tile_y_end - tile_y_start) as i64;
5854    if tile_count > 10000 {
5855        return;
5856    }
5857
5858    let Some(mut tile_buf) = Pixmap::new(ctx.out_w, ctx.out_h) else {
5859        return;
5860    };
5861
5862    let sx_f = ctx.scale_x as f64;
5863    let sy_f = ctx.scale_y as f64;
5864
5865    if params.device_space_tile {
5866        // Device-space tile path: tile elements have CTMs in device space
5867        // (pattern matrix baked in). Use the full render_element pipeline
5868        // which handles all element types (clips, soft masks, shadings,
5869        // groups). For each tile position, shift the viewport origin by the
5870        // tile offset in device space.
5871        for tv in tile_y_start..tile_y_end {
5872            for tu in tile_x_start..tile_x_end {
5873                let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
5874                let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
5875
5876                let tile_ctx = RenderContext {
5877                    vp_x: ctx.vp_x - offset_x as f32,
5878                    vp_y: ctx.vp_y - offset_y as f32,
5879                    scale_x: ctx.scale_x,
5880                    scale_y: ctx.scale_y,
5881                    out_w: ctx.out_w,
5882                    out_h: ctx.out_h,
5883                    effective_dpi: ctx.effective_dpi,
5884                    icc: ctx.icc,
5885                    image_cache: None,
5886                    preprocessed: None,
5887                    elem_idx: 0,
5888                    no_aa: ctx.no_aa,
5889                    opm_zero_transparent: params.overprint_mode == 1,
5890                    knockout_painter_pass: ctx.knockout_painter_pass,
5891                    parent_group_isolated: ctx.parent_group_isolated,
5892                    alpha_extraction_pass: ctx.alpha_extraction_pass,
5893                    layer_set: ctx.layer_set,
5894                };
5895
5896                let mut tile_band = BandState {
5897                    clip_region: None,
5898                    spare_mask: None,
5899                    clip_mask_cache: HashMap::new(),
5900                    clip_mask_seen: HashSet::new(),
5901                    mask_pool: Vec::new(),
5902                    cmyk_buffer: None,
5903                    op_bg_snapshot: None,
5904                    op_touched: None,
5905                    spot_mask: None,
5906                };
5907
5908                for (idx, elem) in params.tile.elements().iter().enumerate() {
5909                    let elem_ctx = RenderContext {
5910                        elem_idx: idx,
5911                        ..tile_ctx
5912                    };
5913                    render_element(&mut tile_buf, &mut tile_band, elem, &elem_ctx);
5914                }
5915            }
5916        }
5917    } else if params.tile.elements().iter().any(|e| {
5918        !matches!(
5919            e,
5920            DisplayElement::Fill { .. }
5921                | DisplayElement::Stroke { .. }
5922                | DisplayElement::Image { .. }
5923                | DisplayElement::Clip { .. }
5924                | DisplayElement::InitClip
5925        )
5926    }) {
5927        // Complex tile path: pre-render one tile into a small pixmap using
5928        // the full render_element pipeline (handles shadings, groups,
5929        // soft masks, etc.), then stamp copies at each tile position.
5930        let bbox = &params.bbox;
5931        let corners_dev = [
5932            pm.transform_point(bbox[0], bbox[1]),
5933            pm.transform_point(bbox[2], bbox[1]),
5934            pm.transform_point(bbox[0], bbox[3]),
5935            pm.transform_point(bbox[2], bbox[3]),
5936        ];
5937        let (mut td_x0, mut td_y0) = (f64::MAX, f64::MAX);
5938        let (mut td_x1, mut td_y1) = (f64::MIN, f64::MIN);
5939        for (x, y) in &corners_dev {
5940            td_x0 = td_x0.min(*x);
5941            td_y0 = td_y0.min(*y);
5942            td_x1 = td_x1.max(*x);
5943            td_y1 = td_y1.max(*y);
5944        }
5945        let tile_pw = ((td_x1 - td_x0) * sx_f).ceil().max(1.0) as u32;
5946        let tile_ph = ((td_y1 - td_y0) * sy_f).ceil().max(1.0) as u32;
5947        let tile_pw = tile_pw.min(8192);
5948        let tile_ph = tile_ph.min(8192);
5949
5950        if let Some(mut one_tile) = Pixmap::new(tile_pw, tile_ph) {
5951            let tile_render_ctx = RenderContext {
5952                vp_x: td_x0 as f32,
5953                vp_y: td_y0 as f32,
5954                scale_x: ctx.scale_x,
5955                scale_y: ctx.scale_y,
5956                out_w: tile_pw,
5957                out_h: tile_ph,
5958                effective_dpi: ctx.effective_dpi,
5959                icc: ctx.icc,
5960                image_cache: None,
5961                preprocessed: None,
5962                elem_idx: 0,
5963                no_aa: ctx.no_aa,
5964                opm_zero_transparent: params.overprint_mode == 1,
5965                knockout_painter_pass: ctx.knockout_painter_pass,
5966                parent_group_isolated: ctx.parent_group_isolated,
5967                alpha_extraction_pass: ctx.alpha_extraction_pass,
5968                layer_set: ctx.layer_set,
5969            };
5970            let mut tile_bs = BandState {
5971                clip_region: None,
5972                spare_mask: None,
5973                clip_mask_cache: HashMap::new(),
5974                clip_mask_seen: HashSet::new(),
5975                mask_pool: Vec::new(),
5976                cmyk_buffer: None,
5977                op_bg_snapshot: None,
5978                op_touched: None,
5979                spot_mask: None,
5980            };
5981            for (idx, elem) in params.tile.elements().iter().enumerate() {
5982                let transformed = transform_element_ctm(elem, pm);
5983                let elem_ctx = RenderContext {
5984                    elem_idx: idx,
5985                    ..tile_render_ctx
5986                };
5987                render_element(&mut one_tile, &mut tile_bs, &transformed, &elem_ctx);
5988            }
5989            // Stamp pre-rendered tile at each position
5990            for tv in tile_y_start..tile_y_end {
5991                for tu in tile_x_start..tile_x_end {
5992                    let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
5993                    let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
5994                    let px = ((td_x0 + offset_x - dev_vp_x) * sx_f) as i32;
5995                    let py = ((td_y0 + offset_y - dev_vp_y) * sy_f) as i32;
5996                    let paint = stet_tiny_skia::PixmapPaint {
5997                        opacity: 1.0,
5998                        blend_mode: BlendMode::SourceOver,
5999                        quality: stet_tiny_skia::FilterQuality::Nearest,
6000                    };
6001                    tile_buf.draw_pixmap(
6002                        px,
6003                        py,
6004                        one_tile.as_ref(),
6005                        &paint,
6006                        Transform::identity(),
6007                        None,
6008                    );
6009                }
6010            }
6011        }
6012    } else {
6013        // Simple tile path: tile elements have identity CTMs.
6014        // Manually apply the pattern matrix + tile offset for each element.
6015        // Only handles Fill, Stroke, Image, and Clip.
6016
6017        // Pre-process Image elements: convert to RGBA once and pre-scale if
6018        // the combined transform would require downscaling (scale < 1.0).
6019        // tiny-skia's draw_pixmap doesn't handle sub-1.0 scale transforms.
6020        struct PreprocessedImage {
6021            rgba: Vec<u8>,
6022            width: u32,
6023            height: u32,
6024            /// Transform from pixel coords to pattern space, possibly adjusted
6025            /// to account for pre-scaling.
6026            img_transform: Transform,
6027        }
6028        let tile_elements = params.tile.elements();
6029        let mut preprocessed: Vec<Option<PreprocessedImage>> =
6030            Vec::with_capacity(tile_elements.len());
6031        // Tile transform scale components (constant across all tiles)
6032        let tt_sx = (pm.a * sx_f) as f32;
6033        let tt_sy = (pm.d * sy_f) as f32;
6034        let tt_kx = (pm.c * sx_f) as f32;
6035        let tt_ky = (pm.b * sy_f) as f32;
6036        for elem in tile_elements {
6037            if let DisplayElement::Image {
6038                sample_data,
6039                params: ip,
6040            } = elem
6041            {
6042                let iw = ip.width;
6043                let ih = ip.height;
6044                if iw > 0 && ih > 0 {
6045                    let mut rgba =
6046                        samples_to_rgba(sample_data, ip, ctx.icc, ctx.opm_zero_transparent);
6047                    if ip.mask_color.is_some() {
6048                        apply_mask_color_rgba(&mut rgba, sample_data, ip);
6049                    }
6050                    let expected = (iw * ih * 4) as usize;
6051                    if rgba.len() >= expected {
6052                        if let Some(inv) = ip.image_matrix.invert() {
6053                            let combined_mat = ip.ctm.concat(&inv);
6054                            let t = to_transform(&combined_mat);
6055                            // Check effective scale: t maps image pixels → pattern space,
6056                            // tile_transform maps pattern space → device space.
6057                            let test = t.post_concat(Transform::from_row(
6058                                tt_sx, tt_ky, tt_kx, tt_sy, 0.0, 0.0,
6059                            ));
6060                            let eff_sx = (test.sx * test.sx + test.ky * test.ky).sqrt();
6061                            let eff_sy = (test.kx * test.kx + test.sy * test.sy).sqrt();
6062                            if eff_sx < 0.99 || eff_sy < 0.99 {
6063                                // Pre-scale image to avoid sub-1.0 draw_pixmap transform.
6064                                // Use floor so the scaled image is smaller than the
6065                                // device-space tile, ensuring the adjusted scale >= 1.0.
6066                                let tw = (iw as f32 * eff_sx).floor().max(1.0) as u32;
6067                                let th = (ih as f32 * eff_sy).floor().max(1.0) as u32;
6068                                let scaled = bilinear_prescale(&rgba, iw, ih, tw, th);
6069                                // Adjust transform: pre-multiply a scale that maps new
6070                                // pixel coords back to original pixel coords
6071                                let adj = Transform::from_scale(
6072                                    iw as f32 / tw as f32,
6073                                    ih as f32 / th as f32,
6074                                );
6075                                preprocessed.push(Some(PreprocessedImage {
6076                                    rgba: scaled,
6077                                    width: tw,
6078                                    height: th,
6079                                    img_transform: t.pre_concat(adj),
6080                                }));
6081                            } else {
6082                                preprocessed.push(Some(PreprocessedImage {
6083                                    rgba,
6084                                    width: iw,
6085                                    height: ih,
6086                                    img_transform: t,
6087                                }));
6088                            }
6089                        } else {
6090                            preprocessed.push(None);
6091                        }
6092                    } else {
6093                        preprocessed.push(None);
6094                    }
6095                } else {
6096                    preprocessed.push(None);
6097                }
6098                // Note: only Image elements push to preprocessed, so img_idx
6099                // in the tile loop correctly indexes this array.
6100            }
6101        }
6102
6103        for tv in tile_y_start..tile_y_end {
6104            for tu in tile_x_start..tile_x_end {
6105                let pat_offset_x = tu as f64 * params.xstep;
6106                let pat_offset_y = tv as f64 * params.ystep;
6107
6108                let tile_transform = Transform::from_row(
6109                    tt_sx,
6110                    tt_ky,
6111                    tt_kx,
6112                    tt_sy,
6113                    ((pm.a * pat_offset_x + pm.c * pat_offset_y + pm.tx - dev_vp_x) * sx_f) as f32,
6114                    ((pm.b * pat_offset_x + pm.d * pat_offset_y + pm.ty - dev_vp_y) * sy_f) as f32,
6115                );
6116
6117                // Clip tile elements to BBox (PDF spec 8.7.4.2)
6118                let bbox_clip = {
6119                    let bb = &params.bbox;
6120                    let mut bp = stet_tiny_skia::PathBuilder::new();
6121                    bp.move_to(bb[0] as f32, bb[1] as f32);
6122                    bp.line_to(bb[2] as f32, bb[1] as f32);
6123                    bp.line_to(bb[2] as f32, bb[3] as f32);
6124                    bp.line_to(bb[0] as f32, bb[3] as f32);
6125                    bp.close();
6126                    bp.finish().and_then(|sp| {
6127                        let mut m = Mask::new(ctx.out_w, ctx.out_h)?;
6128                        m.fill_path(
6129                            &sp,
6130                            stet_tiny_skia::FillRule::Winding,
6131                            false,
6132                            tile_transform,
6133                        );
6134                        Some(m)
6135                    })
6136                };
6137                let mut tile_clip: Option<Mask> = bbox_clip;
6138                let mut img_idx = 0usize;
6139                for elem in tile_elements {
6140                    let clip_ref = tile_clip.as_ref();
6141                    match elem {
6142                        DisplayElement::Clip { path, params: cp } => {
6143                            if let Some(sp) = build_skia_path(path) {
6144                                let t = to_transform(&cp.ctm);
6145                                let combined = t.post_concat(tile_transform);
6146                                let mut mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6147                                mask.fill_path(&sp, to_fill_rule(&cp.fill_rule), false, combined);
6148                                if let Some(prev) = tile_clip.take() {
6149                                    intersect_masks(&mut mask, &prev);
6150                                }
6151                                tile_clip = Some(mask);
6152                            }
6153                        }
6154                        DisplayElement::InitClip => {
6155                            tile_clip = None;
6156                        }
6157                        DisplayElement::Fill { path, params: fp } => {
6158                            if let Some(sp) = build_skia_path(path) {
6159                                let mut paint = if params.paint_type == 1 {
6160                                    to_paint(&fp.color)
6161                                } else {
6162                                    to_paint(
6163                                        params
6164                                            .underlying_color
6165                                            .as_ref()
6166                                            .unwrap_or(&DeviceColor::black()),
6167                                    )
6168                                };
6169                                paint.anti_alias = false;
6170                                let t = to_transform(&fp.ctm);
6171                                let combined = t.post_concat(tile_transform);
6172                                let fr = to_fill_rule(&fp.fill_rule);
6173                                tile_buf.fill_path(&sp, &paint, fr, combined, clip_ref);
6174                            }
6175                        }
6176                        DisplayElement::Stroke { path, params: sp } => {
6177                            if let Some(skp) = build_skia_path(path) {
6178                                // Compose element CTM with pattern matrix so
6179                                // hairline_min_width sees the real device scale,
6180                                // not the tile's identity CTM.
6181                                let effective_ctm = pm.concat(&sp.ctm);
6182                                let mut sp_adj = sp.clone();
6183                                sp_adj.ctm = effective_ctm;
6184                                let stroke = build_stroke(&sp_adj, ctx.effective_dpi);
6185                                let paint = if params.paint_type == 1 {
6186                                    to_paint(&sp.color)
6187                                } else {
6188                                    to_paint(
6189                                        params
6190                                            .underlying_color
6191                                            .as_ref()
6192                                            .unwrap_or(&DeviceColor::black()),
6193                                    )
6194                                };
6195                                let t = to_transform(&sp.ctm);
6196                                let combined = t.post_concat(tile_transform);
6197                                tile_buf.stroke_path(&skp, &paint, &stroke, combined, clip_ref);
6198                            }
6199                        }
6200                        DisplayElement::Image { .. } => {
6201                            if let Some(ref pi) = preprocessed[img_idx] {
6202                                let combined = pi.img_transform.post_concat(tile_transform);
6203                                if let Some(img_ref) = stet_tiny_skia::PixmapRef::from_bytes(
6204                                    &pi.rgba, pi.width, pi.height,
6205                                ) {
6206                                    let paint = stet_tiny_skia::PixmapPaint {
6207                                        opacity: 1.0,
6208                                        blend_mode: BlendMode::SourceOver,
6209                                        quality: stet_tiny_skia::FilterQuality::Nearest,
6210                                    };
6211                                    tile_buf.draw_pixmap(0, 0, img_ref, &paint, combined, clip_ref);
6212                                }
6213                            }
6214                            img_idx += 1;
6215                        }
6216                        _ => {}
6217                    }
6218                }
6219            }
6220        }
6221    }
6222
6223    // Composite tile_buf onto main pixmap through the fill/stroke path
6224    let Some(fill_skia_path) = build_skia_path(&params.path) else {
6225        return;
6226    };
6227    let fill_rule = to_fill_rule(&params.fill_rule);
6228    let mut fill_mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6229    let path_transform = viewport_transform(
6230        Transform::identity(),
6231        ctx.vp_x,
6232        ctx.vp_y,
6233        ctx.scale_x,
6234        ctx.scale_y,
6235    );
6236    if let Some(ref sp) = params.stroke_params {
6237        // Stroke pattern: expand the centerline path to a fill outline
6238        // using the stroke parameters (width, cap, join, miter, dash).
6239        // Apply dash pattern first (Path::stroke doesn't handle dashing).
6240        let stroke = build_stroke(sp, ctx.effective_dpi);
6241        let ctm_transform = to_transform(&sp.ctm);
6242        let combined = ctm_transform.post_concat(path_transform);
6243        let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&combined);
6244        let dashed;
6245        let stroke_path = if let Some(ref dash) = stroke.dash {
6246            dashed = fill_skia_path.dash(dash, res_scale);
6247            match dashed.as_ref() {
6248                Some(p) => p,
6249                None => &fill_skia_path,
6250            }
6251        } else {
6252            &fill_skia_path
6253        };
6254        if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6255            fill_mask.fill_path(
6256                &outline,
6257                stet_tiny_skia::FillRule::Winding,
6258                !ctx.no_aa,
6259                combined,
6260            );
6261        }
6262    } else {
6263        fill_mask.fill_path(&fill_skia_path, fill_rule, !ctx.no_aa, path_transform);
6264    }
6265
6266    if let Some(clip_mask) = mask_ref {
6267        intersect_masks(&mut fill_mask, clip_mask);
6268    }
6269
6270    let img_paint = stet_tiny_skia::PixmapPaint::default();
6271    pixmap.draw_pixmap(
6272        0,
6273        0,
6274        tile_buf.as_ref(),
6275        &img_paint,
6276        Transform::identity(),
6277        Some(&fill_mask),
6278    );
6279}
6280
6281/// Unified clip path handling for both band and viewport rendering.
6282///
6283/// For band rendering (scale=1.0), includes rect fast-path and Y-bbox early exit.
6284/// For viewport rendering (scale!=1.0), uses the general mask path.
6285fn clip_path_unified(
6286    band_state: &mut BandState,
6287    path: &PsPath,
6288    params: &ClipParams,
6289    ctx: &RenderContext<'_>,
6290) {
6291    let is_unit_scale = ctx.scale_x == 1.0 && ctx.scale_y == 1.0;
6292
6293    // Band-mode optimizations (scale=1.0): Y-bbox early exit and rect fast-path
6294    if is_unit_scale {
6295        let y_start = ctx.vp_y as u32;
6296        let x_start = ctx.vp_x as u32;
6297
6298        // Y-bbox early exit: if clip path doesn't overlap this band, set empty clip
6299        // (only valid when CTM is identity — path coords must be in device space).
6300        // Skip when stroke_params is present: the path is in user space and
6301        // needs the stroke CTM transform, so raw Y bounds are meaningless here.
6302        if x_start == 0
6303            && params.stroke_params.is_none()
6304            && params.ctm.a == 1.0
6305            && params.ctm.d == 1.0
6306            && params.ctm.tx == 0.0
6307            && params.ctm.ty == 0.0
6308            && let Some(bbox) = path_y_bbox(path)
6309            && (bbox.y_max <= y_start as f64 || bbox.y_min >= (y_start + ctx.out_h) as f64)
6310        {
6311            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
6312                band_state.recycle_mask(mask);
6313            }
6314            band_state.clip_region = Some(ClipRegion::Rect(ClipRect {
6315                x0: 0,
6316                y0: 0,
6317                x1: 0,
6318                y1: 0,
6319            }));
6320            return;
6321        }
6322
6323        // Rect fast-path (only when x_start==0 and CTM is identity —
6324        // detect_rect uses raw path coords which are only in device space
6325        // when the CTM is identity)
6326        let ctm_is_identity = params.ctm.a == 1.0
6327            && params.ctm.b == 0.0
6328            && params.ctm.c == 0.0
6329            && params.ctm.d == 1.0
6330            && params.ctm.tx == 0.0
6331            && params.ctm.ty == 0.0;
6332        if x_start == 0
6333            && ctm_is_identity
6334            && params.stroke_params.is_none()
6335            && let Some(dev_rect) = detect_rect(path, ctx.out_w, u32::MAX)
6336        {
6337            let new_rect = translate_clip_rect(&dev_rect, y_start, ctx.out_h);
6338            match band_state.clip_region.take() {
6339                None => {
6340                    band_state.clip_region = Some(ClipRegion::Rect(new_rect));
6341                }
6342                Some(ClipRegion::Rect(existing)) => {
6343                    band_state.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6344                }
6345                Some(ClipRegion::Mask(mut mask)) => {
6346                    intersect_mask_with_rect(&mut mask, &new_rect, ctx.out_w, ctx.out_h);
6347                    band_state.clip_region = Some(ClipRegion::Mask(mask));
6348                }
6349            }
6350            return;
6351        }
6352    }
6353
6354    // General path: non-rectangular clip with cache + mask reuse
6355    let fill_rule = to_fill_rule(&params.fill_rule);
6356    let path_hash = hash_clip_path(path, &params.fill_rule);
6357    let prev_region = band_state.clip_region.take();
6358
6359    let mut mask = band_state.take_mask(ctx.out_w, ctx.out_h);
6360
6361    let path_mask = if let Some(cached) = band_state.clip_mask_cache.get(&path_hash) {
6362        mask.data_mut().copy_from_slice(cached.data());
6363        mask
6364    } else {
6365        let Some(skia_path) = build_skia_path(path) else {
6366            band_state.recycle_mask(mask);
6367            band_state.clip_region = prev_region;
6368            return;
6369        };
6370        mask.data_mut().fill(0);
6371        if let Some(ref sp) = params.stroke_params {
6372            // Stroke-based clip: expand centerline to stroke outline.
6373            // Apply dash pattern first (Path::stroke doesn't handle dashing).
6374            let stroke = build_stroke(sp, ctx.effective_dpi);
6375            let transform = ctx.transform(&sp.ctm);
6376            let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&transform);
6377            let dashed;
6378            let stroke_path = if let Some(ref dash) = stroke.dash {
6379                dashed = skia_path.dash(dash, res_scale);
6380                match dashed.as_ref() {
6381                    Some(p) => p,
6382                    None => &skia_path,
6383                }
6384            } else {
6385                &skia_path
6386            };
6387            if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6388                mask.fill_path(
6389                    &outline,
6390                    stet_tiny_skia::FillRule::Winding,
6391                    false,
6392                    transform,
6393                );
6394            }
6395        } else {
6396            let transform = ctx.transform(&params.ctm);
6397            mask.fill_path(&skia_path, fill_rule, false, transform);
6398        }
6399        if !band_state.clip_mask_seen.insert(path_hash) {
6400            band_state.clip_mask_cache.insert(path_hash, mask.clone());
6401        }
6402        mask
6403    };
6404
6405    match prev_region {
6406        None => {
6407            band_state.clip_region = Some(ClipRegion::Mask(path_mask));
6408        }
6409        Some(ClipRegion::Rect(rect)) => {
6410            if rect.is_empty() {
6411                band_state.recycle_mask(path_mask);
6412                // Intersection with empty clip is still empty — preserve empty state.
6413                // Without this, clip_region stays None (= no clip = paint everything).
6414                band_state.clip_region = Some(ClipRegion::Rect(rect));
6415            } else {
6416                let mut mask = path_mask;
6417                intersect_mask_with_rect(&mut mask, &rect, ctx.out_w, ctx.out_h);
6418                band_state.clip_region = Some(ClipRegion::Mask(mask));
6419            }
6420        }
6421        Some(ClipRegion::Mask(mut existing)) => {
6422            intersect_masks(&mut existing, &path_mask);
6423            band_state.recycle_mask(path_mask);
6424            band_state.clip_region = Some(ClipRegion::Mask(existing));
6425        }
6426    }
6427}
6428impl OutputDevice for SkiaDevice {
6429    fn fill_path(&mut self, path: &PsPath, params: &FillParams) {
6430        self.ensure_full_pixmap();
6431        let Some(skia_path) = build_skia_path(path) else {
6432            return;
6433        };
6434        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6435        let mut temp_mask = None;
6436        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6437            return; // empty clip
6438        };
6439
6440        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6441        let transform = to_transform(&params.ctm);
6442        let fill_rule = to_fill_rule(&params.fill_rule);
6443
6444        self.pixmap
6445            .fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
6446    }
6447
6448    fn stroke_path(&mut self, path: &PsPath, params: &StrokeParams) {
6449        self.ensure_full_pixmap();
6450        let stroke = build_stroke(params, self.dpi);
6451        let adjusted;
6452        let draw_path =
6453            if params.stroke_adjust && stroke.width <= 2.0 && ctm_is_device_space(&params.ctm) {
6454                adjusted =
6455                    stroke_adjust_path_viewport(path, stroke.width as f64, 1.0, 1.0, 0.0, 0.0);
6456                &adjusted
6457            } else {
6458                path
6459            };
6460        let Some(skia_path) = build_skia_path(draw_path) else {
6461            return;
6462        };
6463        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6464        let transform = to_transform(&params.ctm);
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        self.pixmap
6473            .stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
6474    }
6475
6476    fn clip_path(&mut self, path: &PsPath, params: &ClipParams) {
6477        self.ensure_full_pixmap();
6478        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6479
6480        // Fast path: detect axis-aligned rectangle
6481        if let Some(new_rect) = detect_rect(path, w, h) {
6482            match self.clip_region.take() {
6483                None => {
6484                    self.clip_region = Some(ClipRegion::Rect(new_rect));
6485                }
6486                Some(ClipRegion::Rect(existing)) => {
6487                    // O(1) rect-rect intersection
6488                    self.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6489                }
6490                Some(ClipRegion::Mask(mut mask)) => {
6491                    // Zero mask pixels outside rect
6492                    intersect_mask_with_rect(&mut mask, &new_rect, w, h);
6493                    self.clip_region = Some(ClipRegion::Mask(mask));
6494                }
6495            }
6496            return;
6497        }
6498
6499        // Slow path: non-rectangular clip with mask caching + allocation reuse.
6500        let fill_rule = to_fill_rule(&params.fill_rule);
6501        let path_hash = hash_clip_path(path, &params.fill_rule);
6502        let prev_region = self.clip_region.take();
6503
6504        // Reuse a spare mask buffer if available (avoids alloc/dealloc per tile).
6505        macro_rules! take_spare {
6506            ($self:expr, $w:expr, $h:expr) => {
6507                $self
6508                    .spare_mask
6509                    .take()
6510                    .unwrap_or_else(|| Mask::new($w, $h).expect("Failed to create mask"))
6511            };
6512        }
6513
6514        // Try cache first; rasterize only on miss
6515        let path_mask = if let Some(cached) = self.clip_mask_cache.get(&path_hash) {
6516            // Cache hit: copy cached data into reused buffer (memcpy, no alloc)
6517            let mut mask = take_spare!(self, w, h);
6518            mask.data_mut().copy_from_slice(cached.data());
6519            mask
6520        } else {
6521            let Some(skia_path) = build_skia_path(path) else {
6522                self.clip_region = prev_region;
6523                return;
6524            };
6525            let transform = to_transform(&params.ctm);
6526            let mut mask = take_spare!(self, w, h);
6527            mask.data_mut().fill(0); // zero before rasterizing (spare may have old data)
6528            mask.fill_path(&skia_path, fill_rule, false, transform);
6529            // Cache on second sight: first time just record, second time store
6530            if !self.clip_mask_seen.insert(path_hash) {
6531                // Seen before — cache it (this clone only happens once per unique path)
6532                self.clip_mask_cache.insert(path_hash, mask.clone());
6533            }
6534            mask
6535        };
6536
6537        match prev_region {
6538            None => {
6539                self.clip_region = Some(ClipRegion::Mask(path_mask));
6540            }
6541            Some(ClipRegion::Rect(rect)) => {
6542                if rect.is_empty() {
6543                    self.spare_mask = Some(path_mask); // recycle
6544                } else {
6545                    let mut mask = path_mask;
6546                    intersect_mask_with_rect(&mut mask, &rect, w, h);
6547                    self.clip_region = Some(ClipRegion::Mask(mask));
6548                }
6549            }
6550            Some(ClipRegion::Mask(mut existing)) => {
6551                intersect_masks(&mut existing, &path_mask);
6552                self.spare_mask = Some(path_mask); // recycle the copy
6553                self.clip_region = Some(ClipRegion::Mask(existing));
6554            }
6555        }
6556    }
6557
6558    fn init_clip(&mut self) {
6559        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6560            self.spare_mask = Some(mask);
6561        }
6562        self.clip_region = None;
6563    }
6564
6565    fn erase_page(&mut self) {
6566        // Only fill the full pixmap when it's actually allocated (non-banded path).
6567        // During banding, self.pixmap is a 1×1 placeholder — filling it is harmless.
6568        self.pixmap.fill(Color::WHITE);
6569        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6570            self.spare_mask = Some(mask);
6571        }
6572        self.clip_region = None;
6573    }
6574
6575    fn show_page(&mut self, output_path: &str) -> Result<(), String> {
6576        let w = self.pixmap.width();
6577        let h = self.pixmap.height();
6578        // Composite onto white background before output
6579        composite_onto_white(self.pixmap.data_mut());
6580        let mut sink = self.sink_factory.create_sink(output_path)?;
6581        sink.begin_page(w, h)?;
6582        sink.write_rows(self.pixmap.data(), h)?;
6583        sink.end_page()
6584    }
6585
6586    fn draw_image(&mut self, sample_data: &[u8], params: &ImageParams) {
6587        self.ensure_full_pixmap();
6588        let w = params.width;
6589        let h = params.height;
6590        if w == 0 || h == 0 {
6591            return;
6592        }
6593        let mut rgba_data =
6594            samples_to_rgba(sample_data, params, self.render_icc_cache.as_ref(), false);
6595        if params.mask_color.is_some() {
6596            apply_mask_color_rgba(&mut rgba_data, sample_data, params);
6597        }
6598        let expected = (w * h * 4) as usize;
6599        if rgba_data.len() < expected {
6600            return;
6601        }
6602
6603        let Some(image_inv) = params.image_matrix.invert() else {
6604            return;
6605        };
6606        let combined = params.ctm.concat(&image_inv);
6607        let raw_transform = enforce_min_image_size(to_transform(&combined), w, h);
6608
6609        let prescaled = prescale_image(&rgba_data, w, h, raw_transform, params.interpolate);
6610        let (img_data, img_w, img_h, transform) = match &prescaled {
6611            Some((data, pw, ph, t)) => (data.as_slice(), *pw, *ph, *t),
6612            None => (rgba_data.as_slice(), w, h, raw_transform),
6613        };
6614
6615        let Some(img_pixmap) = stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h) else {
6616            return;
6617        };
6618
6619        let (pw, ph) = (self.pixmap.width(), self.pixmap.height());
6620        let mut temp_mask = None;
6621        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, pw, ph) else {
6622            return;
6623        };
6624
6625        let paint = stet_tiny_skia::PixmapPaint {
6626            quality: image_filter_quality(transform, params.interpolate),
6627            opacity: params.alpha as f32,
6628            blend_mode: u8_to_blend_mode(params.blend_mode),
6629        };
6630        self.pixmap
6631            .draw_pixmap(0, 0, img_pixmap, &paint, transform, mask_ref);
6632    }
6633
6634    fn paint_axial_shading(&mut self, params: &AxialShadingParams) {
6635        self.ensure_full_pixmap();
6636        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6637        let mut temp_mask = None;
6638        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6639            return;
6640        };
6641        render_axial_shading(
6642            &mut self.pixmap,
6643            params,
6644            0.0,
6645            0.0,
6646            1.0,
6647            1.0,
6648            mask_ref,
6649            self.no_aa,
6650            None,
6651            None,
6652        );
6653    }
6654
6655    fn paint_radial_shading(&mut self, params: &RadialShadingParams) {
6656        self.ensure_full_pixmap();
6657        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6658        let mut temp_mask = None;
6659        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6660            return;
6661        };
6662        render_radial_shading(
6663            &mut self.pixmap,
6664            params,
6665            0.0,
6666            0.0,
6667            1.0,
6668            1.0,
6669            mask_ref,
6670            self.no_aa,
6671            None,
6672            None,
6673        );
6674    }
6675
6676    fn paint_mesh_shading(&mut self, params: &MeshShadingParams) {
6677        self.ensure_full_pixmap();
6678        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6679        let mut temp_mask = None;
6680        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6681            return;
6682        };
6683        render_mesh_shading(
6684            &mut self.pixmap,
6685            params,
6686            0.0,
6687            0.0,
6688            1.0,
6689            1.0,
6690            mask_ref,
6691            None,
6692            None,
6693        );
6694    }
6695
6696    fn paint_patch_shading(&mut self, params: &PatchShadingParams) {
6697        self.ensure_full_pixmap();
6698        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6699        let mut temp_mask = None;
6700        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6701            return;
6702        };
6703        render_patch_shading(
6704            &mut self.pixmap,
6705            params,
6706            0.0,
6707            0.0,
6708            1.0,
6709            1.0,
6710            mask_ref,
6711            None,
6712            None,
6713        );
6714    }
6715
6716    fn paint_pattern_fill(&mut self, params: &stet_graphics::device::PatternFillParams) {
6717        self.ensure_full_pixmap();
6718        let w = self.pixmap.width();
6719        let h = self.pixmap.height();
6720        let mut band_state = BandState {
6721            clip_region: self.clip_region.take(),
6722            spare_mask: self.spare_mask.take(),
6723            clip_mask_cache: HashMap::new(),
6724            clip_mask_seen: HashSet::new(),
6725            mask_pool: Vec::new(),
6726            cmyk_buffer: None,
6727            op_bg_snapshot: None,
6728            op_touched: None,
6729            spot_mask: None,
6730        };
6731        {
6732            let ctx = RenderContext {
6733                vp_x: 0.0,
6734                vp_y: 0.0,
6735                scale_x: 1.0,
6736                scale_y: 1.0,
6737                out_w: w,
6738                out_h: h,
6739                effective_dpi: self.dpi,
6740                icc: None,
6741                image_cache: None,
6742                preprocessed: None,
6743                elem_idx: 0,
6744                no_aa: self.no_aa,
6745                opm_zero_transparent: false,
6746                knockout_painter_pass: KnockoutPainterPass::None,
6747                parent_group_isolated: false,
6748                alpha_extraction_pass: false,
6749                layer_set: &self.layer_set,
6750            };
6751            render_pattern_fill(&mut self.pixmap, &mut band_state, params, &ctx);
6752        }
6753        self.clip_region = band_state.clip_region.take();
6754        if let Some(mask) = band_state.spare_mask.take() {
6755            self.spare_mask = Some(mask);
6756        }
6757    }
6758
6759    fn page_size(&self) -> (u32, u32) {
6760        (self.page_w, self.page_h)
6761    }
6762
6763    fn replay_and_show(&mut self, list: DisplayList, output_path: &str) -> Result<(), String> {
6764        // Wait for any previous background render to complete
6765        self.join_pending()?;
6766
6767        let (page_w, page_h) = self.page_size();
6768
6769        // Audit mode: re-render through the viewport pipeline so visual tests
6770        // can catch viewport-only bugs against the same baselines. Same
6771        // `render_element`, same display list — differs only in how culling
6772        // and epochs are computed.
6773        if self.use_viewport_path {
6774            let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref(), false);
6775            let rgba = render_to_rgba_viewport(
6776                &list,
6777                page_w,
6778                page_h,
6779                self.dpi,
6780                Some(&icc_cache),
6781                self.no_aa,
6782            );
6783            let mut sink = self.sink_factory.create_sink(output_path)?;
6784            sink.begin_page(page_w, page_h)?;
6785            sink.write_rows(&rgba, page_h)?;
6786            sink.end_page()?;
6787            return Ok(());
6788        }
6789
6790        let band_h = select_band_height(page_w, page_h);
6791
6792        // Build ICC cache for this page's display list
6793        let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref(), false);
6794
6795        // If banding not worthwhile, render the full page as a single band.
6796        // This still uses render_element (same as banded path) so that Group
6797        // and SoftMasked elements get proper offscreen compositing.
6798        if band_h >= page_h {
6799            self.ensure_full_pixmap();
6800            let ctx = RenderContext {
6801                vp_x: 0.0,
6802                vp_y: 0.0,
6803                scale_x: 1.0,
6804                scale_y: 1.0,
6805                out_w: page_w,
6806                out_h: page_h,
6807                effective_dpi: self.dpi,
6808                icc: Some(&icc_cache),
6809                image_cache: None,
6810                preprocessed: None,
6811                elem_idx: 0,
6812                no_aa: self.no_aa,
6813                opm_zero_transparent: false,
6814                knockout_painter_pass: KnockoutPainterPass::None,
6815                parent_group_isolated: false,
6816                alpha_extraction_pass: false,
6817                layer_set: &self.layer_set,
6818            };
6819            let mut band_state = BandState {
6820                clip_region: None,
6821                spare_mask: None,
6822                clip_mask_cache: HashMap::new(),
6823                clip_mask_seen: HashSet::new(),
6824                mask_pool: Vec::new(),
6825                cmyk_buffer: None,
6826                op_bg_snapshot: None,
6827                op_touched: None,
6828                spot_mask: None,
6829            };
6830            for (idx, elem) in list.elements().iter().enumerate() {
6831                let elem_ctx = RenderContext {
6832                    elem_idx: idx,
6833                    ..ctx
6834                };
6835                render_element(&mut self.pixmap, &mut band_state, elem, &elem_ctx);
6836            }
6837            return self.show_page(output_path);
6838        }
6839
6840        // Banded path: shrink self.pixmap to free memory — we use a
6841        // band-sized pixmap instead. This avoids holding a multi-GB
6842        // full-page buffer during rendering.
6843        if self.pixmap.width() > 1 {
6844            self.pixmap = Pixmap::new(1, 1).expect("Failed to create placeholder pixmap");
6845        }
6846
6847        // Create the sink for this page before spawning background work
6848        let mut sink = self.sink_factory.create_sink(output_path)?;
6849        let dpi = self.dpi;
6850        let layer_set = self.layer_set.clone();
6851
6852        #[cfg(feature = "parallel")]
6853        {
6854            // Spawn banded rendering on rayon's thread pool, overlapping with
6855            // interpretation of the next page. Using rayon::spawn avoids OS thread
6856            // creation overhead and keeps work on the warmed-up pool.
6857            let no_aa = self.no_aa;
6858            let (tx, rx) = std::sync::mpsc::sync_channel(1);
6859            rayon::spawn(move || {
6860                let result = render_banded_to_sink(
6861                    page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, no_aa, &layer_set,
6862                );
6863                let _ = tx.send(result);
6864            });
6865            self.pending_render = Some(rx);
6866        }
6867        #[cfg(not(feature = "parallel"))]
6868        {
6869            render_banded_to_sink(
6870                page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, self.no_aa, &layer_set,
6871            )?;
6872        }
6873
6874        Ok(())
6875    }
6876
6877    fn finish(&mut self) -> Result<(), String> {
6878        self.join_pending()
6879    }
6880}
6881
6882impl Drop for SkiaDevice {
6883    fn drop(&mut self) {
6884        // Safety net: ensure background render completes before device is destroyed.
6885        if let Some(rx) = self.pending_render.take() {
6886            let _ = rx.recv();
6887        }
6888    }
6889}
6890
6891impl SkiaDevice {
6892    /// Wait for the pending background render to complete, if any.
6893    fn join_pending(&mut self) -> Result<(), String> {
6894        if let Some(rx) = self.pending_render.take() {
6895            match rx.recv() {
6896                Ok(result) => result?,
6897                Err(_) => return Err("Background render task failed".to_string()),
6898            }
6899        }
6900        Ok(())
6901    }
6902}
6903
6904/// Returns true if any descendant transparency group declares an explicit
6905/// `/CS DeviceCMYK`. The renderer uses this to decide whether to allocate a
6906/// parallel CMYK buffer for the band/page so that compositing inside CMYK
6907/// groups can read the exact backdrop CMYK rather than rounding-trip via sRGB.
6908fn has_cmyk_group(list: &DisplayList) -> bool {
6909    use stet_graphics::display_list::GroupColorSpace;
6910    for elem in list.elements() {
6911        match elem {
6912            DisplayElement::Group { elements, params } => {
6913                if params.color_space == GroupColorSpace::DeviceCMYK {
6914                    return true;
6915                }
6916                if has_cmyk_group(elements) {
6917                    return true;
6918                }
6919            }
6920            DisplayElement::SoftMasked { content, mask, .. } => {
6921                if has_cmyk_group(content) || has_cmyk_group(mask) {
6922                    return true;
6923                }
6924            }
6925            DisplayElement::OcgGroup { elements, .. } => {
6926                if has_cmyk_group(elements) {
6927                    return true;
6928                }
6929            }
6930            _ => {}
6931        }
6932    }
6933    false
6934}
6935
6936/// Returns true if every visible element in `elements` is a `Fill` whose
6937/// color carries `native_cmyk`. Clip and `InitClip` ops are skipped (they
6938/// don't paint). Returns `false` for any other shape (shadings, images,
6939/// patterns, nested groups, etc.) where the inner CMYK buffer would be
6940/// derived from sRGB via the lossy `interpolate_cmyk_from_stops` /
6941/// `(1-r,1-g,1-b,0)` inverse rather than tracked from the source CMYK.
6942fn group_only_native_cmyk_fills(elements: &DisplayList) -> bool {
6943    let mut found_paint = false;
6944    for elem in elements.elements() {
6945        match elem {
6946            DisplayElement::InitClip => continue,
6947            DisplayElement::Clip { .. } => continue,
6948            DisplayElement::Fill { params, .. } => {
6949                if params.color.native_cmyk.is_none() {
6950                    return false;
6951                }
6952                found_paint = true;
6953            }
6954            DisplayElement::Stroke { params, .. } => {
6955                // Strokes write a single CMYK value per painted pixel just
6956                // like fills, so the parallel CMYK buffer stays in sync with
6957                // the pixmap. Including strokes here is required by GWG 16.1
6958                // painters whose X path is both filled and stroked with the
6959                // same registration color.
6960                if params.color.native_cmyk.is_none() {
6961                    return false;
6962                }
6963                found_paint = true;
6964            }
6965            _ => return false,
6966        }
6967    }
6968    found_paint
6969}
6970
6971/// Stronger predicate: returns `true` when every paint operation in `elements`
6972/// supplies its color directly as CMYK with one CMYK value per painted pixel
6973/// — i.e. the parallel CMYK buffer is *guaranteed* to match the rendered
6974/// pixmap on a per-pixel basis. When this holds, the per-pixel CMYK
6975/// composite-back can run safely.
6976///
6977/// Importantly, this excludes **shadings** even when their declared color
6978/// space is DeviceCMYK. The pixmap rasterizer interpolates the per-stop
6979/// `.color` (RGB) linearly across the gradient via [`build_gradient_lut`],
6980/// while [`interpolate_cmyk_from_stops`] interpolates the per-stop CMYK
6981/// `raw_components` linearly. Because the system CMYK ICC profile is
6982/// non-linear, the two interpolation strategies produce different intermediate
6983/// colors at each gradient pixel — the buffer no longer represents what the
6984/// pixmap shows, and feeding that into the composite-back yields visibly
6985/// shifted colors. Until the per-pixel rasterizer is taught to interpolate
6986/// CMYK directly (or the buffer is filled by ICC-reversing the pixmap), keep
6987/// shadings on the existing sRGB compositing path.
6988///
6989/// Recurses into nested groups and soft masks. Returns `false` if the group
6990/// contains no paint operations at all (so the composite-back has no work).
6991fn group_content_is_native_cmyk(elements: &DisplayList) -> bool {
6992    let mut found_paint = false;
6993    for elem in elements.elements() {
6994        match elem {
6995            DisplayElement::InitClip => continue,
6996            DisplayElement::Clip { .. } => continue,
6997            DisplayElement::Text { .. } => continue,
6998            DisplayElement::ErasePage => continue,
6999            DisplayElement::Fill { params, .. } => {
7000                if params.color.native_cmyk.is_none() {
7001                    return false;
7002                }
7003                found_paint = true;
7004            }
7005            DisplayElement::Stroke { params, .. } => {
7006                if params.color.native_cmyk.is_none() {
7007                    return false;
7008                }
7009                found_paint = true;
7010            }
7011            DisplayElement::Image { params, .. } => {
7012                if !is_cmyk_color_space(&params.color_space) {
7013                    return false;
7014                }
7015                found_paint = true;
7016            }
7017            DisplayElement::AxialShading { .. }
7018            | DisplayElement::RadialShading { .. }
7019            | DisplayElement::MeshShading { .. }
7020            | DisplayElement::PatchShading { .. } => {
7021                // See doc comment above: shading interpolation strategies
7022                // diverge between pixmap and buffer.
7023                return false;
7024            }
7025            DisplayElement::PatternFill { .. } => {
7026                // Pattern tiles render through their own BandState with
7027                // `cmyk_buffer: None`, so the parallel CMYK buffer can't track
7028                // per-tile source CMYK. Treat patterns as non-CMYK content.
7029                return false;
7030            }
7031            DisplayElement::Group { elements: sub, .. } => {
7032                if !group_content_is_native_cmyk(sub) {
7033                    return false;
7034                }
7035                found_paint = true;
7036            }
7037            DisplayElement::SoftMasked { .. } => {
7038                // Soft masks apply a per-pixel alpha modulation that the
7039                // parallel CMYK buffer cannot represent: the buffer holds raw
7040                // source CMYK while the pixmap holds the soft-masked blend
7041                // (`backdrop * (1 − mask) + source * mask`). Running
7042                // `composite_non_isolated_cmyk` over a soft-masked region
7043                // would feed the unmodulated source CMYK into the blend
7044                // formula and produce the wrong result for any non-Normal
7045                // parent blend mode (5310.pdf phone highlight regression).
7046                // Fall back to the sRGB contribution-extraction path, which
7047                // handles soft masks correctly.
7048                return false;
7049            }
7050            DisplayElement::OcgGroup { elements: sub, .. } => {
7051                if !group_content_is_native_cmyk(sub) {
7052                    return false;
7053                }
7054                found_paint = true;
7055            }
7056            _ => return false,
7057        }
7058    }
7059    found_paint
7060}
7061
7062/// True when `list` is a flat sequence of native-CMYK Fill/Stroke paints
7063/// with Normal blend and full opacity — i.e. the cmyk_buffer's content
7064/// faithfully represents what the pixmap shows. Used by `render_soft_masked`
7065/// to decide whether to interpolate the mask blend in CMYK (ICC→sRGB).
7066/// Rejects Group/SoftMasked/Image/Shading/Pattern and any blend-mode-modulated
7067/// paint because those would diverge from the parallel CMYK snapshot.
7068fn content_list_is_simple_native_cmyk(list: &DisplayList) -> bool {
7069    let mut found_paint = false;
7070    for elem in list.elements() {
7071        match elem {
7072            DisplayElement::InitClip
7073            | DisplayElement::Clip { .. }
7074            | DisplayElement::Text { .. }
7075            | DisplayElement::ErasePage => continue,
7076            DisplayElement::Fill { params, .. } => {
7077                if params.color.native_cmyk.is_none() {
7078                    return false;
7079                }
7080                if params.blend_mode != 0 || params.alpha != 1.0 {
7081                    return false;
7082                }
7083                found_paint = true;
7084            }
7085            DisplayElement::Stroke { params, .. } => {
7086                if params.color.native_cmyk.is_none() {
7087                    return false;
7088                }
7089                if params.blend_mode != 0 || params.alpha != 1.0 {
7090                    return false;
7091                }
7092                found_paint = true;
7093            }
7094            // Recurse into a transparency Group only when the group itself is
7095            // Normal-blend / full-opacity AND its contents are themselves
7096            // simple native CMYK. This lets gradient-feather-style content
7097            // (a Group wrapping a single CMYK fill, GWG 16.11) qualify for
7098            // CMYK-domain mask blending while the prior outer-glow C
7099            // regression (a Group wrapping a Screen-blend white rect, GWG
7100            // 16.10) still gets rejected on the inner blend_mode check.
7101            DisplayElement::Group { params, elements } => {
7102                if params.blend_mode != 0 || params.alpha != 1.0 {
7103                    return false;
7104                }
7105                if !content_list_is_simple_native_cmyk(elements) {
7106                    return false;
7107                }
7108                // A Group whose contents are all clip/text without paint
7109                // adds no paint of its own; don't flip `found_paint` here —
7110                // the recursive call already counted any inner paints.
7111                if elements.elements().iter().any(|e| {
7112                    matches!(
7113                        e,
7114                        DisplayElement::Fill { .. } | DisplayElement::Stroke { .. }
7115                    )
7116                }) {
7117                    found_paint = true;
7118                }
7119            }
7120            _ => return false,
7121        }
7122    }
7123    found_paint
7124}
7125
7126/// Scan a display list for any overprint fill/stroke elements that need CMYK simulation.
7127fn has_overprint_elements(list: &DisplayList) -> bool {
7128    for elem in list.elements() {
7129        match elem {
7130            DisplayElement::Fill { params, .. } => {
7131                if params.overprint {
7132                    return true;
7133                }
7134            }
7135            DisplayElement::Stroke { params, .. } => {
7136                if params.overprint {
7137                    return true;
7138                }
7139            }
7140            DisplayElement::Image { params, .. } => {
7141                if params.overprint {
7142                    return true;
7143                }
7144            }
7145            DisplayElement::AxialShading { params } => {
7146                if params.overprint {
7147                    return true;
7148                }
7149            }
7150            DisplayElement::RadialShading { params } => {
7151                if params.overprint {
7152                    return true;
7153                }
7154            }
7155            DisplayElement::MeshShading { params } => {
7156                if params.overprint {
7157                    return true;
7158                }
7159            }
7160            DisplayElement::PatchShading { params } => {
7161                if params.overprint {
7162                    return true;
7163                }
7164            }
7165            DisplayElement::Group { elements, .. } => {
7166                if has_overprint_elements(elements) {
7167                    return true;
7168                }
7169            }
7170            DisplayElement::SoftMasked { content, mask, .. } => {
7171                if has_overprint_elements(content) || has_overprint_elements(mask) {
7172                    return true;
7173                }
7174            }
7175            DisplayElement::OcgGroup { elements, .. } => {
7176                if has_overprint_elements(elements) {
7177                    return true;
7178                }
7179            }
7180            _ => {}
7181        }
7182    }
7183    false
7184}
7185
7186/// Render an overprint fill: rasterize path to coverage mask, then composite
7187/// at the CMYK level, converting the result to RGB for the pixmap.
7188#[allow(clippy::too_many_arguments)]
7189fn render_overprint_fill(
7190    pixmap: &mut Pixmap,
7191    cmyk_buf: &mut [f32],
7192    op_bg: &mut [u8],
7193    op_touched: &mut [u8],
7194    spot_mask: &[u8],
7195    band_state: &mut BandState,
7196    path: &PsPath,
7197    params: &FillParams,
7198    vp_x: f32,
7199    vp_y: f32,
7200    scale_x: f32,
7201    scale_y: f32,
7202    out_w: u32,
7203    out_h: u32,
7204    icc: Option<&IccCache>,
7205    no_aa: bool,
7206) {
7207    let Some(skia_path) = build_skia_path(path) else {
7208        return;
7209    };
7210    let fill_rule = to_fill_rule(&params.fill_rule);
7211
7212    let mut coverage_mask = match Mask::new(out_w, out_h) {
7213        Some(m) => m,
7214        None => return,
7215    };
7216    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7217    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7218
7219    // Compute path bbox for constrained iteration
7220    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7221        path_device_bbox(&skia_path, transform, out_w, out_h);
7222
7223    // Intersect with clip mask
7224    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7225        None => None,
7226        Some(ClipRegion::Rect(r)) => {
7227            // Only zero coverage within the path bbox (not the full page)
7228            let data = coverage_mask.data_mut();
7229            let stride = out_w as usize;
7230            for y in bbox_y0..bbox_y1 {
7231                let row_start = y * stride;
7232                for x in bbox_x0..bbox_x1 {
7233                    let yu = y as u32;
7234                    let xu = x as u32;
7235                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7236                        data[row_start + x] = 0;
7237                    }
7238                }
7239            }
7240            None
7241        }
7242        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7243    };
7244
7245    // Custom spot paints (Separation/DeviceN whose named colorants don't include
7246    // any process channel) go to a separation plate, not CMYK. In the composite
7247    // preview we layer the spot's alt-CMYK onto the pixmap via multiplicative
7248    // ink stacking and leave the cmyk_buffer untouched — otherwise a later OPM 1
7249    // overprint would see the spot's alt-CMYK as "backdrop" and knock it out.
7250    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7251
7252    // Source CMYK preference: for paints with a process colorant in the mix
7253    // (Separation /Black, DeviceN [Black, …]), prefer `process_cmyk` — it
7254    // carries the named-colorant tint at full f64 precision (e.g. `(0, 0, 0,
7255    // 0.5)` for 50% /Black), matching what `update_cmyk_buffer_for_fill` writes
7256    // into the process buffer. Without this, the X paint reads native (e.g.
7257    // 0.502 from an 8-bit-quantized sampled Function) while the BG wrote
7258    // process (0.500), the per-pixel delta clears the 1e-4 no-op skip
7259    // threshold, and the X over-paints the spot backdrop with plain ICC-grey
7260    // (GWG 3.0 swatches c/i, "50% sep. black over spot").
7261    //
7262    // Custom spots (no process colorant) keep reading `native_cmyk` — that's
7263    // the spot's visual alt-CMYK representation, while `process_cmyk` is
7264    // `(0, 0, 0, 0)` for pure spots (the process buffer should not record
7265    // their tint). Falling back to native here keeps spot-coloured text
7266    // visible (1307.pdf "Business of the Meeting" in PANTONE 7427 C).
7267    let (src_c, src_m, src_y, src_k) = if !is_custom_spot && let Some(c) = params.color.process_cmyk
7268    {
7269        c
7270    } else if let Some(c) = params.color.native_cmyk {
7271        c
7272    } else {
7273        let r = params.color.r;
7274        let g = params.color.g;
7275        let b = params.color.b;
7276        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7277    };
7278
7279    let mut channels = params.painted_channels;
7280    // Non-CMYK fills (painted_channels=0, e.g. Separation spot colors, RGB, Gray)
7281    // replace all color at each pixel — update all CMYK channels to keep buffer in sync.
7282    if channels == 0 {
7283        channels = stet_graphics::device::CMYK_ALL;
7284    }
7285    // OPM 1 per-pixel zero filtering only applies to DeviceCMYK, not DeviceN/Separation
7286    if params.overprint_mode == 1
7287        && channels == stet_graphics::device::CMYK_ALL
7288        && params.is_device_cmyk
7289    {
7290        channels = 0;
7291        if src_c != 0.0 {
7292            channels |= stet_graphics::device::CMYK_C;
7293        }
7294        if src_m != 0.0 {
7295            channels |= stet_graphics::device::CMYK_M;
7296        }
7297        if src_y != 0.0 {
7298            channels |= stet_graphics::device::CMYK_Y;
7299        }
7300        if src_k != 0.0 {
7301            channels |= stet_graphics::device::CMYK_K;
7302        }
7303        // PDF 1.7 §7.6.4.5: OPM 1 with /op true preserves zero-source
7304        // components — leave `channels = 0` for an all-zero CMYK source only
7305        // when the gstate signals "strict overprint": /OPM and /op|/OP were
7306        // set together in the same ExtGState dict (as Adobe Illustrator
7307        // emits) OR /OP and /op were paired in one dict (legacy old-style
7308        // overprint, e.g. GWG 12.0 White Overprint where /GS6 sets both).
7309        // When the current /op was set standalone and OPM was merely
7310        // inherited (e.g. 2495.pdf page 5 page-icon, where /R20 has only
7311        // /op and OPM=1 came from /R11), fall back to legacy knockout so
7312        // a `0 0 0 0 k` paint still acts as a white knockout.
7313        if channels == 0 && !params.opm_paired {
7314            channels = stet_graphics::device::CMYK_ALL;
7315        }
7316    }
7317
7318    // Bulk tiny-skia fast path for the plain CMYK_ALL replace case. Skipped
7319    // only for K-only DeviceCMYK paints under OPM 0 (C=M=Y=0, any K) because
7320    // those match the Black plate of a DeviceN [Black, spot] backdrop and
7321    // need the per-pixel no-op-delta skip to preserve spot-derived colour —
7322    // the bulk fill_path here would otherwise wipe the spot. Other CMYK
7323    // overprints (teal, full-colour, etc.) stay on the fast path to avoid
7324    // AA drift vs the non-overprint rasteriser.
7325    let is_k_only_cmyk = params.is_device_cmyk
7326        && params.overprint_mode == 0
7327        && src_c == 0.0
7328        && src_m == 0.0
7329        && src_y == 0.0;
7330    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7331        let cov_data = coverage_mask.data();
7332        let stride = out_w as usize;
7333        for y in bbox_y0..bbox_y1 {
7334            for x in bbox_x0..bbox_x1 {
7335                let mi = y * stride + x;
7336                let mut cov = cov_data[mi] as f32 / 255.0;
7337                if let Some(clip) = clip_coverage {
7338                    cov *= clip[mi] as f32 / 255.0;
7339                }
7340                if cov > 0.0 {
7341                    let ci = mi * 4;
7342                    cmyk_buf[ci] = src_c as f32;
7343                    cmyk_buf[ci + 1] = src_m as f32;
7344                    cmyk_buf[ci + 2] = src_y as f32;
7345                    cmyk_buf[ci + 3] = src_k as f32;
7346                }
7347            }
7348        }
7349        let mut temp_mask = None;
7350        let Some(mask_ref) =
7351            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
7352        else {
7353            return;
7354        };
7355        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
7356        pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
7357        return;
7358    }
7359
7360    let cov_data = coverage_mask.data();
7361    let stride = out_w as usize;
7362    let px_data = pixmap.data_mut();
7363    let px_stride = out_w as usize * 4;
7364
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                continue;
7374            }
7375
7376            let ci = mi * 4;
7377            let pi = y * px_stride + x * 4;
7378            // Snapshot-based AA blending: on the first overprint touch of a
7379            // pixel that already has a backdrop (alpha > 0), capture the
7380            // pre-paint pixmap RGBA. Subsequent overprints at the same pixel
7381            // blend against the snapshot rather than the current pixmap, so
7382            // AA edges of stacked OPM-1 overprints do not leak colour from
7383            // earlier paints into later ones.
7384            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
7385                op_bg[pi] = px_data[pi];
7386                op_bg[pi + 1] = px_data[pi + 1];
7387                op_bg[pi + 2] = px_data[pi + 2];
7388                op_bg[pi + 3] = px_data[pi + 3];
7389                op_touched[mi] = 1;
7390            }
7391            let cur_c = cmyk_buf[ci] as f64;
7392            let cur_m = cmyk_buf[ci + 1] as f64;
7393            let cur_y = cmyk_buf[ci + 2] as f64;
7394            let cur_k = cmyk_buf[ci + 3] as f64;
7395            // Switch to multiplicative ink-stacking when the pixmap carries a
7396            // contribution not reflected in cmyk_buffer: either this paint is
7397            // itself a custom spot (painted_channels=0, non-CMYK) or the
7398            // process-ink state is empty while the pixmap shows colour *and*
7399            // is actually opaque — that signals a spot (or RGB) paint landed
7400            // here and the "replace" CMYK→RGB model would erase the
7401            // contribution for the channels being overwritten. Fully
7402            // transparent pixels are stored as premultiplied (0,0,0,0), so we
7403            // must require alpha>0 before trusting the RGB — otherwise fresh
7404            // paper (alpha=0) looks like "black backdrop" and multiplicative
7405            // darkening would paint the fill pure black.
7406            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
7407            let pixmap_has_colour = px_data[pi + 3] > 0
7408                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
7409            // Multiplicative ink-stacking only when the pixmap carries a real
7410            // backdrop: either this paint is a custom spot landing on an
7411            // already-coloured pixel, or the process-ink buffer is empty but
7412            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
7413            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
7414            // the fill to pure black, so those pixels fall through to the
7415            // replace path where the source RGB paints normally.
7416            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
7417
7418            // Promoted DeviceGray on a non-spot backdrop: fall back to a
7419            // plain knockout that replaces all four CMYK plates. The
7420            // `maybe_promote_gray_fill` path describes the paint as a
7421            // K-only subset so spot-backed swatches can preserve the spot
7422            // plate (GWG 3.0 "50% gray over spot"), but on a plain CMYK
7423            // backdrop that would preserve the old CMY values and turn the
7424            // cross into the bg colour (GWG 3.0 "50% gray over CMYK" e/k).
7425            // Expanding to CMYK_ALL here restores the regular-fill result
7426            // at those pixels.
7427            //
7428            // Gate on `params.painted_channels == CMYK_K` so this only fires
7429            // for genuinely-promoted DeviceGray. A `0 0 0 0.5 k` DeviceCMYK
7430            // paint filtered to CMYK_K by OPM 1 has `params.painted_channels
7431            // = CMYK_ALL`, and must stay K-subset so its CMY=0 values do
7432            // not wipe a CMYK backdrop (GWG 3.0 "50% K over CMYK" j/d).
7433            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
7434                && channels == stet_graphics::device::CMYK_K
7435                && params.is_device_cmyk
7436                && src_c == 0.0
7437                && src_m == 0.0
7438                && src_y == 0.0;
7439            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
7440                stet_graphics::device::CMYK_ALL
7441            } else {
7442                channels
7443            };
7444
7445            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
7446                src_c
7447            } else {
7448                cur_c
7449            };
7450            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
7451                src_m
7452            } else {
7453                cur_m
7454            };
7455            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
7456                src_y
7457            } else {
7458                cur_y
7459            };
7460            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
7461                src_k
7462            } else {
7463                cur_k
7464            };
7465
7466            // Custom spot paints live on a separation plate — skip the
7467            // cmyk_buffer write so a later OPM 1 overprint still sees the
7468            // original process-ink state as backdrop.
7469            if !is_custom_spot {
7470                cmyk_buf[ci] = new_c as f32;
7471                cmyk_buf[ci + 1] = new_m as f32;
7472                cmyk_buf[ci + 2] = new_y as f32;
7473                cmyk_buf[ci + 3] = new_k as f32;
7474            }
7475
7476            // No-op overprint: the paint's effective CMYK equals the existing
7477            // process state, so no plate actually changes. Skip the pixmap
7478            // write entirely — otherwise ICC(new_cmyk) paints a plain process
7479            // composite that erases any spot-derived colour already visible
7480            // at this pixel (GWG 3.0 "50% K over spot" swatches where the
7481            // backdrop's Black component and the cross's K value match).
7482            //
7483            // Only fire when a DeviceN/Separation paint with spot colorants
7484            // actually landed on this pixel (spot_mask[mi] != 0). On plain
7485            // CMYK backdrops, ICC(cmyk_buf) == pixmap_rgb already, and
7486            // skipping vs replacing produces the same result — but making
7487            // the skip unconditional subtly drifts AA edges because prior
7488            // stroke/fill precision accumulates (regressed GWG 1.0/1.1).
7489            let delta = (new_c - cur_c)
7490                .abs()
7491                .max((new_m - cur_m).abs())
7492                .max((new_y - cur_y).abs())
7493                .max((new_k - cur_k).abs());
7494            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
7495                continue;
7496            }
7497
7498            let (r, g, b) =
7499                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
7500                    // Promoted DeviceGray collapsing to a full replace — use the
7501                    // paint's RGB directly so the pixmap matches the colour a
7502                    // regular non-overprint gray fill would paint at the same
7503                    // pixel. Going through ICC(CMYK) here would produce a
7504                    // slightly different gray (e.g. 151 vs 127) and leave a
7505                    // darker outline where a subsequent non-promoted gray
7506                    // stroke overpaints on top of it.
7507                    //
7508                    // Checked before `use_multiplicative` because a white gray
7509                    // paint (`1 g`, native CMYK (0,0,0,0)) on a coloured RGB
7510                    // backdrop (e.g. the red `Reset Form` button in 682.pdf
7511                    // page 2) would otherwise hit the multiplicative branch
7512                    // with all-zero source CMYK, which leaves the backdrop
7513                    // unchanged — hiding the white label.
7514                    (params.color.r, params.color.g, params.color.b)
7515                } else if use_multiplicative {
7516                    // Multiplicative ink stacking: each painted channel attenuates
7517                    // the corresponding RGB component; preserved channels leave
7518                    // the pixmap's existing colour untouched. This keeps any spot
7519                    // contribution already in the pixmap visible under overprints
7520                    // whose zero-valued CMYK components should not erase it.
7521                    let bg_r = px_data[pi] as f64 / 255.0;
7522                    let bg_g = px_data[pi + 1] as f64 / 255.0;
7523                    let bg_b = px_data[pi + 2] as f64 / 255.0;
7524                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
7525                        1.0 - src_c
7526                    } else {
7527                        1.0
7528                    };
7529                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
7530                        1.0 - src_m
7531                    } else {
7532                        1.0
7533                    };
7534                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
7535                        1.0 - src_y
7536                    } else {
7537                        1.0
7538                    };
7539                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
7540                        1.0 - src_k
7541                    } else {
7542                        1.0
7543                    };
7544                    (
7545                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
7546                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
7547                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
7548                    )
7549                } else if let Some(icc_cache) = icc {
7550                    icc_cache
7551                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
7552                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
7553                } else {
7554                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
7555                };
7556
7557            let a = (cov * params.alpha as f32).min(1.0);
7558            // Blend backdrop: prefer the pre-overprint snapshot only when
7559            // this paint's colour is close to the snapshot — that signals
7560            // the paint effectively returns the pixel to its original
7561            // backdrop (e.g. the almost-white cross in GWG 4.1 cancelling
7562            // the red cross's M/Y contributions). In that case blending
7563            // against the snapshot keeps AA edges clean.
7564            //
7565            // When the paint introduces colour (e.g. a magenta stroke
7566            // following a magenta fill — both lay down ink that should
7567            // stack), fall through to the current pixmap so repeated
7568            // same-colour paints keep compounding at edges instead of
7569            // snapping back to bg.
7570            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
7571                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
7572                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
7573                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
7574                let dr = (op_bg[pi] as f32 - new_r).abs();
7575                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
7576                let db = (op_bg[pi + 2] as f32 - new_b).abs();
7577                if dr.max(dg).max(db) <= 4.0 {
7578                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
7579                } else {
7580                    (
7581                        px_data[pi],
7582                        px_data[pi + 1],
7583                        px_data[pi + 2],
7584                        px_data[pi + 3],
7585                    )
7586                }
7587            } else {
7588                (
7589                    px_data[pi],
7590                    px_data[pi + 1],
7591                    px_data[pi + 2],
7592                    px_data[pi + 3],
7593                )
7594            };
7595            let dst_a = bk_a as f32 / 255.0;
7596            let one_minus_a = 1.0 - a;
7597            let out_a = a + dst_a * one_minus_a;
7598            if out_a > 0.0 {
7599                // tiny-skia stores premultiplied RGBA. Use the standard
7600                // src-over formula in premul space: result_pre = src*a + dst_pre*(1-a).
7601                // The backdrop values are already premultiplied, so no
7602                // additional divide-by-out_a step is needed.
7603                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
7604                    .clamp(0.0, 255.0)
7605                    .round() as u8;
7606                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
7607                    .clamp(0.0, 255.0)
7608                    .round() as u8;
7609                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
7610                    .clamp(0.0, 255.0)
7611                    .round() as u8;
7612                px_data[pi + 3] = (out_a * 255.0).round() as u8;
7613            }
7614        }
7615    }
7616}
7617/// PLRM CMYK-to-RGB formula fallback.
7618fn cmyk_to_rgb_plrm(c: f64, m: f64, y: f64, k: f64) -> (f64, f64, f64) {
7619    (
7620        1.0 - (c + k).min(1.0),
7621        1.0 - (m + k).min(1.0),
7622        1.0 - (y + k).min(1.0),
7623    )
7624}
7625
7626/// Update the CMYK buffer for a non-overprint fill (to track backdrop for future overprints).
7627#[allow(clippy::too_many_arguments)]
7628/// Compute the device-space bounding box of a tiny-skia path after transform,
7629/// clamped to `(0, 0, w, h)`. Returns `(x0, y0, x1, y1)` as pixel indices.
7630fn path_device_bbox(
7631    skia_path: &stet_tiny_skia::Path,
7632    transform: Transform,
7633    w: u32,
7634    h: u32,
7635) -> (usize, usize, usize, usize) {
7636    let b = skia_path.bounds();
7637    let mut corners = [
7638        stet_tiny_skia::Point {
7639            x: b.left(),
7640            y: b.top(),
7641        },
7642        stet_tiny_skia::Point {
7643            x: b.right(),
7644            y: b.top(),
7645        },
7646        stet_tiny_skia::Point {
7647            x: b.right(),
7648            y: b.bottom(),
7649        },
7650        stet_tiny_skia::Point {
7651            x: b.left(),
7652            y: b.bottom(),
7653        },
7654    ];
7655    transform.map_points(&mut corners);
7656    let min_x = corners.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
7657    let min_y = corners.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
7658    let max_x = corners
7659        .iter()
7660        .map(|p| p.x)
7661        .fold(f32::NEG_INFINITY, f32::max);
7662    let max_y = corners
7663        .iter()
7664        .map(|p| p.y)
7665        .fold(f32::NEG_INFINITY, f32::max);
7666    // Floor/ceil + clamp to output dimensions (with 1px margin for AA)
7667    let x0 = (min_x.floor() as i32 - 1).max(0) as usize;
7668    let y0 = (min_y.floor() as i32 - 1).max(0) as usize;
7669    let x1 = (max_x.ceil() as i32 + 1).clamp(0, w as i32) as usize;
7670    let y1 = (max_y.ceil() as i32 + 1).clamp(0, h as i32) as usize;
7671    (x0, y0, x1, y1)
7672}
7673
7674fn update_cmyk_buffer_for_fill(
7675    cmyk_buf: &mut [f32],
7676    spot_mask: &mut [u8],
7677    path: &PsPath,
7678    params: &FillParams,
7679    vp_x: f32,
7680    vp_y: f32,
7681    scale_x: f32,
7682    scale_y: f32,
7683    out_w: u32,
7684    out_h: u32,
7685    clip_region: &Option<ClipRegion>,
7686    no_aa: bool,
7687    icc: Option<&IccCache>,
7688) {
7689    // Custom spot paints (Separation/DeviceN naming no process channel) go to
7690    // their own separation plate — the process CMYK buffer must be zeroed
7691    // under the paint (knockout) so a later overprint sees "no process ink"
7692    // and falls into the multiplicative-blend branch that preserves the
7693    // spot's visible contribution in the pixmap.
7694    //
7695    // The `process_cmyk.is_some()` guard distinguishes "Separation/DeviceN
7696    // custom spot" (where `process_cmyk` is `Some((0,0,0,0))` per
7697    // `separation_process_cmyk`) from "any other non-CMYK fill that
7698    // happens to satisfy `painted_channels == 0 && !is_device_cmyk`" —
7699    // notably DeviceRGB, DeviceGray, and ICCBased RGB. The latter need to
7700    // deposit their full process CMYK into the buffer (via `native_cmyk`
7701    // from the proofing chain or via the ICC reverse) so the
7702    // `cmyk_group_blend` composite-back in `composite_non_isolated_cmyk`
7703    // can blend them correctly. Without this guard, GWG 16.1's
7704    // ICCBased-RGB swatches landed `(0,0,0,0)` in the form's CMYK
7705    // buffer; every separable blend then composited the X mark against a
7706    // zero source CMYK, painting the X with the form's source pixmap
7707    // RGB unchanged and producing the test's "X visible" failure.
7708    let is_custom_spot = params.painted_channels == 0
7709        && !params.is_device_cmyk
7710        && params.color.process_cmyk.is_some();
7711
7712    // A DeviceN/Separation paint leaves "spot contribution" on the pixmap
7713    // when its full alt-CMYK (`native_cmyk`) differs from the process-only
7714    // tint (`process_cmyk`) — the extra RGB in the pixmap comes from a spot
7715    // plate that `cmyk_buf` cannot reflect. Pure DeviceCMYK paints have
7716    // `process_cmyk == None` (fall back to native), so no spot contribution.
7717    //
7718    // A "real" custom spot paint (`is_custom_spot && native_cmyk.is_some()`)
7719    // also deposits spot RGB that `cmyk_buf` loses (it's zeroed by the
7720    // custom-spot branch). Exclude DeviceRGB / DeviceGray / ICCBased-RGB
7721    // paints — those also satisfy `is_custom_spot = painted==0 &&
7722    // !is_device_cmyk` but carry no spot-plate contribution, and flagging
7723    // them would gate later OPM-1 cancel skips on a signal that doesn't
7724    // actually mean anything.
7725    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
7726        || matches!(
7727            (params.color.native_cmyk, params.color.process_cmyk),
7728            (Some(nat), Some(proc_))
7729                if (nat.0 - proc_.0).abs() > 1e-6
7730                    || (nat.1 - proc_.1).abs() > 1e-6
7731                    || (nat.2 - proc_.2).abs() > 1e-6
7732                    || (nat.3 - proc_.3).abs() > 1e-6
7733        );
7734
7735    // Source CMYK preference: process-only CMYK (from Separation/DeviceN paints
7736    // so spot-colorant tint contributions stay out of the process buffer) >
7737    // native CMYK (full alt-CMYK tint, fine for pure DeviceCMYK paints) > ICC
7738    // reverse (sRGB→CMYK via the system CMYK profile) > PLRM (1−r, 1−g, 1−b, 0)
7739    // fallback. The ICC reverse keeps non-CMYK fills (RGB/Gray/Lab/etc.)
7740    // representable as accurate CMYK in the parallel buffer so the
7741    // non-isolated CMYK composite-back can blend them correctly.
7742    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
7743        (0.0, 0.0, 0.0, 0.0)
7744    } else if let Some(c) = params.color.process_cmyk {
7745        c
7746    } else if let Some(c) = params.color.native_cmyk {
7747        c
7748    } else if let Some(cmyk) = icc.and_then(|i| {
7749        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
7750    }) {
7751        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
7752    } else {
7753        (
7754            (1.0 - params.color.r).clamp(0.0, 1.0),
7755            (1.0 - params.color.g).clamp(0.0, 1.0),
7756            (1.0 - params.color.b).clamp(0.0, 1.0),
7757            0.0,
7758        )
7759    };
7760    let Some(skia_path) = build_skia_path(path) else {
7761        return;
7762    };
7763
7764    let mut coverage_mask = match Mask::new(out_w, out_h) {
7765        Some(m) => m,
7766        None => return,
7767    };
7768    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7769    let fill_rule = to_fill_rule(&params.fill_rule);
7770    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7771
7772    let cov_data = coverage_mask.data();
7773    let clip_data: Option<&[u8]> = match clip_region {
7774        Some(ClipRegion::Mask(m)) => Some(m.data()),
7775        _ => None,
7776    };
7777
7778    // Constrain iteration to the path's device-space bounding box
7779    let (mut bx0, mut by0, mut bx1, mut by1) =
7780        path_device_bbox(&skia_path, transform, out_w, out_h);
7781    if let Some(ClipRegion::Rect(r)) = clip_region {
7782        bx0 = bx0.max(r.x0 as usize);
7783        by0 = by0.max(r.y0 as usize);
7784        bx1 = bx1.min(r.x1 as usize);
7785        by1 = by1.min(r.y1 as usize);
7786    }
7787
7788    let stride = out_w as usize;
7789    for y in by0..by1 {
7790        for x in bx0..bx1 {
7791            let mi = y * stride + x;
7792            let mut cov = cov_data[mi] as f32 / 255.0;
7793            if let Some(clip) = clip_data {
7794                cov *= clip[mi] as f32 / 255.0;
7795            }
7796            if cov > 0.0 {
7797                let ci = mi * 4;
7798                cmyk_buf[ci] = src_c as f32;
7799                cmyk_buf[ci + 1] = src_m as f32;
7800                cmyk_buf[ci + 2] = src_y as f32;
7801                cmyk_buf[ci + 3] = src_k as f32;
7802                if has_spot_contrib {
7803                    spot_mask[mi] = 1;
7804                }
7805            }
7806        }
7807    }
7808}
7809
7810/// Render an overprint stroke: convert the stroke outline to a fill path,
7811/// rasterize a coverage mask, then composite per-pixel in CMYK so the painted
7812/// channels of the stroke colour replace the matching backdrop channels and
7813/// the result lands in the pixmap as RGB. Mirrors `render_overprint_fill`.
7814#[allow(clippy::too_many_arguments)]
7815fn render_overprint_stroke(
7816    pixmap: &mut Pixmap,
7817    cmyk_buf: &mut [f32],
7818    op_bg: &mut [u8],
7819    op_touched: &mut [u8],
7820    spot_mask: &[u8],
7821    band_state: &mut BandState,
7822    skia_path: &stet_tiny_skia::Path,
7823    stroke: &Stroke,
7824    transform: Transform,
7825    params: &StrokeParams,
7826    out_w: u32,
7827    out_h: u32,
7828    icc: Option<&IccCache>,
7829    no_aa: bool,
7830) {
7831    // Convert stroke outline to fill path. Mirrors update_cmyk_buffer_for_stroke_overprint.
7832    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
7833        .sqrt()
7834        .max(1.0);
7835    let dashed_op;
7836    let stroke_src = if let Some(ref dash) = stroke.dash {
7837        dashed_op = skia_path.dash(dash, resolution_scale);
7838        match dashed_op.as_ref() {
7839            Some(p) => p,
7840            None => skia_path,
7841        }
7842    } else {
7843        skia_path
7844    };
7845    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
7846        return;
7847    };
7848    let Some(stroked) = stroked_user.transform(transform) else {
7849        return;
7850    };
7851
7852    let mut coverage_mask = match Mask::new(out_w, out_h) {
7853        Some(m) => m,
7854        None => return,
7855    };
7856    coverage_mask.fill_path(
7857        &stroked,
7858        SkiaFillRule::Winding,
7859        !no_aa,
7860        Transform::identity(),
7861    );
7862
7863    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7864        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
7865
7866    // Intersect with clip mask (same logic as render_overprint_fill).
7867    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7868        None => None,
7869        Some(ClipRegion::Rect(r)) => {
7870            let data = coverage_mask.data_mut();
7871            let stride = out_w as usize;
7872            for y in bbox_y0..bbox_y1 {
7873                let row_start = y * stride;
7874                for x in bbox_x0..bbox_x1 {
7875                    let yu = y as u32;
7876                    let xu = x as u32;
7877                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7878                        data[row_start + x] = 0;
7879                    }
7880                }
7881            }
7882            None
7883        }
7884        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7885    };
7886
7887    // See render_overprint_fill for the rationale: a custom spot stroke must
7888    // preserve the process CMYK buffer and blend multiplicatively in RGB so
7889    // later OPM 1 overprints don't knock out the spot's visible colour.
7890    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7891
7892    // Source CMYK preference: for paints with a process colorant in the mix,
7893    // prefer `process_cmyk` so the no-op-delta skip in the per-pixel loop sees
7894    // the same exact value the BG paint wrote into `cmyk_buf`. Custom spots
7895    // keep reading `native_cmyk` (the spot's visual alt-CMYK; process_cmyk is
7896    // (0,0,0,0) for pure spots). See `render_overprint_fill` for the full
7897    // rationale (GWG 3.0 swatches c/i, 1307.pdf spot text).
7898    let (src_c, src_m, src_y, src_k) = if !is_custom_spot && let Some(c) = params.color.process_cmyk
7899    {
7900        c
7901    } else if let Some(c) = params.color.native_cmyk {
7902        c
7903    } else {
7904        let r = params.color.r;
7905        let g = params.color.g;
7906        let b = params.color.b;
7907        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7908    };
7909
7910    let mut channels = params.painted_channels;
7911    if channels == 0 {
7912        channels = stet_graphics::device::CMYK_ALL;
7913    }
7914    if params.overprint_mode == 1
7915        && channels == stet_graphics::device::CMYK_ALL
7916        && params.is_device_cmyk
7917    {
7918        channels = 0;
7919        if src_c != 0.0 {
7920            channels |= stet_graphics::device::CMYK_C;
7921        }
7922        if src_m != 0.0 {
7923            channels |= stet_graphics::device::CMYK_M;
7924        }
7925        if src_y != 0.0 {
7926            channels |= stet_graphics::device::CMYK_Y;
7927        }
7928        if src_k != 0.0 {
7929            channels |= stet_graphics::device::CMYK_K;
7930        }
7931        // See render_overprint_fill: an all-zero CMYK source preserves the
7932        // backdrop only when /OPM and /op|/OP were set together (paired) in
7933        // the same ExtGState. Inherited-OPM cases fall back to legacy
7934        // knockout.
7935        if channels == 0 && !params.opm_paired {
7936            channels = stet_graphics::device::CMYK_ALL;
7937        }
7938    }
7939
7940    let is_k_only_cmyk = params.is_device_cmyk
7941        && params.overprint_mode == 0
7942        && src_c == 0.0
7943        && src_m == 0.0
7944        && src_y == 0.0;
7945    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7946        // Full-channel replacement: write source CMYK to buffer for covered
7947        // pixels and let tiny-skia stroke the pixmap with the source colour.
7948        // Only K-only DeviceCMYK OPM 0 paints are routed to the per-pixel
7949        // path (see render_overprint_fill).
7950        let cov_data = coverage_mask.data();
7951        let stride = out_w as usize;
7952        for y in bbox_y0..bbox_y1 {
7953            for x in bbox_x0..bbox_x1 {
7954                let mi = y * stride + x;
7955                let mut cov = cov_data[mi] as f32 / 255.0;
7956                if let Some(clip) = clip_coverage {
7957                    cov *= clip[mi] as f32 / 255.0;
7958                }
7959                if cov > 0.0 {
7960                    let ci = mi * 4;
7961                    cmyk_buf[ci] = src_c as f32;
7962                    cmyk_buf[ci + 1] = src_m as f32;
7963                    cmyk_buf[ci + 2] = src_y as f32;
7964                    cmyk_buf[ci + 3] = src_k as f32;
7965                }
7966            }
7967        }
7968        let mut temp_mask = None;
7969        let Some(mask_ref) =
7970            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
7971        else {
7972            return;
7973        };
7974        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
7975        pixmap.stroke_path(skia_path, &paint, stroke, transform, mask_ref);
7976        return;
7977    }
7978
7979    let cov_data = coverage_mask.data();
7980    let stride = out_w as usize;
7981    let px_data = pixmap.data_mut();
7982    let px_stride = out_w as usize * 4;
7983
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                continue;
7993            }
7994
7995            let ci = mi * 4;
7996            let pi = y * px_stride + x * 4;
7997            // Snapshot-based AA blending — see render_overprint_fill for the
7998            // rationale. Capture the pre-paint pixmap on first overprint touch
7999            // so stacked overprints at the same pixel blend against the
8000            // original backdrop rather than each other.
8001            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8002                op_bg[pi] = px_data[pi];
8003                op_bg[pi + 1] = px_data[pi + 1];
8004                op_bg[pi + 2] = px_data[pi + 2];
8005                op_bg[pi + 3] = px_data[pi + 3];
8006                op_touched[mi] = 1;
8007            }
8008            let cur_c = cmyk_buf[ci] as f64;
8009            let cur_m = cmyk_buf[ci + 1] as f64;
8010            let cur_y = cmyk_buf[ci + 2] as f64;
8011            let cur_k = cmyk_buf[ci + 3] as f64;
8012            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8013            let pixmap_has_colour = px_data[pi + 3] > 0
8014                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
8015            // Multiplicative ink-stacking only when the pixmap carries a real
8016            // backdrop: either this paint is a custom spot landing on an
8017            // already-coloured pixel, or the process-ink buffer is empty but
8018            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
8019            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
8020            // the fill to pure black, so those pixels fall through to the
8021            // replace path where the source RGB paints normally.
8022            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
8023
8024            // Promoted DeviceGray on non-spot backdrop: replace all channels
8025            // (see render_overprint_fill).
8026            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
8027                && channels == stet_graphics::device::CMYK_K
8028                && params.is_device_cmyk
8029                && src_c == 0.0
8030                && src_m == 0.0
8031                && src_y == 0.0;
8032            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
8033                stet_graphics::device::CMYK_ALL
8034            } else {
8035                channels
8036            };
8037
8038            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
8039                src_c
8040            } else {
8041                cur_c
8042            };
8043            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
8044                src_m
8045            } else {
8046                cur_m
8047            };
8048            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
8049                src_y
8050            } else {
8051                cur_y
8052            };
8053            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
8054                src_k
8055            } else {
8056                cur_k
8057            };
8058
8059            if !is_custom_spot {
8060                cmyk_buf[ci] = new_c as f32;
8061                cmyk_buf[ci + 1] = new_m as f32;
8062                cmyk_buf[ci + 2] = new_y as f32;
8063                cmyk_buf[ci + 3] = new_k as f32;
8064            }
8065
8066            // No-op overprint skip — see render_overprint_fill for rationale.
8067            let delta = (new_c - cur_c)
8068                .abs()
8069                .max((new_m - cur_m).abs())
8070                .max((new_y - cur_y).abs())
8071                .max((new_k - cur_k).abs());
8072            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
8073                continue;
8074            }
8075
8076            let (r, g, b) =
8077                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
8078                    // Promoted DeviceGray collapsing to a full replace — see
8079                    // render_overprint_fill for the rationale (must run before
8080                    // the multiplicative branch so a `1 g` / `1 G` white paint
8081                    // doesn't get folded into the backdrop via zero-source
8082                    // multiplication).
8083                    (params.color.r, params.color.g, params.color.b)
8084                } else if use_multiplicative {
8085                    let bg_r = px_data[pi] as f64 / 255.0;
8086                    let bg_g = px_data[pi + 1] as f64 / 255.0;
8087                    let bg_b = px_data[pi + 2] as f64 / 255.0;
8088                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
8089                        1.0 - src_c
8090                    } else {
8091                        1.0
8092                    };
8093                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
8094                        1.0 - src_m
8095                    } else {
8096                        1.0
8097                    };
8098                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
8099                        1.0 - src_y
8100                    } else {
8101                        1.0
8102                    };
8103                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
8104                        1.0 - src_k
8105                    } else {
8106                        1.0
8107                    };
8108                    (
8109                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
8110                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
8111                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
8112                    )
8113                } else if let Some(icc_cache) = icc {
8114                    icc_cache
8115                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8116                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8117                } else {
8118                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8119                };
8120
8121            let a = (cov * params.alpha as f32).min(1.0);
8122            // Blend backdrop: prefer snapshot only when this paint's colour
8123            // closely matches the snapshot — see render_overprint_fill for
8124            // the rationale (keeps aw-on-red-style cancel paints clean at
8125            // edges while preserving additive same-colour stacking).
8126            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
8127                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
8128                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
8129                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
8130                let dr = (op_bg[pi] as f32 - new_r).abs();
8131                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
8132                let db = (op_bg[pi + 2] as f32 - new_b).abs();
8133                if dr.max(dg).max(db) <= 4.0 {
8134                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
8135                } else {
8136                    (
8137                        px_data[pi],
8138                        px_data[pi + 1],
8139                        px_data[pi + 2],
8140                        px_data[pi + 3],
8141                    )
8142                }
8143            } else {
8144                (
8145                    px_data[pi],
8146                    px_data[pi + 1],
8147                    px_data[pi + 2],
8148                    px_data[pi + 3],
8149                )
8150            };
8151            let dst_a = bk_a as f32 / 255.0;
8152            let one_minus_a = 1.0 - a;
8153            let out_a = a + dst_a * one_minus_a;
8154            if out_a > 0.0 {
8155                // tiny-skia stores premultiplied RGBA (see render_overprint_fill).
8156                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
8157                    .clamp(0.0, 255.0)
8158                    .round() as u8;
8159                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
8160                    .clamp(0.0, 255.0)
8161                    .round() as u8;
8162                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
8163                    .clamp(0.0, 255.0)
8164                    .round() as u8;
8165                px_data[pi + 3] = (out_a * 255.0).round() as u8;
8166            }
8167        }
8168    }
8169}
8170
8171/// Update the CMYK buffer for a non-overprint stroke. Mirrors
8172/// [`update_cmyk_buffer_for_fill`] but rasterizes a stroked outline path
8173/// instead of a filled one. Source-CMYK selection follows the same
8174/// native_cmyk → ICC reverse → PLRM cascade.
8175#[allow(clippy::too_many_arguments)]
8176fn update_cmyk_buffer_for_stroke(
8177    cmyk_buf: &mut [f32],
8178    spot_mask: &mut [u8],
8179    path: &PsPath,
8180    params: &StrokeParams,
8181    stroke: &Stroke,
8182    transform: Transform,
8183    out_w: u32,
8184    out_h: u32,
8185    clip_region: &Option<ClipRegion>,
8186    no_aa: bool,
8187    icc: Option<&IccCache>,
8188) {
8189    // Custom spot strokes knockout the process CMYK plates — zero the buffer
8190    // under the stroke so later overprints fall into the multiplicative-blend
8191    // branch (see update_cmyk_buffer_for_fill, including the
8192    // `process_cmyk.is_some()` carve-out that keeps DeviceRGB / ICCBased-RGB
8193    // strokes off this branch so their proofing-chain CMYK reaches the
8194    // buffer).
8195    let is_custom_spot = params.painted_channels == 0
8196        && !params.is_device_cmyk
8197        && params.color.process_cmyk.is_some();
8198    // See update_cmyk_buffer_for_fill for rationale.
8199    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
8200        || matches!(
8201            (params.color.native_cmyk, params.color.process_cmyk),
8202            (Some(nat), Some(proc_))
8203                if (nat.0 - proc_.0).abs() > 1e-6
8204                    || (nat.1 - proc_.1).abs() > 1e-6
8205                    || (nat.2 - proc_.2).abs() > 1e-6
8206                    || (nat.3 - proc_.3).abs() > 1e-6
8207        );
8208
8209    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
8210        (0.0, 0.0, 0.0, 0.0)
8211    } else if let Some(c) = params.color.process_cmyk {
8212        c
8213    } else if let Some(c) = params.color.native_cmyk {
8214        c
8215    } else if let Some(cmyk) = icc.and_then(|i| {
8216        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
8217    }) {
8218        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
8219    } else {
8220        (
8221            (1.0 - params.color.r).clamp(0.0, 1.0),
8222            (1.0 - params.color.g).clamp(0.0, 1.0),
8223            (1.0 - params.color.b).clamp(0.0, 1.0),
8224            0.0,
8225        )
8226    };
8227
8228    let Some(skia_path) = build_skia_path(path) else {
8229        return;
8230    };
8231
8232    // Convert the stroke outline into a fill path so we can rasterize it via
8233    // Mask::fill_path. Mirrors the dance in the overprint stroke branch:
8234    // dash → stroke-to-outline (in user space) → device transform.
8235    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
8236        .sqrt()
8237        .max(1.0);
8238    let dashed_op;
8239    let stroke_src = if let Some(ref dash) = stroke.dash {
8240        dashed_op = skia_path.dash(dash, resolution_scale);
8241        match dashed_op.as_ref() {
8242            Some(p) => p,
8243            None => &skia_path,
8244        }
8245    } else {
8246        &skia_path
8247    };
8248    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
8249        return;
8250    };
8251    let Some(stroked) = stroked_user.transform(transform) else {
8252        return;
8253    };
8254
8255    let mut coverage_mask = match Mask::new(out_w, out_h) {
8256        Some(m) => m,
8257        None => return,
8258    };
8259    coverage_mask.fill_path(
8260        &stroked,
8261        SkiaFillRule::Winding,
8262        !no_aa,
8263        Transform::identity(),
8264    );
8265
8266    let cov_data = coverage_mask.data();
8267    let clip_data: Option<&[u8]> = match clip_region {
8268        Some(ClipRegion::Mask(m)) => Some(m.data()),
8269        _ => None,
8270    };
8271
8272    let (mut bx0, mut by0, mut bx1, mut by1) =
8273        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
8274    if let Some(ClipRegion::Rect(r)) = clip_region {
8275        bx0 = bx0.max(r.x0 as usize);
8276        by0 = by0.max(r.y0 as usize);
8277        bx1 = bx1.min(r.x1 as usize);
8278        by1 = by1.min(r.y1 as usize);
8279    }
8280
8281    let stride = out_w as usize;
8282    for y in by0..by1 {
8283        for x in bx0..bx1 {
8284            let mi = y * stride + x;
8285            let mut cov = cov_data[mi] as f32 / 255.0;
8286            if let Some(clip) = clip_data {
8287                cov *= clip[mi] as f32 / 255.0;
8288            }
8289            if cov > 0.0 {
8290                let ci = mi * 4;
8291                cmyk_buf[ci] = src_c as f32;
8292                cmyk_buf[ci + 1] = src_m as f32;
8293                cmyk_buf[ci + 2] = src_y as f32;
8294                cmyk_buf[ci + 3] = src_k as f32;
8295                if has_spot_contrib {
8296                    spot_mask[mi] = 1;
8297                }
8298            }
8299        }
8300    }
8301}
8302
8303/// Render an overprint image with viewport params.
8304#[allow(clippy::too_many_arguments)]
8305fn render_overprint_image(
8306    pixmap: &mut Pixmap,
8307    cmyk_buf: &mut [f32],
8308    op_bg: &mut [u8],
8309    op_touched: &mut [u8],
8310    band_state: &mut BandState,
8311    sample_data: &[u8],
8312    params: &ImageParams,
8313    vp_x: f32,
8314    vp_y: f32,
8315    scale_x: f32,
8316    scale_y: f32,
8317    out_w: u32,
8318    out_h: u32,
8319    icc: Option<&IccCache>,
8320) {
8321    let iw = params.width as usize;
8322    let ih = params.height as usize;
8323    let Some(image_inv) = params.image_matrix.invert() else {
8324        return;
8325    };
8326    let combined = params.ctm.concat(&image_inv);
8327    let Some(inv_combined) = combined.invert() else {
8328        return;
8329    };
8330
8331    let px_data = pixmap.data_mut();
8332    let stride = out_w as usize;
8333    let inv_sx = 1.0 / scale_x as f64;
8334    let inv_sy = 1.0 / scale_y as f64;
8335
8336    let clip_data: Option<&[u8]> = match &band_state.clip_region {
8337        Some(ClipRegion::Mask(m)) => Some(m.data()),
8338        _ => None,
8339    };
8340    let clip_rect = match &band_state.clip_region {
8341        Some(ClipRegion::Rect(r)) => Some(*r),
8342        _ => None,
8343    };
8344
8345    let mask_info = if let ImageColorSpace::Mask {
8346        color, polarity, ..
8347    } = &params.color_space
8348    {
8349        let (src_c, src_m, src_y, src_k) = color.native_cmyk.unwrap_or_else(|| {
8350            let r = color.r;
8351            let g = color.g;
8352            let b = color.b;
8353            (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
8354        });
8355        Some((src_c, src_m, src_y, src_k, *polarity, iw.div_ceil(8)))
8356    } else {
8357        None
8358    };
8359
8360    for by in 0..out_h as usize {
8361        for bx in 0..out_w as usize {
8362            if let Some(ref r) = clip_rect
8363                && ((by as u32) < r.y0
8364                    || (by as u32) >= r.y1
8365                    || (bx as u32) < r.x0
8366                    || (bx as u32) >= r.x1)
8367            {
8368                continue;
8369            }
8370            if let Some(clip) = clip_data {
8371                let ci_clip = by * stride + bx;
8372                if clip[ci_clip] == 0 {
8373                    let bh = out_h as usize;
8374                    let has_neighbor = (bx > 0 && clip[ci_clip - 1] != 0)
8375                        || (bx + 1 < stride && clip[ci_clip + 1] != 0)
8376                        || (by > 0 && clip[ci_clip - stride] != 0)
8377                        || (by + 1 < bh && clip[ci_clip + stride] != 0)
8378                        || (bx > 0 && by > 0 && clip[ci_clip - stride - 1] != 0)
8379                        || (bx + 1 < stride && by > 0 && clip[ci_clip - stride + 1] != 0)
8380                        || (bx > 0 && by + 1 < bh && clip[ci_clip + stride - 1] != 0)
8381                        || (bx + 1 < stride && by + 1 < bh && clip[ci_clip + stride + 1] != 0);
8382                    if !has_neighbor {
8383                        continue;
8384                    }
8385                }
8386            }
8387
8388            // Map output pixel to device space, then to image space
8389            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8390            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8391            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8392            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8393
8394            let col = ix.floor() as i64;
8395            let row = iy.floor() as i64;
8396            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8397                continue;
8398            }
8399            let col = col as usize;
8400            let row = row as usize;
8401
8402            let (src_c, src_m, src_y, src_k) =
8403                if let Some((mc, mm, my, mk, polarity, bytes_per_row)) = mask_info {
8404                    let byte_idx = row * bytes_per_row + col / 8;
8405                    let bit_offset = 7 - (col % 8);
8406                    let bit = if byte_idx < sample_data.len() {
8407                        (sample_data[byte_idx] >> bit_offset) & 1
8408                    } else {
8409                        0
8410                    };
8411                    let paint = if polarity { bit == 1 } else { bit == 0 };
8412                    if !paint {
8413                        continue;
8414                    }
8415                    (mc, mm, my, mk)
8416                } else if let Some(cmyk) =
8417                    sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8418                {
8419                    cmyk
8420                } else {
8421                    continue;
8422                };
8423
8424            let mi = by * stride + bx;
8425            let ci = mi * 4;
8426            let pi = mi * 4;
8427
8428            // Spot-tint images (Separation / DeviceN with CMYK alt and at
8429            // least one non-process colorant): per PDF spec 11.7.4.5 the
8430            // image affects only the device colorants identified by its color
8431            // space.  In composite preview that means:
8432            //   * Where the CMYK buffer is empty (fresh paper or a custom
8433            //     spot painted earlier whose alt-CMYK we never tracked),
8434            //     paint the pixel directly from the image's tint output —
8435            //     the spot's full alt-CMYK contribution shows up, and a
8436            //     same-spot underlying paint (e.g. a /GWG-Green X under an
8437            //     image whose GWG-Green is zero) is knocked out because
8438            //     ICC(0,0,0,0) is white.
8439            //   * Where the CMYK buffer carries prior CMYK (a `1 0 1 0.5 k`
8440            //     ✓ underneath), REPLACE only the NAMED PROCESS plates with
8441            //     the image's tint output and PRESERVE the rest, then
8442            //     recompose the pixmap.  A duotone DeviceN [Black, Green]
8443            //     image's "no ink" pixel knocks the ✓'s K=0.5 down to 0 —
8444            //     lightening it to (C=1, M=0, Y=1, K=0) — while leaving its
8445            //     C=1, Y=1 untouched.
8446            if image_cs_has_spot_tint_transform(&params.color_space) {
8447                let cur_c = cmyk_buf[ci] as f64;
8448                let cur_m = cmyk_buf[ci + 1] as f64;
8449                let cur_y = cmyk_buf[ci + 2] as f64;
8450                let cur_k = cmyk_buf[ci + 3] as f64;
8451                let cur_is_zero = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8452                let named = params.painted_channels;
8453                // OPM=1 zero-source preservation: when the image's tint
8454                // output for a named plate is zero, the underlying value is
8455                // preserved instead of replaced.  Without this, a duotone
8456                // DeviceN [Black, GWG-Green] image's "no ink" pixel
8457                // overwrote the K=0.5 of an underlying CMYK ✓ with 0,
8458                // rendering the checkmark too light versus Adobe Acrobat.
8459                let opm1 = params.overprint_mode == 1;
8460                let (new_c, new_m, new_y, new_k) = if cur_is_zero {
8461                    (src_c, src_m, src_y, src_k)
8462                } else {
8463                    let nc =
8464                        if named & stet_graphics::device::CMYK_C != 0 && !(opm1 && src_c == 0.0) {
8465                            src_c
8466                        } else {
8467                            cur_c
8468                        };
8469                    let nm =
8470                        if named & stet_graphics::device::CMYK_M != 0 && !(opm1 && src_m == 0.0) {
8471                            src_m
8472                        } else {
8473                            cur_m
8474                        };
8475                    let ny =
8476                        if named & stet_graphics::device::CMYK_Y != 0 && !(opm1 && src_y == 0.0) {
8477                            src_y
8478                        } else {
8479                            cur_y
8480                        };
8481                    let nk =
8482                        if named & stet_graphics::device::CMYK_K != 0 && !(opm1 && src_k == 0.0) {
8483                            src_k
8484                        } else {
8485                            cur_k
8486                        };
8487                    (nc, nm, ny, nk)
8488                };
8489                cmyk_buf[ci] = new_c as f32;
8490                cmyk_buf[ci + 1] = new_m as f32;
8491                cmyk_buf[ci + 2] = new_y as f32;
8492                cmyk_buf[ci + 3] = new_k as f32;
8493                // When the alt space is non-CMYK (e.g., DeviceN with Lab alt),
8494                // src_* came from named-colorant extraction and only describes
8495                // the named process plates — spot contributions are missing.
8496                // For fresh-paper pixels (cur_is_zero), reconstruct the visual
8497                // via the tint transform's alt → RGB output instead so the
8498                // spot's true colour shows through. Composite cells (cur not
8499                // zero) still go through CMYK → RGB on the plate-replaced
8500                // values so process plates from the underlay are honoured.
8501                let alt_is_non_cmyk = image_cs_alt_is_non_cmyk(&params.color_space);
8502                let (r, g, b) = if cur_is_zero
8503                    && alt_is_non_cmyk
8504                    && let Some(rgb) =
8505                        sample_pixel_visual_rgb(sample_data, &params.color_space, iw, row, col)
8506                {
8507                    rgb
8508                } else if let Some(icc_cache) = icc {
8509                    icc_cache
8510                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8511                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8512                } else {
8513                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8514                };
8515                if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8516                    op_bg[pi] = px_data[pi];
8517                    op_bg[pi + 1] = px_data[pi + 1];
8518                    op_bg[pi + 2] = px_data[pi + 2];
8519                    op_bg[pi + 3] = px_data[pi + 3];
8520                    op_touched[mi] = 1;
8521                }
8522                px_data[pi] = (r * 255.0).round() as u8;
8523                px_data[pi + 1] = (g * 255.0).round() as u8;
8524                px_data[pi + 2] = (b * 255.0).round() as u8;
8525                px_data[pi + 3] = 255;
8526                continue;
8527            }
8528
8529            let mut channels = params.painted_channels;
8530            // Non-CMYK images (painted_channels=0, e.g. Separation/DeviceN spot colors)
8531            // replace all CMYK channels with the tinted equivalent.
8532            if channels == 0 {
8533                channels = stet_graphics::device::CMYK_ALL;
8534            }
8535            let is_direct_cmyk = matches!(
8536                &params.color_space,
8537                ImageColorSpace::DeviceCMYK
8538                    | ImageColorSpace::ICCBased { n: 4, .. }
8539                    | ImageColorSpace::Mask { .. }
8540            );
8541            // Custom spot image: process plates stay untouched and the per-pixel
8542            // sampled CMYK is the spot's alt-CMYK, which we layer multiplicatively
8543            // onto the pixmap. For image masks, the spot identity lives on the
8544            // fill color (recognise them via painted_channels=0 paired with a
8545            // native-CMYK fill color from the alt-space conversion). Indexed
8546            // images inherit the base space, so an Indexed /DeviceCMYK palette
8547            // is NOT a custom spot even when painted_channels=0. Plain DeviceCMYK
8548            // / ICCBased(4) images keep is_custom_spot=false so standard OPM 1
8549            // behaviour still applies.
8550            let is_custom_spot = params.painted_channels == 0
8551                && !is_cmyk_color_space(&params.color_space)
8552                && match &params.color_space {
8553                    ImageColorSpace::Mask { color, .. } => color.native_cmyk.is_some(),
8554                    _ => true,
8555                };
8556            if params.overprint_mode == 1
8557                && channels == stet_graphics::device::CMYK_ALL
8558                && is_direct_cmyk
8559            {
8560                channels = 0;
8561                if src_c != 0.0 {
8562                    channels |= stet_graphics::device::CMYK_C;
8563                }
8564                if src_m != 0.0 {
8565                    channels |= stet_graphics::device::CMYK_M;
8566                }
8567                if src_y != 0.0 {
8568                    channels |= stet_graphics::device::CMYK_Y;
8569                }
8570                if src_k != 0.0 {
8571                    channels |= stet_graphics::device::CMYK_K;
8572                }
8573            }
8574
8575            let cur_c = cmyk_buf[ci] as f64;
8576            let cur_m = cmyk_buf[ci + 1] as f64;
8577            let cur_y = cmyk_buf[ci + 2] as f64;
8578            let cur_k = cmyk_buf[ci + 3] as f64;
8579            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8580            let pixmap_has_colour = px_data[pi + 3] > 0
8581                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
8582            // Multiplicative ink-stacking only when the pixmap carries a real
8583            // backdrop: either this paint is a custom spot landing on an
8584            // already-coloured pixel, or the process-ink buffer is empty but
8585            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
8586            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
8587            // the fill to pure black, so those pixels fall through to the
8588            // replace path where the source RGB paints normally.
8589            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
8590
8591            let new_c = if channels & stet_graphics::device::CMYK_C != 0 {
8592                src_c
8593            } else {
8594                cur_c
8595            };
8596            let new_m = if channels & stet_graphics::device::CMYK_M != 0 {
8597                src_m
8598            } else {
8599                cur_m
8600            };
8601            let new_y = if channels & stet_graphics::device::CMYK_Y != 0 {
8602                src_y
8603            } else {
8604                cur_y
8605            };
8606            let new_k = if channels & stet_graphics::device::CMYK_K != 0 {
8607                src_k
8608            } else {
8609                cur_k
8610            };
8611
8612            if !is_custom_spot {
8613                cmyk_buf[ci] = new_c as f32;
8614                cmyk_buf[ci + 1] = new_m as f32;
8615                cmyk_buf[ci + 2] = new_y as f32;
8616                cmyk_buf[ci + 3] = new_k as f32;
8617            }
8618
8619            let (r, g, b) = if use_multiplicative {
8620                let bg_r = px_data[pi] as f64 / 255.0;
8621                let bg_g = px_data[pi + 1] as f64 / 255.0;
8622                let bg_b = px_data[pi + 2] as f64 / 255.0;
8623                let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
8624                    1.0 - src_c
8625                } else {
8626                    1.0
8627                };
8628                let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
8629                    1.0 - src_m
8630                } else {
8631                    1.0
8632                };
8633                let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
8634                    1.0 - src_y
8635                } else {
8636                    1.0
8637                };
8638                let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
8639                    1.0 - src_k
8640                } else {
8641                    1.0
8642                };
8643                (
8644                    (bg_r * over_r * k_fac).clamp(0.0, 1.0),
8645                    (bg_g * over_g * k_fac).clamp(0.0, 1.0),
8646                    (bg_b * over_b * k_fac).clamp(0.0, 1.0),
8647                )
8648            } else if let Some(icc_cache) = icc {
8649                icc_cache
8650                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8651                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8652            } else {
8653                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8654            };
8655
8656            // Snapshot the pre-paint pixmap so a later overprint fill/stroke
8657            // at this pixel can blend against it (see render_overprint_fill).
8658            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8659                op_bg[pi] = px_data[pi];
8660                op_bg[pi + 1] = px_data[pi + 1];
8661                op_bg[pi + 2] = px_data[pi + 2];
8662                op_bg[pi + 3] = px_data[pi + 3];
8663                op_touched[mi] = 1;
8664            }
8665
8666            px_data[pi] = (r * 255.0).round() as u8;
8667            px_data[pi + 1] = (g * 255.0).round() as u8;
8668            px_data[pi + 2] = (b * 255.0).round() as u8;
8669            px_data[pi + 3] = 255;
8670        }
8671    }
8672}
8673
8674/// Update CMYK buffer for a non-overprint image.
8675///
8676/// For native-CMYK image color spaces (DeviceCMYK / ICCBased(4) / Separation
8677/// or DeviceN with CMYK alt), the source CMYK is sampled directly via
8678/// `sample_pixel_cmyk`. For non-CMYK source spaces (RGB/Gray/Lab/etc.), the
8679/// already-composited pixmap pixel is read and reverse-converted to CMYK via
8680/// the system CMYK ICC profile, falling back to the PLRM formula. This keeps
8681/// the parallel CMYK buffer faithful for any image painter inside a
8682/// CMYK-tracked context.
8683#[allow(clippy::too_many_arguments)]
8684fn update_cmyk_buffer_for_image(
8685    cmyk_buf: &mut [f32],
8686    sample_data: &[u8],
8687    pixmap_rgba: &[u8],
8688    params: &ImageParams,
8689    vp_x: f32,
8690    vp_y: f32,
8691    scale_x: f32,
8692    scale_y: f32,
8693    out_w: u32,
8694    out_h: u32,
8695    clip_region: &Option<ClipRegion>,
8696    icc: Option<&IccCache>,
8697) {
8698    let iw = params.width as usize;
8699    let ih = params.height as usize;
8700    let Some(image_inv) = params.image_matrix.invert() else {
8701        return;
8702    };
8703    let combined = params.ctm.concat(&image_inv);
8704    let Some(inv_combined) = combined.invert() else {
8705        return;
8706    };
8707    let stride = out_w as usize;
8708    let inv_sx = 1.0 / scale_x as f64;
8709    let inv_sy = 1.0 / scale_y as f64;
8710
8711    let mask_info = if let ImageColorSpace::Mask {
8712        color, polarity, ..
8713    } = &params.color_space
8714    {
8715        let Some((c, m, y, k)) = color.native_cmyk else {
8716            return;
8717        };
8718        Some((
8719            c as f32,
8720            m as f32,
8721            y as f32,
8722            k as f32,
8723            *polarity,
8724            iw.div_ceil(8),
8725        ))
8726    } else {
8727        None
8728    };
8729
8730    let clip_data: Option<&[u8]> = match clip_region {
8731        Some(ClipRegion::Mask(m)) => Some(m.data()),
8732        _ => None,
8733    };
8734    let clip_rect = match clip_region {
8735        Some(ClipRegion::Rect(r)) => Some(*r),
8736        _ => None,
8737    };
8738
8739    for by in 0..out_h as usize {
8740        for bx in 0..out_w as usize {
8741            if let Some(ref r) = clip_rect
8742                && ((by as u32) < r.y0
8743                    || (by as u32) >= r.y1
8744                    || (bx as u32) < r.x0
8745                    || (bx as u32) >= r.x1)
8746            {
8747                continue;
8748            }
8749            if let Some(clip) = clip_data
8750                && clip[by * stride + bx] == 0
8751            {
8752                continue;
8753            }
8754
8755            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8756            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8757            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8758            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8759
8760            let col = ix.floor() as i64;
8761            let row = iy.floor() as i64;
8762            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8763                continue;
8764            }
8765            let col = col as usize;
8766            let row = row as usize;
8767
8768            let ci = (by * stride + bx) * 4;
8769            if let Some((sc, sm, sy, sk, polarity, bytes_per_row)) = mask_info {
8770                let byte_idx = row * bytes_per_row + col / 8;
8771                let bit_offset = 7 - (col % 8);
8772                let bit = if byte_idx < sample_data.len() {
8773                    (sample_data[byte_idx] >> bit_offset) & 1
8774                } else {
8775                    0
8776                };
8777                let paint = if polarity { bit == 1 } else { bit == 0 };
8778                if paint {
8779                    cmyk_buf[ci] = sc;
8780                    cmyk_buf[ci + 1] = sm;
8781                    cmyk_buf[ci + 2] = sy;
8782                    cmyk_buf[ci + 3] = sk;
8783                }
8784            } else if let Some((sc, sm, sy, sk)) =
8785                sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8786            {
8787                cmyk_buf[ci] = sc as f32;
8788                cmyk_buf[ci + 1] = sm as f32;
8789                cmyk_buf[ci + 2] = sy as f32;
8790                cmyk_buf[ci + 3] = sk as f32;
8791            } else if ci + 3 < pixmap_rgba.len() && pixmap_rgba[ci + 3] > 0 {
8792                // Non-CMYK source space: reverse-convert the composited pixmap
8793                // pixel to CMYK via the system profile. Falls back to PLRM
8794                // (1 − r, 1 − g, 1 − b, 0) when no ICC reverse is available.
8795                let r = pixmap_rgba[ci] as f64 / 255.0;
8796                let g = pixmap_rgba[ci + 1] as f64 / 255.0;
8797                let b = pixmap_rgba[ci + 2] as f64 / 255.0;
8798                let cmyk =
8799                    if let Some(c) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
8800                        c
8801                    } else {
8802                        [
8803                            (1.0 - r).clamp(0.0, 1.0),
8804                            (1.0 - g).clamp(0.0, 1.0),
8805                            (1.0 - b).clamp(0.0, 1.0),
8806                            0.0,
8807                        ]
8808                    };
8809                cmyk_buf[ci] = cmyk[0] as f32;
8810                cmyk_buf[ci + 1] = cmyk[1] as f32;
8811                cmyk_buf[ci + 2] = cmyk[2] as f32;
8812                cmyk_buf[ci + 3] = cmyk[3] as f32;
8813            }
8814        }
8815    }
8816}
8817/// Check if an image color space can be rendered through the overprint path.
8818/// Image masks always work (they use the fill color's native CMYK).
8819/// Other color spaces must be CMYK-resolvable via `sample_pixel_cmyk`.
8820fn image_supports_overprint(cs: &ImageColorSpace) -> bool {
8821    use stet_graphics::device::cmyk_channel_for_name;
8822    match cs {
8823        ImageColorSpace::Mask { .. } => true,
8824        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => true,
8825        ImageColorSpace::Separation {
8826            alt_space, name, ..
8827        } => {
8828            matches!(
8829                alt_space.as_ref(),
8830                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8831            ) || cmyk_channel_for_name(name) != 0
8832        }
8833        ImageColorSpace::DeviceN {
8834            alt_space, names, ..
8835        } => {
8836            matches!(
8837                alt_space.as_ref(),
8838                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8839            ) || names.iter().any(|n| cmyk_channel_for_name(n) != 0)
8840        }
8841        ImageColorSpace::Indexed { base, .. } => image_supports_overprint(base),
8842        _ => false,
8843    }
8844}
8845
8846/// Check if an image color space is CMYK-based (DeviceCMYK, ICCBased 4-component, or Indexed over CMYK).
8847fn is_cmyk_color_space(cs: &ImageColorSpace) -> bool {
8848    match cs {
8849        ImageColorSpace::DeviceCMYK => true,
8850        ImageColorSpace::ICCBased { n: 4, .. } => true,
8851        ImageColorSpace::Indexed { base, .. } => is_cmyk_color_space(base),
8852        _ => false,
8853    }
8854}
8855
8856/// True when an image's color space is a Separation/DeviceN with at least
8857/// one non-process spot colorant. These images represent paint that affects
8858/// a virtual spot plate; the per-pixel CMYK produced by the tint transform
8859/// (when alt is CMYK) — or extracted directly from named process colorants
8860/// (when alt is non-CMYK) — must blend with the tracked CMYK buffer per
8861/// OPM=1: named process plates are replaced and unnamed plates are preserved.
8862fn image_cs_has_spot_tint_transform(cs: &ImageColorSpace) -> bool {
8863    use stet_graphics::device::cmyk_channel_for_name;
8864    let is_cmyk_alt = |alt: &ImageColorSpace| {
8865        matches!(
8866            alt,
8867            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8868        )
8869    };
8870    match cs {
8871        ImageColorSpace::Separation {
8872            name, alt_space, ..
8873        } => cmyk_channel_for_name(name) == 0 && is_cmyk_alt(alt_space.as_ref()),
8874        ImageColorSpace::DeviceN {
8875            names, alt_space, ..
8876        } => {
8877            let has_spot = names.iter().any(|n| cmyk_channel_for_name(n) == 0);
8878            let has_process = names.iter().any(|n| cmyk_channel_for_name(n) != 0);
8879            has_spot && (is_cmyk_alt(alt_space.as_ref()) || has_process)
8880        }
8881        ImageColorSpace::Indexed { base, .. } => image_cs_has_spot_tint_transform(base),
8882        _ => false,
8883    }
8884}
8885
8886/// True when the image's tint transform alt is non-CMYK (Lab/RGB/Gray/etc.).
8887/// In that case the per-pixel CMYK from `sample_pixel_cmyk` only carries the
8888/// named process colorants extracted directly — it doesn't capture spot
8889/// colorant contributions, so visual painting (when the buffer is fresh)
8890/// must come from `sample_pixel_visual_rgb` instead of CMYK→RGB conversion.
8891fn image_cs_alt_is_non_cmyk(cs: &ImageColorSpace) -> bool {
8892    let is_cmyk_alt = |alt: &ImageColorSpace| {
8893        matches!(
8894            alt,
8895            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8896        )
8897    };
8898    match cs {
8899        ImageColorSpace::Separation { alt_space, .. }
8900        | ImageColorSpace::DeviceN { alt_space, .. } => !is_cmyk_alt(alt_space.as_ref()),
8901        ImageColorSpace::Indexed { base, .. } => image_cs_alt_is_non_cmyk(base),
8902        _ => false,
8903    }
8904}
8905
8906/// Sample a pixel's visual RGB (0..1) via the tint-transform → alt-space →
8907/// RGB chain. Used by the spot-tint overprint path when the image's alt is
8908/// non-CMYK; for those images the named-colorant CMYK extraction loses the
8909/// spot contribution, but the tint table still produces the correct visual.
8910fn sample_pixel_visual_rgb(
8911    sample_data: &[u8],
8912    cs: &ImageColorSpace,
8913    iw: usize,
8914    row: usize,
8915    col: usize,
8916) -> Option<(f64, f64, f64)> {
8917    let to_f64 = |(r, g, b): (u8, u8, u8)| (r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0);
8918    match cs {
8919        ImageColorSpace::Separation {
8920            alt_space,
8921            tint_table,
8922            ..
8923        } => {
8924            let si = row * iw + col;
8925            if si >= sample_data.len() {
8926                return None;
8927            }
8928            let tint = sample_data[si] as f32 / 255.0;
8929            let no = tint_table.num_outputs as usize;
8930            let mut comps = vec![0.0f32; no];
8931            tint_table.lookup_1d(tint, &mut comps);
8932            Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
8933        }
8934        ImageColorSpace::DeviceN {
8935            alt_space,
8936            tint_table,
8937            ..
8938        } => {
8939            let ni = tint_table.num_inputs as usize;
8940            let si = (row * iw + col) * ni;
8941            if si + ni > sample_data.len() {
8942                return None;
8943            }
8944            let mut inputs = vec![0.0f32; ni];
8945            for (c, inp) in inputs.iter_mut().enumerate() {
8946                *inp = sample_data[si + c] as f32 / 255.0;
8947            }
8948            let no = tint_table.num_outputs as usize;
8949            let mut comps = vec![0.0f32; no];
8950            tint_table.lookup_nd(&inputs, &mut comps);
8951            Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
8952        }
8953        ImageColorSpace::Indexed {
8954            base,
8955            hival,
8956            lookup,
8957        } => {
8958            let pi = row * iw + col;
8959            if pi >= sample_data.len() {
8960                return None;
8961            }
8962            let idx = (sample_data[pi] as usize).min(*hival as usize);
8963            let base_ncomp = base.num_components() as usize;
8964            let li = idx * base_ncomp;
8965            if li + base_ncomp > lookup.len() {
8966                return None;
8967            }
8968            match base.as_ref() {
8969                ImageColorSpace::Separation {
8970                    alt_space,
8971                    tint_table,
8972                    ..
8973                } => {
8974                    let tint = lookup[li] as f32 / 255.0;
8975                    let no = tint_table.num_outputs as usize;
8976                    let mut comps = vec![0.0f32; no];
8977                    tint_table.lookup_1d(tint, &mut comps);
8978                    Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
8979                }
8980                ImageColorSpace::DeviceN {
8981                    alt_space,
8982                    tint_table,
8983                    ..
8984                } => {
8985                    let ni = tint_table.num_inputs as usize;
8986                    let mut inputs = vec![0.0f32; ni];
8987                    for (c, inp) in inputs.iter_mut().enumerate() {
8988                        if c < base_ncomp {
8989                            *inp = lookup[li + c] as f32 / 255.0;
8990                        }
8991                    }
8992                    let no = tint_table.num_outputs as usize;
8993                    let mut comps = vec![0.0f32; no];
8994                    tint_table.lookup_nd(&inputs, &mut comps);
8995                    Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
8996                }
8997                _ => None,
8998            }
8999        }
9000        _ => None,
9001    }
9002}
9003
9004/// Extract CMYK values from DeviceN colorant inputs by mapping each named
9005/// process colorant directly to its CMYK channel. Spot colorants and `/None`
9006/// don't contribute. Used when the DeviceN's alt is non-CMYK so the tint
9007/// transform can't produce CMYK; the named-colorant inputs are themselves the
9008/// per-pixel ink amounts for the named process plates.
9009fn devicen_named_cmyk(names: &[Vec<u8>], inputs: &[u8]) -> (f64, f64, f64, f64) {
9010    use stet_graphics::device::{CMYK_C, CMYK_K, CMYK_M, CMYK_Y, cmyk_channel_for_name};
9011    let mut c = 0.0;
9012    let mut m = 0.0;
9013    let mut y = 0.0;
9014    let mut k = 0.0;
9015    for (i, name) in names.iter().enumerate() {
9016        let bit = cmyk_channel_for_name(name);
9017        if bit == 0 {
9018            continue;
9019        }
9020        let v = inputs.get(i).copied().unwrap_or(0) as f64 / 255.0;
9021        if bit & CMYK_C != 0 {
9022            c = v;
9023        }
9024        if bit & CMYK_M != 0 {
9025            m = v;
9026        }
9027        if bit & CMYK_Y != 0 {
9028            y = v;
9029        }
9030        if bit & CMYK_K != 0 {
9031            k = v;
9032        }
9033    }
9034    (c, m, y, k)
9035}
9036
9037/// Sample a single pixel's CMYK values from image data, handling DeviceCMYK,
9038/// ICCBased(4), Separation/DeviceN (CMYK alt via tint table, or non-CMYK alt
9039/// via named-colorant extraction), and Indexed color spaces. Returns None for
9040/// non-CMYK images.
9041fn sample_pixel_cmyk(
9042    sample_data: &[u8],
9043    cs: &ImageColorSpace,
9044    iw: usize,
9045    row: usize,
9046    col: usize,
9047) -> Option<(f64, f64, f64, f64)> {
9048    use stet_graphics::device::cmyk_channel_for_name;
9049    let is_cmyk_alt = |alt: &ImageColorSpace| {
9050        matches!(
9051            alt,
9052            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
9053        )
9054    };
9055    match cs {
9056        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => {
9057            let si = (row * iw + col) * 4;
9058            if si + 3 < sample_data.len() {
9059                Some((
9060                    sample_data[si] as f64 / 255.0,
9061                    sample_data[si + 1] as f64 / 255.0,
9062                    sample_data[si + 2] as f64 / 255.0,
9063                    sample_data[si + 3] as f64 / 255.0,
9064                ))
9065            } else {
9066                None
9067            }
9068        }
9069        ImageColorSpace::Separation {
9070            alt_space,
9071            tint_table,
9072            name,
9073        } => {
9074            let si = row * iw + col;
9075            if si >= sample_data.len() {
9076                return None;
9077            }
9078            let tint = sample_data[si] as f32 / 255.0;
9079            if is_cmyk_alt(alt_space.as_ref()) {
9080                let mut alt = [0.0f32; 4];
9081                tint_table.lookup_1d(tint, &mut alt);
9082                return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
9083            }
9084            // Non-CMYK alt: only a named process colorant is recoverable.
9085            let bit = cmyk_channel_for_name(name);
9086            if bit == 0 {
9087                return None;
9088            }
9089            let names = vec![name.clone()];
9090            let inputs = [(tint * 255.0).round() as u8];
9091            Some(devicen_named_cmyk(&names, &inputs))
9092        }
9093        ImageColorSpace::DeviceN {
9094            alt_space,
9095            tint_table,
9096            names,
9097        } => {
9098            let ni = tint_table.num_inputs as usize;
9099            let si = (row * iw + col) * ni;
9100            if si + ni > sample_data.len() {
9101                return None;
9102            }
9103            if is_cmyk_alt(alt_space.as_ref()) {
9104                let mut inputs = vec![0.0f32; ni];
9105                for (c, inp) in inputs.iter_mut().enumerate() {
9106                    *inp = sample_data[si + c] as f32 / 255.0;
9107                }
9108                let mut alt = [0.0f32; 4];
9109                tint_table.lookup_nd(&inputs, &mut alt);
9110                return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
9111            }
9112            // Non-CMYK alt: extract from named process colorants directly.
9113            if !names.iter().any(|n| cmyk_channel_for_name(n) != 0) {
9114                return None;
9115            }
9116            Some(devicen_named_cmyk(names, &sample_data[si..si + ni]))
9117        }
9118        ImageColorSpace::Indexed {
9119            base,
9120            hival,
9121            lookup,
9122        } => {
9123            let pi = row * iw + col;
9124            if pi >= sample_data.len() {
9125                return None;
9126            }
9127            let idx = sample_data[pi] as usize;
9128            let idx = idx.min(*hival as usize);
9129            let base_ncomp = base.num_components() as usize;
9130            let li = idx * base_ncomp;
9131            // For direct CMYK base (4 components): read CMYK from lookup table
9132            if is_cmyk_color_space(base) && base_ncomp == 4 && li + 3 < lookup.len() {
9133                return Some((
9134                    lookup[li] as f64 / 255.0,
9135                    lookup[li + 1] as f64 / 255.0,
9136                    lookup[li + 2] as f64 / 255.0,
9137                    lookup[li + 3] as f64 / 255.0,
9138                ));
9139            }
9140            // For Separation/DeviceN base: extract base components from lookup, then tint
9141            if li + base_ncomp <= lookup.len() {
9142                match base.as_ref() {
9143                    ImageColorSpace::Separation {
9144                        alt_space,
9145                        tint_table,
9146                        name,
9147                    } => {
9148                        let tint = lookup[li] as f32 / 255.0;
9149                        if is_cmyk_alt(alt_space.as_ref()) {
9150                            let mut alt = [0.0f32; 4];
9151                            tint_table.lookup_1d(tint, &mut alt);
9152                            return Some((
9153                                alt[0] as f64,
9154                                alt[1] as f64,
9155                                alt[2] as f64,
9156                                alt[3] as f64,
9157                            ));
9158                        }
9159                        // Non-CMYK alt: only named process colorants extractable.
9160                        let bit = cmyk_channel_for_name(name);
9161                        if bit == 0 {
9162                            return None;
9163                        }
9164                        let names = vec![name.clone()];
9165                        let inputs = [(tint * 255.0).round() as u8];
9166                        return Some(devicen_named_cmyk(&names, &inputs));
9167                    }
9168                    ImageColorSpace::DeviceN {
9169                        alt_space,
9170                        tint_table,
9171                        names,
9172                    } => {
9173                        let ni = tint_table.num_inputs as usize;
9174                        if is_cmyk_alt(alt_space.as_ref()) {
9175                            let mut inputs = vec![0.0f32; ni];
9176                            for (c, inp) in inputs.iter_mut().enumerate() {
9177                                if c < base_ncomp {
9178                                    *inp = lookup[li + c] as f32 / 255.0;
9179                                }
9180                            }
9181                            let mut alt = [0.0f32; 4];
9182                            tint_table.lookup_nd(&inputs, &mut alt);
9183                            return Some((
9184                                alt[0] as f64,
9185                                alt[1] as f64,
9186                                alt[2] as f64,
9187                                alt[3] as f64,
9188                            ));
9189                        }
9190                        // Non-CMYK alt: extract from named process colorants directly.
9191                        if !names.iter().any(|n| cmyk_channel_for_name(n) != 0) {
9192                            return None;
9193                        }
9194                        let take = ni.min(base_ncomp);
9195                        return Some(devicen_named_cmyk(names, &lookup[li..li + take]));
9196                    }
9197                    _ => {}
9198                }
9199            }
9200            None
9201        }
9202        _ => None,
9203    }
9204}
9205/// Banded rendering as a free function — runs on a background thread.
9206///
9207/// Renders the display list in horizontal bands and streams the output
9208/// to a `PageSink`. This function is self-contained: it creates its own
9209/// band pixmaps, clip state, and streams rows to the sink.
9210#[allow(clippy::too_many_arguments)]
9211fn render_banded_to_sink(
9212    page_w: u32,
9213    page_h: u32,
9214    band_h: u32,
9215    dpi: f64,
9216    list: &DisplayList,
9217    sink: &mut dyn stet_graphics::device::PageSink,
9218    icc_cache: &IccCache,
9219    no_aa: bool,
9220    layer_set: &LayerSet,
9221) -> Result<(), String> {
9222    // Precompute Y bounding boxes for culling
9223    let bboxes = precompute_bboxes(list, dpi);
9224
9225    // Build clip epochs — groups of elements between InitClip boundaries.
9226    // Epochs whose paint elements don't overlap a band can be skipped entirely,
9227    // avoiding both the per-element iteration AND clip mask rasterization.
9228    let epochs = build_clip_epochs(list, &bboxes);
9229
9230    // Pre-populate clip_mask_seen so repeated clip paths get cached from first band
9231    let clip_seen = precompute_clip_seen(list);
9232
9233    // Allocate a CMYK buffer at the page level when CMYK math is needed:
9234    // overprint simulation, an explicit DeviceCMYK page-level transparency
9235    // group (PDF spec §11.6.7), or any descendant group that declares its own
9236    // DeviceCMYK transparency CS.
9237    use stet_graphics::display_list::GroupColorSpace;
9238    let needs_cmyk_buffer = has_overprint_elements(list)
9239        || list.page_group_color_space() == GroupColorSpace::DeviceCMYK
9240        || has_cmyk_group(list);
9241
9242    // Pre-convert and prescale images once (instead of per-band)
9243    let preprocessed_images = preprocess_images_for_bands(list, Some(icc_cache));
9244
9245    // Extra rows rendered above and below each band to provide anti-aliasing
9246    // context at band seams. Without this, tiny-skia clips geometry at the
9247    // pixmap edge, producing visible discontinuities in thin diagonal strokes.
9248    const BAND_OVERLAP: u32 = 6;
9249
9250    let render_h = band_h + 2 * BAND_OVERLAP;
9251
9252    // Initialize the sink for this page
9253    sink.begin_page(page_w, page_h)?;
9254
9255    let num_bands = page_h.div_ceil(band_h);
9256    let elements = list.elements();
9257    let row_bytes = page_w as usize * 4;
9258    let icc_ref = Some(icc_cache);
9259
9260    // Closure that renders a single band and returns its RGBA pixels.
9261    let render_band = |band_idx: u32| -> Vec<u8> {
9262        let y_start = band_idx * band_h;
9263        let actual_h = (page_h - y_start).min(band_h);
9264
9265        let render_y_start = y_start.saturating_sub(BAND_OVERLAP);
9266        let render_y_end_f = ((y_start + actual_h + BAND_OVERLAP).min(page_h)) as f64;
9267        let band_offset = y_start - render_y_start;
9268
9269        let mut band_pixmap = Pixmap::new(page_w, render_h).expect("Failed to create band pixmap");
9270        // Start transparent — white background composited after content rendering
9271        band_pixmap.as_mut().data_mut().fill(0x00);
9272
9273        let cmyk_buf = if needs_cmyk_buffer {
9274            // CMYK buffer for the render region (including overlap)
9275            Some(vec![0.0f32; page_w as usize * render_h as usize * 4])
9276        } else {
9277            None
9278        };
9279
9280        let mut band_state = BandState {
9281            clip_region: None,
9282            spare_mask: None,
9283            clip_mask_cache: HashMap::new(),
9284            clip_mask_seen: clip_seen.clone(),
9285            mask_pool: Vec::new(),
9286            cmyk_buffer: cmyk_buf,
9287            op_bg_snapshot: None,
9288            op_touched: None,
9289            spot_mask: None,
9290        };
9291
9292        // Epoch-based replay
9293        for epoch in &epochs {
9294            if !epoch.has_erase_page {
9295                match epoch.paint_bbox {
9296                    Some(ref pb)
9297                        if pb.y_max <= render_y_start as f64 || pb.y_min >= render_y_end_f =>
9298                    {
9299                        continue;
9300                    }
9301                    None => continue,
9302                    _ => {}
9303                }
9304            }
9305
9306            for i in epoch.start_idx..epoch.end_idx {
9307                // OcgGroups containing Clip/InitClip must always be
9308                // processed so their clip-state changes apply for every
9309                // band — per-element Y culling would strand clip mutations
9310                // inside a group whose paint content doesn't touch the
9311                // current band.
9312                let force_process = matches!(
9313                    &elements[i],
9314                    DisplayElement::OcgGroup { elements: inner, .. }
9315                        if contains_clip_op(inner)
9316                );
9317                if !force_process
9318                    && let Some(ref bbox) = bboxes[i]
9319                    && (bbox.y_max <= render_y_start as f64 || bbox.y_min >= render_y_end_f)
9320                {
9321                    continue;
9322                }
9323                let ctx = RenderContext {
9324                    vp_x: 0.0,
9325                    vp_y: render_y_start as f32,
9326                    scale_x: 1.0,
9327                    scale_y: 1.0,
9328                    out_w: page_w,
9329                    out_h: render_h,
9330                    effective_dpi: dpi,
9331                    icc: icc_ref,
9332                    image_cache: None,
9333                    preprocessed: Some(&preprocessed_images),
9334                    elem_idx: i,
9335                    no_aa,
9336                    opm_zero_transparent: false,
9337                    knockout_painter_pass: KnockoutPainterPass::None,
9338                    parent_group_isolated: false,
9339                    alpha_extraction_pass: false,
9340                    layer_set,
9341                };
9342                render_element(&mut band_pixmap, &mut band_state, &elements[i], &ctx);
9343            }
9344        }
9345
9346        // Composite content onto white background (premultiplied alpha)
9347        composite_onto_white(band_pixmap.data_mut());
9348
9349        // Extract only the actual band rows (skip overlap)
9350        let start_byte = band_offset as usize * row_bytes;
9351        let total_bytes = actual_h as usize * row_bytes;
9352        band_pixmap.data()[start_byte..start_byte + total_bytes].to_vec()
9353    };
9354
9355    // Render bands in parallel (when available), write to sink in order.
9356    #[cfg(feature = "parallel")]
9357    {
9358        // Process in chunks of `chunk_size` bands to limit peak memory
9359        // (each rendered band is ~band_h * page_w * 4 bytes).
9360        // Cap at 8 threads — sequential sink writing bottleneck means
9361        // additional cores yield no speedup (benchmarked: 8→7.8s plateau).
9362        let chunk_size = rayon::current_num_threads().max(1);
9363
9364        for chunk_start in (0..num_bands).step_by(chunk_size) {
9365            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
9366
9367            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
9368                .into_par_iter()
9369                .map(&render_band)
9370                .collect();
9371
9372            for (i, band_data) in rendered.iter().enumerate() {
9373                let band_idx = chunk_start + i as u32;
9374                let y_start = band_idx * band_h;
9375                let actual_h = (page_h - y_start).min(band_h);
9376                sink.write_rows(band_data, actual_h)?;
9377            }
9378        }
9379    }
9380    #[cfg(not(feature = "parallel"))]
9381    {
9382        // Sequential single-threaded rendering
9383        for band_idx in 0..num_bands {
9384            let band_data = render_band(band_idx);
9385            let y_start = band_idx * band_h;
9386            let actual_h = (page_h - y_start).min(band_h);
9387            sink.write_rows(&band_data, actual_h)?;
9388        }
9389    }
9390
9391    sink.end_page()
9392}
9393
9394/// 2D bounding box in device pixels.
9395#[derive(Clone, Copy)]
9396struct BBox2D {
9397    x_min: f64,
9398    y_min: f64,
9399    x_max: f64,
9400    y_max: f64,
9401}
9402
9403/// Compute full 2D bounding boxes for display list elements (for viewport culling).
9404fn precompute_full_bboxes(list: &DisplayList, dpi: f64) -> Vec<Option<BBox2D>> {
9405    list.elements()
9406        .iter()
9407        .map(|elem| match elem {
9408            DisplayElement::Fill { path, params } => fill_device_full_bbox(path, &params.ctm),
9409            DisplayElement::Stroke { path, params } => {
9410                path_full_bbox(path).map(|mut bbox| {
9411                    // Use effective line width: actual width or hairline minimum
9412                    let effective_lw = params.line_width.max(hairline_min_width(&params.ctm, dpi));
9413                    let expand = effective_lw * params.miter_limit * 0.5;
9414                    let m = &params.ctm;
9415                    let is_identity = m.a == 1.0
9416                        && m.b == 0.0
9417                        && m.c == 0.0
9418                        && m.d == 1.0
9419                        && m.tx == 0.0
9420                        && m.ty == 0.0;
9421                    if is_identity {
9422                        bbox.x_min -= expand;
9423                        bbox.x_max += expand;
9424                        bbox.y_min -= expand;
9425                        bbox.y_max += expand;
9426                    } else {
9427                        // Path is in user space — expand for stroke, then
9428                        // transform bbox corners through CTM to device space.
9429                        let col_x_len = (m.a * m.a + m.b * m.b).sqrt().max(1.0);
9430                        let col_y_len = (m.c * m.c + m.d * m.d).sqrt().max(1.0);
9431                        let expand_x = effective_lw * col_x_len * params.miter_limit * 0.5;
9432                        let expand_y = effective_lw * col_y_len * params.miter_limit * 0.5;
9433                        bbox.x_min -= expand_x;
9434                        bbox.x_max += expand_x;
9435                        bbox.y_min -= expand_y;
9436                        bbox.y_max += expand_y;
9437                        // Transform all 4 corners to device space
9438                        let corners = [
9439                            (
9440                                m.a * bbox.x_min + m.c * bbox.y_min + m.tx,
9441                                m.b * bbox.x_min + m.d * bbox.y_min + m.ty,
9442                            ),
9443                            (
9444                                m.a * bbox.x_max + m.c * bbox.y_min + m.tx,
9445                                m.b * bbox.x_max + m.d * bbox.y_min + m.ty,
9446                            ),
9447                            (
9448                                m.a * bbox.x_min + m.c * bbox.y_max + m.tx,
9449                                m.b * bbox.x_min + m.d * bbox.y_max + m.ty,
9450                            ),
9451                            (
9452                                m.a * bbox.x_max + m.c * bbox.y_max + m.tx,
9453                                m.b * bbox.x_max + m.d * bbox.y_max + m.ty,
9454                            ),
9455                        ];
9456                        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9457                        bbox.x_max = corners
9458                            .iter()
9459                            .map(|c| c.0)
9460                            .fold(f64::NEG_INFINITY, f64::max);
9461                        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9462                        bbox.y_max = corners
9463                            .iter()
9464                            .map(|c| c.1)
9465                            .fold(f64::NEG_INFINITY, f64::max);
9466                    }
9467                    bbox
9468                })
9469            }
9470            DisplayElement::Image { params, .. } => image_full_bbox(params),
9471            DisplayElement::AxialShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9472            DisplayElement::RadialShading { params } => {
9473                shading_full_bbox(&params.bbox, &params.ctm)
9474            }
9475            DisplayElement::MeshShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9476            DisplayElement::PatchShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9477            DisplayElement::PatternFill { params } => pattern_fill_full_bbox(params),
9478            DisplayElement::Group { params, .. } => Some(BBox2D {
9479                x_min: params.bbox[0],
9480                y_min: params.bbox[1],
9481                x_max: params.bbox[2],
9482                y_max: params.bbox[3],
9483            }),
9484            DisplayElement::SoftMasked { params, .. } => Some(BBox2D {
9485                x_min: params.bbox[0],
9486                y_min: params.bbox[1],
9487                x_max: params.bbox[2],
9488                y_max: params.bbox[3],
9489            }),
9490            DisplayElement::OcgGroup {
9491                elements,
9492                visibility,
9493            } => {
9494                // Hidden groups without clip ops contribute nothing. Hidden
9495                // + has clip ops is force-processed at the render-loop layer
9496                // (see the viewport render_region_prepared loop) so we still
9497                // return the paint bounds here for correct epoch bbox.
9498                if !visibility.default_visible() && !contains_clip_op(elements) {
9499                    return None;
9500                }
9501                let child_bboxes = precompute_full_bboxes(elements, dpi);
9502                let mut x_min = f64::INFINITY;
9503                let mut y_min = f64::INFINITY;
9504                let mut x_max = f64::NEG_INFINITY;
9505                let mut y_max = f64::NEG_INFINITY;
9506                for cb in child_bboxes.into_iter().flatten() {
9507                    x_min = x_min.min(cb.x_min);
9508                    y_min = y_min.min(cb.y_min);
9509                    x_max = x_max.max(cb.x_max);
9510                    y_max = y_max.max(cb.y_max);
9511                }
9512                if x_min <= x_max && y_min <= y_max {
9513                    Some(BBox2D {
9514                        x_min,
9515                        y_min,
9516                        x_max,
9517                        y_max,
9518                    })
9519                } else {
9520                    None
9521                }
9522            }
9523            _ => None, // Clip, InitClip, ErasePage: always process
9524        })
9525        .collect()
9526}
9527
9528/// Compute the device-space bounding box of a Clip element's path.
9529///
9530/// Clip paths emitted by the PDF reader use `ctm = identity`, so the path
9531/// segments are already in device space. For Clips that come from other
9532/// sources (PostScript, the pattern transform path), the `ctm` field may
9533/// be non-identity and the path is in user space — transform the path's
9534/// bbox corners through the CTM in that case. Stroke-clips are expanded
9535/// by half the line width.
9536fn clip_path_bbox(path: &PsPath, params: &ClipParams) -> Option<BBox2D> {
9537    let mut bbox = path_full_bbox(path)?;
9538    let ctm = &params.ctm;
9539    let is_identity = ctm.a == 1.0
9540        && ctm.b == 0.0
9541        && ctm.c == 0.0
9542        && ctm.d == 1.0
9543        && ctm.tx == 0.0
9544        && ctm.ty == 0.0;
9545    if !is_identity {
9546        let corners = [
9547            ctm.transform_point(bbox.x_min, bbox.y_min),
9548            ctm.transform_point(bbox.x_max, bbox.y_min),
9549            ctm.transform_point(bbox.x_min, bbox.y_max),
9550            ctm.transform_point(bbox.x_max, bbox.y_max),
9551        ];
9552        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9553        bbox.x_max = corners
9554            .iter()
9555            .map(|c| c.0)
9556            .fold(f64::NEG_INFINITY, f64::max);
9557        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9558        bbox.y_max = corners
9559            .iter()
9560            .map(|c| c.1)
9561            .fold(f64::NEG_INFINITY, f64::max);
9562    }
9563    if let Some(sp) = &params.stroke_params {
9564        let scale = (ctm.a * ctm.a + ctm.b * ctm.b)
9565            .sqrt()
9566            .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt())
9567            .max(1.0);
9568        let expand = sp.line_width * 0.5 * scale;
9569        bbox.x_min -= expand;
9570        bbox.x_max += expand;
9571        bbox.y_min -= expand;
9572        bbox.y_max += expand;
9573    }
9574    Some(bbox)
9575}
9576
9577/// Intersect two bboxes; returns `None` if they don't overlap.
9578fn intersect_bbox(a: &BBox2D, b: &BBox2D) -> Option<BBox2D> {
9579    let x_min = a.x_min.max(b.x_min);
9580    let y_min = a.y_min.max(b.y_min);
9581    let x_max = a.x_max.min(b.x_max);
9582    let y_max = a.y_max.min(b.y_max);
9583    if x_min < x_max && y_min < y_max {
9584        Some(BBox2D {
9585            x_min,
9586            y_min,
9587            x_max,
9588            y_max,
9589        })
9590    } else {
9591        None
9592    }
9593}
9594
9595/// Compute the union of all paint elements' device-space bounds in
9596/// `list`, with awareness of the active clip stack.
9597///
9598/// Used by the soft-mask rasterization path: a SoftMasked element's
9599/// `params.bbox` is derived from the form's `/BBox` transformed by the
9600/// gs-time CTM, but the form's internal `cm` operators may translate
9601/// individual paint elements outside that bbox. The mask raster needs to
9602/// be sized against the actual paint bounds, not the form bbox.
9603///
9604/// **Why clip-awareness matters**: a mask form may contain a shading
9605/// without an explicit `/BBox`, in which case `precompute_full_bboxes`
9606/// returns a sentinel "infinite" bbox (`shading_full_bbox` falls back to
9607/// `0..1e9`) so band rendering doesn't cull it. If `compute_paint_bounds`
9608/// just unioned that, the result would exceed the mask raster size cap
9609/// and `rasterize_mask` would return `None`, making the entire SoftMasked
9610/// element invisible. Tracking the active clip stack lets us bound those
9611/// shadings to their effective paint area.
9612///
9613/// Returns `None` when the list contains no paintable elements or when
9614/// no element survives clip culling.
9615fn compute_paint_bounds(list: &DisplayList, _dpi: f64) -> Option<BBox2D> {
9616    // Active clip stack: each entry is the intersection so far. The
9617    // current clip is `clip_stack.last()`; an empty stack means
9618    // "unbounded" (no clip established yet, or just after InitClip).
9619    let mut clip_stack: Vec<BBox2D> = Vec::new();
9620    let mut union: Option<BBox2D> = None;
9621
9622    let push_paint = |union: &mut Option<BBox2D>, clip_stack: &[BBox2D], bbox: BBox2D| {
9623        // Intersect against the active clip if any. If the clip is
9624        // tighter than the bbox, the visible region is the intersection;
9625        // if the bbox is fully clipped away, skip it.
9626        let visible = match clip_stack.last() {
9627            Some(clip) => match intersect_bbox(clip, &bbox) {
9628                Some(b) => b,
9629                None => return,
9630            },
9631            None => bbox,
9632        };
9633        *union = Some(match union.take() {
9634            None => visible,
9635            Some(u) => BBox2D {
9636                x_min: u.x_min.min(visible.x_min),
9637                y_min: u.y_min.min(visible.y_min),
9638                x_max: u.x_max.max(visible.x_max),
9639                y_max: u.y_max.max(visible.y_max),
9640            },
9641        });
9642    };
9643
9644    for elem in list.elements() {
9645        match elem {
9646            DisplayElement::Clip { path, params } => {
9647                if let Some(cb) = clip_path_bbox(path, params) {
9648                    let new_top = match clip_stack.last() {
9649                        Some(prev) => match intersect_bbox(prev, &cb) {
9650                            Some(b) => b,
9651                            // Clip cleared the visible region; push an
9652                            // empty bbox so subsequent paints are
9653                            // clipped away.
9654                            None => BBox2D {
9655                                x_min: 0.0,
9656                                y_min: 0.0,
9657                                x_max: 0.0,
9658                                y_max: 0.0,
9659                            },
9660                        },
9661                        None => cb,
9662                    };
9663                    clip_stack.push(new_top);
9664                }
9665            }
9666            DisplayElement::InitClip | DisplayElement::ErasePage => {
9667                clip_stack.clear();
9668            }
9669            DisplayElement::Fill { path, .. } => {
9670                if let Some(b) = path_full_bbox(path) {
9671                    push_paint(&mut union, &clip_stack, b);
9672                }
9673            }
9674            DisplayElement::Stroke { path, params } => {
9675                if let Some(mut b) = path_full_bbox(path) {
9676                    let expand = params.line_width * params.miter_limit * 0.5;
9677                    b.x_min -= expand;
9678                    b.x_max += expand;
9679                    b.y_min -= expand;
9680                    b.y_max += expand;
9681                    push_paint(&mut union, &clip_stack, b);
9682                }
9683            }
9684            DisplayElement::Image { params, .. } => {
9685                if let Some(b) = image_full_bbox(params) {
9686                    push_paint(&mut union, &clip_stack, b);
9687                }
9688            }
9689            DisplayElement::AxialShading { params } => {
9690                let b = match &params.bbox {
9691                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9692                    None => clip_stack.last().copied(),
9693                };
9694                if let Some(b) = b {
9695                    push_paint(&mut union, &clip_stack, b);
9696                }
9697            }
9698            DisplayElement::RadialShading { params } => {
9699                let b = match &params.bbox {
9700                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9701                    None => clip_stack.last().copied(),
9702                };
9703                if let Some(b) = b {
9704                    push_paint(&mut union, &clip_stack, b);
9705                }
9706            }
9707            DisplayElement::MeshShading { params } => {
9708                let b = match &params.bbox {
9709                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9710                    None => clip_stack.last().copied(),
9711                };
9712                if let Some(b) = b {
9713                    push_paint(&mut union, &clip_stack, b);
9714                }
9715            }
9716            DisplayElement::PatchShading { params } => {
9717                let b = match &params.bbox {
9718                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9719                    None => clip_stack.last().copied(),
9720                };
9721                if let Some(b) = b {
9722                    push_paint(&mut union, &clip_stack, b);
9723                }
9724            }
9725            DisplayElement::PatternFill { params } => {
9726                if let Some(b) = pattern_fill_full_bbox(params) {
9727                    push_paint(&mut union, &clip_stack, b);
9728                }
9729            }
9730            DisplayElement::Group { params, .. } => {
9731                push_paint(
9732                    &mut union,
9733                    &clip_stack,
9734                    BBox2D {
9735                        x_min: params.bbox[0],
9736                        y_min: params.bbox[1],
9737                        x_max: params.bbox[2],
9738                        y_max: params.bbox[3],
9739                    },
9740                );
9741            }
9742            DisplayElement::SoftMasked { params, .. } => {
9743                push_paint(
9744                    &mut union,
9745                    &clip_stack,
9746                    BBox2D {
9747                        x_min: params.bbox[0],
9748                        y_min: params.bbox[1],
9749                        x_max: params.bbox[2],
9750                        y_max: params.bbox[3],
9751                    },
9752                );
9753            }
9754            DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
9755            DisplayElement::OcgGroup { .. } => {
9756                // OCG groups have no inherent bbox; their children's bounds
9757                // are unknown without recursion. Conservative: skip here —
9758                // if the mask form contains OCG layers, the parent bbox cap
9759                // provides a sufficient upper bound.
9760            }
9761            _ => {}
9762        }
9763    }
9764    union
9765}
9766
9767/// Compute full 2D bounds from path segments.
9768/// Compute device-space 2D bounds for a Fill element, accounting for CTM.
9769/// Paths may be stored in device space (identity CTM) or user space
9770/// (non-identity CTM, e.g. synthesized annotation appearances).
9771fn fill_device_full_bbox(path: &PsPath, ctm: &Matrix) -> Option<BBox2D> {
9772    let bbox = path_full_bbox(path)?;
9773    let is_identity = ctm.a == 1.0
9774        && ctm.b == 0.0
9775        && ctm.c == 0.0
9776        && ctm.d == 1.0
9777        && ctm.tx == 0.0
9778        && ctm.ty == 0.0;
9779    if is_identity {
9780        return Some(bbox);
9781    }
9782    let corners = [
9783        (bbox.x_min, bbox.y_min),
9784        (bbox.x_max, bbox.y_min),
9785        (bbox.x_min, bbox.y_max),
9786        (bbox.x_max, bbox.y_max),
9787    ];
9788    let mut x_min = f64::INFINITY;
9789    let mut x_max = f64::NEG_INFINITY;
9790    let mut y_min = f64::INFINITY;
9791    let mut y_max = f64::NEG_INFINITY;
9792    for (x, y) in &corners {
9793        let dx = ctm.a * x + ctm.c * y + ctm.tx;
9794        let dy = ctm.b * x + ctm.d * y + ctm.ty;
9795        x_min = x_min.min(dx);
9796        x_max = x_max.max(dx);
9797        y_min = y_min.min(dy);
9798        y_max = y_max.max(dy);
9799    }
9800    Some(BBox2D {
9801        x_min,
9802        y_min,
9803        x_max,
9804        y_max,
9805    })
9806}
9807
9808fn path_full_bbox(path: &PsPath) -> Option<BBox2D> {
9809    let mut x_min = f64::INFINITY;
9810    let mut x_max = f64::NEG_INFINITY;
9811    let mut y_min = f64::INFINITY;
9812    let mut y_max = f64::NEG_INFINITY;
9813    for seg in &path.segments {
9814        match seg {
9815            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => {
9816                x_min = x_min.min(*x);
9817                x_max = x_max.max(*x);
9818                y_min = y_min.min(*y);
9819                y_max = y_max.max(*y);
9820            }
9821            PathSegment::CurveTo {
9822                x1,
9823                y1,
9824                x2,
9825                y2,
9826                x3,
9827                y3,
9828            } => {
9829                x_min = x_min.min(*x1).min(*x2).min(*x3);
9830                x_max = x_max.max(*x1).max(*x2).max(*x3);
9831                y_min = y_min.min(*y1).min(*y2).min(*y3);
9832                y_max = y_max.max(*y1).max(*y2).max(*y3);
9833            }
9834            PathSegment::ClosePath => {}
9835        }
9836    }
9837    if x_min <= x_max {
9838        Some(BBox2D {
9839            x_min,
9840            y_min,
9841            x_max,
9842            y_max,
9843        })
9844    } else {
9845        None
9846    }
9847}
9848
9849/// Compute full 2D bounds for a PatternFill element.
9850/// For stroke patterns, the path is in user space and must be transformed
9851/// through the CTM to get device-space bounds, then expanded by half
9852/// the stroke width.
9853fn pattern_fill_full_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<BBox2D> {
9854    if let Some(ref sp) = params.stroke_params {
9855        let bbox = path_full_bbox(&params.path)?;
9856        let ctm = &sp.ctm;
9857        let corners = [
9858            ctm.transform_point(bbox.x_min, bbox.y_min),
9859            ctm.transform_point(bbox.x_max, bbox.y_min),
9860            ctm.transform_point(bbox.x_min, bbox.y_max),
9861            ctm.transform_point(bbox.x_max, bbox.y_max),
9862        ];
9863        let mut dev_bbox = BBox2D {
9864            x_min: f64::INFINITY,
9865            y_min: f64::INFINITY,
9866            x_max: f64::NEG_INFINITY,
9867            y_max: f64::NEG_INFINITY,
9868        };
9869        for (x, y) in &corners {
9870            dev_bbox.x_min = dev_bbox.x_min.min(*x);
9871            dev_bbox.y_min = dev_bbox.y_min.min(*y);
9872            dev_bbox.x_max = dev_bbox.x_max.max(*x);
9873            dev_bbox.y_max = dev_bbox.y_max.max(*y);
9874        }
9875        let half_w = sp.line_width
9876            * 0.5
9877            * (ctm.a * ctm.a + ctm.b * ctm.b)
9878                .sqrt()
9879                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
9880        dev_bbox.x_min -= half_w;
9881        dev_bbox.y_min -= half_w;
9882        dev_bbox.x_max += half_w;
9883        dev_bbox.y_max += half_w;
9884        Some(dev_bbox)
9885    } else {
9886        path_full_bbox(&params.path)
9887    }
9888}
9889
9890/// Compute Y-axis bounds for a PatternFill element (banded rendering).
9891fn pattern_fill_y_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<YBBox> {
9892    let bbox = pattern_fill_full_bbox(params)?;
9893    Some(YBBox {
9894        y_min: bbox.y_min,
9895        y_max: bbox.y_max,
9896    })
9897}
9898
9899/// Compute full 2D bounds for an image from its transform.
9900fn image_full_bbox(params: &ImageParams) -> Option<BBox2D> {
9901    let m = &params.ctm;
9902    let im = &params.image_matrix;
9903    let im_inv = im.invert()?;
9904    let combined = m.concat(&im_inv);
9905    // Image occupies [0, width] × [0, height] in image space
9906    let w = params.width as f64;
9907    let h = params.height as f64;
9908    let corners = [
9909        combined.transform_point(0.0, 0.0),
9910        combined.transform_point(w, 0.0),
9911        combined.transform_point(0.0, h),
9912        combined.transform_point(w, h),
9913    ];
9914    let mut x_min = f64::INFINITY;
9915    let mut x_max = f64::NEG_INFINITY;
9916    let mut y_min = f64::INFINITY;
9917    let mut y_max = f64::NEG_INFINITY;
9918    for (x, y) in &corners {
9919        x_min = x_min.min(*x);
9920        x_max = x_max.max(*x);
9921        y_min = y_min.min(*y);
9922        y_max = y_max.max(*y);
9923    }
9924    Some(BBox2D {
9925        x_min,
9926        y_min,
9927        x_max,
9928        y_max,
9929    })
9930}
9931
9932/// Compute full 2D bounds for a shading element from its BBox.
9933fn shading_full_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<BBox2D> {
9934    if let Some(bbox) = bbox {
9935        let corners = [
9936            ctm.transform_point(bbox[0], bbox[1]),
9937            ctm.transform_point(bbox[2], bbox[1]),
9938            ctm.transform_point(bbox[0], bbox[3]),
9939            ctm.transform_point(bbox[2], bbox[3]),
9940        ];
9941        let mut x_min = f64::INFINITY;
9942        let mut x_max = f64::NEG_INFINITY;
9943        let mut y_min = f64::INFINITY;
9944        let mut y_max = f64::NEG_INFINITY;
9945        for (x, y) in &corners {
9946            x_min = x_min.min(*x);
9947            x_max = x_max.max(*x);
9948            y_min = y_min.min(*y);
9949            y_max = y_max.max(*y);
9950        }
9951        Some(BBox2D {
9952            x_min,
9953            y_min,
9954            x_max,
9955            y_max,
9956        })
9957    } else {
9958        Some(BBox2D {
9959            x_min: 0.0,
9960            y_min: 0.0,
9961            x_max: 1e9,
9962            y_max: 1e9,
9963        })
9964    }
9965}
9966
9967/// Build 2D clip epochs for viewport culling.
9968fn build_viewport_epochs(list: &DisplayList, bboxes: &[Option<BBox2D>]) -> Vec<ViewportEpoch> {
9969    let elements = list.elements();
9970    let mut epochs = Vec::new();
9971    let mut epoch_start = 0;
9972    let mut x_min = f64::INFINITY;
9973    let mut x_max = f64::NEG_INFINITY;
9974    let mut y_min = f64::INFINITY;
9975    let mut y_max = f64::NEG_INFINITY;
9976    let mut has_erase = false;
9977
9978    for (i, element) in elements.iter().enumerate() {
9979        if matches!(element, DisplayElement::InitClip) && i > epoch_start {
9980            epochs.push(ViewportEpoch {
9981                start_idx: epoch_start,
9982                end_idx: i,
9983                paint_bbox: if x_min <= x_max {
9984                    Some(BBox2D {
9985                        x_min,
9986                        y_min,
9987                        x_max,
9988                        y_max,
9989                    })
9990                } else {
9991                    None
9992                },
9993                has_erase_page: has_erase,
9994            });
9995            epoch_start = i;
9996            x_min = f64::INFINITY;
9997            x_max = f64::NEG_INFINITY;
9998            y_min = f64::INFINITY;
9999            y_max = f64::NEG_INFINITY;
10000            has_erase = false;
10001        }
10002        if matches!(element, DisplayElement::ErasePage) {
10003            has_erase = true;
10004        }
10005        if let Some(ref bbox) = bboxes[i] {
10006            x_min = x_min.min(bbox.x_min);
10007            x_max = x_max.max(bbox.x_max);
10008            y_min = y_min.min(bbox.y_min);
10009            y_max = y_max.max(bbox.y_max);
10010        }
10011    }
10012    if epoch_start < elements.len() {
10013        epochs.push(ViewportEpoch {
10014            start_idx: epoch_start,
10015            end_idx: elements.len(),
10016            paint_bbox: if x_min <= x_max {
10017                Some(BBox2D {
10018                    x_min,
10019                    y_min,
10020                    x_max,
10021                    y_max,
10022                })
10023            } else {
10024                None
10025            },
10026            has_erase_page: has_erase,
10027        });
10028    }
10029    epochs
10030}
10031
10032/// Clip epoch with full 2D bounding box for viewport culling.
10033struct ViewportEpoch {
10034    start_idx: usize,
10035    end_idx: usize,
10036    paint_bbox: Option<BBox2D>,
10037    has_erase_page: bool,
10038}
10039
10040/// Pre-computed metadata for fast viewport rendering.
10041///
10042/// Compute once per display list via [`prepare_display_list()`],
10043/// reuse across all [`render_region_prepared()`] calls. This avoids
10044/// three expensive traversals (bboxes, epochs, clip_seen) on every pan.
10045pub struct PreparedDisplayList {
10046    bboxes: Vec<Option<BBox2D>>,
10047    epochs: Vec<ViewportEpoch>,
10048    clip_seen: HashSet<u64>,
10049}
10050
10051/// Precompute display list metadata for fast viewport rendering.
10052///
10053/// Uses a conservative DPI (72.0) for hairline expansion in bounding boxes,
10054/// producing safe overestimates that work at any zoom level without recomputation.
10055pub fn prepare_display_list(list: &DisplayList) -> PreparedDisplayList {
10056    let bboxes = precompute_full_bboxes(list, 72.0);
10057    let epochs = build_viewport_epochs(list, &bboxes);
10058    let clip_seen = precompute_clip_seen(list);
10059    PreparedDisplayList {
10060        bboxes,
10061        epochs,
10062        clip_seen,
10063    }
10064}
10065
10066/// Pre-converted and prescaled image for banded rendering.
10067///
10068/// Built once per page before the band loop so that expensive RGBA conversion
10069/// and box-filter prescaling run once instead of once-per-band.
10070struct PreprocessedImage {
10071    /// RGBA pixel data (prescaled if applicable).
10072    data: Vec<u8>,
10073    /// Dimensions after prescaling.
10074    width: u32,
10075    height: u32,
10076    /// Scale/rotation part of the adjusted transform.
10077    /// Per-band rendering reconstructs the full transform by combining these
10078    /// with the band-specific translation (tx, ty).
10079    adj_sx: f32,
10080    adj_ky: f32,
10081    adj_kx: f32,
10082    adj_sy: f32,
10083    /// Filter quality for draw_pixmap.
10084    quality: stet_tiny_skia::FilterQuality,
10085}
10086
10087/// Pre-converted RGBA image data cache, indexed by display list element index.
10088///
10089/// Built once per page after display list capture. Reused across all viewport
10090/// renders so that ICC color conversion (especially CMYK→sRGB) is not repeated
10091/// on every pan/zoom.
10092pub struct ImageCache {
10093    /// RGBA data per element index. `None` for non-image elements.
10094    entries: Vec<Option<Vec<u8>>>,
10095}
10096
10097impl ImageCache {
10098    /// Build cache by pre-converting all images in the display list.
10099    pub fn build(list: &DisplayList, icc: Option<&IccCache>) -> Self {
10100        let entries = list
10101            .elements()
10102            .iter()
10103            .map(|elem| {
10104                if let DisplayElement::Image {
10105                    sample_data,
10106                    params,
10107                } = elem
10108                {
10109                    if params.width == 0 || params.height == 0 {
10110                        return None;
10111                    }
10112                    let mut rgba = samples_to_rgba(sample_data, params, icc, false);
10113                    if params.mask_color.is_some() {
10114                        apply_mask_color_rgba(&mut rgba, sample_data, params);
10115                    }
10116                    Some(rgba)
10117                } else {
10118                    None
10119                }
10120            })
10121            .collect();
10122        Self { entries }
10123    }
10124
10125    /// Get pre-converted RGBA for the element at the given index.
10126    pub fn get(&self, index: usize) -> Option<&[u8]> {
10127        self.entries.get(index).and_then(|e| e.as_deref())
10128    }
10129}
10130
10131/// Build preprocessed image cache for banded rendering.
10132///
10133/// For each Image element, converts to RGBA and prescales once.
10134/// Banded rendering then only needs `draw_pixmap` per band.
10135fn preprocess_images_for_bands(
10136    list: &DisplayList,
10137    icc: Option<&IccCache>,
10138) -> Vec<Option<PreprocessedImage>> {
10139    list.elements()
10140        .iter()
10141        .map(|elem| {
10142            let DisplayElement::Image {
10143                sample_data,
10144                params,
10145            } = elem
10146            else {
10147                return None;
10148            };
10149            let iw = params.width;
10150            let ih = params.height;
10151            if iw == 0 || ih == 0 {
10152                return None;
10153            }
10154            // Skip overprint images — they use a separate rendering path
10155            if params.overprint {
10156                return None;
10157            }
10158
10159            // Convert to RGBA
10160            let mut rgba = samples_to_rgba(sample_data, params, icc, false);
10161            if params.mask_color.is_some() {
10162                apply_mask_color_rgba(&mut rgba, sample_data, params);
10163            }
10164
10165            // Compute the device-space transform (vp_y=0, scale=1.0)
10166            let image_inv = params.image_matrix.invert()?;
10167            let combined = params.ctm.concat(&image_inv);
10168            let base_transform = enforce_min_image_size(to_transform(&combined), iw, ih);
10169
10170            // Prescale
10171            let (data, width, height, adj_t) =
10172                match prescale_image(&rgba, iw, ih, base_transform, params.interpolate) {
10173                    Some((d, w, h, t)) => {
10174                        drop(rgba); // free the full-size RGBA
10175                        (d, w, h, t)
10176                    }
10177                    None => (rgba, iw, ih, base_transform),
10178                };
10179
10180            let quality = image_filter_quality(adj_t, params.interpolate);
10181
10182            Some(PreprocessedImage {
10183                data,
10184                width,
10185                height,
10186                adj_sx: adj_t.sx,
10187                adj_ky: adj_t.ky,
10188                adj_kx: adj_t.kx,
10189                adj_sy: adj_t.sy,
10190                quality,
10191            })
10192        })
10193        .collect()
10194}
10195
10196/// Render a rectangular viewport region using precomputed metadata.
10197///
10198/// Like [`render_region()`] but skips the three precomputation passes,
10199/// using the [`PreparedDisplayList`] instead. Significantly faster for
10200/// repeated renders of the same display list (e.g., panning at a fixed zoom).
10201#[allow(clippy::too_many_arguments)]
10202pub fn render_region_prepared(
10203    list: &DisplayList,
10204    prepared: &PreparedDisplayList,
10205    vp_x: f64,
10206    vp_y: f64,
10207    vp_w: f64,
10208    vp_h: f64,
10209    pixel_w: u32,
10210    pixel_h: u32,
10211    dpi: f64,
10212    icc: Option<&IccCache>,
10213    image_cache: Option<&ImageCache>,
10214    no_aa: bool,
10215) -> Vec<u8> {
10216    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
10217        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10218    }
10219
10220    let layer_set = LayerSet::new();
10221    let scale_x = pixel_w as f64 / vp_w;
10222    let scale_y = pixel_h as f64 / vp_h;
10223    let effective_dpi = dpi * scale_x;
10224
10225    // Allocate a pixmap with the same OVERLAP padding as the banded page
10226    // renderer. This is essential for matching the banded baseline: the page
10227    // pipeline always allocates `band_h + 2*BAND_OVERLAP` rows, even for a
10228    // single-band render. tiny-skia's `Mask::fill_path` chooses between
10229    // edge-clipped and unclipped rasterization based on whether the path
10230    // bounds fit within the mask, and the two paths produce subtly different
10231    // winding counts at some pixels. Without the OVERLAP padding here, the
10232    // viewport pipeline rasterizes clip paths into a tighter mask than the
10233    // banded pipeline does, producing 39 (and other counts) of edge-pixel
10234    // divergences on samples like 1915_1.pdf.
10235    const OVERLAP: u32 = 6;
10236    let render_h = pixel_h + 2 * OVERLAP;
10237    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
10238    // Start transparent — white background composited after content rendering
10239    pixmap.fill(Color::TRANSPARENT);
10240
10241    let cmyk_buf = if has_overprint_elements(list)
10242        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10243        || has_cmyk_group(list)
10244    {
10245        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10246    } else {
10247        None
10248    };
10249
10250    let mut state = BandState {
10251        clip_region: None,
10252        spare_mask: None,
10253        clip_mask_cache: HashMap::new(),
10254        clip_mask_seen: prepared.clip_seen.clone(),
10255        mask_pool: Vec::new(),
10256        cmyk_buffer: cmyk_buf,
10257        op_bg_snapshot: None,
10258        op_touched: None,
10259        spot_mask: None,
10260    };
10261
10262    let elements = list.elements();
10263    let vp_x_f = vp_x as f32;
10264    let vp_y_f = vp_y as f32;
10265    let sx = scale_x as f32;
10266    let sy = scale_y as f32;
10267    let vp_x_max = vp_x + vp_w;
10268    let vp_y_max = vp_y + vp_h;
10269
10270    for epoch in &prepared.epochs {
10271        if !epoch.has_erase_page {
10272            match epoch.paint_bbox {
10273                Some(ref pb)
10274                    if pb.x_max <= vp_x
10275                        || pb.x_min >= vp_x_max
10276                        || pb.y_max <= vp_y
10277                        || pb.y_min >= vp_y_max =>
10278                {
10279                    continue;
10280                }
10281                None => continue,
10282                _ => {}
10283            }
10284        }
10285
10286        #[allow(clippy::needless_range_loop)]
10287        for i in epoch.start_idx..epoch.end_idx {
10288            // OcgGroups with Clip/InitClip must always be processed — see
10289            // the banded renderer for the rationale.
10290            let force_process = matches!(
10291                &elements[i],
10292                DisplayElement::OcgGroup { elements: inner, .. }
10293                    if contains_clip_op(inner)
10294            );
10295            if !force_process
10296                && let Some(ref bbox) = prepared.bboxes[i]
10297                && (bbox.x_max <= vp_x
10298                    || bbox.x_min >= vp_x_max
10299                    || bbox.y_max <= vp_y
10300                    || bbox.y_min >= vp_y_max)
10301            {
10302                continue;
10303            }
10304            let ctx = RenderContext {
10305                vp_x: vp_x_f,
10306                vp_y: vp_y_f,
10307                scale_x: sx,
10308                scale_y: sy,
10309                out_w: pixel_w,
10310                out_h: render_h,
10311                effective_dpi,
10312                icc,
10313                image_cache,
10314                preprocessed: None,
10315                elem_idx: i,
10316                no_aa,
10317                opm_zero_transparent: false,
10318                knockout_painter_pass: KnockoutPainterPass::None,
10319                parent_group_isolated: false,
10320                alpha_extraction_pass: false,
10321                layer_set: &layer_set,
10322            };
10323            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10324        }
10325    }
10326
10327    // Composite onto white background
10328    composite_onto_white(pixmap.data_mut());
10329    // Extract only the requested pixel_h rows (skip the OVERLAP padding at the bottom).
10330    let row_bytes = pixel_w as usize * 4;
10331    let end = pixel_h as usize * row_bytes;
10332    pixmap.data()[..end].to_vec()
10333}
10334
10335/// Compute the number of bands and band height for viewport banding.
10336///
10337/// Returns `(num_bands, band_height)` using the same L2-cache-budget logic
10338/// as the full-page banded renderer.
10339pub fn viewport_band_count(pixel_w: u32, pixel_h: u32) -> (u32, u32) {
10340    let band_h = select_band_height(pixel_w, pixel_h);
10341    let num_bands = if band_h >= pixel_h {
10342        1
10343    } else {
10344        pixel_h.div_ceil(band_h)
10345    };
10346    (num_bands, band_h)
10347}
10348
10349/// Render a single horizontal band of a viewport region.
10350///
10351/// This is the per-band counterpart to [`render_region_prepared()`]. The caller
10352/// loops over `band_idx` in `0..num_bands`, collecting RGBA strips that tile
10353/// vertically to form the full viewport image.
10354///
10355/// Returns RGBA pixel data for `actual_h` rows (may be less than `band_h` for
10356/// the last band).
10357#[allow(clippy::too_many_arguments)]
10358pub fn render_region_single_band(
10359    list: &DisplayList,
10360    prepared: &PreparedDisplayList,
10361    vp_x: f64,
10362    vp_y: f64,
10363    vp_w: f64,
10364    vp_h: f64,
10365    pixel_w: u32,
10366    pixel_h: u32,
10367    band_idx: u32,
10368    band_h: u32,
10369    num_bands: u32,
10370    dpi: f64,
10371    icc: Option<&IccCache>,
10372    image_cache: Option<&ImageCache>,
10373    no_aa: bool,
10374) -> Vec<u8> {
10375    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
10376        let actual_h = if band_idx < num_bands - 1 {
10377            band_h
10378        } else {
10379            pixel_h - band_idx * band_h
10380        };
10381        return vec![0xFF; pixel_w as usize * actual_h as usize * 4];
10382    }
10383
10384    let layer_set = LayerSet::new();
10385    let scale_x = pixel_w as f64 / vp_w;
10386    let scale_y = pixel_h as f64 / vp_h;
10387    let effective_dpi = dpi * scale_x;
10388
10389    // Output Y range for this band
10390    let out_y_start = band_idx * band_h;
10391    let actual_h = if band_idx < num_bands - 1 {
10392        band_h
10393    } else {
10394        pixel_h - out_y_start
10395    };
10396
10397    // Add overlap above/below for anti-aliasing at seams.
10398    //
10399    // The pixmap is always `band_h + 2*OVERLAP` rows — matching the page
10400    // renderer (`render_banded_to_sink`) — even at the bottom band, where
10401    // content rendering stops at `pixel_h`. Without this, the bottom band's
10402    // pixmap is shorter than the page renderer's, and tiny-skia's
10403    // `Mask::fill_path` rasterizes clip paths into a tighter mask, producing
10404    // edge-pixel divergences from the banded baseline (39 pixels on
10405    // 1915_1.pdf, etc.). The extra rows below `pixel_h` are unused for output
10406    // but ensure mask-size-independent rasterization.
10407    const OVERLAP: u32 = 6;
10408    let render_y_start = out_y_start.saturating_sub(OVERLAP);
10409    let render_y_end = (out_y_start + actual_h + OVERLAP).min(pixel_h);
10410    let render_h = band_h + 2 * OVERLAP;
10411    let overlap_top = out_y_start - render_y_start;
10412
10413    // Source-space Y range for culling
10414    let src_y_min = vp_y + render_y_start as f64 / scale_y;
10415    let src_y_max = vp_y + render_y_end as f64 / scale_y;
10416
10417    // Adjusted viewport offset for this band's pixmap
10418    let band_vp_y = vp_y + render_y_start as f64 / scale_y;
10419
10420    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create band pixmap");
10421    pixmap.fill(Color::TRANSPARENT);
10422
10423    let cmyk_buf = if has_overprint_elements(list)
10424        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10425        || has_cmyk_group(list)
10426    {
10427        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10428    } else {
10429        None
10430    };
10431
10432    let mut state = BandState {
10433        clip_region: None,
10434        spare_mask: None,
10435        clip_mask_cache: HashMap::new(),
10436        clip_mask_seen: prepared.clip_seen.clone(),
10437        mask_pool: Vec::new(),
10438        cmyk_buffer: cmyk_buf,
10439        op_bg_snapshot: None,
10440        op_touched: None,
10441        spot_mask: None,
10442    };
10443
10444    let elements = list.elements();
10445    let vp_x_f = vp_x as f32;
10446    let band_vp_y_f = band_vp_y as f32;
10447    let sx = scale_x as f32;
10448    let sy = scale_y as f32;
10449    let vp_x_max = vp_x + vp_w;
10450
10451    for epoch in &prepared.epochs {
10452        if !epoch.has_erase_page {
10453            match epoch.paint_bbox {
10454                Some(ref pb)
10455                    if pb.x_max <= vp_x
10456                        || pb.x_min >= vp_x_max
10457                        || pb.y_max <= src_y_min
10458                        || pb.y_min >= src_y_max =>
10459                {
10460                    continue;
10461                }
10462                None => continue,
10463                _ => {}
10464            }
10465        }
10466
10467        #[allow(clippy::needless_range_loop)]
10468        for i in epoch.start_idx..epoch.end_idx {
10469            // OcgGroups containing Clip/InitClip must always be processed
10470            // regardless of this band's bbox — see the full-page banded
10471            // renderer for the rationale.
10472            let force_process = matches!(
10473                &elements[i],
10474                DisplayElement::OcgGroup { elements: inner, .. }
10475                    if contains_clip_op(inner)
10476            );
10477            if !force_process
10478                && let Some(ref bbox) = prepared.bboxes[i]
10479                && (bbox.x_max <= vp_x
10480                    || bbox.x_min >= vp_x_max
10481                    || bbox.y_max <= src_y_min
10482                    || bbox.y_min >= src_y_max)
10483            {
10484                continue;
10485            }
10486            let ctx = RenderContext {
10487                vp_x: vp_x_f,
10488                vp_y: band_vp_y_f,
10489                scale_x: sx,
10490                scale_y: sy,
10491                out_w: pixel_w,
10492                out_h: render_h,
10493                effective_dpi,
10494                icc,
10495                image_cache,
10496                preprocessed: None,
10497                elem_idx: i,
10498                no_aa,
10499                opm_zero_transparent: false,
10500                knockout_painter_pass: KnockoutPainterPass::None,
10501                parent_group_isolated: false,
10502                alpha_extraction_pass: false,
10503                layer_set: &layer_set,
10504            };
10505            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10506        }
10507    }
10508
10509    // Composite onto white background
10510    composite_onto_white(pixmap.data_mut());
10511
10512    // Extract only the non-overlap rows
10513    let row_bytes = pixel_w as usize * 4;
10514    let start = overlap_top as usize * row_bytes;
10515    let end = start + actual_h as usize * row_bytes;
10516    pixmap.data()[start..end].to_vec()
10517}
10518
10519/// Render a viewport region using parallel banded rendering via rayon.
10520///
10521/// This is the WASM counterpart to the parallel path in `render_banded_to_sink`.
10522/// All bands are rendered in parallel using `par_iter`, then assembled into the
10523/// final RGBA buffer in order.
10524///
10525/// Requires the `parallel` feature (rayon). Falls back to sequential rendering
10526/// if `parallel` is not enabled.
10527#[allow(clippy::too_many_arguments)]
10528pub fn render_region_prepared_parallel(
10529    list: &DisplayList,
10530    prepared: &PreparedDisplayList,
10531    vp_x: f64,
10532    vp_y: f64,
10533    vp_w: f64,
10534    vp_h: f64,
10535    pixel_w: u32,
10536    pixel_h: u32,
10537    dpi: f64,
10538    icc: Option<&IccCache>,
10539    image_cache: Option<&ImageCache>,
10540    no_aa: bool,
10541) -> Vec<u8> {
10542    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10543
10544    if num_bands <= 1 {
10545        // Single band — no parallelism needed
10546        return render_region_prepared(
10547            list,
10548            prepared,
10549            vp_x,
10550            vp_y,
10551            vp_w,
10552            vp_h,
10553            pixel_w,
10554            pixel_h,
10555            dpi,
10556            icc,
10557            image_cache,
10558            no_aa,
10559        );
10560    }
10561
10562    let render_band = |band_idx: u32| -> Vec<u8> {
10563        render_region_single_band(
10564            list,
10565            prepared,
10566            vp_x,
10567            vp_y,
10568            vp_w,
10569            vp_h,
10570            pixel_w,
10571            pixel_h,
10572            band_idx,
10573            band_h,
10574            num_bands,
10575            dpi,
10576            icc,
10577            image_cache,
10578            no_aa,
10579        )
10580    };
10581
10582    let row_bytes = pixel_w as usize * 4;
10583    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10584
10585    #[cfg(feature = "parallel")]
10586    {
10587        let chunk_size = rayon::current_num_threads().max(1);
10588
10589        for chunk_start in (0..num_bands).step_by(chunk_size) {
10590            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10591
10592            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10593                .into_par_iter()
10594                .map(&render_band)
10595                .collect();
10596
10597            for (i, band_data) in rendered.iter().enumerate() {
10598                let band_idx = chunk_start + i as u32;
10599                let y_start = (band_idx * band_h) as usize;
10600                let dest_start = y_start * row_bytes;
10601                let len = band_data.len();
10602                result[dest_start..dest_start + len].copy_from_slice(band_data);
10603            }
10604        }
10605    }
10606    #[cfg(not(feature = "parallel"))]
10607    {
10608        for band_idx in 0..num_bands {
10609            let band_data = render_band(band_idx);
10610            let y_start = (band_idx * band_h) as usize;
10611            let dest_start = y_start * row_bytes;
10612            let len = band_data.len();
10613            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10614        }
10615    }
10616
10617    result
10618}
10619
10620/// Like [`render_region_prepared_parallel()`] but with an atomic progress counter.
10621///
10622/// The counter is incremented after each chunk of bands completes. The total
10623/// number of bands is returned alongside the counter via [`viewport_band_count()`].
10624#[allow(clippy::too_many_arguments)]
10625pub fn render_region_prepared_parallel_with_progress(
10626    list: &DisplayList,
10627    prepared: &PreparedDisplayList,
10628    vp_x: f64,
10629    vp_y: f64,
10630    vp_w: f64,
10631    vp_h: f64,
10632    pixel_w: u32,
10633    pixel_h: u32,
10634    dpi: f64,
10635    icc: Option<&IccCache>,
10636    image_cache: Option<&ImageCache>,
10637    no_aa: bool,
10638    progress: &std::sync::atomic::AtomicU32,
10639) -> Vec<u8> {
10640    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10641
10642    if num_bands <= 1 {
10643        let result = render_region_prepared(
10644            list,
10645            prepared,
10646            vp_x,
10647            vp_y,
10648            vp_w,
10649            vp_h,
10650            pixel_w,
10651            pixel_h,
10652            dpi,
10653            icc,
10654            image_cache,
10655            no_aa,
10656        );
10657        progress.store(1, std::sync::atomic::Ordering::Relaxed);
10658        return result;
10659    }
10660
10661    let render_band = |band_idx: u32| -> Vec<u8> {
10662        render_region_single_band(
10663            list,
10664            prepared,
10665            vp_x,
10666            vp_y,
10667            vp_w,
10668            vp_h,
10669            pixel_w,
10670            pixel_h,
10671            band_idx,
10672            band_h,
10673            num_bands,
10674            dpi,
10675            icc,
10676            image_cache,
10677            no_aa,
10678        )
10679    };
10680
10681    let row_bytes = pixel_w as usize * 4;
10682    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10683
10684    #[cfg(feature = "parallel")]
10685    {
10686        let chunk_size = rayon::current_num_threads().max(1);
10687
10688        for chunk_start in (0..num_bands).step_by(chunk_size) {
10689            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10690
10691            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10692                .into_par_iter()
10693                .map(&render_band)
10694                .collect();
10695
10696            for (i, band_data) in rendered.iter().enumerate() {
10697                let band_idx = chunk_start + i as u32;
10698                let y_start = (band_idx * band_h) as usize;
10699                let dest_start = y_start * row_bytes;
10700                let len = band_data.len();
10701                result[dest_start..dest_start + len].copy_from_slice(band_data);
10702            }
10703            progress.store(chunk_end, std::sync::atomic::Ordering::Relaxed);
10704        }
10705    }
10706    #[cfg(not(feature = "parallel"))]
10707    {
10708        for band_idx in 0..num_bands {
10709            let band_data = render_band(band_idx);
10710            let y_start = (band_idx * band_h) as usize;
10711            let dest_start = y_start * row_bytes;
10712            let len = band_data.len();
10713            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10714            progress.store(band_idx + 1, std::sync::atomic::Ordering::Relaxed);
10715        }
10716    }
10717
10718    result
10719}
10720
10721/// Like [`render_region_prepared_parallel()`] but checks a cancellation flag
10722/// between band chunks. Returns `None` if cancelled.
10723#[allow(clippy::too_many_arguments)]
10724pub fn render_region_prepared_parallel_cancellable(
10725    list: &DisplayList,
10726    prepared: &PreparedDisplayList,
10727    vp_x: f64,
10728    vp_y: f64,
10729    vp_w: f64,
10730    vp_h: f64,
10731    pixel_w: u32,
10732    pixel_h: u32,
10733    dpi: f64,
10734    icc: Option<&IccCache>,
10735    image_cache: Option<&ImageCache>,
10736    no_aa: bool,
10737    cancelled: &std::sync::atomic::AtomicBool,
10738) -> Option<Vec<u8>> {
10739    if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10740        return None;
10741    }
10742
10743    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10744
10745    if num_bands <= 1 {
10746        return Some(render_region_prepared(
10747            list,
10748            prepared,
10749            vp_x,
10750            vp_y,
10751            vp_w,
10752            vp_h,
10753            pixel_w,
10754            pixel_h,
10755            dpi,
10756            icc,
10757            image_cache,
10758            no_aa,
10759        ));
10760    }
10761
10762    let render_band = |band_idx: u32| -> Vec<u8> {
10763        render_region_single_band(
10764            list,
10765            prepared,
10766            vp_x,
10767            vp_y,
10768            vp_w,
10769            vp_h,
10770            pixel_w,
10771            pixel_h,
10772            band_idx,
10773            band_h,
10774            num_bands,
10775            dpi,
10776            icc,
10777            image_cache,
10778            no_aa,
10779        )
10780    };
10781
10782    let row_bytes = pixel_w as usize * 4;
10783    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10784
10785    #[cfg(feature = "parallel")]
10786    {
10787        let chunk_size = rayon::current_num_threads().max(1);
10788
10789        for chunk_start in (0..num_bands).step_by(chunk_size) {
10790            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10791                return None;
10792            }
10793            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10794
10795            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10796                .into_par_iter()
10797                .map(&render_band)
10798                .collect();
10799
10800            for (i, band_data) in rendered.iter().enumerate() {
10801                let band_idx = chunk_start + i as u32;
10802                let y_start = (band_idx * band_h) as usize;
10803                let dest_start = y_start * row_bytes;
10804                let len = band_data.len();
10805                result[dest_start..dest_start + len].copy_from_slice(band_data);
10806            }
10807        }
10808    }
10809    #[cfg(not(feature = "parallel"))]
10810    {
10811        for band_idx in 0..num_bands {
10812            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10813                return None;
10814            }
10815            let band_data = render_band(band_idx);
10816            let y_start = (band_idx * band_h) as usize;
10817            let dest_start = y_start * row_bytes;
10818            let len = band_data.len();
10819            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10820        }
10821    }
10822
10823    Some(result)
10824}
10825
10826/// Render a full-page display list to RGBA pixels using the banded parallel renderer.
10827///
10828/// This is the preferred way to render a complete page — it uses rayon parallelism
10829/// (when the `parallel` feature is enabled) and L2-cache-friendly band sizing.
10830/// For sub-region / zoomed viewport rendering, use `render_region` instead.
10831///
10832/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`, composited onto white.
10833pub fn render_to_rgba(
10834    list: &DisplayList,
10835    pixel_w: u32,
10836    pixel_h: u32,
10837    dpi: f64,
10838    icc: Option<&IccCache>,
10839    no_aa: bool,
10840) -> Vec<u8> {
10841    render_to_rgba_with_layers(list, pixel_w, pixel_h, dpi, icc, no_aa, &LayerSet::new())
10842}
10843
10844/// Like [`render_to_rgba`] but consults the supplied [`LayerSet`] when
10845/// evaluating each `OcgGroup`'s visibility.
10846///
10847/// Pass `&LayerSet::new()` (or use [`render_to_rgba`]) to fall back to
10848/// each OCG's `default_visible` baked from the document's default
10849/// configuration.
10850#[allow(clippy::too_many_arguments)]
10851pub fn render_to_rgba_with_layers(
10852    list: &DisplayList,
10853    pixel_w: u32,
10854    pixel_h: u32,
10855    dpi: f64,
10856    icc: Option<&IccCache>,
10857    no_aa: bool,
10858    layer_set: &LayerSet,
10859) -> Vec<u8> {
10860    if pixel_w == 0 || pixel_h == 0 {
10861        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10862    }
10863
10864    let mut icc_cache = match icc {
10865        Some(c) => c.clone(),
10866        None => IccCache::new(),
10867    };
10868    // Register any ICC profiles from shadings in the display list
10869    // (the caller's cache only has image profiles)
10870    register_shading_icc_profiles(list, &mut icc_cache);
10871
10872    let mut sink = MemorySink {
10873        data: Vec::new(),
10874        width: 0,
10875    };
10876
10877    let band_h = select_band_height(pixel_w, pixel_h);
10878    if let Err(e) = render_banded_to_sink(
10879        pixel_w, pixel_h, band_h, dpi, list, &mut sink, &icc_cache, no_aa, layer_set,
10880    ) {
10881        eprintln!("render_to_rgba: banded render failed: {e}");
10882        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10883    }
10884
10885    sink.data
10886}
10887
10888/// Render a display list to RGBA using the **viewport** code path, with
10889/// the viewport set to the full page at 1:1 scale.
10890///
10891/// This exists to audit the viewport pipeline (`render_region_prepared_*`)
10892/// against the same baselines the banded PNG path uses. The two paths share
10893/// `render_element` and the same display list, so their output should be
10894/// pixel-identical on a correctly implemented display list. Differences
10895/// indicate a bug in one of the two culling / epoch / bbox pipelines.
10896///
10897/// The CLI exposes this as `--device viewport-png`; the visual test runner
10898/// uses it to double-cover each sample without maintaining a second
10899/// baseline.
10900pub fn render_to_rgba_viewport(
10901    list: &DisplayList,
10902    pixel_w: u32,
10903    pixel_h: u32,
10904    dpi: f64,
10905    icc: Option<&IccCache>,
10906    no_aa: bool,
10907) -> Vec<u8> {
10908    if pixel_w == 0 || pixel_h == 0 {
10909        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10910    }
10911
10912    let mut icc_cache = match icc {
10913        Some(c) => c.clone(),
10914        None => IccCache::new(),
10915    };
10916    register_shading_icc_profiles(list, &mut icc_cache);
10917
10918    let prepared = prepare_display_list(list);
10919    render_region_prepared_parallel(
10920        list,
10921        &prepared,
10922        0.0,
10923        0.0,
10924        pixel_w as f64,
10925        pixel_h as f64,
10926        pixel_w,
10927        pixel_h,
10928        dpi,
10929        Some(&icc_cache),
10930        None,
10931        no_aa,
10932    )
10933}
10934
10935/// Debug helper: format both bbox precomputations side-by-side.
10936///
10937/// Returns one line per element describing its Y-only bbox (used by the
10938/// banded page pipeline) and its 2D bbox (used by the viewport pipeline).
10939/// Elements that disagree on presence, or whose 2D bbox's Y extent differs
10940/// from the Y-only bbox, are marked with `DIFF`.
10941fn debug_bbox_lines(list: &DisplayList, dpi: f64, depth: usize, out: &mut Vec<String>) {
10942    let y_bboxes = precompute_bboxes(list, dpi);
10943    let full_bboxes = precompute_full_bboxes(list, dpi);
10944    let elements = list.elements();
10945    let indent = "  ".repeat(depth);
10946    for (i, elem) in elements.iter().enumerate() {
10947        let kind = match elem {
10948            DisplayElement::Fill { .. } => "Fill",
10949            DisplayElement::Stroke { .. } => "Stroke",
10950            DisplayElement::Image { .. } => "Image",
10951            DisplayElement::AxialShading { .. } => "AxialShading",
10952            DisplayElement::RadialShading { .. } => "RadialShading",
10953            DisplayElement::MeshShading { .. } => "MeshShading",
10954            DisplayElement::PatchShading { .. } => "PatchShading",
10955            DisplayElement::PatternFill { .. } => "PatternFill",
10956            DisplayElement::Group { .. } => "Group",
10957            DisplayElement::SoftMasked { .. } => "SoftMasked",
10958            DisplayElement::OcgGroup { .. } => "OcgGroup",
10959            DisplayElement::Clip { .. } => "Clip",
10960            DisplayElement::InitClip => "InitClip",
10961            DisplayElement::ErasePage => "ErasePage",
10962            DisplayElement::Text { .. } => "Text",
10963            _ => "Unknown",
10964        };
10965        let yb = &y_bboxes[i];
10966        let fb = &full_bboxes[i];
10967        let mut diff = false;
10968        if yb.is_some() != fb.is_some() {
10969            diff = true;
10970        }
10971        if let (Some(yb), Some(fb)) = (yb, fb)
10972            && ((yb.y_min - fb.y_min).abs() > 1e-9 || (yb.y_max - fb.y_max).abs() > 1e-9)
10973        {
10974            diff = true;
10975        }
10976        let yb_s = match yb {
10977            Some(b) => format!("Y[{:8.3}..{:8.3}]", b.y_min, b.y_max),
10978            None => "Y[None]".to_string(),
10979        };
10980        let fb_s = match fb {
10981            Some(b) => format!(
10982                "2D[x {:8.3}..{:8.3} y {:8.3}..{:8.3}]",
10983                b.x_min, b.x_max, b.y_min, b.y_max
10984            ),
10985            None => "2D[None]".to_string(),
10986        };
10987        out.push(format!(
10988            "{}{:4} {:15} {:30} {:55} {}",
10989            indent,
10990            i,
10991            kind,
10992            yb_s,
10993            fb_s,
10994            if diff { "DIFF" } else { "" }
10995        ));
10996        if let DisplayElement::Stroke { path, params } = elem {
10997            let rp = path_full_bbox(path);
10998            let m = &params.ctm;
10999            out.push(format!(
11000                "{}        ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] lw={:.4} miter={:.4} raw={}",
11001                indent,
11002                m.a,
11003                m.b,
11004                m.c,
11005                m.d,
11006                m.tx,
11007                m.ty,
11008                params.line_width,
11009                params.miter_limit,
11010                match rp {
11011                    Some(b) => format!(
11012                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11013                        b.x_min, b.x_max, b.y_min, b.y_max
11014                    ),
11015                    None => "None".to_string(),
11016                }
11017            ));
11018        }
11019        if let DisplayElement::Clip { path, params } = elem {
11020            let rp = path_full_bbox(path);
11021            let m = &params.ctm;
11022            out.push(format!(
11023                "{}        clip ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] rule={:?} raw={}",
11024                indent,
11025                m.a,
11026                m.b,
11027                m.c,
11028                m.d,
11029                m.tx,
11030                m.ty,
11031                params.fill_rule,
11032                match rp {
11033                    Some(b) => format!(
11034                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11035                        b.x_min, b.x_max, b.y_min, b.y_max
11036                    ),
11037                    None => "None".to_string(),
11038                }
11039            ));
11040        }
11041        if let DisplayElement::PatchShading { params } = elem {
11042            out.push(format!(
11043                "{}        patch ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] bbox={:?} patches={}",
11044                indent,
11045                params.ctm.a,
11046                params.ctm.b,
11047                params.ctm.c,
11048                params.ctm.d,
11049                params.ctm.tx,
11050                params.ctm.ty,
11051                params.bbox,
11052                params.patches.len()
11053            ));
11054            if !params.patches.is_empty() {
11055                let patch = &params.patches[0];
11056                // Compute device-space bbox of patch points
11057                let mut x_min = f64::INFINITY;
11058                let mut y_min = f64::INFINITY;
11059                let mut x_max = f64::NEG_INFINITY;
11060                let mut y_max = f64::NEG_INFINITY;
11061                for &(px, py) in &patch.points {
11062                    let (dx, dy) = params.ctm.transform_point(px, py);
11063                    x_min = x_min.min(dx);
11064                    y_min = y_min.min(dy);
11065                    x_max = x_max.max(dx);
11066                    y_max = y_max.max(dy);
11067                }
11068                out.push(format!(
11069                    "{}        patch[0] pts={} dev x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11070                    indent,
11071                    patch.points.len(),
11072                    x_min,
11073                    x_max,
11074                    y_min,
11075                    y_max
11076                ));
11077            }
11078        }
11079        if let DisplayElement::Group {
11080            elements: inner,
11081            params,
11082        } = elem
11083        {
11084            out.push(format!(
11085                "{}        group bbox={:?} iso={} ko={} alpha={} bm={} cs={:?}",
11086                indent,
11087                params.bbox,
11088                params.isolated,
11089                params.knockout,
11090                params.alpha,
11091                params.blend_mode,
11092                params.color_space
11093            ));
11094            debug_bbox_lines(inner, dpi, depth + 1, out);
11095        }
11096        if let DisplayElement::SoftMasked {
11097            content, params, ..
11098        } = elem
11099        {
11100            out.push(format!(
11101                "{}        softmasked bbox={:?}",
11102                indent, params.bbox
11103            ));
11104            debug_bbox_lines(content, dpi, depth + 1, out);
11105        }
11106        if let DisplayElement::OcgGroup {
11107            elements: inner,
11108            visibility,
11109        } = elem
11110        {
11111            out.push(format!(
11112                "{}        ocg default_visible={}",
11113                indent,
11114                visibility.default_visible()
11115            ));
11116            debug_bbox_lines(inner, dpi, depth + 1, out);
11117        }
11118    }
11119}
11120
11121pub fn debug_bbox_comparison(list: &DisplayList, dpi: f64) -> Vec<String> {
11122    let mut out = Vec::new();
11123    debug_bbox_lines(list, dpi, 0, &mut out);
11124    out
11125}
11126
11127/// In-memory page sink that collects RGBA rows into a Vec.
11128struct MemorySink {
11129    data: Vec<u8>,
11130    width: u32,
11131}
11132
11133impl stet_graphics::device::PageSink for MemorySink {
11134    fn begin_page(&mut self, width: u32, height: u32) -> Result<(), String> {
11135        self.width = width;
11136        self.data.reserve(width as usize * height as usize * 4);
11137        Ok(())
11138    }
11139
11140    fn write_rows(&mut self, rgba_rows: &[u8], _num_rows: u32) -> Result<(), String> {
11141        self.data.extend_from_slice(rgba_rows);
11142        Ok(())
11143    }
11144
11145    fn end_page(&mut self) -> Result<(), String> {
11146        Ok(())
11147    }
11148}
11149
11150/// Render a rectangular viewport region of a display list to RGBA pixels.
11151///
11152/// - `list`: The display list to render (in device-space coordinates at the reference DPI)
11153/// - `vp_x, vp_y, vp_w, vp_h`: Viewport rectangle in device-space pixels
11154/// - `pixel_w, pixel_h`: Output pixel dimensions
11155/// - `dpi`: Reference DPI (for hairline width decisions)
11156///
11157/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`.
11158#[allow(clippy::too_many_arguments)]
11159pub fn render_region(
11160    list: &DisplayList,
11161    vp_x: f64,
11162    vp_y: f64,
11163    vp_w: f64,
11164    vp_h: f64,
11165    pixel_w: u32,
11166    pixel_h: u32,
11167    dpi: f64,
11168    icc: Option<&IccCache>,
11169    image_cache: Option<&ImageCache>,
11170    no_aa: bool,
11171) -> Vec<u8> {
11172    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
11173        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
11174    }
11175
11176    let layer_set = LayerSet::new();
11177    let scale_x = pixel_w as f64 / vp_w;
11178    let scale_y = pixel_h as f64 / vp_h;
11179    // Effective DPI for hairline decisions — reference DPI scaled by zoom
11180    let effective_dpi = dpi * scale_x;
11181
11182    let bboxes = precompute_full_bboxes(list, effective_dpi);
11183    let epochs = build_viewport_epochs(list, &bboxes);
11184    let clip_seen = precompute_clip_seen(list);
11185
11186    // OVERLAP padding to match `render_banded_to_sink`. See the comment in
11187    // `render_region_prepared` for why this is required for tiny-skia
11188    // mask-rasterization parity with the page renderer.
11189    const OVERLAP: u32 = 6;
11190    let render_h = pixel_h + 2 * OVERLAP;
11191
11192    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
11193    pixmap.fill(Color::TRANSPARENT);
11194
11195    let cmyk_buf = if has_overprint_elements(list)
11196        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
11197        || has_cmyk_group(list)
11198    {
11199        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
11200    } else {
11201        None
11202    };
11203
11204    let mut state = BandState {
11205        clip_region: None,
11206        spare_mask: None,
11207        clip_mask_cache: HashMap::new(),
11208        clip_mask_seen: clip_seen,
11209        mask_pool: Vec::new(),
11210        cmyk_buffer: cmyk_buf,
11211        op_bg_snapshot: None,
11212        op_touched: None,
11213        spot_mask: None,
11214    };
11215
11216    let elements = list.elements();
11217    let vp_x_f = vp_x as f32;
11218    let vp_y_f = vp_y as f32;
11219    let sx = scale_x as f32;
11220    let sy = scale_y as f32;
11221    let vp_x_max = vp_x + vp_w;
11222    let vp_y_max = vp_y + vp_h;
11223
11224    for epoch in &epochs {
11225        // Epoch-level culling
11226        if !epoch.has_erase_page {
11227            match epoch.paint_bbox {
11228                Some(ref pb)
11229                    if pb.x_max <= vp_x
11230                        || pb.x_min >= vp_x_max
11231                        || pb.y_max <= vp_y
11232                        || pb.y_min >= vp_y_max =>
11233                {
11234                    continue;
11235                }
11236                None => continue,
11237                _ => {}
11238            }
11239        }
11240
11241        for i in epoch.start_idx..epoch.end_idx {
11242            // OcgGroups with Clip/InitClip must always be processed — see
11243            // render_region_prepared for the rationale.
11244            let force_process = matches!(
11245                &elements[i],
11246                DisplayElement::OcgGroup { elements: inner, .. }
11247                    if contains_clip_op(inner)
11248            );
11249            // Element-level culling
11250            if !force_process
11251                && let Some(ref bbox) = bboxes[i]
11252                && (bbox.x_max <= vp_x
11253                    || bbox.x_min >= vp_x_max
11254                    || bbox.y_max <= vp_y
11255                    || bbox.y_min >= vp_y_max)
11256            {
11257                continue;
11258            }
11259            let ctx = RenderContext {
11260                vp_x: vp_x_f,
11261                vp_y: vp_y_f,
11262                scale_x: sx,
11263                scale_y: sy,
11264                out_w: pixel_w,
11265                out_h: render_h,
11266                effective_dpi,
11267                icc,
11268                image_cache,
11269                preprocessed: None,
11270                elem_idx: i,
11271                no_aa,
11272                opm_zero_transparent: false,
11273                knockout_painter_pass: KnockoutPainterPass::None,
11274                parent_group_isolated: false,
11275                alpha_extraction_pass: false,
11276                layer_set: &layer_set,
11277            };
11278            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
11279        }
11280    }
11281
11282    composite_onto_white(pixmap.data_mut());
11283    // Extract only the requested pixel_h rows (skip OVERLAP padding).
11284    let row_bytes = pixel_w as usize * 4;
11285    let end = pixel_h as usize * row_bytes;
11286    pixmap.data()[..end].to_vec()
11287}
11288/// Copy a rectangular region from parent pixmap into a smaller crop pixmap.
11289fn copy_backdrop_crop(
11290    parent: &Pixmap,
11291    crop_x: i32,
11292    crop_y: i32,
11293    crop_w: u32,
11294    crop_h: u32,
11295) -> Vec<u8> {
11296    let pw = parent.width() as usize;
11297    let src = parent.data();
11298    let cw = crop_w as usize;
11299    let ch = crop_h as usize;
11300    let cx = crop_x as usize;
11301    let cy = crop_y as usize;
11302    let mut backdrop = vec![0u8; cw * ch * 4];
11303    for row in 0..ch {
11304        let src_off = ((cy + row) * pw + cx) * 4;
11305        let dst_off = row * cw * 4;
11306        backdrop[dst_off..dst_off + cw * 4].copy_from_slice(&src[src_off..src_off + cw * 4]);
11307    }
11308    backdrop
11309}
11310// ---- Shading rendering ----
11311
11312/// Sutherland-Hodgman polygon clipping against a half-plane.
11313/// Keeps the side where `nx*(x-px) + ny*(y-py) >= 0`.
11314fn clip_polygon_halfplane(
11315    poly: &[(f32, f32)],
11316    nx: f32,
11317    ny: f32,
11318    px: f32,
11319    py: f32,
11320) -> Vec<(f32, f32)> {
11321    if poly.is_empty() {
11322        return vec![];
11323    }
11324    let dot = |x: f32, y: f32| nx * (x - px) + ny * (y - py);
11325    let mut out = Vec::with_capacity(poly.len() + 1);
11326    let n = poly.len();
11327    for i in 0..n {
11328        let (ax, ay) = poly[i];
11329        let (bx, by) = poly[(i + 1) % n];
11330        let da = dot(ax, ay);
11331        let db = dot(bx, by);
11332        if da >= 0.0 {
11333            out.push((ax, ay));
11334        }
11335        if (da >= 0.0) != (db >= 0.0) {
11336            // Edge crosses the clipping line — compute intersection
11337            let t = da / (da - db);
11338            out.push((ax + t * (bx - ax), ay + t * (by - ay)));
11339        }
11340    }
11341    out
11342}
11343
11344/// Render an axial (linear) gradient shading.
11345#[allow(clippy::too_many_arguments)]
11346fn render_axial_shading(
11347    pixmap: &mut Pixmap,
11348    params: &AxialShadingParams,
11349    vp_x: f32,
11350    vp_y: f32,
11351    scale_x: f32,
11352    scale_y: f32,
11353    clip_mask: Option<&Mask>,
11354    no_aa: bool,
11355    cmyk_buf: Option<&mut [f32]>,
11356    icc: Option<&IccCache>,
11357) {
11358    let pw = pixmap.width();
11359    let ph = pixmap.height();
11360    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
11361        return;
11362    }
11363
11364    let (mut rx_min, mut ry_min, mut rx_max, mut ry_max) = if let Some(bbox) = &params.bbox {
11365        let corners = [
11366            params.ctm.transform_point(bbox[0], bbox[1]),
11367            params.ctm.transform_point(bbox[2], bbox[1]),
11368            params.ctm.transform_point(bbox[0], bbox[3]),
11369            params.ctm.transform_point(bbox[2], bbox[3]),
11370        ];
11371        let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
11372        let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
11373        let x_max = corners
11374            .iter()
11375            .map(|c| c.0)
11376            .fold(f64::NEG_INFINITY, f64::max);
11377        let y_max = corners
11378            .iter()
11379            .map(|c| c.1)
11380            .fold(f64::NEG_INFINITY, f64::max);
11381        (
11382            ((x_min as f32 - vp_x) * scale_x).max(0.0),
11383            ((y_min as f32 - vp_y) * scale_y).max(0.0),
11384            ((x_max as f32 - vp_x) * scale_x).min(pw as f32),
11385            ((y_max as f32 - vp_y) * scale_y).min(ph as f32),
11386        )
11387    } else {
11388        (0.0, 0.0, pw as f32, ph as f32)
11389    };
11390
11391    if rx_max <= rx_min || ry_max <= ry_min {
11392        return;
11393    }
11394
11395    // Transform endpoints to device space for perpendicular clipping
11396    let (dx0, dy0) = params.ctm.transform_point(params.x0, params.y0);
11397    let (dx1, dy1) = params.ctm.transform_point(params.x1, params.y1);
11398
11399    // When extend is false on a side, clip the fill area along a line
11400    // perpendicular to the gradient axis through that endpoint. For diagonal
11401    // gradients this produces a diagonal cutoff (not axis-aligned).
11402    let needs_perpendicular_clip = (!params.extend_start || !params.extend_end) && {
11403        let axis_x = dx1 - dx0;
11404        let axis_y = dy1 - dy0;
11405        axis_x.abs() > 1e-6 && axis_y.abs() > 1e-6
11406    };
11407
11408    // Detect rotated BBox: if CTM has rotation components (b or c non-zero),
11409    // the BBox is not axis-aligned in device space and needs proper polygon clipping.
11410    let bbox_is_rotated =
11411        params.bbox.is_some() && (params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10);
11412
11413    if needs_perpendicular_clip {
11414        // Diagonal gradient with non-extended side — fall back to tiny-skia
11415        // for Sutherland-Hodgman polygon clipping.
11416        let stops = build_gradient_stops(&params.color_stops);
11417        if stops.is_empty() {
11418            return;
11419        }
11420        let start = stet_tiny_skia::Point::from_xy(params.x0 as f32, params.y0 as f32);
11421        let end = stet_tiny_skia::Point::from_xy(params.x1 as f32, params.y1 as f32);
11422        let gradient_transform =
11423            viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
11424        let Some(gradient) = stet_tiny_skia::LinearGradient::new(
11425            start,
11426            end,
11427            stops,
11428            stet_tiny_skia::SpreadMode::Pad,
11429            gradient_transform,
11430        ) else {
11431            return;
11432        };
11433        let paint = Paint {
11434            shader: gradient,
11435            anti_alias: !no_aa,
11436            ..Paint::default()
11437        };
11438
11439        // Use rotated BBox polygon when CTM has rotation, otherwise axis-aligned rect
11440        let mut poly: Vec<(f32, f32)> = if bbox_is_rotated {
11441            let bbox = params.bbox.as_ref().unwrap();
11442            let corners = [
11443                params.ctm.transform_point(bbox[0], bbox[1]),
11444                params.ctm.transform_point(bbox[2], bbox[1]),
11445                params.ctm.transform_point(bbox[2], bbox[3]),
11446                params.ctm.transform_point(bbox[0], bbox[3]),
11447            ];
11448            corners
11449                .iter()
11450                .map(|(x, y)| ((*x as f32 - vp_x) * scale_x, (*y as f32 - vp_y) * scale_y))
11451                .collect()
11452        } else {
11453            vec![
11454                (rx_min, ry_min),
11455                (rx_max, ry_min),
11456                (rx_max, ry_max),
11457                (rx_min, ry_max),
11458            ]
11459        };
11460        let ax = (dx1 - dx0) as f32 * scale_x;
11461        let ay = (dy1 - dy0) as f32 * scale_y;
11462        if !params.extend_start {
11463            let px = (dx0 as f32 - vp_x) * scale_x;
11464            let py = (dy0 as f32 - vp_y) * scale_y;
11465            poly = clip_polygon_halfplane(&poly, ax, ay, px, py);
11466        }
11467        if !params.extend_end {
11468            let px = (dx1 as f32 - vp_x) * scale_x;
11469            let py = (dy1 as f32 - vp_y) * scale_y;
11470            poly = clip_polygon_halfplane(&poly, -ax, -ay, px, py);
11471        }
11472        if poly.len() >= 3 {
11473            let mut pb = PathBuilder::new();
11474            pb.move_to(poly[0].0, poly[0].1);
11475            for &(x, y) in &poly[1..] {
11476                pb.line_to(x, y);
11477            }
11478            pb.close();
11479            if let Some(path) = pb.finish() {
11480                pixmap.fill_path(
11481                    &path,
11482                    &paint,
11483                    SkiaFillRule::Winding,
11484                    Transform::identity(),
11485                    clip_mask,
11486                );
11487            }
11488        }
11489    } else {
11490        // Common case: axis-aligned or both sides extended — direct rasterization.
11491        // Clip fill rect to gradient extent when sides aren't extended.
11492        if !params.extend_start || !params.extend_end {
11493            let axis_x = dx1 - dx0;
11494            let axis_y = dy1 - dy0;
11495            let gx0 = (dx0 as f32 - vp_x) * scale_x;
11496            let gy0 = (dy0 as f32 - vp_y) * scale_y;
11497            let gx1 = (dx1 as f32 - vp_x) * scale_x;
11498            let gy1 = (dy1 as f32 - vp_y) * scale_y;
11499
11500            if axis_x.abs() >= axis_y.abs() {
11501                if !params.extend_start {
11502                    if axis_x >= 0.0 {
11503                        rx_min = rx_min.max(gx0);
11504                    } else {
11505                        rx_max = rx_max.min(gx0);
11506                    }
11507                }
11508                if !params.extend_end {
11509                    if axis_x >= 0.0 {
11510                        rx_max = rx_max.min(gx1);
11511                    } else {
11512                        rx_min = rx_min.max(gx1);
11513                    }
11514                }
11515            } else {
11516                if !params.extend_start {
11517                    if axis_y >= 0.0 {
11518                        ry_min = ry_min.max(gy0);
11519                    } else {
11520                        ry_max = ry_max.min(gy0);
11521                    }
11522                }
11523                if !params.extend_end {
11524                    if axis_y >= 0.0 {
11525                        ry_max = ry_max.min(gy1);
11526                    } else {
11527                        ry_min = ry_min.max(gy1);
11528                    }
11529                }
11530            }
11531            if rx_max <= rx_min || ry_max <= ry_min {
11532                return;
11533            }
11534        }
11535
11536        // Compute gradient axis in shading space.
11537        let ax = params.x1 - params.x0;
11538        let ay = params.y1 - params.y0;
11539        let axis_sq = ax * ax + ay * ay;
11540        if axis_sq < 1e-20 {
11541            return;
11542        }
11543
11544        // Size the LUT to the gradient's pixel span so each entry covers ≤1 pixel.
11545        // This ensures nearest-neighbor lookup produces pixel-perfect sharp edges
11546        // at stitching function discontinuities without banding in smooth gradients.
11547        let pixel_dx = (dx1 - dx0) * scale_x as f64;
11548        let pixel_dy = (dy1 - dy0) * scale_y as f64;
11549        let pixel_axis_len = (pixel_dx * pixel_dx + pixel_dy * pixel_dy).sqrt();
11550        let lut_size = (pixel_axis_len as usize)
11551            .max(params.color_stops.len())
11552            .max(256)
11553            .min(16384);
11554        let lut = build_gradient_lut(&params.color_stops, lut_size);
11555
11556        let Some(inv) = params.ctm.invert() else {
11557            return;
11558        };
11559        let inv_sx = 1.0 / scale_x as f64;
11560        let inv_sy = 1.0 / scale_y as f64;
11561        let dev_origin_x = vp_x as f64;
11562        let dev_origin_y = vp_y as f64;
11563
11564        // Shading-space coords as linear function of pixel coords:
11565        //   sx = sx_base + dsx_dx * px + dsx_dy * py
11566        //   sy = sy_base + dsy_dx * px + dsy_dy * py
11567        let sx_base = inv.a * dev_origin_x + inv.c * dev_origin_y + inv.tx;
11568        let sy_base = inv.b * dev_origin_x + inv.d * dev_origin_y + inv.ty;
11569        let dsx_dx = inv.a * inv_sx;
11570        let dsx_dy = inv.c * inv_sy;
11571        let dsy_dx = inv.b * inv_sx;
11572        let dsy_dy = inv.d * inv_sy;
11573
11574        // t = dot(P_shading - P0, axis) / dot(axis, axis)
11575        let inv_axis_sq = 1.0 / axis_sq;
11576        let t_origin = ((sx_base - params.x0) * ax + (sy_base - params.y0) * ay) * inv_axis_sq;
11577        let dt_dx = (dsx_dx * ax + dsy_dx * ay) * inv_axis_sq;
11578        let dt_dy = (dsx_dy * ax + dsy_dy * ay) * inv_axis_sq;
11579
11580        // Per-pixel rotated BBox clipping: reuse inverse CTM to map each pixel
11581        // back to shading space and check against the original BBox.
11582        let bbox_pixel_clip = if bbox_is_rotated {
11583            let bbox = params.bbox.as_ref().unwrap();
11584            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11585            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11586            Some((
11587                dsx_dx, dsx_dy, sx_base, dsy_dx, dsy_dy, sy_base, bx0, by0, bx1, by1,
11588            ))
11589        } else {
11590            None
11591        };
11592
11593        let ix_min = rx_min.floor() as u32;
11594        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11595        let iy_min = ry_min.floor() as u32;
11596        let iy_max = ry_max.ceil().min(ph as f32) as u32;
11597
11598        let stride = pw as usize * 4;
11599        let data = pixmap.data_mut();
11600        let mask_data = clip_mask.map(|m| m.data());
11601        let alpha = (params.alpha.clamp(0.0, 1.0) * 255.0 + 0.5) as u16;
11602
11603        for py in iy_min..iy_max {
11604            let t_row = t_origin + dt_dy * py as f64;
11605            let row_offset = py as usize * stride;
11606
11607            // Precompute row-base values for rotated BBox check
11608            let (ux_row, uy_row) =
11609                if let Some((_, dux_dy, ux_base, _, duy_dy, uy_base, ..)) = &bbox_pixel_clip {
11610                    (ux_base + dux_dy * py as f64, uy_base + duy_dy * py as f64)
11611                } else {
11612                    (0.0, 0.0)
11613                };
11614
11615            for px in ix_min..ix_max {
11616                // Check clip mask
11617                if let Some(md) = mask_data {
11618                    if md[py as usize * pw as usize + px as usize] == 0 {
11619                        continue;
11620                    }
11621                }
11622
11623                // Per-pixel rotated BBox clip
11624                if let Some((dux_dx, _, _, duy_dx, _, _, bx0, by0, bx1, by1)) = &bbox_pixel_clip {
11625                    let ux = ux_row + dux_dx * px as f64;
11626                    let uy = uy_row + duy_dx * px as f64;
11627                    if ux < *bx0 || ux > *bx1 || uy < *by0 || uy > *by1 {
11628                        continue;
11629                    }
11630                }
11631
11632                let t = t_row + dt_dx * px as f64;
11633                let t_clamped = t.clamp(0.0, 1.0);
11634                let idx = (t_clamped * (lut_size - 1) as f64 + 0.5) as usize;
11635                let [r, g, b, _] = lut[idx.min(lut_size - 1)];
11636
11637                let offset = row_offset + px as usize * 4;
11638                if alpha >= 255 {
11639                    data[offset] = r;
11640                    data[offset + 1] = g;
11641                    data[offset + 2] = b;
11642                    data[offset + 3] = 255;
11643                } else {
11644                    // Alpha blend: premultiply and composite over existing pixel
11645                    let a = alpha as u16;
11646                    let inv_a = 255 - a;
11647                    data[offset] = ((r as u16 * a + data[offset] as u16 * inv_a + 127) / 255) as u8;
11648                    data[offset + 1] =
11649                        ((g as u16 * a + data[offset + 1] as u16 * inv_a + 127) / 255) as u8;
11650                    data[offset + 2] =
11651                        ((b as u16 * a + data[offset + 2] as u16 * inv_a + 127) / 255) as u8;
11652                    data[offset + 3] = ((a + data[offset + 3] as u16 * inv_a / 255).min(255)) as u8;
11653                }
11654            }
11655        }
11656    }
11657
11658    // Update CMYK tracking buffer for axial shading
11659    if let Some(buf) = cmyk_buf {
11660        let pw = pixmap.width();
11661        let inv_sx = 1.0 / scale_x as f64;
11662        let inv_sy = 1.0 / scale_y as f64;
11663        let axis_x = params.x1 - params.x0;
11664        let axis_y = params.y1 - params.y0;
11665        let axis_len_sq = axis_x * axis_x + axis_y * axis_y;
11666        let Some(inv_ctm) = params.ctm.invert() else {
11667            return;
11668        };
11669
11670        let iy_min = ry_min.floor() as u32;
11671        let iy_max = ry_max.ceil().min(pixmap.height() as f32) as u32;
11672        let ix_min = rx_min.floor() as u32;
11673        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11674
11675        for py in iy_min..iy_max {
11676            let dev_y = py as f64 * inv_sy + vp_y as f64;
11677            for px in ix_min..ix_max {
11678                let dev_x = px as f64 * inv_sx + vp_x as f64;
11679                let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11680                let t = if axis_len_sq > 1e-10 {
11681                    ((ux - params.x0) * axis_x + (uy - params.y0) * axis_y) / axis_len_sq
11682                } else {
11683                    0.0
11684                };
11685                if t < 0.0 && !params.extend_start {
11686                    continue;
11687                }
11688                if t > 1.0 && !params.extend_end {
11689                    continue;
11690                }
11691                let clamped = t.clamp(0.0, 1.0);
11692
11693                if let Some(mask) = clip_mask {
11694                    let mi = py as usize * pw as usize + px as usize;
11695                    if mask.data()[mi] == 0 {
11696                        continue;
11697                    }
11698                }
11699
11700                let color = interpolate_color_stops(&params.color_stops, clamped);
11701                let cmyk = interpolate_cmyk_from_stops(
11702                    &params.color_stops,
11703                    &params.color_space,
11704                    clamped,
11705                    &color,
11706                    icc,
11707                );
11708                let ci = (py as usize * pw as usize + px as usize) * 4;
11709                if ci + 3 < buf.len() {
11710                    if params.spot_tint_blend && params.overprint {
11711                        // Per PDF spec 11.7.4.5 a Separation/DeviceN gradient
11712                        // only affects the device colorants identified by its
11713                        // color space: plates for NAMED PROCESS colorants are
11714                        // REPLACED with the gradient's CMYK value at this
11715                        // pixel, plates not tied to a named process colorant
11716                        // are PRESERVED.  The LUT-painted pixmap already
11717                        // carries the spot's full ICC-converted color, so:
11718                        //
11719                        // Gated on `overprint` because the LUT pass for
11720                        // non-overprint shadings carries the author-intended
11721                        // blend mode (e.g. 2265.pdf draws each circle wedge
11722                        // twice — Normal then Multiply — and the multiplied
11723                        // pixmap is the wedge's final color).  Recomposing
11724                        // here would overwrite the multiply-darkened result
11725                        // with a single ICC sample of the source CMYK.
11726                        //   * Where the CMYK buffer is empty (fresh paper),
11727                        //     leave the pixmap alone — re-running CMYK→RGB
11728                        //     here would round-trip through the system
11729                        //     profile and produce a perceptibly different
11730                        //     gradient curve (the snowman shading regression
11731                        //     guarded against in the original recompose
11732                        //     branch).  Just record the named-process
11733                        //     contribution to the buffer for later overprint
11734                        //     tracking.
11735                        //   * Where the CMYK buffer has prior values (a
11736                        //     CMYK fill underneath, e.g. a `1 0 1 0.5 k`
11737                        //     checkmark under the strip), the LUT-paint had
11738                        //     wiped that underlying paint from the pixmap.
11739                        //     Recompose the pixmap from the merged CMYK
11740                        //     (REPLACE named, preserve non-named) to restore
11741                        //     the checkmark with the gradient's named-plate
11742                        //     contribution layered on top.
11743                        let cur_c = buf[ci] as f64;
11744                        let cur_m = buf[ci + 1] as f64;
11745                        let cur_y = buf[ci + 2] as f64;
11746                        let cur_k = buf[ci + 3] as f64;
11747                        let cur_is_zero =
11748                            cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
11749                        let named = params.painted_channels;
11750                        if cur_is_zero {
11751                            if named & stet_graphics::device::CMYK_C != 0 {
11752                                buf[ci] = cmyk.0 as f32;
11753                            }
11754                            if named & stet_graphics::device::CMYK_M != 0 {
11755                                buf[ci + 1] = cmyk.1 as f32;
11756                            }
11757                            if named & stet_graphics::device::CMYK_Y != 0 {
11758                                buf[ci + 2] = cmyk.2 as f32;
11759                            }
11760                            if named & stet_graphics::device::CMYK_K != 0 {
11761                                buf[ci + 3] = cmyk.3 as f32;
11762                            }
11763                        } else {
11764                            let new_c = if named & stet_graphics::device::CMYK_C != 0 {
11765                                cmyk.0
11766                            } else {
11767                                cur_c
11768                            };
11769                            let new_m = if named & stet_graphics::device::CMYK_M != 0 {
11770                                cmyk.1
11771                            } else {
11772                                cur_m
11773                            };
11774                            let new_y = if named & stet_graphics::device::CMYK_Y != 0 {
11775                                cmyk.2
11776                            } else {
11777                                cur_y
11778                            };
11779                            let new_k = if named & stet_graphics::device::CMYK_K != 0 {
11780                                cmyk.3
11781                            } else {
11782                                cur_k
11783                            };
11784                            buf[ci] = new_c as f32;
11785                            buf[ci + 1] = new_m as f32;
11786                            buf[ci + 2] = new_y as f32;
11787                            buf[ci + 3] = new_k as f32;
11788                            let (rv, gv, bv) = if let Some(icc_cache) = icc {
11789                                icc_cache
11790                                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
11791                                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
11792                            } else {
11793                                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
11794                            };
11795                            let stride = pixmap.data().len() / pixmap.height() as usize;
11796                            let offset = py as usize * stride + px as usize * 4;
11797                            let data = pixmap.data_mut();
11798                            data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11799                            data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11800                            data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11801                        }
11802                    } else if params.overprint
11803                        && params.painted_channels != stet_graphics::device::CMYK_ALL
11804                    {
11805                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11806                            buf[ci] = cmyk.0 as f32;
11807                        }
11808                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11809                            buf[ci + 1] = cmyk.1 as f32;
11810                        }
11811                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11812                            buf[ci + 2] = cmyk.2 as f32;
11813                        }
11814                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
11815                            buf[ci + 3] = cmyk.3 as f32;
11816                        }
11817                        // Recomposite RGB from merged CMYK via ICC
11818                        let c = buf[ci] as f64;
11819                        let m = buf[ci + 1] as f64;
11820                        let y = buf[ci + 2] as f64;
11821                        let k = buf[ci + 3] as f64;
11822                        let (rv, gv, bv) = if let Some(icc_cache) = icc {
11823                            icc_cache
11824                                .convert_cmyk_readonly(c, m, y, k)
11825                                .unwrap_or_else(|| cmyk_to_rgb_plrm(c, m, y, k))
11826                        } else {
11827                            cmyk_to_rgb_plrm(c, m, y, k)
11828                        };
11829                        let stride = pixmap.data().len() / pixmap.height() as usize;
11830                        let offset = py as usize * stride + px as usize * 4;
11831                        let data = pixmap.data_mut();
11832                        data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11833                        data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11834                        data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11835                    } else {
11836                        // Non-overprint axial shading: write the source CMYK
11837                        // to the buffer for any consumer that needs it (e.g.
11838                        // overprint sibling tracking) but leave the pixmap
11839                        // alone — `build_gradient_lut` already painted the
11840                        // pixel with linearly-interpolated source RGB, and
11841                        // round-tripping CMYK→RGB through the ICC profile
11842                        // produces a different gradient curve (linear in
11843                        // CMYK rather than linear in RGB) that diverges
11844                        // visibly from the LUT result. The CMYK buffer is
11845                        // only consumed by `composite_non_isolated_cmyk`,
11846                        // which excludes shading-containing groups via
11847                        // `group_content_is_native_cmyk`, so the
11848                        // buffer/pixmap mismatch never reaches a consumer
11849                        // that would notice. Reintroducing the round-trip
11850                        // here was the 3000_9 / 3000_10 snowman shading
11851                        // regression in the silly-weaving-bird plan.
11852                        buf[ci] = cmyk.0 as f32;
11853                        buf[ci + 1] = cmyk.1 as f32;
11854                        buf[ci + 2] = cmyk.2 as f32;
11855                        buf[ci + 3] = cmyk.3 as f32;
11856                    }
11857                }
11858            }
11859        }
11860    }
11861}
11862
11863/// Render a radial gradient shading.
11864#[allow(clippy::too_many_arguments)]
11865fn render_radial_shading(
11866    pixmap: &mut Pixmap,
11867    params: &RadialShadingParams,
11868    vp_x: f32,
11869    vp_y: f32,
11870    scale_x: f32,
11871    scale_y: f32,
11872    clip_mask: Option<&Mask>,
11873    _no_aa: bool,
11874    mut cmyk_buf: Option<&mut [f32]>,
11875    icc: Option<&IccCache>,
11876) {
11877    let pw = pixmap.width();
11878    let ph = pixmap.height();
11879    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
11880        return;
11881    }
11882
11883    let Some(inv_ctm) = params.ctm.invert() else {
11884        return;
11885    };
11886
11887    let (px_min, py_min, px_max, py_max) = if let Some(bbox) = &params.bbox {
11888        let corners = [
11889            params.ctm.transform_point(bbox[0], bbox[1]),
11890            params.ctm.transform_point(bbox[2], bbox[1]),
11891            params.ctm.transform_point(bbox[0], bbox[3]),
11892            params.ctm.transform_point(bbox[2], bbox[3]),
11893        ];
11894        let x_min = corners
11895            .iter()
11896            .map(|c| c.0 as f32)
11897            .fold(f32::INFINITY, f32::min);
11898        let y_min = corners
11899            .iter()
11900            .map(|c| c.1 as f32)
11901            .fold(f32::INFINITY, f32::min);
11902        let x_max = corners
11903            .iter()
11904            .map(|c| c.0 as f32)
11905            .fold(f32::NEG_INFINITY, f32::max);
11906        let y_max = corners
11907            .iter()
11908            .map(|c| c.1 as f32)
11909            .fold(f32::NEG_INFINITY, f32::max);
11910        (
11911            ((x_min - vp_x) * scale_x).max(0.0) as u32,
11912            ((y_min - vp_y) * scale_y).max(0.0) as u32,
11913            (((x_max - vp_x) * scale_x).ceil() as u32).min(pw),
11914            (((y_max - vp_y) * scale_y).ceil() as u32).min(ph),
11915        )
11916    } else {
11917        (0, 0, pw, ph)
11918    };
11919
11920    let inv_sx = 1.0 / scale_x as f64;
11921    let inv_sy = 1.0 / scale_y as f64;
11922
11923    // Rotated BBox: check per-pixel user-space containment
11924    let rotated_bbox = if let Some(bbox) = &params.bbox {
11925        if params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10 {
11926            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11927            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11928            Some((bx0, by0, bx1, by1))
11929        } else {
11930            None
11931        }
11932    } else {
11933        None
11934    };
11935
11936    let data = pixmap.data_mut();
11937    let stride = pw as usize * 4;
11938
11939    for py in py_min..py_max {
11940        let dev_y = py as f64 * inv_sy + vp_y as f64;
11941        for px in px_min..px_max {
11942            let dev_x = px as f64 * inv_sx + vp_x as f64;
11943            let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11944
11945            // Per-pixel rotated BBox clip
11946            if let Some((bx0, by0, bx1, by1)) = rotated_bbox {
11947                if ux < bx0 || ux > bx1 || uy < by0 || uy > by1 {
11948                    continue;
11949                }
11950            }
11951
11952            let t = solve_radial_t(
11953                ux,
11954                uy,
11955                params.x0,
11956                params.y0,
11957                params.r0,
11958                params.x1,
11959                params.y1,
11960                params.r1,
11961                params.extend_start,
11962                params.extend_end,
11963            );
11964            if let Some(t) = t {
11965                let clamped = t.clamp(0.0, 1.0);
11966                let color = interpolate_color_stops(&params.color_stops, clamped);
11967
11968                let clipped = clip_mask
11969                    .is_some_and(|mask| mask.data()[py as usize * pw as usize + px as usize] == 0);
11970
11971                if clipped {
11972                    continue;
11973                }
11974
11975                // Decide whether this pixel should use the multiplicative
11976                // ink-stacking blend to preserve a spot backdrop. We mirror
11977                // the rule in `render_overprint_fill`: overprint + subset
11978                // painted channels + buffer effectively empty at this pixel
11979                // means the pixmap carries a non-CMYK contribution (or the
11980                // pixel is fresh), so per-channel ink-stacking gives the
11981                // correct result whether the backdrop was spot-painted or
11982                // plain.
11983                let cmyk = interpolate_cmyk_from_stops(
11984                    &params.color_stops,
11985                    &params.color_space,
11986                    clamped,
11987                    &color,
11988                    icc,
11989                );
11990                let ci = (py as usize * pw as usize + px as usize) * 4;
11991                let buffer_clean = if let Some(ref buf) = cmyk_buf {
11992                    if ci + 3 < buf.len() {
11993                        buf[ci] == 0.0
11994                            && buf[ci + 1] == 0.0
11995                            && buf[ci + 2] == 0.0
11996                            && buf[ci + 3] == 0.0
11997                    } else {
11998                        false
11999                    }
12000                } else {
12001                    false
12002                };
12003                let offset_for_check = py as usize * stride + px as usize * 4;
12004                let pixmap_has_colour = data[offset_for_check + 3] > 0
12005                    && (data[offset_for_check] < 250
12006                        || data[offset_for_check + 1] < 250
12007                        || data[offset_for_check + 2] < 250);
12008                let use_multiplicative = params.overprint
12009                    && params.painted_channels != stet_graphics::device::CMYK_ALL
12010                    && buffer_clean
12011                    && pixmap_has_colour;
12012
12013                // Write CMYK buffer at non-clipped pixels
12014                if let Some(ref mut buf) = cmyk_buf
12015                    && ci + 3 < buf.len()
12016                {
12017                    if params.overprint
12018                        && params.painted_channels != stet_graphics::device::CMYK_ALL
12019                    {
12020                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12021                            buf[ci] = cmyk.0 as f32;
12022                        }
12023                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12024                            buf[ci + 1] = cmyk.1 as f32;
12025                        }
12026                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12027                            buf[ci + 2] = cmyk.2 as f32;
12028                        }
12029                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12030                            buf[ci + 3] = cmyk.3 as f32;
12031                        }
12032                    } else {
12033                        buf[ci] = cmyk.0 as f32;
12034                        buf[ci + 1] = cmyk.1 as f32;
12035                        buf[ci + 2] = cmyk.2 as f32;
12036                        buf[ci + 3] = cmyk.3 as f32;
12037                    }
12038                }
12039
12040                let offset = py as usize * stride + px as usize * 4;
12041                if use_multiplicative {
12042                    // Ink-stack the per-stop CMYK onto the pixmap RGB. Only
12043                    // channels named by painted_channels contribute; others
12044                    // leave the pixmap untouched, so a spot-painted backdrop
12045                    // survives with just the named inks darkening it.
12046                    let bg_r = data[offset] as f64 / 255.0;
12047                    let bg_g = data[offset + 1] as f64 / 255.0;
12048                    let bg_b = data[offset + 2] as f64 / 255.0;
12049                    let over_r = if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12050                        1.0 - cmyk.0
12051                    } else {
12052                        1.0
12053                    };
12054                    let over_g = if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12055                        1.0 - cmyk.1
12056                    } else {
12057                        1.0
12058                    };
12059                    let over_b = if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12060                        1.0 - cmyk.2
12061                    } else {
12062                        1.0
12063                    };
12064                    let k_fac = if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12065                        1.0 - cmyk.3
12066                    } else {
12067                        1.0
12068                    };
12069                    data[offset] = ((bg_r * over_r * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12070                    data[offset + 1] =
12071                        ((bg_g * over_g * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12072                    data[offset + 2] =
12073                        ((bg_b * over_b * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12074                    data[offset + 3] = 255;
12075                } else {
12076                    data[offset] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
12077                    data[offset + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
12078                    data[offset + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
12079                    data[offset + 3] = 255;
12080
12081                    // Recomposite RGB from the CMYK buffer via ICC only for
12082                    // overprint DeviceCMYK shadings on a CMYK-only backdrop,
12083                    // where the per-channel merge in the buffer means the
12084                    // displayed pixel must reflect the merged CMYK rather
12085                    // than the source's RGB. For non-overprint shadings the
12086                    // LUT-rendered pixmap (above) is already correct, and
12087                    // round-tripping CMYK→RGB through the ICC profile
12088                    // produces a different gradient curve (linear in CMYK
12089                    // rather than linear in RGB) — that drift was the
12090                    // 3000_9 / 3000_10 snowman shading regression. The CMYK
12091                    // buffer is only consumed by `composite_non_isolated_cmyk`,
12092                    // which excludes shading-containing groups via
12093                    // `group_content_is_native_cmyk`, so the buffer/pixmap
12094                    // mismatch never reaches a consumer that would notice.
12095                    if params.overprint
12096                        && params.painted_channels != stet_graphics::device::CMYK_ALL
12097                        && matches!(
12098                            params.color_space,
12099                            ShadingColorSpace::DeviceCMYK
12100                                | ShadingColorSpace::Separation { .. }
12101                                | ShadingColorSpace::DeviceN { .. }
12102                        )
12103                        && let Some(ref mut buf) = cmyk_buf
12104                        && ci + 3 < buf.len()
12105                        && let Some(icc_cache) = icc
12106                    {
12107                        let c = buf[ci] as f64;
12108                        let m = buf[ci + 1] as f64;
12109                        let y = buf[ci + 2] as f64;
12110                        let k = buf[ci + 3] as f64;
12111                        if let Some((r, g, b)) = icc_cache.convert_cmyk_readonly(c, m, y, k) {
12112                            data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
12113                            data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
12114                            data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
12115                        }
12116                    }
12117                }
12118            }
12119        }
12120    }
12121}
12122/// Solve for the parameter t of a two-circle radial gradient at point (px, py).
12123///
12124/// Returns the largest root of the circle equation that falls within the valid
12125/// domain and has R(t) >= 0. The valid domain is [0,1], extended by extend flags.
12126#[allow(clippy::too_many_arguments)]
12127fn solve_radial_t(
12128    px: f64,
12129    py: f64,
12130    x0: f64,
12131    y0: f64,
12132    r0: f64,
12133    x1: f64,
12134    y1: f64,
12135    r1: f64,
12136    extend_start: bool,
12137    extend_end: bool,
12138) -> Option<f64> {
12139    // Parametric: C(t) = (1-t)*C0 + t*C1, R(t) = (1-t)*r0 + t*r1
12140    // Solve: (px - Cx(t))^2 + (py - Cy(t))^2 = R(t)^2
12141    let cdx = x1 - x0;
12142    let cdy = y1 - y0;
12143    let dr = r1 - r0;
12144
12145    let a = cdx * cdx + cdy * cdy - dr * dr;
12146    let dpx = px - x0;
12147    let dpy = py - y0;
12148    let b = -2.0 * (dpx * cdx + dpy * cdy + r0 * dr);
12149    let c = dpx * dpx + dpy * dpy - r0 * r0;
12150
12151    // Helper: check if a root is in the valid domain
12152    let in_domain = |t: f64| -> bool {
12153        (0.0..=1.0).contains(&t) || (t < 0.0 && extend_start) || (t > 1.0 && extend_end)
12154    };
12155
12156    if a.abs() < 1e-10 {
12157        // Linear case
12158        if b.abs() < 1e-10 {
12159            return None;
12160        }
12161        let t = -c / b;
12162        let radius = r0 + t * dr;
12163        if radius >= 0.0 && in_domain(t) {
12164            return Some(t);
12165        }
12166        return None;
12167    }
12168
12169    let discriminant = b * b - 4.0 * a * c;
12170    if discriminant < 0.0 {
12171        return None;
12172    }
12173    let sqrt_d = discriminant.sqrt();
12174    let t1 = (-b + sqrt_d) / (2.0 * a);
12175    let t2 = (-b - sqrt_d) / (2.0 * a);
12176
12177    // Pick the largest root that is in the valid domain and has R(t) >= 0
12178    let mut best: Option<f64> = None;
12179    for t in [t1, t2] {
12180        let radius = r0 + t * dr;
12181        if radius >= 0.0 && in_domain(t) {
12182            best = Some(match best {
12183                Some(prev) => prev.max(t),
12184                None => t,
12185            });
12186        }
12187    }
12188    best
12189}
12190
12191/// Render a Gouraud-shaded triangle mesh.
12192#[allow(clippy::too_many_arguments)]
12193fn render_mesh_shading(
12194    pixmap: &mut Pixmap,
12195    params: &MeshShadingParams,
12196    vp_x: f32,
12197    vp_y: f32,
12198    scale_x: f32,
12199    scale_y: f32,
12200    clip_mask: Option<&Mask>,
12201    mut cmyk_buf: Option<&mut [f32]>,
12202    icc: Option<&IccCache>,
12203) {
12204    let pw = pixmap.width() as usize;
12205    let ph = pixmap.height() as usize;
12206    if pw == 0 || ph == 0 {
12207        return;
12208    }
12209    let data = pixmap.data_mut();
12210    let stride = pw * 4;
12211
12212    let lut = params.color_lut.as_deref();
12213
12214    for tri in &params.triangles {
12215        let (dx0, dy0) = params.ctm.transform_point(tri.v0.x, tri.v0.y);
12216        let (dx1, dy1) = params.ctm.transform_point(tri.v1.x, tri.v1.y);
12217        let (dx2, dy2) = params.ctm.transform_point(tri.v2.x, tri.v2.y);
12218
12219        let x0 = (dx0 as f32 - vp_x) * scale_x;
12220        let y0 = (dy0 as f32 - vp_y) * scale_y;
12221        let x1 = (dx1 as f32 - vp_x) * scale_x;
12222        let y1 = (dy1 as f32 - vp_y) * scale_y;
12223        let x2 = (dx2 as f32 - vp_x) * scale_x;
12224        let y2 = (dy2 as f32 - vp_y) * scale_y;
12225
12226        let min_x = (x0.min(x1).min(x2).floor().max(0.0)) as usize;
12227        let max_x = (x0.max(x1).max(x2).ceil() as usize).min(pw);
12228        let min_y = (y0.min(y1).min(y2).floor().max(0.0)) as usize;
12229        let max_y = (y0.max(y1).max(y2).ceil() as usize).min(ph);
12230
12231        if min_x >= max_x || min_y >= max_y {
12232            continue;
12233        }
12234
12235        let x0 = x0 as f64;
12236        let y0 = y0 as f64;
12237        let x1 = x1 as f64;
12238        let y1 = y1 as f64;
12239        let x2 = x2 as f64;
12240        let y2 = y2 as f64;
12241        // Swap vertices 1 and 2 when the triangle has reversed winding
12242        // (from a CTM with negative determinant, e.g. X- or Y-flip).
12243        // This ensures barycentric coordinates stay positive for interior
12244        // points regardless of the CTM orientation.
12245        let denom = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2);
12246        if denom.abs() < 1e-10 {
12247            continue;
12248        }
12249        let (x1, y1, x2, y2) = if denom < 0.0 {
12250            (x2, y2, x1, y1)
12251        } else {
12252            (x1, y1, x2, y2)
12253        };
12254        let (v1_ref, v2_ref) = if denom < 0.0 {
12255            (&tri.v2, &tri.v1)
12256        } else {
12257            (&tri.v1, &tri.v2)
12258        };
12259        let denom = denom.abs();
12260        let inv_denom = 1.0 / denom;
12261
12262        for py in min_y..max_y {
12263            for px in min_x..max_x {
12264                let pxf = px as f64 + 0.5;
12265                let pyf = py as f64 + 0.5;
12266
12267                let w0 = ((y1 - y2) * (pxf - x2) + (x2 - x1) * (pyf - y2)) * inv_denom;
12268                let w1 = ((y2 - y0) * (pxf - x2) + (x0 - x2) * (pyf - y2)) * inv_denom;
12269                let w2 = 1.0 - w0 - w1;
12270
12271                if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
12272                    continue;
12273                }
12274
12275                let clipped = clip_mask.is_some_and(|mask| mask.data()[py * pw + px] == 0);
12276
12277                let w0c = w0.max(0.0);
12278                let w1c = w1.max(0.0);
12279                let w2c = w2.max(0.0);
12280                let wsum = w0c + w1c + w2c;
12281                let w0n = w0c / wsum;
12282                let w1n = w1c / wsum;
12283                let w2n = w2c / wsum;
12284
12285                // Per-pixel color: either LUT lookup (for function-based meshes)
12286                // or direct Gouraud interpolation of vertex DeviceColors.
12287                let (r, g, b) = if let Some(lut) = lut {
12288                    // Interpolate raw function input values per-pixel
12289                    let raw = w0n * tri.v0.raw_components[0]
12290                        + w1n * v1_ref.raw_components[0]
12291                        + w2n * v2_ref.raw_components[0];
12292                    let raw = raw.clamp(0.0, 1.0);
12293                    // Linear interpolation in the LUT
12294                    let fi = raw * (lut.len() - 1) as f64;
12295                    let i0 = (fi as usize).min(lut.len().saturating_sub(2));
12296                    let frac = fi - i0 as f64;
12297                    let c0 = &lut[i0];
12298                    let c1 = &lut[i0 + 1];
12299                    (
12300                        c0.r + frac * (c1.r - c0.r),
12301                        c0.g + frac * (c1.g - c0.g),
12302                        c0.b + frac * (c1.b - c0.b),
12303                    )
12304                } else {
12305                    (
12306                        w0n * tri.v0.color.r + w1n * v1_ref.color.r + w2n * v2_ref.color.r,
12307                        w0n * tri.v0.color.g + w1n * v1_ref.color.g + w2n * v2_ref.color.g,
12308                        w0n * tri.v0.color.b + w1n * v1_ref.color.b + w2n * v2_ref.color.b,
12309                    )
12310                };
12311
12312                // Write CMYK buffer
12313                if let Some(ref mut buf) = cmyk_buf {
12314                    let ci = (py * pw + px) * 4;
12315                    if ci + 3 < buf.len() {
12316                        let cmyk = interpolate_cmyk_from_vertices(
12317                            &tri.v0,
12318                            v1_ref,
12319                            v2_ref,
12320                            w0n,
12321                            w1n,
12322                            w2n,
12323                            &params.color_space,
12324                            r,
12325                            g,
12326                            b,
12327                            icc,
12328                        );
12329                        if params.overprint
12330                            && params.painted_channels != stet_graphics::device::CMYK_ALL
12331                        {
12332                            if !clipped {
12333                                if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12334                                    buf[ci] = cmyk.0 as f32;
12335                                }
12336                                if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12337                                    buf[ci + 1] = cmyk.1 as f32;
12338                                }
12339                                if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12340                                    buf[ci + 2] = cmyk.2 as f32;
12341                                }
12342                                if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12343                                    buf[ci + 3] = cmyk.3 as f32;
12344                                }
12345                            }
12346                        } else {
12347                            buf[ci] = cmyk.0 as f32;
12348                            buf[ci + 1] = cmyk.1 as f32;
12349                            buf[ci + 2] = cmyk.2 as f32;
12350                            buf[ci + 3] = cmyk.3 as f32;
12351                        }
12352                    }
12353                }
12354
12355                if clipped {
12356                    continue;
12357                }
12358
12359                let offset = py * stride + px * 4;
12360                data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
12361                data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
12362                data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
12363                data[offset + 3] = 255;
12364            }
12365        }
12366    }
12367}
12368
12369/// Render a Coons/tensor-product patch mesh by subdividing into triangles.
12370#[allow(clippy::too_many_arguments)]
12371fn render_patch_shading(
12372    pixmap: &mut Pixmap,
12373    params: &PatchShadingParams,
12374    vp_x: f32,
12375    vp_y: f32,
12376    scale_x: f32,
12377    scale_y: f32,
12378    clip_mask: Option<&Mask>,
12379    cmyk_buf: Option<&mut [f32]>,
12380    icc: Option<&IccCache>,
12381) {
12382    let mut triangles = Vec::new();
12383    let scale = scale_x.max(scale_y) as f64;
12384    for patch in &params.patches {
12385        if patch.points.len() >= 12 {
12386            // Compute device-space extent to choose subdivision level
12387            let mut x_min = f64::INFINITY;
12388            let mut y_min = f64::INFINITY;
12389            let mut x_max = f64::NEG_INFINITY;
12390            let mut y_max = f64::NEG_INFINITY;
12391            for &(px, py) in &patch.points {
12392                let (dx, dy) = params.ctm.transform_point(px, py);
12393                x_min = x_min.min(dx);
12394                y_min = y_min.min(dy);
12395                x_max = x_max.max(dx);
12396                y_max = y_max.max(dy);
12397            }
12398            let extent = (x_max - x_min).max(y_max - y_min).abs() * scale;
12399            // Target ~2 device pixels per boundary segment
12400            let n = (extent / 2.0).ceil().clamp(8.0, 64.0) as usize;
12401            // Extract ICC profile hash for per-grid-point color conversion
12402            let icc_profile_hash = match &params.color_space {
12403                stet_graphics::device::ShadingColorSpace::ICCBased { profile_hash, .. } => {
12404                    Some(profile_hash)
12405                }
12406                _ => None,
12407            };
12408            subdivide_patch_to_triangles(patch, &mut triangles, n, icc_profile_hash, icc);
12409        }
12410    }
12411    if !triangles.is_empty() {
12412        let mesh_params = MeshShadingParams {
12413            triangles,
12414            ctm: params.ctm,
12415            bbox: params.bbox,
12416            color_space: params.color_space.clone(),
12417            overprint: params.overprint,
12418            overprint_mode: params.overprint_mode,
12419            painted_channels: params.painted_channels,
12420            color_lut: params.color_lut.clone(),
12421            alpha: params.alpha,
12422            blend_mode: params.blend_mode,
12423            alpha_is_shape: params.alpha_is_shape,
12424        };
12425        render_mesh_shading(
12426            pixmap,
12427            &mesh_params,
12428            vp_x,
12429            vp_y,
12430            scale_x,
12431            scale_y,
12432            clip_mask,
12433            cmyk_buf,
12434            icc,
12435        );
12436    }
12437}
12438/// Subdivide a Coons/tensor patch into triangles via grid subdivision.
12439/// Evaluates the patch at NxN points and triangulates the resulting grid.
12440/// When an ICC profile hash and cache are provided, interpolates colors in the
12441/// source ICC color space and converts per-grid-point for accurate rendering.
12442fn subdivide_patch_to_triangles(
12443    patch: &stet_graphics::device::ShadingPatch,
12444    triangles: &mut Vec<stet_graphics::device::ShadingTriangle>,
12445    n: usize,
12446    icc_profile_hash: Option<&stet_graphics::icc::ProfileHash>,
12447    icc_cache: Option<&IccCache>,
12448) {
12449    // Evaluate patch at grid points.
12450    // Use tensor-product evaluation when 16 control points are available (Type 7),
12451    // otherwise fall back to Coons blending (Type 6, 12 points).
12452    let mut grid: Vec<(f64, f64, DeviceColor, Vec<f64>)> = Vec::with_capacity((n + 1) * (n + 1));
12453    let use_tensor = patch.points.len() >= 16;
12454    let has_raw = !patch.raw_colors[0].is_empty();
12455    // Use per-grid-point ICC conversion when profile info is available
12456    let use_icc_interp = has_raw && icc_profile_hash.is_some() && icc_cache.is_some();
12457
12458    for row in 0..=n {
12459        let v = row as f64 / n as f64;
12460        for col in 0..=n {
12461            let u = col as f64 / n as f64;
12462            let (x, y) = if use_tensor {
12463                eval_tensor_patch(patch, u, v)
12464            } else {
12465                eval_coons_patch(patch, u, v)
12466            };
12467            let raw = if has_raw {
12468                bilinear_raw(&patch.raw_colors, u, v)
12469            } else {
12470                vec![]
12471            };
12472            // When ICC profile is available, convert the interpolated raw
12473            // components at each grid point for accurate color rendering.
12474            // This interpolates in the source color space (e.g. ProPhoto RGB)
12475            // and converts per-grid-point, rather than interpolating pre-converted
12476            // sRGB values from only the 4 corners.
12477            let color = if use_icc_interp {
12478                if let Some((r, g, b)) = icc_cache
12479                    .unwrap()
12480                    .convert_color_readonly(icc_profile_hash.unwrap(), &raw)
12481                {
12482                    DeviceColor::from_rgb(r, g, b)
12483                } else {
12484                    bilinear_color(&patch.colors, u, v)
12485                }
12486            } else {
12487                bilinear_color(&patch.colors, u, v)
12488            };
12489            grid.push((x, y, color, raw));
12490        }
12491    }
12492
12493    // Triangulate grid
12494    let cols = n + 1;
12495    for row in 0..n {
12496        for col in 0..n {
12497            let i00 = row * cols + col;
12498            let i10 = i00 + 1;
12499            let i01 = i00 + cols;
12500            let i11 = i01 + 1;
12501
12502            let (x00, y00, c00, r00) = &grid[i00];
12503            let (x10, y10, c10, r10) = &grid[i10];
12504            let (x01, y01, c01, r01) = &grid[i01];
12505            let (x11, y11, c11, r11) = &grid[i11];
12506
12507            use stet_graphics::device::ShadingVertex;
12508            triangles.push(stet_graphics::device::ShadingTriangle {
12509                v0: ShadingVertex {
12510                    x: *x00,
12511                    y: *y00,
12512                    color: c00.clone(),
12513                    raw_components: r00.clone(),
12514                },
12515                v1: ShadingVertex {
12516                    x: *x10,
12517                    y: *y10,
12518                    color: c10.clone(),
12519                    raw_components: r10.clone(),
12520                },
12521                v2: ShadingVertex {
12522                    x: *x01,
12523                    y: *y01,
12524                    color: c01.clone(),
12525                    raw_components: r01.clone(),
12526                },
12527            });
12528            triangles.push(stet_graphics::device::ShadingTriangle {
12529                v0: ShadingVertex {
12530                    x: *x10,
12531                    y: *y10,
12532                    color: c10.clone(),
12533                    raw_components: r10.clone(),
12534                },
12535                v1: ShadingVertex {
12536                    x: *x11,
12537                    y: *y11,
12538                    color: c11.clone(),
12539                    raw_components: r11.clone(),
12540                },
12541                v2: ShadingVertex {
12542                    x: *x01,
12543                    y: *y01,
12544                    color: c01.clone(),
12545                    raw_components: r01.clone(),
12546                },
12547            });
12548        }
12549    }
12550}
12551
12552/// Evaluate a Coons patch at parameter (u, v).
12553/// The 12 control points define 4 cubic Bezier boundary curves.
12554fn eval_coons_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12555    let pts = &patch.points;
12556    if pts.len() < 12 {
12557        return (0.0, 0.0);
12558    }
12559
12560    // Side 0 (bottom): pts[0..4], u goes 0→1
12561    // Side 1 (right): pts[3..7], v goes 0→1
12562    // Side 2 (top): pts[6..10], u goes 1→0 (reversed)
12563    // Side 3 (left): pts[9..12] + pts[0], v goes 1→0 (reversed)
12564    let c0 = eval_cubic_bezier(pts[0], pts[1], pts[2], pts[3], u);
12565    let c2 = eval_cubic_bezier(pts[6], pts[7], pts[8], pts[9], 1.0 - u);
12566    let d0 = eval_cubic_bezier(pts[0], pts[11], pts[10], pts[9], v);
12567    let d1 = eval_cubic_bezier(pts[3], pts[4], pts[5], pts[6], v);
12568
12569    // Bilinear blending of corners
12570    let p00 = pts[0];
12571    let p10 = pts[3];
12572    let p01 = pts[9];
12573    let p11 = pts[6];
12574    let bx = (1.0 - u) * (1.0 - v) * p00.0
12575        + u * (1.0 - v) * p10.0
12576        + (1.0 - u) * v * p01.0
12577        + u * v * p11.0;
12578    let by = (1.0 - u) * (1.0 - v) * p00.1
12579        + u * (1.0 - v) * p10.1
12580        + (1.0 - u) * v * p01.1
12581        + u * v * p11.1;
12582
12583    // Coons blending: S(u,v) = c(u,v) + d(u,v) - B(u,v)
12584    let x = (1.0 - v) * c0.0 + v * c2.0 + (1.0 - u) * d0.0 + u * d1.0 - bx;
12585    let y = (1.0 - v) * c0.1 + v * c2.1 + (1.0 - u) * d0.1 + u * d1.1 - by;
12586
12587    (x, y)
12588}
12589
12590/// Evaluate a Type 7 tensor-product patch at parameter (u, v).
12591///
12592/// Uses 16 control points arranged in a 4×4 grid, evaluated as a bicubic
12593/// Bernstein surface: S(u,v) = ΣΣ B_i(u) * B_j(v) * P_ij
12594///
12595/// PDF spec (ISO 32000, Table 85) data ordering for flag=0:
12596///   p₁₁ p₁₂ p₁₃ p₁₄  p₂₁ p₂₂ p₂₃ p₂₄  p₃₁ p₃₂ p₃₃ p₃₄  p₄₁ p₄₂ p₄₃ p₄₄
12597///
12598/// In the grid (Figure 86), column index = u direction, row index = v direction:
12599///   grid[v=0][u] = p₁₁, p₂₁, p₃₁, p₄₁  = pts[0], pts[4], pts[8],  pts[12]
12600///   grid[v=⅓][u] = p₁₂, p₂₂, p₃₂, p₄₂  = pts[1], pts[5], pts[9],  pts[13]
12601///   grid[v=⅔][u] = p₁₃, p₂₃, p₃₃, p₄₃  = pts[2], pts[6], pts[10], pts[14]
12602///   grid[v=1][u] = p₁₄, p₂₄, p₃₄, p₄₄  = pts[3], pts[7], pts[11], pts[15]
12603fn eval_tensor_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12604    let pts = &patch.points;
12605
12606    // Map data indices to 4×4 grid [row][col].
12607    // pts[0..12] are boundary points around the perimeter (same as Type 6).
12608    // pts[12..16] are the 4 interior control points.
12609    let grid: [[usize; 4]; 4] = [[0, 1, 2, 3], [11, 12, 13, 4], [10, 15, 14, 5], [9, 8, 7, 6]];
12610
12611    // Cubic Bernstein basis values
12612    let su = 1.0 - u;
12613    let bu = [su * su * su, 3.0 * su * su * u, 3.0 * su * u * u, u * u * u];
12614    let sv = 1.0 - v;
12615    let bv = [sv * sv * sv, 3.0 * sv * sv * v, 3.0 * sv * v * v, v * v * v];
12616
12617    let mut x = 0.0;
12618    let mut y = 0.0;
12619    for j in 0..4 {
12620        for i in 0..4 {
12621            let w = bu[i] * bv[j];
12622            let p = pts[grid[j][i]];
12623            x += w * p.0;
12624            y += w * p.1;
12625        }
12626    }
12627    (x, y)
12628}
12629
12630/// Evaluate a cubic Bezier curve at parameter t.
12631fn eval_cubic_bezier(
12632    p0: (f64, f64),
12633    p1: (f64, f64),
12634    p2: (f64, f64),
12635    p3: (f64, f64),
12636    t: f64,
12637) -> (f64, f64) {
12638    let s = 1.0 - t;
12639    let s2 = s * s;
12640    let t2 = t * t;
12641    let b0 = s2 * s;
12642    let b1 = 3.0 * s2 * t;
12643    let b2 = 3.0 * s * t2;
12644    let b3 = t2 * t;
12645    (
12646        b0 * p0.0 + b1 * p1.0 + b2 * p2.0 + b3 * p3.0,
12647        b0 * p0.1 + b1 * p1.1 + b2 * p2.1 + b3 * p3.1,
12648    )
12649}
12650
12651/// Bilinear color interpolation across patch corners.
12652fn bilinear_color(colors: &[DeviceColor; 4], u: f64, v: f64) -> DeviceColor {
12653    let r = (1.0 - u) * (1.0 - v) * colors[0].r
12654        + u * (1.0 - v) * colors[1].r
12655        + (1.0 - u) * v * colors[3].r
12656        + u * v * colors[2].r;
12657    let g = (1.0 - u) * (1.0 - v) * colors[0].g
12658        + u * (1.0 - v) * colors[1].g
12659        + (1.0 - u) * v * colors[3].g
12660        + u * v * colors[2].g;
12661    let b = (1.0 - u) * (1.0 - v) * colors[0].b
12662        + u * (1.0 - v) * colors[1].b
12663        + (1.0 - u) * v * colors[3].b
12664        + u * v * colors[2].b;
12665    DeviceColor::from_rgb(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
12666}
12667
12668/// Bilinear interpolation of raw color components across patch corners.
12669fn bilinear_raw(raw_colors: &[Vec<f64>; 4], u: f64, v: f64) -> Vec<f64> {
12670    let n = raw_colors[0].len();
12671    let mut result = vec![0.0; n];
12672    for i in 0..n {
12673        result[i] = (1.0 - u) * (1.0 - v) * raw_colors[0][i]
12674            + u * (1.0 - v) * raw_colors[1][i]
12675            + (1.0 - u) * v * raw_colors[3][i]
12676            + u * v * raw_colors[2][i];
12677    }
12678    result
12679}
12680
12681/// Pre-rasterize color stops into a 256-entry RGBA lookup table.
12682///
12683/// Each entry is linearly interpolated from the color stops. Used by the
12684/// direct-rasterization axial shading path to replace per-pixel stop search
12685/// with a single array lookup.
12686fn build_gradient_lut(stops: &[stet_graphics::device::ColorStop], size: usize) -> Vec<[u8; 4]> {
12687    let size = size.max(2);
12688    let mut lut = vec![[0u8; 4]; size];
12689    if stops.is_empty() {
12690        return lut;
12691    }
12692    let mut si = 0usize; // current stop index
12693    let last = (size - 1) as f64;
12694    for i in 0..size {
12695        let t = i as f64 / last;
12696        // Advance stop index
12697        while si + 1 < stops.len() && stops[si + 1].position < t {
12698            si += 1;
12699        }
12700        let (r, g, b) = if si + 1 >= stops.len() {
12701            let c = &stops[stops.len() - 1].color;
12702            (c.r, c.g, c.b)
12703        } else if t <= stops[si].position {
12704            let c = &stops[si].color;
12705            (c.r, c.g, c.b)
12706        } else {
12707            let t0 = stops[si].position;
12708            let t1 = stops[si + 1].position;
12709            let frac = if (t1 - t0).abs() < 1e-10 {
12710                0.0
12711            } else {
12712                (t - t0) / (t1 - t0)
12713            };
12714            let c0 = &stops[si].color;
12715            let c1 = &stops[si + 1].color;
12716            (
12717                c0.r + frac * (c1.r - c0.r),
12718                c0.g + frac * (c1.g - c0.g),
12719                c0.b + frac * (c1.b - c0.b),
12720            )
12721        };
12722        lut[i] = [
12723            (r * 255.0).round().clamp(0.0, 255.0) as u8,
12724            (g * 255.0).round().clamp(0.0, 255.0) as u8,
12725            (b * 255.0).round().clamp(0.0, 255.0) as u8,
12726            255,
12727        ];
12728    }
12729    lut
12730}
12731
12732/// Build tiny-skia gradient stops from color stops.
12733fn build_gradient_stops(
12734    stops: &[stet_graphics::device::ColorStop],
12735) -> Vec<stet_tiny_skia::GradientStop> {
12736    let mut result = Vec::with_capacity(stops.len());
12737    for stop in stops {
12738        let r = (stop.color.r * 255.0).round().clamp(0.0, 255.0) as u8;
12739        let g = (stop.color.g * 255.0).round().clamp(0.0, 255.0) as u8;
12740        let b = (stop.color.b * 255.0).round().clamp(0.0, 255.0) as u8;
12741        result.push(stet_tiny_skia::GradientStop::new(
12742            stop.position as f32,
12743            Color::from_rgba8(r, g, b, 255),
12744        ));
12745    }
12746    result
12747}
12748
12749/// Interpolate between color stops at a given position (0.0..=1.0).
12750fn interpolate_color_stops(
12751    stops: &[stet_graphics::device::ColorStop],
12752    position: f64,
12753) -> DeviceColor {
12754    if stops.is_empty() {
12755        return DeviceColor::from_gray(0.0);
12756    }
12757    if stops.len() == 1 || position <= stops[0].position {
12758        return stops[0].color.clone();
12759    }
12760    if position >= stops.last().unwrap().position {
12761        return stops.last().unwrap().color.clone();
12762    }
12763
12764    // Find the two stops bracketing this position
12765    for i in 1..stops.len() {
12766        if position <= stops[i].position {
12767            let t0 = stops[i - 1].position;
12768            let t1 = stops[i].position;
12769            let frac = if (t1 - t0).abs() < 1e-10 {
12770                0.0
12771            } else {
12772                (position - t0) / (t1 - t0)
12773            };
12774            let c0 = &stops[i - 1].color;
12775            let c1 = &stops[i].color;
12776            return DeviceColor::from_rgb(
12777                (c0.r + frac * (c1.r - c0.r)).clamp(0.0, 1.0),
12778                (c0.g + frac * (c1.g - c0.g)).clamp(0.0, 1.0),
12779                (c0.b + frac * (c1.b - c0.b)).clamp(0.0, 1.0),
12780            );
12781        }
12782    }
12783
12784    stops.last().unwrap().color.clone()
12785}
12786
12787/// Derive CMYK values from color stops at parameter t.
12788///
12789/// For DeviceCMYK shading color spaces the per-stop `raw_components` carry the
12790/// authoritative 4-channel CMYK values (already tint-transformed for
12791/// Separation/DeviceN with a CMYK alt) — those are interpolated directly.
12792///
12793/// For non-CMYK source color spaces (DeviceRGB, DeviceGray, CalRGB, CalGray,
12794/// ICCBased non-4) the interpolated sRGB color is round-tripped to CMYK via
12795/// the system CMYK ICC profile so the parallel CMYK buffer holds an accurate
12796/// representation. Falls back to PLRM `(1−r, 1−g, 1−b, 0)` when no system
12797/// profile is registered (e.g. `--no-icc`).
12798fn interpolate_cmyk_from_stops(
12799    stops: &[stet_graphics::device::ColorStop],
12800    cs: &ShadingColorSpace,
12801    t: f64,
12802    color: &DeviceColor,
12803    icc: Option<&IccCache>,
12804) -> (f64, f64, f64, f64) {
12805    let rgb_to_cmyk = |c: &DeviceColor| -> (f64, f64, f64, f64) {
12806        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(c.r, c.g, c.b)) {
12807            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
12808        } else {
12809            (
12810                (1.0 - c.r).clamp(0.0, 1.0),
12811                (1.0 - c.g).clamp(0.0, 1.0),
12812                (1.0 - c.b).clamp(0.0, 1.0),
12813                0.0,
12814            )
12815        }
12816    };
12817
12818    match cs {
12819        // Separation/DeviceN with a CMYK alternate carry tint-transformed CMYK
12820        // in `raw_components`, the same shape as DeviceCMYK — handle them on
12821        // the same path so spot-shading round-trips render identically.
12822        ShadingColorSpace::DeviceCMYK
12823        | ShadingColorSpace::Separation { .. }
12824        | ShadingColorSpace::DeviceN { .. } => {
12825            // Interpolate raw CMYK components from stops
12826            if stops.len() == 1 {
12827                let rc = &stops[0].raw_components;
12828                if rc.len() >= 4 {
12829                    return (rc[0], rc[1], rc[2], rc[3]);
12830                }
12831            }
12832            // Find surrounding stops and interpolate
12833            let mut lo = &stops[0];
12834            let mut hi = stops.last().unwrap();
12835            for i in 0..stops.len() - 1 {
12836                if stops[i + 1].position >= t {
12837                    lo = &stops[i];
12838                    hi = &stops[i + 1];
12839                    break;
12840                }
12841            }
12842            let span = hi.position - lo.position;
12843            let frac = if span > 1e-10 {
12844                (t - lo.position) / span
12845            } else {
12846                0.0
12847            };
12848            let frac = frac.clamp(0.0, 1.0);
12849            if lo.raw_components.len() >= 4 && hi.raw_components.len() >= 4 {
12850                (
12851                    lo.raw_components[0] + frac * (hi.raw_components[0] - lo.raw_components[0]),
12852                    lo.raw_components[1] + frac * (hi.raw_components[1] - lo.raw_components[1]),
12853                    lo.raw_components[2] + frac * (hi.raw_components[2] - lo.raw_components[2]),
12854                    lo.raw_components[3] + frac * (hi.raw_components[3] - lo.raw_components[3]),
12855                )
12856            } else {
12857                rgb_to_cmyk(color)
12858            }
12859        }
12860        _ => rgb_to_cmyk(color),
12861    }
12862}
12863
12864/// Derive CMYK values from triangle mesh vertices using barycentric weights.
12865///
12866/// Mirrors [`interpolate_cmyk_from_stops`]: DeviceCMYK source spaces use the
12867/// per-vertex `raw_components`, non-CMYK spaces ICC-reverse the interpolated
12868/// sRGB color, and PLRM is the last-resort fallback.
12869#[allow(clippy::too_many_arguments)]
12870fn interpolate_cmyk_from_vertices(
12871    v0: &ShadingVertex,
12872    v1: &ShadingVertex,
12873    v2: &ShadingVertex,
12874    w0: f64,
12875    w1: f64,
12876    w2: f64,
12877    cs: &ShadingColorSpace,
12878    r: f64,
12879    g: f64,
12880    b: f64,
12881    icc: Option<&IccCache>,
12882) -> (f64, f64, f64, f64) {
12883    let rgb_to_cmyk = |r: f64, g: f64, b: f64| -> (f64, f64, f64, f64) {
12884        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
12885            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
12886        } else {
12887            (
12888                (1.0 - r).clamp(0.0, 1.0),
12889                (1.0 - g).clamp(0.0, 1.0),
12890                (1.0 - b).clamp(0.0, 1.0),
12891                0.0,
12892            )
12893        }
12894    };
12895
12896    match cs {
12897        ShadingColorSpace::DeviceCMYK
12898        | ShadingColorSpace::Separation { .. }
12899        | ShadingColorSpace::DeviceN { .. } => {
12900            if v0.raw_components.len() >= 4
12901                && v1.raw_components.len() >= 4
12902                && v2.raw_components.len() >= 4
12903            {
12904                (
12905                    w0 * v0.raw_components[0]
12906                        + w1 * v1.raw_components[0]
12907                        + w2 * v2.raw_components[0],
12908                    w0 * v0.raw_components[1]
12909                        + w1 * v1.raw_components[1]
12910                        + w2 * v2.raw_components[1],
12911                    w0 * v0.raw_components[2]
12912                        + w1 * v1.raw_components[2]
12913                        + w2 * v2.raw_components[2],
12914                    w0 * v0.raw_components[3]
12915                        + w1 * v1.raw_components[3]
12916                        + w2 * v2.raw_components[3],
12917                )
12918            } else {
12919                rgb_to_cmyk(r, g, b)
12920            }
12921        }
12922        _ => rgb_to_cmyk(r, g, b),
12923    }
12924}
12925
12926#[cfg(test)]
12927mod tests {
12928    use super::*;
12929    use stet_graphics::color::DashPattern;
12930    use stet_graphics::device::{BgUcrState, HalftoneState, TransferState};
12931
12932    #[test]
12933    fn test_create_device() {
12934        let dev = SkiaDevice::new(100, 100);
12935        assert_eq!(dev.page_size(), (100, 100));
12936    }
12937
12938    #[test]
12939    fn test_fill_rect() {
12940        let mut dev = SkiaDevice::new(100, 100);
12941        let mut path = PsPath::new();
12942        path.segments.push(PathSegment::MoveTo(10.0, 10.0));
12943        path.segments.push(PathSegment::LineTo(90.0, 10.0));
12944        path.segments.push(PathSegment::LineTo(90.0, 90.0));
12945        path.segments.push(PathSegment::LineTo(10.0, 90.0));
12946        path.segments.push(PathSegment::ClosePath);
12947
12948        let params = FillParams {
12949            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
12950            fill_rule: FillRule::NonZeroWinding,
12951            ctm: Matrix::identity(),
12952            is_text_glyph: false,
12953            overprint: false,
12954            overprint_mode: 0,
12955            opm_paired: false,
12956            painted_channels: 0,
12957            is_device_cmyk: false,
12958            spot_color: None,
12959            icc_color: None,
12960            rendering_intent: 0,
12961            transfer: TransferState::default(),
12962            halftone: HalftoneState::default(),
12963            bg_ucr: BgUcrState::default(),
12964            alpha: 1.0,
12965            blend_mode: 0,
12966            alpha_is_shape: false,
12967        };
12968        dev.fill_path(&path, &params);
12969
12970        // Check that pixel at center is red
12971        let pixel = dev.pixmap().pixel(50, 50).unwrap();
12972        assert_eq!(pixel.red(), 255);
12973        assert_eq!(pixel.green(), 0);
12974        assert_eq!(pixel.blue(), 0);
12975    }
12976
12977    #[test]
12978    fn test_stroke_line() {
12979        let mut dev = SkiaDevice::new(100, 100);
12980        let mut path = PsPath::new();
12981        path.segments.push(PathSegment::MoveTo(10.0, 50.0));
12982        path.segments.push(PathSegment::LineTo(90.0, 50.0));
12983
12984        let params = StrokeParams {
12985            color: DeviceColor::from_rgb(0.0, 0.0, 1.0),
12986            line_width: 4.0,
12987            line_cap: LineCap::Butt,
12988            line_join: LineJoin::Miter,
12989            miter_limit: 10.0,
12990            dash_pattern: DashPattern::solid(),
12991            ctm: Matrix::identity(),
12992            stroke_adjust: false,
12993            is_text_glyph: false,
12994            overprint: false,
12995            overprint_mode: 0,
12996            opm_paired: false,
12997            painted_channels: 0,
12998            is_device_cmyk: false,
12999            spot_color: None,
13000            icc_color: None,
13001            rendering_intent: 0,
13002            transfer: TransferState::default(),
13003            halftone: HalftoneState::default(),
13004            bg_ucr: BgUcrState::default(),
13005            alpha: 1.0,
13006            blend_mode: 0,
13007            alpha_is_shape: false,
13008        };
13009        dev.stroke_path(&path, &params);
13010
13011        // Check that pixel on the line is blue
13012        let pixel = dev.pixmap().pixel(50, 50).unwrap();
13013        assert_eq!(pixel.blue(), 255);
13014    }
13015
13016    #[test]
13017    fn test_clip() {
13018        let mut dev = SkiaDevice::new(100, 100);
13019
13020        // Set clip to left half
13021        let mut clip_path = PsPath::new();
13022        clip_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13023        clip_path.segments.push(PathSegment::LineTo(50.0, 0.0));
13024        clip_path.segments.push(PathSegment::LineTo(50.0, 100.0));
13025        clip_path.segments.push(PathSegment::LineTo(0.0, 100.0));
13026        clip_path.segments.push(PathSegment::ClosePath);
13027
13028        let clip_params = ClipParams {
13029            fill_rule: FillRule::NonZeroWinding,
13030            ctm: Matrix::identity(),
13031            stroke_params: None,
13032        };
13033        dev.clip_path(&clip_path, &clip_params);
13034
13035        // Fill entire page with red
13036        let mut fill_path = PsPath::new();
13037        fill_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13038        fill_path.segments.push(PathSegment::LineTo(100.0, 0.0));
13039        fill_path.segments.push(PathSegment::LineTo(100.0, 100.0));
13040        fill_path.segments.push(PathSegment::LineTo(0.0, 100.0));
13041        fill_path.segments.push(PathSegment::ClosePath);
13042
13043        let fill_params = FillParams {
13044            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
13045            fill_rule: FillRule::NonZeroWinding,
13046            ctm: Matrix::identity(),
13047            is_text_glyph: false,
13048            overprint: false,
13049            overprint_mode: 0,
13050            opm_paired: false,
13051            painted_channels: 0,
13052            is_device_cmyk: false,
13053            spot_color: None,
13054            icc_color: None,
13055            rendering_intent: 0,
13056            transfer: TransferState::default(),
13057            halftone: HalftoneState::default(),
13058            bg_ucr: BgUcrState::default(),
13059            alpha: 1.0,
13060            blend_mode: 0,
13061            alpha_is_shape: false,
13062        };
13063        dev.fill_path(&fill_path, &fill_params);
13064
13065        // Left half should be red
13066        let left_pixel = dev.pixmap().pixel(25, 50).unwrap();
13067        assert_eq!(left_pixel.red(), 255);
13068
13069        // Right half should still be white
13070        let right_pixel = dev.pixmap().pixel(75, 50).unwrap();
13071        assert_eq!(right_pixel.red(), 255);
13072        assert_eq!(right_pixel.green(), 255); // white
13073    }
13074
13075    #[test]
13076    fn test_erase_page() {
13077        let mut dev = SkiaDevice::new(100, 100);
13078        // Fill with red
13079        let mut path = PsPath::new();
13080        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13081        path.segments.push(PathSegment::LineTo(100.0, 0.0));
13082        path.segments.push(PathSegment::LineTo(100.0, 100.0));
13083        path.segments.push(PathSegment::LineTo(0.0, 100.0));
13084        path.segments.push(PathSegment::ClosePath);
13085        let params = FillParams {
13086            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
13087            fill_rule: FillRule::NonZeroWinding,
13088            ctm: Matrix::identity(),
13089            is_text_glyph: false,
13090            overprint: false,
13091            overprint_mode: 0,
13092            opm_paired: false,
13093            painted_channels: 0,
13094            is_device_cmyk: false,
13095            spot_color: None,
13096            icc_color: None,
13097            rendering_intent: 0,
13098            transfer: TransferState::default(),
13099            halftone: HalftoneState::default(),
13100            bg_ucr: BgUcrState::default(),
13101            alpha: 1.0,
13102            blend_mode: 0,
13103            alpha_is_shape: false,
13104        };
13105        dev.fill_path(&path, &params);
13106
13107        dev.erase_page();
13108
13109        // Should be white again
13110        let pixel = dev.pixmap().pixel(50, 50).unwrap();
13111        assert_eq!(pixel.red(), 255);
13112        assert_eq!(pixel.green(), 255);
13113        assert_eq!(pixel.blue(), 255);
13114    }
13115
13116    #[test]
13117    fn test_show_page() {
13118        let mut dev = SkiaDevice::new(10, 10);
13119        let path = std::env::temp_dir().join("stet_test_output.png");
13120        let path_str = path.to_string_lossy();
13121        let result = dev.show_page(&path_str);
13122        assert!(result.is_ok());
13123        assert!(path.exists());
13124        std::fs::remove_file(&path).ok();
13125    }
13126
13127    #[test]
13128    fn test_transform() {
13129        let mut dev = SkiaDevice::new(200, 200);
13130        // Draw at origin with a translate transform
13131        let mut path = PsPath::new();
13132        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13133        path.segments.push(PathSegment::LineTo(10.0, 0.0));
13134        path.segments.push(PathSegment::LineTo(10.0, 10.0));
13135        path.segments.push(PathSegment::LineTo(0.0, 10.0));
13136        path.segments.push(PathSegment::ClosePath);
13137
13138        let params = FillParams {
13139            color: DeviceColor::from_rgb(0.0, 1.0, 0.0),
13140            fill_rule: FillRule::NonZeroWinding,
13141            ctm: Matrix::translate(100.0, 100.0),
13142            is_text_glyph: false,
13143            overprint: false,
13144            overprint_mode: 0,
13145            opm_paired: false,
13146            painted_channels: 0,
13147            is_device_cmyk: false,
13148            spot_color: None,
13149            icc_color: None,
13150            rendering_intent: 0,
13151            transfer: TransferState::default(),
13152            halftone: HalftoneState::default(),
13153            bg_ucr: BgUcrState::default(),
13154            alpha: 1.0,
13155            blend_mode: 0,
13156            alpha_is_shape: false,
13157        };
13158        dev.fill_path(&path, &params);
13159
13160        // Pixel at translated location should be green
13161        let pixel = dev.pixmap().pixel(105, 105).unwrap();
13162        assert_eq!(pixel.green(), 255);
13163        assert_eq!(pixel.red(), 0);
13164    }
13165
13166    fn make_test_fill_at(x: f64, y: f64, w: f64, h: f64) -> DisplayElement {
13167        let mut path = PsPath::new();
13168        path.segments.push(PathSegment::MoveTo(x, y));
13169        path.segments.push(PathSegment::LineTo(x + w, y));
13170        path.segments.push(PathSegment::LineTo(x + w, y + h));
13171        path.segments.push(PathSegment::LineTo(x, y + h));
13172        path.segments.push(PathSegment::ClosePath);
13173        DisplayElement::Fill {
13174            path,
13175            params: FillParams {
13176                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
13177                fill_rule: FillRule::NonZeroWinding,
13178                ctm: Matrix::identity(),
13179                is_text_glyph: false,
13180                overprint: false,
13181                overprint_mode: 0,
13182                opm_paired: false,
13183                painted_channels: 0,
13184                is_device_cmyk: false,
13185                spot_color: None,
13186                icc_color: None,
13187                rendering_intent: 0,
13188                transfer: TransferState::default(),
13189                halftone: HalftoneState::default(),
13190                bg_ucr: BgUcrState::default(),
13191                alpha: 1.0,
13192                blend_mode: 0,
13193                alpha_is_shape: false,
13194            },
13195        }
13196    }
13197
13198    #[test]
13199    fn test_compute_paint_bounds_two_fills() {
13200        let mut list = DisplayList::new();
13201        list.push(make_test_fill_at(10.0, 20.0, 30.0, 40.0)); // [10..40, 20..60]
13202        list.push(make_test_fill_at(100.0, 50.0, 50.0, 25.0)); // [100..150, 50..75]
13203
13204        let bounds = compute_paint_bounds(&list, 72.0).expect("expected union bounds");
13205        assert!(
13206            (bounds.x_min - 10.0).abs() < 1e-9,
13207            "x_min was {}",
13208            bounds.x_min
13209        );
13210        assert!(
13211            (bounds.y_min - 20.0).abs() < 1e-9,
13212            "y_min was {}",
13213            bounds.y_min
13214        );
13215        assert!(
13216            (bounds.x_max - 150.0).abs() < 1e-9,
13217            "x_max was {}",
13218            bounds.x_max
13219        );
13220        assert!(
13221            (bounds.y_max - 75.0).abs() < 1e-9,
13222            "y_max was {}",
13223            bounds.y_max
13224        );
13225    }
13226
13227    #[test]
13228    fn test_compute_paint_bounds_empty_list() {
13229        let list = DisplayList::new();
13230        assert!(compute_paint_bounds(&list, 72.0).is_none());
13231    }
13232
13233    #[test]
13234    fn test_compute_paint_bounds_only_clip_returns_none() {
13235        let mut list = DisplayList::new();
13236        list.push(DisplayElement::InitClip);
13237        // Clip / InitClip / ErasePage are skipped (return None from
13238        // precompute_full_bboxes), so a list of only clip ops yields no bounds.
13239        assert!(compute_paint_bounds(&list, 72.0).is_none());
13240    }
13241
13242    #[test]
13243    fn test_rasterize_mask_anchors_to_paint_bounds() {
13244        use stet_graphics::display_list::{SoftMaskParams, SoftMaskSubtype};
13245
13246        // A 50×40 white fill at page coords (200, 300)..(250, 340).
13247        // Mask paint bounds in device units: x [200..250], y [300..340].
13248        let mut mask = DisplayList::new();
13249        let mut path = PsPath::new();
13250        path.segments.push(PathSegment::MoveTo(200.0, 300.0));
13251        path.segments.push(PathSegment::LineTo(250.0, 300.0));
13252        path.segments.push(PathSegment::LineTo(250.0, 340.0));
13253        path.segments.push(PathSegment::LineTo(200.0, 340.0));
13254        path.segments.push(PathSegment::ClosePath);
13255        mask.push(DisplayElement::Fill {
13256            path,
13257            params: FillParams {
13258                color: DeviceColor::from_rgb(1.0, 1.0, 1.0),
13259                fill_rule: FillRule::NonZeroWinding,
13260                ctm: Matrix::identity(),
13261                is_text_glyph: false,
13262                overprint: false,
13263                overprint_mode: 0,
13264                opm_paired: false,
13265                painted_channels: 0,
13266                is_device_cmyk: false,
13267                spot_color: None,
13268                icc_color: None,
13269                rendering_intent: 0,
13270                transfer: TransferState::default(),
13271                halftone: HalftoneState::default(),
13272                bg_ucr: BgUcrState::default(),
13273                alpha: 1.0,
13274                blend_mode: 0,
13275                alpha_is_shape: false,
13276            },
13277        });
13278
13279        let params = SoftMaskParams {
13280            subtype: SoftMaskSubtype::Luminosity,
13281            // Form bbox; intentionally tighter than paint bounds — the
13282            // raster should follow paint bounds, not this.
13283            bbox: [0.0, 0.0, 100.0, 100.0],
13284            backdrop_color: None, // black backdrop → out-of-bounds value = 0
13285            transfer_invert: false,
13286            has_nested_mask_scope: false,
13287            parent_clip_bbox: None,
13288        };
13289
13290        let raster = rasterize_mask(
13291            &mask,
13292            &params,
13293            None,
13294            false,
13295            72.0,
13296            1.0,
13297            1.0,
13298            &LayerSet::new(),
13299        )
13300        .expect("expected raster");
13301
13302        // Origin must be at (or just before) the paint bounds, with the
13303        // 1-pixel AA pad.
13304        assert_eq!(raster.origin_x, 199);
13305        assert_eq!(raster.origin_y, 299);
13306        // Width / height = paint bounds + 2 pixels of pad (1 each side).
13307        assert_eq!(raster.width, 52);
13308        assert_eq!(raster.height, 42);
13309        assert_eq!(raster.scale_x, 1.0);
13310        assert_eq!(raster.scale_y, 1.0);
13311
13312        // The raster should be non-zero somewhere inside the painted region.
13313        // Sample the center of the painted area: page (225, 320) → mask
13314        // index (225 - 199, 320 - 299) = (26, 21).
13315        let mx = 225 - raster.origin_x;
13316        let my = 320 - raster.origin_y;
13317        assert!(mx >= 0 && (mx as u32) < raster.width);
13318        assert!(my >= 0 && (my as u32) < raster.height);
13319        let center_value = raster.data[(my as usize) * raster.width as usize + mx as usize];
13320        assert_eq!(
13321            center_value, 255,
13322            "center of painted mask should be opaque white (lum=255)"
13323        );
13324
13325        // A point outside the paint bounds (page (300, 320)) maps to mask
13326        // index (101, 21) which is outside the raster width — sampling
13327        // there should fall back to out_of_bounds_mask_value(params) = 0.
13328        let mx_out = 300 - raster.origin_x;
13329        let in_bounds = mx_out >= 0 && (mx_out as u32) < raster.width;
13330        assert!(!in_bounds, "page x=300 should be outside the mask raster");
13331        assert_eq!(
13332            out_of_bounds_mask_value(&params),
13333            0,
13334            "black backdrop → out-of-bounds = 0"
13335        );
13336    }
13337
13338    #[test]
13339    fn test_band_local_to_mask_formula() {
13340        // Verify the band-local → page-pixel → mask-index arithmetic for
13341        // several band offsets. This is the highest-risk part of Step 4
13342        // because it bridges three coordinate systems:
13343        //
13344        //   band-local pixel (x, y)
13345        //     + (crop_x, crop_y)            → soft-mask offset within band
13346        //     + (vp_x_pixels, vp_y_pixels)  → page-pixel position
13347        //     - (origin_x, origin_y)        → mask raster index
13348
13349        // Mask raster anchored at page-pixel (200, 300).
13350        let raster_origin_x = 200i32;
13351        let raster_origin_y = 300i32;
13352
13353        // Helper that runs the formula from render_soft_masked.
13354        let sample = |vp_x_dev: f32,
13355                      vp_y_dev: f32,
13356                      scale: f32,
13357                      crop_x: i32,
13358                      crop_y: i32,
13359                      x: i32,
13360                      y: i32|
13361         -> (i32, i32) {
13362            let vp_x_pixels = (vp_x_dev * scale).round() as i32;
13363            let vp_y_pixels = (vp_y_dev * scale).round() as i32;
13364            let page_x = vp_x_pixels + crop_x + x;
13365            let page_y = vp_y_pixels + crop_y + y;
13366            let mx = page_x - raster_origin_x;
13367            let my = page_y - raster_origin_y;
13368            (mx, my)
13369        };
13370
13371        // Case 1: band starts at page Y=0 (top band of page).
13372        // vp_y=0, scale=1. The soft-mask top-left page (220, 310) must
13373        // map to mask index (20, 10).
13374        // crop_x = floor((220 - 0) * 1) = 220, crop_y = floor((310 - 0) * 1) = 310
13375        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 0, 0);
13376        assert_eq!((mx, my), (20, 10), "top band: smask top-left");
13377
13378        // 5 pixels into the smask region (band-local): page (225, 315)
13379        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 5, 5);
13380        assert_eq!((mx, my), (25, 15), "top band: 5px into smask");
13381
13382        // Case 2: band starts at page Y=400. The smask region [310..340]
13383        // doesn't intersect this band — covered by the early-return path.
13384        // But test a band that DOES intersect the smask, e.g. starting at
13385        // Y=305. Then page-Y 310 is band-local Y=5.
13386        // vp_y_pixels = round(305 * 1) = 305
13387        // crop_y = floor((310 - 305) * 1) = 5  (band-local)
13388        // For content y=0 (band-local), page_y = 305 + 5 + 0 = 310 ✓
13389        let (mx, my) = sample(0.0, 305.0, 1.0, 220, 5, 0, 0);
13390        assert_eq!((mx, my), (20, 10), "mid band: smask top-left");
13391
13392        // Case 3: viewport rendering at scale 2. vp_x=100.0, vp_y=150.0,
13393        // scale=2. Page pixel offset = (200, 300). The smask region
13394        // [220..270] in device units = [440..540] in page-pixels at scale 2.
13395        // But the mask raster was built at scale 1, so this is a
13396        // SCALE-MISMATCH case — the cache would invalidate and rebuild.
13397        // We're not testing the rebuild, just that the formula computes
13398        // the right page-pixel coords:
13399        //   vp_x_pixels = round(100 * 2) = 200
13400        //   smask in band: page (440..540), band-local (240..340)
13401        //   crop_x = max(0, floor((220 - 100) * 2)) = 240
13402        //   For x=0 (band-local), page_x = 200 + 240 + 0 = 440 ✓
13403        let vp_x_pixels = (100.0_f32 * 2.0).round() as i32;
13404        let crop_x = ((220.0_f32 - 100.0) * 2.0).floor() as i32;
13405        let page_x_for_x_zero = vp_x_pixels + crop_x;
13406        assert_eq!(page_x_for_x_zero, 440, "viewport scale-2: page-x at x=0");
13407    }
13408
13409    // --- obscured-fill skip (§ GWG reference-under-test pattern) ---
13410
13411    fn x_path() -> PsPath {
13412        let mut p = PsPath::new();
13413        p.segments.push(PathSegment::MoveTo(10.0, 10.0));
13414        p.segments.push(PathSegment::LineTo(20.0, 20.0));
13415        p.segments.push(PathSegment::LineTo(30.0, 10.0));
13416        p.segments.push(PathSegment::LineTo(20.0, 0.0));
13417        p.segments.push(PathSegment::ClosePath);
13418        p
13419    }
13420
13421    fn x_path_perturbed() -> PsPath {
13422        // Same shape, sub-unit rounding — stand-in for GWG's 0.001-unit
13423        // coordinate drift between duplicated path emissions.
13424        let mut p = PsPath::new();
13425        p.segments.push(PathSegment::MoveTo(10.001, 10.0));
13426        p.segments.push(PathSegment::LineTo(20.0, 19.999));
13427        p.segments.push(PathSegment::LineTo(30.002, 10.001));
13428        p.segments.push(PathSegment::LineTo(19.999, 0.0));
13429        p.segments.push(PathSegment::ClosePath);
13430        p
13431    }
13432
13433    fn fill(path: PsPath, alpha: f64, blend: u8) -> DisplayElement {
13434        DisplayElement::Fill {
13435            path,
13436            params: FillParams {
13437                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
13438                fill_rule: FillRule::NonZeroWinding,
13439                ctm: Matrix::identity(),
13440                is_text_glyph: false,
13441                overprint: false,
13442                overprint_mode: 0,
13443                opm_paired: false,
13444                painted_channels: 0,
13445                is_device_cmyk: false,
13446                spot_color: None,
13447                icc_color: None,
13448                rendering_intent: 0,
13449                transfer: TransferState::default(),
13450                halftone: HalftoneState::default(),
13451                bg_ucr: BgUcrState::default(),
13452                alpha,
13453                blend_mode: blend,
13454                alpha_is_shape: false,
13455            },
13456        }
13457    }
13458
13459    fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> PsPath {
13460        let mut p = PsPath::new();
13461        p.segments.push(PathSegment::MoveTo(x0, y0));
13462        p.segments.push(PathSegment::LineTo(x1, y0));
13463        p.segments.push(PathSegment::LineTo(x1, y1));
13464        p.segments.push(PathSegment::LineTo(x0, y1));
13465        p.segments.push(PathSegment::ClosePath);
13466        p
13467    }
13468
13469    fn clip_elem(path: PsPath) -> DisplayElement {
13470        DisplayElement::Clip {
13471            path,
13472            params: ClipParams {
13473                fill_rule: FillRule::NonZeroWinding,
13474                ctm: Matrix::identity(),
13475                stroke_params: None,
13476            },
13477        }
13478    }
13479
13480    fn group_elem(
13481        inner: Vec<DisplayElement>,
13482        bbox: [f64; 4],
13483        isolated: bool,
13484        alpha: f64,
13485        blend: u8,
13486    ) -> DisplayElement {
13487        let mut dl = DisplayList::new();
13488        for e in inner {
13489            dl.push(e);
13490        }
13491        DisplayElement::Group {
13492            elements: dl,
13493            params: stet_graphics::display_list::GroupParams {
13494                bbox,
13495                isolated,
13496                knockout: false,
13497                blend_mode: blend,
13498                alpha,
13499                color_space: stet_graphics::display_list::GroupColorSpace::Inherited,
13500            },
13501        }
13502    }
13503
13504    fn dl(elements: Vec<DisplayElement>) -> DisplayList {
13505        let mut d = DisplayList::new();
13506        for e in elements {
13507            d.push(e);
13508        }
13509        d
13510    }
13511
13512    #[test]
13513    fn obscured_skip_fires_on_matching_fill_plus_iso_group() {
13514        // Classic GWG pattern: parent Fill, then a clip, then an isolated
13515        // alpha-1 Group whose first paint is a matching Fill.
13516        let parent = fill(x_path(), 1.0, 0);
13517        let inner = vec![fill(x_path_perturbed(), 1.0, 0)];
13518        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13519        let d = dl(vec![
13520            parent,
13521            clip_elem(rect_path(0.0, -5.0, 40.0, 30.0)),
13522            grp,
13523        ]);
13524        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
13525    }
13526
13527    #[test]
13528    fn obscured_skip_does_not_fire_on_non_isolated_group() {
13529        let parent = fill(x_path(), 1.0, 0);
13530        let inner = vec![fill(x_path(), 1.0, 0)];
13531        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], false, 1.0, 0);
13532        let d = dl(vec![parent, grp]);
13533        assert!(compute_obscured_fill_skips(&d).is_empty());
13534    }
13535
13536    #[test]
13537    fn obscured_skip_does_not_fire_on_partial_alpha_group() {
13538        let parent = fill(x_path(), 1.0, 0);
13539        let inner = vec![fill(x_path(), 1.0, 0)];
13540        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 0.5, 0);
13541        let d = dl(vec![parent, grp]);
13542        assert!(compute_obscured_fill_skips(&d).is_empty());
13543    }
13544
13545    #[test]
13546    fn obscured_skip_does_not_fire_on_non_normal_blend() {
13547        let parent = fill(x_path(), 1.0, 0);
13548        let inner = vec![fill(x_path(), 1.0, 0)];
13549        // blend_mode = 10 (Difference) on the group — composite-back
13550        // semantics differ from Normal, so skipping parent is unsafe.
13551        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 10);
13552        let d = dl(vec![parent, grp]);
13553        assert!(compute_obscured_fill_skips(&d).is_empty());
13554    }
13555
13556    #[test]
13557    fn obscured_skip_does_not_fire_when_paths_differ() {
13558        let parent = fill(rect_path(0.0, 0.0, 5.0, 5.0), 1.0, 0);
13559        let inner = vec![fill(x_path(), 1.0, 0)];
13560        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13561        let d = dl(vec![parent, grp]);
13562        assert!(compute_obscured_fill_skips(&d).is_empty());
13563    }
13564
13565    #[test]
13566    fn obscured_skip_does_not_fire_when_group_bbox_too_small() {
13567        // Parent fills a rectangle larger than the group's declared
13568        // bbox — the form's BBox would clip the inner fill to a subset
13569        // of the parent's extent, so the parent cannot be dropped.
13570        let big = rect_path(0.0, 0.0, 100.0, 100.0);
13571        let parent = fill(big.clone(), 1.0, 0);
13572        let inner = vec![fill(big, 1.0, 0)];
13573        // Group bbox only covers [0..10, 0..10], much smaller than parent.
13574        let grp = group_elem(inner, [0.0, 0.0, 10.0, 10.0], true, 1.0, 0);
13575        let d = dl(vec![parent, grp]);
13576        assert!(compute_obscured_fill_skips(&d).is_empty());
13577    }
13578
13579    #[test]
13580    fn obscured_skip_does_not_fire_when_intervening_clip_too_small() {
13581        // A clip between the parent fill and the group is narrower than
13582        // the parent's extent — dropping the parent's fill would reveal
13583        // backdrop where the group couldn't paint.
13584        let parent = fill(x_path(), 1.0, 0);
13585        let narrow_clip = clip_elem(rect_path(12.0, 5.0, 18.0, 15.0));
13586        let inner = vec![fill(x_path(), 1.0, 0)];
13587        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13588        let d = dl(vec![parent, narrow_clip, grp]);
13589        assert!(compute_obscured_fill_skips(&d).is_empty());
13590    }
13591
13592    #[test]
13593    fn obscured_skip_does_not_fire_when_inner_clip_too_small() {
13594        // Clip *inside* the group is narrower than the parent's extent.
13595        let parent = fill(x_path(), 1.0, 0);
13596        let inner = vec![
13597            clip_elem(rect_path(12.0, 5.0, 18.0, 15.0)),
13598            fill(x_path(), 1.0, 0),
13599        ];
13600        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13601        let d = dl(vec![parent, grp]);
13602        assert!(compute_obscured_fill_skips(&d).is_empty());
13603    }
13604
13605    #[test]
13606    fn obscured_skip_fires_when_inner_clip_is_wider_than_parent_path() {
13607        // A clip inside the group that's larger than the parent's fill
13608        // doesn't threaten coverage; still safe to skip the parent.
13609        let parent = fill(x_path(), 1.0, 0);
13610        let inner = vec![
13611            clip_elem(rect_path(-10.0, -10.0, 40.0, 30.0)),
13612            fill(x_path_perturbed(), 1.0, 0),
13613        ];
13614        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13615        let d = dl(vec![parent, grp]);
13616        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
13617    }
13618
13619    #[test]
13620    fn obscured_skip_does_not_fire_on_partial_alpha_parent() {
13621        // A parent fill at alpha < 1 might blend with backdrop; dropping
13622        // it changes the visual even when the group overpaints.
13623        let parent = fill(x_path(), 0.5, 0);
13624        let inner = vec![fill(x_path(), 1.0, 0)];
13625        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13626        let d = dl(vec![parent, grp]);
13627        assert!(compute_obscured_fill_skips(&d).is_empty());
13628    }
13629}