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 profile.
1573pub fn build_icc_cache_for_list(
1574    list: &DisplayList,
1575    system_cmyk_bytes: Option<&std::sync::Arc<Vec<u8>>>,
1576) -> IccCache {
1577    let mut cache = IccCache::new();
1578    let mut seen = HashSet::new();
1579
1580    // Register system CMYK profile first
1581    if let Some(cmyk_bytes) = system_cmyk_bytes
1582        && let Some(hash) = cache.register_profile(cmyk_bytes)
1583    {
1584        seen.insert(hash);
1585        // Set the default CMYK hash so convert_image_8bit works for DeviceCMYK
1586        cache.set_default_cmyk_hash(hash);
1587        // Pre-warm the sRGB→CMYK reverse transform so band renderers, which
1588        // only hold an `&IccCache`, can use `convert_rgb_to_cmyk_readonly`
1589        // when populating the parallel CMYK buffer for non-CMYK painters.
1590        cache.prepare_reverse_cmyk();
1591    }
1592
1593    // Scan display list for ICCBased images and shadings (recursing into Groups)
1594    fn scan_elements(
1595        elements: &[DisplayElement],
1596        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1597        cache: &mut IccCache,
1598    ) {
1599        for element in elements {
1600            // Recurse into groups
1601            if let DisplayElement::Group { elements: sub, .. } = element {
1602                scan_elements(sub.elements(), seen, cache);
1603            }
1604            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1605                scan_elements(content.elements(), seen, cache);
1606                scan_elements(mask.elements(), seen, cache);
1607            }
1608            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1609                scan_elements(sub.elements(), seen, cache);
1610            }
1611            // Shading color spaces
1612            let shading_cs = match element {
1613                DisplayElement::AxialShading { params } => Some(&params.color_space),
1614                DisplayElement::RadialShading { params } => Some(&params.color_space),
1615                DisplayElement::MeshShading { params } => Some(&params.color_space),
1616                DisplayElement::PatchShading { params } => Some(&params.color_space),
1617                _ => None,
1618            };
1619            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1620                n,
1621                profile_hash,
1622                profile_data,
1623            }) = shading_cs
1624            {
1625                if seen.insert(*profile_hash) {
1626                    cache.register_profile_with_n(profile_data, Some(*n));
1627                }
1628            }
1629            // Image color spaces
1630            if let DisplayElement::Image { params, .. } = element {
1631                match &params.color_space {
1632                    ImageColorSpace::ICCBased {
1633                        n,
1634                        profile_hash,
1635                        profile_data,
1636                    } if seen.insert(*profile_hash) => {
1637                        cache.register_profile_with_n(profile_data, Some(*n));
1638                    }
1639                    ImageColorSpace::Indexed { base, .. }
1640                        if matches!(base.as_ref(), ImageColorSpace::ICCBased { .. }) =>
1641                    {
1642                        if let ImageColorSpace::ICCBased {
1643                            n,
1644                            profile_hash,
1645                            profile_data,
1646                        } = base.as_ref()
1647                        {
1648                            if seen.insert(*profile_hash) {
1649                                cache.register_profile_with_n(profile_data, Some(*n));
1650                            }
1651                        }
1652                    }
1653                    _ => {}
1654                }
1655            }
1656        }
1657    }
1658    scan_elements(list.elements(), &mut seen, &mut cache);
1659
1660    cache
1661}
1662
1663/// Register ICC profiles from shading elements in a display list.
1664///
1665/// Recursively scans Groups and SoftMasks for ICCBased shading color spaces
1666/// and registers their profiles in the cache.
1667fn register_shading_icc_profiles(list: &DisplayList, cache: &mut IccCache) {
1668    fn register_image_iccs(
1669        cs: &ImageColorSpace,
1670        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1671        cache: &mut IccCache,
1672    ) {
1673        match cs {
1674            ImageColorSpace::ICCBased {
1675                n,
1676                profile_hash,
1677                profile_data,
1678            } => {
1679                if seen.insert(*profile_hash) {
1680                    cache.register_profile_with_n(profile_data, Some(*n));
1681                }
1682            }
1683            ImageColorSpace::Indexed { base, .. } => register_image_iccs(base, seen, cache),
1684            ImageColorSpace::Separation { alt_space, .. }
1685            | ImageColorSpace::DeviceN { alt_space, .. } => {
1686                register_image_iccs(alt_space, seen, cache)
1687            }
1688            _ => {}
1689        }
1690    }
1691    fn scan(
1692        elements: &[DisplayElement],
1693        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1694        cache: &mut IccCache,
1695    ) {
1696        for element in elements {
1697            if let DisplayElement::Group { elements: sub, .. } = element {
1698                scan(sub.elements(), seen, cache);
1699            }
1700            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1701                scan(content.elements(), seen, cache);
1702                scan(mask.elements(), seen, cache);
1703            }
1704            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1705                scan(sub.elements(), seen, cache);
1706            }
1707            let shading_cs = match element {
1708                DisplayElement::AxialShading { params } => Some(&params.color_space),
1709                DisplayElement::RadialShading { params } => Some(&params.color_space),
1710                DisplayElement::MeshShading { params } => Some(&params.color_space),
1711                DisplayElement::PatchShading { params } => Some(&params.color_space),
1712                _ => None,
1713            };
1714            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1715                n,
1716                profile_hash,
1717                profile_data,
1718            }) = shading_cs
1719                && seen.insert(*profile_hash)
1720            {
1721                cache.register_profile_with_n(profile_data, Some(*n));
1722            }
1723            if let DisplayElement::Image { params, .. } = element {
1724                register_image_iccs(&params.color_space, seen, cache);
1725            }
1726        }
1727    }
1728    let mut seen = HashSet::new();
1729    scan(list.elements(), &mut seen, cache);
1730}
1731
1732/// Convert raw image samples to RGBA for rasterization.
1733///
1734/// Handles all `ImageColorSpace` variants, producing width×height×4 RGBA bytes.
1735fn samples_to_rgba(
1736    data: &[u8],
1737    params: &ImageParams,
1738    icc: Option<&IccCache>,
1739    opm_zero_transparent: bool,
1740) -> Vec<u8> {
1741    let w = params.width as usize;
1742    let h = params.height as usize;
1743    let npixels = w * h;
1744    let bpc = params.bits_per_component;
1745    match &params.color_space {
1746        ImageColorSpace::PreconvertedRGBA => {
1747            // Already RGBA — just return as-is
1748            data.to_vec()
1749        }
1750        ImageColorSpace::DeviceGray => {
1751            let mut rgba = vec![255u8; npixels * 4];
1752            if bpc == 16 {
1753                for i in 0..npixels {
1754                    let g = data.get(i * 2).copied().unwrap_or(0);
1755                    let pi = i * 4;
1756                    rgba[pi] = g;
1757                    rgba[pi + 1] = g;
1758                    rgba[pi + 2] = g;
1759                }
1760            } else {
1761                for i in 0..npixels {
1762                    let g = data.get(i).copied().unwrap_or(0);
1763                    let pi = i * 4;
1764                    rgba[pi] = g;
1765                    rgba[pi + 1] = g;
1766                    rgba[pi + 2] = g;
1767                }
1768            }
1769            rgba
1770        }
1771        ImageColorSpace::DeviceRGB => {
1772            let mut rgba = vec![255u8; npixels * 4];
1773            if bpc == 16 {
1774                // 16 BPC: 6 bytes per pixel (R_hi R_lo G_hi G_lo B_hi B_lo)
1775                // Take high byte of each 16-bit sample
1776                for i in 0..npixels {
1777                    let si = i * 6;
1778                    let pi = i * 4;
1779                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1780                    rgba[pi + 1] = data.get(si + 2).copied().unwrap_or(0);
1781                    rgba[pi + 2] = data.get(si + 4).copied().unwrap_or(0);
1782                }
1783            } else {
1784                for i in 0..npixels {
1785                    let si = i * 3;
1786                    let pi = i * 4;
1787                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1788                    rgba[pi + 1] = data.get(si + 1).copied().unwrap_or(0);
1789                    rgba[pi + 2] = data.get(si + 2).copied().unwrap_or(0);
1790                }
1791            }
1792            rgba
1793        }
1794        ImageColorSpace::DeviceCMYK => {
1795            // Try ICC-based CMYK→RGB conversion via system CMYK profile.
1796            // Convert as many complete pixels as the data allows; PLRM-fallback
1797            // for any remaining pixels with insufficient data.
1798            if let Some(cache) = icc
1799                && let Some(cmyk_hash) = cache.default_cmyk_hash()
1800            {
1801                let avail_pixels = data.len() / 4;
1802                let icc_pixels = avail_pixels.min(npixels);
1803                if icc_pixels > 0
1804                    && let Some(rgb) = cache.convert_image_8bit(cmyk_hash, data, icc_pixels)
1805                {
1806                    let mut rgba = vec![255u8; npixels * 4];
1807                    for i in 0..icc_pixels {
1808                        rgba[i * 4] = rgb[i * 3];
1809                        rgba[i * 4 + 1] = rgb[i * 3 + 1];
1810                        rgba[i * 4 + 2] = rgb[i * 3 + 2];
1811                        // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1812                        if opm_zero_transparent {
1813                            let si = i * 4;
1814                            if data[si] == 0
1815                                && data[si + 1] == 0
1816                                && data[si + 2] == 0
1817                                && data[si + 3] == 0
1818                            {
1819                                rgba[i * 4 + 3] = 0;
1820                            }
1821                        }
1822                    }
1823                    // Remaining pixels (if data was short) stay white (0xFF)
1824                    return rgba;
1825                }
1826            }
1827            // Fallback: PLRM CMYK→RGB formula
1828            let mut rgba = vec![255u8; npixels * 4];
1829            for i in 0..npixels {
1830                let si = i * 4;
1831                let c = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1832                let m = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1833                let y = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1834                let k = data.get(si + 3).copied().unwrap_or(0) as f64 / 255.0;
1835                let r = (1.0 - c.min(1.0)) * (1.0 - k.min(1.0));
1836                let g = (1.0 - m.min(1.0)) * (1.0 - k.min(1.0));
1837                let b = (1.0 - y.min(1.0)) * (1.0 - k.min(1.0));
1838                let pi = i * 4;
1839                rgba[pi] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
1840                rgba[pi + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
1841                rgba[pi + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
1842                // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1843                if opm_zero_transparent
1844                    && data.get(si).copied().unwrap_or(0) == 0
1845                    && data.get(si + 1).copied().unwrap_or(0) == 0
1846                    && data.get(si + 2).copied().unwrap_or(0) == 0
1847                    && data.get(si + 3).copied().unwrap_or(0) == 0
1848                {
1849                    rgba[pi + 3] = 0;
1850                }
1851            }
1852            rgba
1853        }
1854        ImageColorSpace::ICCBased {
1855            n,
1856            profile_hash,
1857            profile_data,
1858        } => {
1859            // Try ICC-based conversion if cache is available
1860            if let Some(cache) = icc
1861                && cache.has_profile(profile_hash)
1862                && let Some(rgb) = cache.convert_image_8bit(profile_hash, data, npixels)
1863            {
1864                let mut rgba = vec![255u8; npixels * 4];
1865                for i in 0..npixels {
1866                    rgba[i * 4] = rgb[i * 3];
1867                    rgba[i * 4 + 1] = rgb[i * 3 + 1];
1868                    rgba[i * 4 + 2] = rgb[i * 3 + 2];
1869                    // OPM=1 on 4-component (CMYK) ICC profiles
1870                    if opm_zero_transparent && *n == 4 {
1871                        let si = i * *n as usize;
1872                        if si + 3 < data.len()
1873                            && data[si] == 0
1874                            && data[si + 1] == 0
1875                            && data[si + 2] == 0
1876                            && data[si + 3] == 0
1877                        {
1878                            rgba[i * 4 + 3] = 0;
1879                        }
1880                    }
1881                }
1882                return rgba;
1883            }
1884            // Fallback to device equivalent based on component count
1885            let _ = (profile_hash, profile_data);
1886            let fallback = match n {
1887                1 => ImageColorSpace::DeviceGray,
1888                4 => ImageColorSpace::DeviceCMYK,
1889                _ => ImageColorSpace::DeviceRGB,
1890            };
1891            let p = ImageParams {
1892                color_space: fallback,
1893                bits_per_component: 8,
1894                ..params.clone()
1895            };
1896            samples_to_rgba(data, &p, icc, opm_zero_transparent)
1897        }
1898        ImageColorSpace::Indexed {
1899            base,
1900            hival,
1901            lookup,
1902        } => {
1903            let base_ncomp = base.num_components() as usize;
1904            // Expand indexed samples to base color space, then convert
1905            let mut expanded = Vec::with_capacity(npixels * base_ncomp);
1906            for i in 0..npixels {
1907                let idx = data.get(i).copied().unwrap_or(0) as usize;
1908                let idx = idx.min(*hival as usize);
1909                let offset = idx * base_ncomp;
1910                for c in 0..base_ncomp {
1911                    expanded.push(lookup.get(offset + c).copied().unwrap_or(0));
1912                }
1913            }
1914            let p = ImageParams {
1915                color_space: *base.clone(),
1916                bits_per_component: 8,
1917                ..params.clone()
1918            };
1919            samples_to_rgba(&expanded, &p, icc, opm_zero_transparent)
1920        }
1921        ImageColorSpace::CIEBasedABC { params: cie_params } => {
1922            let mut rgba = vec![255u8; npixels * 4];
1923            for i in 0..npixels {
1924                let si = i * 3;
1925                let a = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1926                let b = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1927                let c = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1928                let color = DeviceColor::from_cie_abc(a, b, c, cie_params);
1929                let pi = i * 4;
1930                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1931                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1932                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1933            }
1934            rgba
1935        }
1936        ImageColorSpace::CIEBasedA { params: cie_params } => {
1937            let mut rgba = vec![255u8; npixels * 4];
1938            for i in 0..npixels {
1939                let val = data.get(i).copied().unwrap_or(0) as f64 / 255.0;
1940                let color = DeviceColor::from_cie_a(val, cie_params);
1941                let pi = i * 4;
1942                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1943                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1944                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1945            }
1946            rgba
1947        }
1948        ImageColorSpace::Lab { range, .. } => {
1949            let mut rgba = vec![255u8; npixels * 4];
1950            let a_span = range[1] - range[0];
1951            let b_span = range[3] - range[2];
1952            for i in 0..npixels {
1953                let si = i * 3;
1954                let l = data.get(si).copied().unwrap_or(0) as f64 / 255.0 * 100.0;
1955                let a = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0 * a_span + range[0];
1956                let b = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0 * b_span + range[2];
1957                let color = DeviceColor::from_lab(l, a, b, range);
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::Separation {
1966            alt_space,
1967            tint_table,
1968            ..
1969        } => {
1970            // 1 byte per pixel → lookup in tint table → convert alt space to RGB
1971            // For CMYK alt space with ICC, build bulk CMYK data and convert via ICC
1972            if matches!(alt_space.as_ref(), ImageColorSpace::DeviceCMYK)
1973                && let Some(rgba) = tint_separation_via_icc(data, npixels, tint_table, icc)
1974            {
1975                return rgba;
1976            }
1977            let mut rgba = vec![255u8; npixels * 4];
1978            let no = tint_table.num_outputs as usize;
1979            let mut alt_comps = vec![0.0f32; no];
1980            for i in 0..npixels {
1981                let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
1982                tint_table.lookup_1d(tint, &mut alt_comps);
1983                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
1984                let pi = i * 4;
1985                rgba[pi] = r;
1986                rgba[pi + 1] = g;
1987                rgba[pi + 2] = b;
1988            }
1989            rgba
1990        }
1991        ImageColorSpace::DeviceN {
1992            alt_space,
1993            tint_table,
1994            ..
1995        } => {
1996            let ni = tint_table.num_inputs as usize;
1997            let no = tint_table.num_outputs as usize;
1998            // For CMYK alt space with ICC, build bulk CMYK data and convert via ICC
1999            if matches!(alt_space.as_ref(), ImageColorSpace::DeviceCMYK)
2000                && let Some(rgba) = tint_devicen_via_icc(data, npixels, ni, tint_table, icc)
2001            {
2002                return rgba;
2003            }
2004            let mut rgba = vec![255u8; npixels * 4];
2005            let mut inputs = vec![0.0f32; ni];
2006            let mut alt_comps = vec![0.0f32; no];
2007            for i in 0..npixels {
2008                let si = i * ni;
2009                for (c, inp) in inputs.iter_mut().enumerate() {
2010                    *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
2011                }
2012                tint_table.lookup_nd(&inputs, &mut alt_comps);
2013                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
2014                let pi = i * 4;
2015                rgba[pi] = r;
2016                rgba[pi + 1] = g;
2017                rgba[pi + 2] = b;
2018            }
2019            rgba
2020        }
2021        ImageColorSpace::Mask { color, polarity } => {
2022            let mut rgba = vec![0u8; npixels * 4];
2023            let r = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
2024            let g = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
2025            let b = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
2026            let bytes_per_row = (w).div_ceil(8);
2027            for row in 0..h {
2028                for col in 0..w {
2029                    let byte_idx = row * bytes_per_row + col / 8;
2030                    let bit_offset = 7 - (col % 8);
2031                    let bit = if byte_idx < data.len() {
2032                        (data[byte_idx] >> bit_offset) & 1
2033                    } else {
2034                        0
2035                    };
2036                    let paint = if *polarity { bit == 1 } else { bit == 0 };
2037                    if paint {
2038                        let pi = (row * w + col) * 4;
2039                        rgba[pi] = r;
2040                        rgba[pi + 1] = g;
2041                        rgba[pi + 2] = b;
2042                        rgba[pi + 3] = 255;
2043                    }
2044                }
2045            }
2046            rgba
2047        }
2048        _ => vec![0u8; npixels * 4],
2049    }
2050}
2051
2052/// Convert Separation (1-input) tint table output through ICC CMYK profile.
2053/// Builds 4-byte CMYK data from tint table, then bulk-converts via ICC 8-bit transform.
2054fn tint_separation_via_icc(
2055    data: &[u8],
2056    npixels: usize,
2057    tint_table: &TintLookupTable,
2058    icc: Option<&IccCache>,
2059) -> Option<Vec<u8>> {
2060    let cache = icc?;
2061    let cmyk_hash = cache.default_cmyk_hash()?;
2062    // Build CMYK byte buffer from tint table
2063    let mut cmyk_data = vec![0u8; npixels * 4];
2064    let mut alt_comps = [0.0f32; 4];
2065    for i in 0..npixels {
2066        let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
2067        tint_table.lookup_1d(tint, &mut alt_comps);
2068        let si = i * 4;
2069        cmyk_data[si] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2070        cmyk_data[si + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2071        cmyk_data[si + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2072        cmyk_data[si + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2073    }
2074    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2075    let mut rgba = vec![255u8; npixels * 4];
2076    for i in 0..npixels {
2077        rgba[i * 4] = rgb[i * 3];
2078        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2079        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2080    }
2081    Some(rgba)
2082}
2083
2084/// Convert DeviceN (N-input) tint table output through ICC CMYK profile.
2085fn tint_devicen_via_icc(
2086    data: &[u8],
2087    npixels: usize,
2088    ni: usize,
2089    tint_table: &TintLookupTable,
2090    icc: Option<&IccCache>,
2091) -> Option<Vec<u8>> {
2092    let cache = icc?;
2093    let cmyk_hash = cache.default_cmyk_hash()?;
2094    let mut cmyk_data = vec![0u8; npixels * 4];
2095    let mut inputs = vec![0.0f32; ni];
2096    let mut alt_comps = [0.0f32; 4];
2097    for i in 0..npixels {
2098        let si = i * ni;
2099        for (c, inp) in inputs.iter_mut().enumerate() {
2100            *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
2101        }
2102        tint_table.lookup_nd(&inputs, &mut alt_comps);
2103        let di = i * 4;
2104        cmyk_data[di] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2105        cmyk_data[di + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2106        cmyk_data[di + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2107        cmyk_data[di + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2108    }
2109    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2110    let mut rgba = vec![255u8; npixels * 4];
2111    for i in 0..npixels {
2112        rgba[i * 4] = rgb[i * 3];
2113        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2114        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2115    }
2116    Some(rgba)
2117}
2118
2119/// Convert alt-space f32 component values to RGB bytes.
2120fn alt_comps_to_rgb(comps: &[f32], alt_space: &ImageColorSpace) -> (u8, u8, u8) {
2121    match alt_space {
2122        ImageColorSpace::DeviceGray => {
2123            let g = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2124            (g, g, g)
2125        }
2126        ImageColorSpace::DeviceRGB => {
2127            let r = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2128            let g = (comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2129            let b = (comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2130            (r, g, b)
2131        }
2132        ImageColorSpace::DeviceCMYK => {
2133            let c = comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0);
2134            let m = comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2135            let y = comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2136            let k = comps.get(3).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2137            let r = ((1.0 - (c + k).min(1.0)) * 255.0).round() as u8;
2138            let g = ((1.0 - (m + k).min(1.0)) * 255.0).round() as u8;
2139            let b = ((1.0 - (y + k).min(1.0)) * 255.0).round() as u8;
2140            (r, g, b)
2141        }
2142        _ => (0, 0, 0),
2143    }
2144}
2145
2146/// Apply ImageType 4 mask color transparency to RGBA data.
2147fn apply_mask_color_rgba(rgba: &mut [u8], sample_data: &[u8], params: &ImageParams) {
2148    let mask_color = match &params.mask_color {
2149        Some(mc) => mc,
2150        None => return,
2151    };
2152    let ncomp = params.color_space.num_components() as usize;
2153    let npixels = params.width as usize * params.height as usize;
2154    let is_range = mask_color.len() == 2 * ncomp;
2155
2156    for i in 0..npixels {
2157        let si = i * ncomp;
2158        let matched = if is_range {
2159            (0..ncomp).all(|c| {
2160                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2161                let min_val = mask_color.get(c * 2).copied().unwrap_or(0);
2162                let max_val = mask_color.get(c * 2 + 1).copied().unwrap_or(0);
2163                sample >= min_val && sample <= max_val
2164            })
2165        } else {
2166            (0..ncomp).all(|c| {
2167                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2168                let target = mask_color.get(c).copied().unwrap_or(0);
2169                sample == target
2170            })
2171        };
2172        if matched {
2173            let pi = i * 4;
2174            if pi + 3 < rgba.len() {
2175                rgba[pi] = 0;
2176                rgba[pi + 1] = 0;
2177                rgba[pi + 2] = 0;
2178                rgba[pi + 3] = 0;
2179            }
2180        }
2181    }
2182}
2183
2184/// Choose filter quality for image drawing.
2185///
2186/// When `interpolate` is false, use Nearest for upscaling (crisp pixel edges)
2187/// and Bilinear only for downscaling (proper area averaging). When `interpolate`
2188/// is true, use Bilinear for any scaling.
2189fn image_filter_quality(transform: Transform, interpolate: bool) -> stet_tiny_skia::FilterQuality {
2190    let eff_sx = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2191    let eff_sy = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2192    let min_scale = eff_sx.min(eff_sy);
2193    // Near-exact 1:1: Nearest is pixel-perfect and faster
2194    if (eff_sx - 1.0).abs() < 0.01 && (eff_sy - 1.0).abs() < 0.01 {
2195        stet_tiny_skia::FilterQuality::Nearest
2196    } else if !interpolate && min_scale >= 0.95 {
2197        // Non-interpolated upscaling: nearest-neighbor for crisp pixel edges
2198        stet_tiny_skia::FilterQuality::Nearest
2199    } else {
2200        stet_tiny_skia::FilterQuality::Bilinear
2201    }
2202}
2203
2204/// For rotated/sheared transforms: integer box-filter pre-downsample, leaving
2205/// the fractional remainder to tiny-skia's bilinear.
2206///
2207/// Returns `None` if no pre-scaling is needed.
2208fn prescale_image(
2209    rgba_data: &[u8],
2210    w: u32,
2211    h: u32,
2212    transform: Transform,
2213    interpolate: bool,
2214) -> Option<(Vec<u8>, u32, u32, Transform)> {
2215    // Compute effective scale factors from the 2×2 part of the transform.
2216    let scale_x = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2217    let scale_y = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2218    let min_scale = scale_x.min(scale_y);
2219
2220    // Upscaling: only apply bicubic resampling when Interpolate is true.
2221    // Per PLRM/PDF spec, non-interpolated images should use nearest-neighbor
2222    // for upscaling (crisp pixel boundaries, no smoothing).
2223    if min_scale > 1.05 {
2224        if interpolate {
2225            let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2226            if is_axis_aligned && w >= 2 && h >= 2 {
2227                let dw = (w as f32 * transform.sx.abs()).round().max(1.0) as u32;
2228                let dh = (h as f32 * transform.sy.abs()).round().max(1.0) as u32;
2229                if dw > w || dh > h {
2230                    let resampled = bicubic_resample(rgba_data, w, h, dw, dh);
2231                    let new_sx = transform.sx * w as f32 / dw as f32;
2232                    let new_sy = transform.sy * h as f32 / dh as f32;
2233                    let adjusted = Transform::from_row(
2234                        new_sx,
2235                        transform.ky,
2236                        transform.kx,
2237                        new_sy,
2238                        transform.tx,
2239                        transform.ty,
2240                    );
2241                    return Some((resampled, dw, dh, adjusted));
2242                }
2243            }
2244        }
2245        return None;
2246    }
2247
2248    // Near 1:1 — no prescaling needed.
2249    if min_scale >= 0.95 {
2250        return None;
2251    }
2252
2253    // Axis-aligned: use area-average box filter to target dimensions.
2254    // Much faster than Lanczos3 and produces equally good results for downscaling.
2255    let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2256    if is_axis_aligned && w >= 2 && h >= 2 {
2257        let dw = (w as f32 * transform.sx.abs()).ceil().max(1.0) as u32;
2258        let dh = (h as f32 * transform.sy.abs()).ceil().max(1.0) as u32;
2259        if dw < w || dh < h {
2260            let resampled = box_resample(rgba_data, w, h, dw, dh);
2261            // Adjust transform so scale ≈ ±1 (sign preserved), same translation.
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    // Fallback for rotated/sheared: integer box filter.
2277    let factor = (1.0 / min_scale) as u32;
2278    if factor < 2 || w < factor || h < factor {
2279        return None;
2280    }
2281    let nw = w / factor;
2282    let nh = h / factor;
2283    if nw == 0 || nh == 0 {
2284        return None;
2285    }
2286    let area = factor * factor;
2287    let half = area / 2;
2288    let stride = w as usize * 4;
2289    let mut out = vec![0u8; (nw * nh * 4) as usize];
2290    for dy in 0..nh {
2291        for dx in 0..nw {
2292            let (mut r, mut g, mut b, mut a) = (0u32, 0u32, 0u32, 0u32);
2293            let sy0 = (dy * factor) as usize;
2294            let sx0 = (dx * factor) as usize;
2295            for iy in 0..factor as usize {
2296                let row = (sy0 + iy) * stride + sx0 * 4;
2297                for ix in 0..factor as usize {
2298                    let i = row + ix * 4;
2299                    r += rgba_data[i] as u32;
2300                    g += rgba_data[i + 1] as u32;
2301                    b += rgba_data[i + 2] as u32;
2302                    a += rgba_data[i + 3] as u32;
2303                }
2304            }
2305            let di = (dy * nw + dx) as usize * 4;
2306            out[di] = ((r + half) / area) as u8;
2307            out[di + 1] = ((g + half) / area) as u8;
2308            out[di + 2] = ((b + half) / area) as u8;
2309            out[di + 3] = ((a + half) / area) as u8;
2310        }
2311    }
2312    let f = factor as f32;
2313    let adjusted = Transform::from_row(
2314        transform.sx * f,
2315        transform.ky * f,
2316        transform.kx * f,
2317        transform.sy * f,
2318        transform.tx,
2319        transform.ty,
2320    );
2321    Some((out, nw, nh, adjusted))
2322}
2323
2324/// Translate a device-space ClipRect into band-local coordinates.
2325fn translate_clip_rect(rect: &ClipRect, y_start: u32, band_h: u32) -> ClipRect {
2326    ClipRect {
2327        x0: rect.x0,
2328        y0: rect.y0.saturating_sub(y_start).min(band_h),
2329        x1: rect.x1,
2330        y1: rect.y1.saturating_sub(y_start).min(band_h),
2331    }
2332}
2333
2334/// Ensure an image transform maps to at least 1 device pixel in each dimension.
2335///
2336/// PDFs commonly draw rules and borders using tiny image masks (1×1 or 4×1 pixels)
2337/// scaled via the CTM to thin rectangles. At low DPI these can map to sub-pixel
2338/// device dimensions and vanish. This adjusts the transform's scale components
2339/// so the image covers at least 1 pixel in each direction.
2340fn enforce_min_image_size(transform: Transform, img_w: u32, img_h: u32) -> Transform {
2341    // Effective device-space dimensions
2342    let eff_w =
2343        ((transform.sx * img_w as f32).powi(2) + (transform.ky * img_w as f32).powi(2)).sqrt();
2344    let eff_h =
2345        ((transform.kx * img_h as f32).powi(2) + (transform.sy * img_h as f32).powi(2)).sqrt();
2346
2347    if eff_w >= 1.0 && eff_h >= 1.0 {
2348        return transform;
2349    }
2350
2351    // Only boost if the image is a thin rule (large aspect ratio).
2352    // Small images that are sub-pixel in both dimensions (e.g. tiny dots)
2353    // are left as-is — boosting them would create visible artifacts.
2354    let ratio = eff_w.max(eff_h) / eff_w.min(eff_h).max(0.001);
2355    if ratio < 3.0 {
2356        return transform;
2357    }
2358
2359    let mut t = transform;
2360    if eff_w < 1.0 && eff_w > 0.001 {
2361        let boost = 1.0 / eff_w;
2362        t.sx *= boost;
2363        t.ky *= boost;
2364    }
2365    if eff_h < 1.0 && eff_h > 0.001 {
2366        let boost = 1.0 / eff_h;
2367        t.kx *= boost;
2368        t.sy *= boost;
2369    }
2370    t
2371}
2372
2373/// Compute minimum line width for hairline strokes at a given DPI and CTM.
2374/// Returns the minimum width in user-space units that ensures at least
2375/// 0.5 device pixels at ≤150 DPI or 1.0 device pixel above 150 DPI.
2376fn hairline_min_width(ctm: &Matrix, dpi: f64) -> f64 {
2377    let (a, b, c, d) = (ctm.a, ctm.b, ctm.c, ctm.d);
2378    let sum_sq = a * a + b * b + c * c + d * d;
2379    let diff = ((a * a + b * b - c * c - d * d).powi(2) + 4.0 * (a * c + b * d).powi(2)).sqrt();
2380    let s_max = (0.5 * (sum_sq + diff)).max(0.0).sqrt();
2381    let min_px = if dpi <= 150.0 { 0.5 } else { 1.0 };
2382    if s_max > 1e-10 {
2383        min_px / s_max
2384    } else {
2385        min_px
2386    }
2387}
2388
2389/// True when the paint's source CMYK is K-only (C=M=Y=0, any K).
2390/// Used to route OPM 0 DeviceCMYK paints that encode "K-only" — like
2391/// `0 0 0 0.5 k` — through the per-pixel overprint path, so the no-op delta
2392/// skip can preserve a spot-painted backdrop at pixels where K already equals
2393/// the source value.
2394fn is_k_only_src(color: &DeviceColor) -> bool {
2395    if let Some((c, m, y, _k)) = color.native_cmyk {
2396        c == 0.0 && m == 0.0 && y == 0.0
2397    } else {
2398        false
2399    }
2400}
2401
2402/// Detect a DeviceGray paint that should be promoted to CMYK_K for overprint.
2403///
2404/// DeviceGray `g` sets `painted_channels = 0` and leaves `native_cmyk = None`,
2405/// so overprint dispatch can't see it as a K-ink paint. When overprint is
2406/// active we re-describe the paint as DeviceCMYK `(0, 0, 0, 1-g)` with
2407/// `painted_channels = CMYK_K`: it flows through the subset path, only the K
2408/// plate is touched, and the pixmap is updated multiplicatively so any
2409/// backdrop spot contribution survives.
2410fn needs_gray_promotion(
2411    overprint: bool,
2412    painted_channels: u8,
2413    is_device_cmyk: bool,
2414    color: &DeviceColor,
2415) -> Option<f64> {
2416    if !overprint
2417        || painted_channels != 0
2418        || is_device_cmyk
2419        || color.native_cmyk.is_some()
2420        || color.process_cmyk.is_some()
2421    {
2422        return None;
2423    }
2424    let r = color.r;
2425    if (r - color.g).abs() > f64::EPSILON || (r - color.b).abs() > f64::EPSILON {
2426        return None;
2427    }
2428    Some(r.clamp(0.0, 1.0))
2429}
2430
2431/// Promote a gray `FillParams` to a DeviceCMYK K-only overprint description if
2432/// the paint qualifies (see [`needs_gray_promotion`]).
2433fn maybe_promote_gray_fill<'a>(
2434    params: &'a FillParams,
2435    buf: &'a mut Option<FillParams>,
2436) -> &'a FillParams {
2437    if let Some(gray) = needs_gray_promotion(
2438        params.overprint,
2439        params.painted_channels,
2440        params.is_device_cmyk,
2441        &params.color,
2442    ) {
2443        let mut promoted = params.clone();
2444        promoted.is_device_cmyk = true;
2445        promoted.painted_channels = stet_graphics::device::CMYK_K;
2446        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2447        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2448        *buf = Some(promoted);
2449        return buf.as_ref().unwrap();
2450    }
2451    params
2452}
2453
2454/// Promote a gray `StrokeParams` to a DeviceCMYK K-only overprint description.
2455fn maybe_promote_gray_stroke<'a>(
2456    params: &'a StrokeParams,
2457    buf: &'a mut Option<StrokeParams>,
2458) -> &'a StrokeParams {
2459    if let Some(gray) = needs_gray_promotion(
2460        params.overprint,
2461        params.painted_channels,
2462        params.is_device_cmyk,
2463        &params.color,
2464    ) {
2465        let mut promoted = params.clone();
2466        promoted.is_device_cmyk = true;
2467        promoted.painted_channels = stet_graphics::device::CMYK_K;
2468        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2469        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2470        *buf = Some(promoted);
2471        return buf.as_ref().unwrap();
2472    }
2473    params
2474}
2475
2476/// Build a stroke with minimum line-width enforcement (shared by trait impl and band rendering).
2477/// `dpi` is the device resolution, used to select the hairline minimum width:
2478/// at ≤150 DPI use 0.6 device pixels; above 150 DPI use 1.0 device pixel.
2479fn build_stroke(params: &StrokeParams, dpi: f64) -> Stroke {
2480    let min_lw = hairline_min_width(&params.ctm, dpi);
2481    let mut stroke = Stroke {
2482        width: (params.line_width as f32).max(min_lw as f32),
2483        line_cap: to_line_cap(params.line_cap),
2484        line_join: to_line_join(params.line_join),
2485        miter_limit: params.miter_limit as f32,
2486        ..Stroke::default()
2487    };
2488    if !params.dash_pattern.array.is_empty() {
2489        let mut dash_array: Vec<f32> = params
2490            .dash_pattern
2491            .array
2492            .iter()
2493            .map(|&v| v as f32)
2494            .collect();
2495        // PostScript allows odd-length dash arrays (implicitly doubled),
2496        // but tiny-skia requires even length. Double odd arrays to match PS semantics.
2497        if dash_array.len() % 2 == 1 {
2498            let clone = dash_array.clone();
2499            dash_array.extend_from_slice(&clone);
2500        }
2501        if let Some(dash) = StrokeDash::new(dash_array, params.dash_pattern.offset as f32) {
2502            stroke.dash = Some(dash);
2503        }
2504    }
2505    stroke
2506}
2507
2508/// Apply stroke adjustment: snap axis-aligned path segments to device pixel
2509/// centers so thin strokes render with consistent weight.
2510///
2511/// For a stroke of width W in device pixels:
2512/// - Odd-integer width (1, 3, ...): snap to half-pixel (floor(x) + 0.5)
2513/// - Even-integer width or non-integer: snap to pixel edge (round(x))
2514/// - For hairlines (device width < 1.5): always snap to half-pixel
2515///
2516/// Only axis-aligned segments (horizontal/vertical lines) are snapped.
2517/// Diagonal/curved segments are left as-is since snapping would distort them.
2518///
2519/// Check whether a CTM indicates the path is already in device space (identity
2520/// or simple Y-flip/translation). Stroke adjustment snaps coordinates to pixel
2521/// boundaries, which only makes sense when path coordinates are device pixels.
2522/// PDF Form XObjects with large scale factors (e.g. [405, 0, 0, 283, ...]) would
2523/// cause catastrophic snapping if treated as device-space paths.
2524fn ctm_is_device_space(ctm: &Matrix) -> bool {
2525    (ctm.a.abs() - 1.0).abs() < 0.01
2526        && ctm.b.abs() < 0.01
2527        && ctm.c.abs() < 0.01
2528        && (ctm.d.abs() - 1.0).abs() < 0.01
2529}
2530
2531/// Apply stroke adjustment for viewport rendering.
2532///
2533/// Path coordinates are in reference-DPI device space. The viewport transform
2534/// maps them to output pixels: out = (ref - vp_origin) * scale.
2535/// We snap in output pixel space then map back to reference space.
2536fn stroke_adjust_path_viewport(
2537    path: &PsPath,
2538    device_width: f64,
2539    scale_x: f64,
2540    scale_y: f64,
2541    vp_x: f64,
2542    vp_y: f64,
2543) -> PsPath {
2544    let use_half_pixel = device_width < 1.5 || (device_width.round() as i32) % 2 == 1;
2545
2546    // Snap a reference-space coordinate to the output pixel grid, then map back
2547    let snap_x = |v: f64| -> f64 {
2548        let out = (v - vp_x) * scale_x;
2549        let snapped = if use_half_pixel {
2550            out.floor() + 0.5
2551        } else {
2552            out.round()
2553        };
2554        snapped / scale_x + vp_x
2555    };
2556    let snap_y = |v: f64| -> f64 {
2557        let out = (v - vp_y) * scale_y;
2558        let snapped = if use_half_pixel {
2559            out.floor() + 0.5
2560        } else {
2561            out.round()
2562        };
2563        snapped / scale_y + vp_y
2564    };
2565
2566    let mut result = PsPath::new();
2567    let mut prev_x = 0.0_f64;
2568    let mut prev_y = 0.0_f64;
2569
2570    for seg in &path.segments {
2571        match *seg {
2572            PathSegment::MoveTo(x, y) => {
2573                prev_x = x;
2574                prev_y = y;
2575                result.segments.push(PathSegment::MoveTo(x, y));
2576            }
2577            PathSegment::LineTo(x, y) => {
2578                let is_horizontal = (y - prev_y).abs() < 1e-6;
2579                let is_vertical = (x - prev_x).abs() < 1e-6;
2580
2581                if is_horizontal {
2582                    let snapped_y = snap_y(y);
2583                    if let Some(PathSegment::MoveTo(_, ly) | PathSegment::LineTo(_, ly)) =
2584                        result.segments.last_mut()
2585                    {
2586                        *ly = snapped_y;
2587                    }
2588                    result.segments.push(PathSegment::LineTo(x, snapped_y));
2589                    prev_x = x;
2590                    prev_y = snapped_y;
2591                } else if is_vertical {
2592                    let snapped_x = snap_x(x);
2593                    if let Some(PathSegment::MoveTo(lx, _) | PathSegment::LineTo(lx, _)) =
2594                        result.segments.last_mut()
2595                    {
2596                        *lx = snapped_x;
2597                    }
2598                    result.segments.push(PathSegment::LineTo(snapped_x, y));
2599                    prev_x = snapped_x;
2600                    prev_y = y;
2601                } else {
2602                    result.segments.push(PathSegment::LineTo(x, y));
2603                    prev_x = x;
2604                    prev_y = y;
2605                }
2606            }
2607            PathSegment::CurveTo {
2608                x1,
2609                y1,
2610                x2,
2611                y2,
2612                x3,
2613                y3,
2614            } => {
2615                result.segments.push(PathSegment::CurveTo {
2616                    x1,
2617                    y1,
2618                    x2,
2619                    y2,
2620                    x3,
2621                    y3,
2622                });
2623                prev_x = x3;
2624                prev_y = y3;
2625            }
2626            PathSegment::ClosePath => {
2627                result.segments.push(PathSegment::ClosePath);
2628            }
2629        }
2630    }
2631    result
2632}
2633
2634/// Process a single display list element into a pixmap using the given render context.
2635///
2636/// This unified function handles both band rendering (scale=1.0) and viewport
2637/// rendering (arbitrary scale). Band rendering is viewport rendering with
2638/// `scale_x = scale_y = 1.0`.
2639fn render_element(
2640    pixmap: &mut Pixmap,
2641    band_state: &mut BandState,
2642    element: &DisplayElement,
2643    ctx: &RenderContext<'_>,
2644) {
2645    match element {
2646        DisplayElement::Fill { path, params } => {
2647            // DeviceGray with overprint behaves as a K-only process paint —
2648            // promote it to DeviceCMYK (0, 0, 0, 1-gray) with painted_channels
2649            // set to CMYK_K so it flows through the overprint subset path,
2650            // preserving backdrop CMY plates and the spot-derived visual
2651            // instead of knocking the pixmap out with plain RGB gray.
2652            let mut promoted_fill: Option<FillParams> = None;
2653            let params = maybe_promote_gray_fill(params, &mut promoted_fill);
2654            // Use the overprint compositing path whenever the fill needs
2655            // per-channel CMYK rendering. Five cases trigger it:
2656            //   1. Subset painted_channels (Separation /Magenta, DeviceN, etc.)
2657            //      — only the named channels touch the buffer; the rest are
2658            //      preserved from the backdrop.
2659            //   2. DeviceCMYK + OPM 1 — zero-valued components don't paint, so
2660            //      a per-pixel filter is required.
2661            //   3. Custom spot (painted_channels=0, non-CMYK, with native_cmyk)
2662            //      under overprint — process plates must be preserved; the
2663            //      spot's alt-CMYK only contributes multiplicatively to RGB.
2664            //   4. DeviceCMYK + overprint (any OPM) with CMYK_ALL — the per-
2665            //      pixel path lets us recognise a "no-op" overprint (src CMYK
2666            //      == backdrop CMYK) and leave the pixmap untouched, which
2667            //      preserves any spot-derived colour already visible there.
2668            //   5. (Combinations of the above.)
2669            // Only fires for Normal blend; non-Normal blend modes handle zero
2670            // values through their blend math, not through overprint filtering.
2671            // Includes text glyphs: when overprint is meaningful (the test
2672            // suite's GWG 1.0 swatches f/a use Separation /Magenta + glyphs),
2673            // correctness wins over the slight AA difference vs tiny-skia.
2674            let painted = params.painted_channels;
2675            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2676            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2677            let custom_spot =
2678                painted == 0 && !params.is_device_cmyk && params.color.native_cmyk.is_some();
2679            // A "near-K-only" DeviceCMYK paint under OPM 0 — e.g. `0 0 0 0.5 k`
2680            // — matches the Black-component plate of a DeviceN [Black, spot]
2681            // backdrop exactly. Routing it through the per-pixel path lets the
2682            // no-op-delta skip preserve the spot-derived colour instead of
2683            // wiping it with plain grey (GWG 3.0 "50% K over spot").
2684            let is_k_only_cmyk =
2685                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2686            let needs_overprint = params.overprint
2687                && band_state.cmyk_buffer.is_some()
2688                && params.blend_mode == 0
2689                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2690
2691            if needs_overprint {
2692                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2693                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2694                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2695                render_overprint_fill(
2696                    pixmap,
2697                    &mut cmyk_buf,
2698                    &mut op_bg,
2699                    &mut op_touched,
2700                    &spot_mask,
2701                    band_state,
2702                    path,
2703                    params,
2704                    ctx.vp_x,
2705                    ctx.vp_y,
2706                    ctx.scale_x,
2707                    ctx.scale_y,
2708                    ctx.out_w,
2709                    ctx.out_h,
2710                    ctx.icc,
2711                    ctx.no_aa,
2712                );
2713                band_state.cmyk_buffer = Some(cmyk_buf);
2714                band_state.restore_op_buffers(op_bg, op_touched);
2715                band_state.restore_spot_mask(spot_mask);
2716            } else {
2717                let Some(skia_path) = build_skia_path(path) else {
2718                    return;
2719                };
2720                let mut temp_mask = None;
2721                let Some(mask_ref) = resolve_clip_mask(
2722                    &band_state.clip_region,
2723                    &mut temp_mask,
2724                    ctx.out_w,
2725                    ctx.out_h,
2726                ) else {
2727                    return;
2728                };
2729                let paint =
2730                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2731                let transform = ctx.transform(&params.ctm);
2732
2733                // Detect degenerate fill paths: rectangles/lines with zero extent
2734                // in one dimension. These are commonly used in PDFs to draw table
2735                // grid lines as zero-width or zero-height filled rectangles.
2736                // Since they have no area, fill_path produces nothing. Render them
2737                // as hairline strokes instead.
2738                if is_degenerate_fill(path) {
2739                    let stroke = Stroke {
2740                        width: 1.0,
2741                        ..Stroke::default()
2742                    };
2743                    pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2744                } else {
2745                    let fill_rule = to_fill_rule(&params.fill_rule);
2746                    pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
2747                }
2748
2749                // Update CMYK tracking buffer for non-overprint fills
2750                if band_state.cmyk_buffer.is_some() {
2751                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2752                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2753                    update_cmyk_buffer_for_fill(
2754                        &mut cmyk_buf,
2755                        &mut spot_mask,
2756                        path,
2757                        params,
2758                        ctx.vp_x,
2759                        ctx.vp_y,
2760                        ctx.scale_x,
2761                        ctx.scale_y,
2762                        ctx.out_w,
2763                        ctx.out_h,
2764                        &band_state.clip_region,
2765                        ctx.no_aa,
2766                        ctx.icc,
2767                    );
2768                    band_state.cmyk_buffer = Some(cmyk_buf);
2769                    band_state.restore_spot_mask(spot_mask);
2770                }
2771            }
2772        }
2773        DisplayElement::Stroke { path, params } => {
2774            let mut promoted_stroke: Option<StrokeParams> = None;
2775            let params = maybe_promote_gray_stroke(params, &mut promoted_stroke);
2776            let transform = ctx.transform(&params.ctm);
2777            // Build stroke using the composited transform so hairline width
2778            // calculations account for the actual output resolution.
2779            let vp_ctm = Matrix {
2780                a: transform.sx as f64,
2781                b: transform.ky as f64,
2782                c: transform.kx as f64,
2783                d: transform.sy as f64,
2784                tx: 0.0,
2785                ty: 0.0,
2786            };
2787            let vp_params = StrokeParams {
2788                ctm: vp_ctm,
2789                ..params.clone()
2790            };
2791            let stroke = build_stroke(&vp_params, ctx.effective_dpi);
2792
2793            // Apply stroke adjustment — snap in output device space
2794            let adjusted;
2795            let draw_path = if params.stroke_adjust
2796                && stroke.width <= 2.0
2797                && ctm_is_device_space(&params.ctm)
2798            {
2799                adjusted = stroke_adjust_path_viewport(
2800                    path,
2801                    stroke.width as f64,
2802                    ctx.scale_x as f64,
2803                    ctx.scale_y as f64,
2804                    ctx.vp_x as f64,
2805                    ctx.vp_y as f64,
2806                );
2807                &adjusted
2808            } else {
2809                path
2810            };
2811
2812            // Mirror the Fill gating: per-channel CMYK rendering kicks in for
2813            // subset painted_channels (Separation /Magenta, DeviceN, etc.), for
2814            // DeviceCMYK + OPM 1 (zero-valued source components don't paint),
2815            // or for a custom spot (painted=0, non-CMYK) under overprint — so
2816            // the spot applies multiplicatively to RGB without disturbing the
2817            // process plates. GWG 1.0 swatch a/b/f/g need this for the magenta
2818            // X stroke that overlays the same path the fill already drew.
2819            let painted = params.painted_channels;
2820            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2821            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2822            let custom_spot =
2823                painted == 0 && !params.is_device_cmyk && params.color.native_cmyk.is_some();
2824            let is_k_only_cmyk =
2825                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2826            let needs_overprint = params.overprint
2827                && band_state.cmyk_buffer.is_some()
2828                && params.blend_mode == 0
2829                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2830
2831            let Some(skia_path) = build_skia_path(draw_path) else {
2832                return;
2833            };
2834            let mut temp_mask = None;
2835            let Some(mask_ref) = resolve_clip_mask(
2836                &band_state.clip_region,
2837                &mut temp_mask,
2838                ctx.out_w,
2839                ctx.out_h,
2840            ) else {
2841                return;
2842            };
2843
2844            if needs_overprint {
2845                // Convert the stroke outline to a fill path and route it
2846                // through the same per-channel CMYK compositing logic the
2847                // fill path uses, so the post-overprint result lands in the
2848                // pixmap (not the raw source colour).
2849                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2850                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2851                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2852                render_overprint_stroke(
2853                    pixmap,
2854                    &mut cmyk_buf,
2855                    &mut op_bg,
2856                    &mut op_touched,
2857                    &spot_mask,
2858                    band_state,
2859                    &skia_path,
2860                    &stroke,
2861                    transform,
2862                    params,
2863                    ctx.out_w,
2864                    ctx.out_h,
2865                    ctx.icc,
2866                    ctx.no_aa,
2867                );
2868                band_state.cmyk_buffer = Some(cmyk_buf);
2869                band_state.restore_op_buffers(op_bg, op_touched);
2870                band_state.restore_spot_mask(spot_mask);
2871            } else {
2872                let paint =
2873                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2874                pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2875
2876                if band_state.cmyk_buffer.is_some() {
2877                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2878                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2879                    update_cmyk_buffer_for_stroke(
2880                        &mut cmyk_buf,
2881                        &mut spot_mask,
2882                        draw_path,
2883                        params,
2884                        &stroke,
2885                        transform,
2886                        ctx.out_w,
2887                        ctx.out_h,
2888                        &band_state.clip_region,
2889                        ctx.no_aa,
2890                        ctx.icc,
2891                    );
2892                    band_state.cmyk_buffer = Some(cmyk_buf);
2893                    band_state.restore_spot_mask(spot_mask);
2894                }
2895            }
2896        }
2897        DisplayElement::Clip { path, params } => {
2898            clip_path_unified(band_state, path, params, ctx);
2899        }
2900        DisplayElement::InitClip => {
2901            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2902                band_state.recycle_mask(mask);
2903            }
2904            band_state.clip_region = None;
2905        }
2906        DisplayElement::ErasePage => {
2907            pixmap.fill(Color::TRANSPARENT);
2908            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2909                band_state.recycle_mask(mask);
2910            }
2911            band_state.clip_region = None;
2912        }
2913        DisplayElement::Image {
2914            sample_data,
2915            params,
2916        } => {
2917            let iw = params.width;
2918            let ih = params.height;
2919            if iw == 0 || ih == 0 {
2920                return;
2921            }
2922
2923            let needs_overprint = params.overprint
2924                && band_state.cmyk_buffer.is_some()
2925                && image_supports_overprint(&params.color_space);
2926
2927            if needs_overprint {
2928                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2929                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2930                render_overprint_image(
2931                    pixmap,
2932                    &mut cmyk_buf,
2933                    &mut op_bg,
2934                    &mut op_touched,
2935                    band_state,
2936                    sample_data,
2937                    params,
2938                    ctx.vp_x,
2939                    ctx.vp_y,
2940                    ctx.scale_x,
2941                    ctx.scale_y,
2942                    ctx.out_w,
2943                    ctx.out_h,
2944                    ctx.icc,
2945                );
2946                band_state.cmyk_buffer = Some(cmyk_buf);
2947                band_state.restore_op_buffers(op_bg, op_touched);
2948            } else if let Some(pp) = ctx
2949                .preprocessed
2950                .and_then(|pp| pp.get(ctx.elem_idx))
2951                .and_then(|e| e.as_ref())
2952            {
2953                // Fast path: use pre-converted and prescaled image data.
2954                // Only the per-band translation differs; scale factors are cached.
2955                let Some(image_inv) = params.image_matrix.invert() else {
2956                    return;
2957                };
2958                let combined = params.ctm.concat(&image_inv);
2959                let raw_transform = ctx.transform(&combined);
2960                let transform = Transform::from_row(
2961                    pp.adj_sx,
2962                    pp.adj_ky,
2963                    pp.adj_kx,
2964                    pp.adj_sy,
2965                    raw_transform.tx,
2966                    raw_transform.ty,
2967                );
2968
2969                let Some(img_pixmap) =
2970                    stet_tiny_skia::PixmapRef::from_bytes(&pp.data, pp.width, pp.height)
2971                else {
2972                    return;
2973                };
2974                #[allow(unused_assignments)]
2975                let mut temp_mask = None;
2976                let mask_ref = match &band_state.clip_region {
2977                    None => None,
2978                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
2979                    Some(ClipRegion::Rect(rect)) => {
2980                        if rect.is_empty() {
2981                            return;
2982                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
2983                            None
2984                        } else {
2985                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
2986                            temp_mask.as_ref()
2987                        }
2988                    }
2989                };
2990                let img_paint = stet_tiny_skia::PixmapPaint {
2991                    quality: pp.quality,
2992                    opacity: params.alpha as f32,
2993                    blend_mode: u8_to_blend_mode(params.blend_mode),
2994                };
2995                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
2996
2997                // Update CMYK tracking buffer for non-overprint images on the
2998                // fast path. Reading from the post-draw pixmap means the same
2999                // helper handles native-CMYK and non-CMYK source images, even
3000                // though `pp.data` is prescaled and we no longer have a
3001                // matching native RGBA buffer.
3002                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
3003                    update_cmyk_buffer_for_image(
3004                        cmyk_buf,
3005                        sample_data,
3006                        pixmap.data(),
3007                        params,
3008                        ctx.vp_x,
3009                        ctx.vp_y,
3010                        ctx.scale_x,
3011                        ctx.scale_y,
3012                        ctx.out_w,
3013                        ctx.out_h,
3014                        &band_state.clip_region,
3015                        ctx.icc,
3016                    );
3017                }
3018            } else {
3019                // Use pre-converted RGBA from image cache when available
3020                let owned_rgba;
3021                let rgba_data: &[u8] = if let Some(cached) =
3022                    ctx.image_cache.and_then(|c| c.get(ctx.elem_idx))
3023                {
3024                    cached
3025                } else {
3026                    owned_rgba = {
3027                        let mut rgba =
3028                            samples_to_rgba(sample_data, params, ctx.icc, ctx.opm_zero_transparent);
3029                        if params.mask_color.is_some() {
3030                            apply_mask_color_rgba(&mut rgba, sample_data, params);
3031                        }
3032                        rgba
3033                    };
3034                    &owned_rgba
3035                };
3036                let expected = (iw * ih * 4) as usize;
3037                if rgba_data.len() < expected {
3038                    return;
3039                }
3040                let Some(image_inv) = params.image_matrix.invert() else {
3041                    return;
3042                };
3043                let combined = params.ctm.concat(&image_inv);
3044                let raw_transform = enforce_min_image_size(ctx.transform(&combined), iw, ih);
3045
3046                // Pre-scale images that are being downscaled. Even non-interpolated
3047                // images need proper area averaging when shrinking — "no interpolation"
3048                // means don't smooth when *upscaling*, but downscaling without averaging
3049                // produces aliased garbage.
3050                let prescaled =
3051                    prescale_image(rgba_data, iw, ih, raw_transform, params.interpolate);
3052                let (img_data, img_w, img_h, transform) = match &prescaled {
3053                    Some((data, w, h, t)) => (data.as_slice(), *w, *h, *t),
3054                    None => (rgba_data, iw, ih, raw_transform),
3055                };
3056
3057                let Some(img_pixmap) =
3058                    stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h)
3059                else {
3060                    return;
3061                };
3062                #[allow(unused_assignments)]
3063                let mut temp_mask = None;
3064                let mask_ref = match &band_state.clip_region {
3065                    None => None,
3066                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
3067                    Some(ClipRegion::Rect(rect)) => {
3068                        if rect.is_empty() {
3069                            return;
3070                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
3071                            None
3072                        } else {
3073                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
3074                            temp_mask.as_ref()
3075                        }
3076                    }
3077                };
3078                let img_paint = stet_tiny_skia::PixmapPaint {
3079                    quality: image_filter_quality(transform, params.interpolate),
3080                    opacity: params.alpha as f32,
3081                    blend_mode: u8_to_blend_mode(params.blend_mode),
3082                };
3083                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
3084
3085                // Update CMYK tracking buffer for non-overprint images. Sample
3086                // the now-composited pixmap so non-CMYK source images can be
3087                // reverse-converted to CMYK via the system profile.
3088                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
3089                    update_cmyk_buffer_for_image(
3090                        cmyk_buf,
3091                        sample_data,
3092                        pixmap.data(),
3093                        params,
3094                        ctx.vp_x,
3095                        ctx.vp_y,
3096                        ctx.scale_x,
3097                        ctx.scale_y,
3098                        ctx.out_w,
3099                        ctx.out_h,
3100                        &band_state.clip_region,
3101                        ctx.icc,
3102                    );
3103                }
3104            }
3105        }
3106        DisplayElement::AxialShading { params } => {
3107            let mut temp_mask = None;
3108            let Some(mask_ref) = resolve_clip_mask(
3109                &band_state.clip_region,
3110                &mut temp_mask,
3111                ctx.out_w,
3112                ctx.out_h,
3113            ) else {
3114                return;
3115            };
3116            render_axial_shading(
3117                pixmap,
3118                params,
3119                ctx.vp_x,
3120                ctx.vp_y,
3121                ctx.scale_x,
3122                ctx.scale_y,
3123                mask_ref,
3124                ctx.no_aa,
3125                band_state.cmyk_buffer.as_deref_mut(),
3126                ctx.icc,
3127            );
3128        }
3129        DisplayElement::RadialShading { params } => {
3130            let mut temp_mask = None;
3131            let Some(mask_ref) = resolve_clip_mask(
3132                &band_state.clip_region,
3133                &mut temp_mask,
3134                ctx.out_w,
3135                ctx.out_h,
3136            ) else {
3137                return;
3138            };
3139            render_radial_shading(
3140                pixmap,
3141                params,
3142                ctx.vp_x,
3143                ctx.vp_y,
3144                ctx.scale_x,
3145                ctx.scale_y,
3146                mask_ref,
3147                ctx.no_aa,
3148                band_state.cmyk_buffer.as_deref_mut(),
3149                ctx.icc,
3150            );
3151        }
3152        DisplayElement::MeshShading { params } => {
3153            let mut temp_mask = None;
3154            let Some(mask_ref) = resolve_clip_mask(
3155                &band_state.clip_region,
3156                &mut temp_mask,
3157                ctx.out_w,
3158                ctx.out_h,
3159            ) else {
3160                return;
3161            };
3162            render_mesh_shading(
3163                pixmap,
3164                params,
3165                ctx.vp_x,
3166                ctx.vp_y,
3167                ctx.scale_x,
3168                ctx.scale_y,
3169                mask_ref,
3170                band_state.cmyk_buffer.as_deref_mut(),
3171                ctx.icc,
3172            );
3173        }
3174        DisplayElement::PatchShading { params } => {
3175            let mut temp_mask = None;
3176            let Some(mask_ref) = resolve_clip_mask(
3177                &band_state.clip_region,
3178                &mut temp_mask,
3179                ctx.out_w,
3180                ctx.out_h,
3181            ) else {
3182                return;
3183            };
3184            render_patch_shading(
3185                pixmap,
3186                params,
3187                ctx.vp_x,
3188                ctx.vp_y,
3189                ctx.scale_x,
3190                ctx.scale_y,
3191                mask_ref,
3192                band_state.cmyk_buffer.as_deref_mut(),
3193                ctx.icc,
3194            );
3195        }
3196        DisplayElement::PatternFill { params } => {
3197            render_pattern_fill(pixmap, band_state, params, ctx);
3198        }
3199        DisplayElement::Group { elements, params } => {
3200            render_group(pixmap, band_state, elements, params, ctx);
3201        }
3202        DisplayElement::SoftMasked {
3203            mask,
3204            content,
3205            params,
3206            mask_cache,
3207        } => {
3208            render_soft_masked(pixmap, band_state, mask, content, params, mask_cache, ctx);
3209        }
3210        DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
3211        DisplayElement::OcgGroup {
3212            elements,
3213            visibility,
3214        } => {
3215            // Visible groups render every child. OFF-by-default groups still
3216            // apply Clip/InitClip so the band's clip state stays in sync —
3217            // otherwise a transient clip from the previous group would leak
3218            // into the next visible one. Paint ops are skipped; that's what
3219            // "hidden layer" means.
3220            let visible = ctx.layer_set.evaluate(visibility);
3221            for (idx, elem) in elements.elements().iter().enumerate() {
3222                if !visible
3223                    && !matches!(elem, DisplayElement::Clip { .. } | DisplayElement::InitClip)
3224                {
3225                    continue;
3226                }
3227                let elem_ctx = RenderContext {
3228                    elem_idx: idx,
3229                    ..*ctx
3230                };
3231                render_element(pixmap, band_state, elem, &elem_ctx);
3232            }
3233        }
3234        _ => {}
3235    }
3236}
3237
3238/// Compute the cropped output-pixel region for a group's device-space bounding box.
3239///
3240/// Returns `(crop_x, crop_y, crop_w, crop_h)` in output pixels, or `None` if
3241/// the group is entirely outside the viewport or cropping isn't worthwhile.
3242fn compute_group_crop(bbox: &[f64; 4], ctx: &RenderContext<'_>) -> Option<(i32, i32, u32, u32)> {
3243    // Transform device-space bbox to output pixel coords
3244    let px_min = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
3245    let py_min = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
3246    let px_max = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
3247    let py_max = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
3248
3249    // Clip to output bounds
3250    let x0 = px_min.max(0);
3251    let y0 = py_min.max(0);
3252    let x1 = px_max.min(ctx.out_w as i32);
3253    let y1 = py_max.min(ctx.out_h as i32);
3254
3255    if x0 >= x1 || y0 >= y1 {
3256        return None;
3257    }
3258
3259    let crop_w = (x1 - x0) as u32;
3260    let crop_h = (y1 - y0) as u32;
3261
3262    // Only crop if it saves at least 25% of pixels
3263    let crop_pixels = crop_w as u64 * crop_h as u64;
3264    let full_pixels = ctx.out_w as u64 * ctx.out_h as u64;
3265    if crop_pixels * 4 >= full_pixels * 3 {
3266        return None;
3267    }
3268
3269    Some((x0, y0, crop_w, crop_h))
3270}
3271
3272/// Apply a separable PDF blend mode in DeviceCMYK using the spec's "effective"
3273/// inversion convention (PDF 1.7 §11.3.5.2): the inverse value `1−c` is used as
3274/// input to the RGB-style blend function, and the result is inverted back.
3275fn blend_cmyk_separable_channel(cb: f64, cs: f64, mode: u8) -> f64 {
3276    let cbi = 1.0 - cb;
3277    let csi = 1.0 - cs;
3278    let result_inv = match mode {
3279        1 => cbi * csi,             // Multiply
3280        2 => cbi + csi - cbi * csi, // Screen
3281        3 => {
3282            // Overlay(b, s) = HardLight(s, b)
3283            if cbi <= 0.5 {
3284                2.0 * cbi * csi
3285            } else {
3286                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3287            }
3288        }
3289        4 => cbi.min(csi), // Darken
3290        5 => cbi.max(csi), // Lighten
3291        6 => {
3292            // ColorDodge
3293            if csi >= 1.0 {
3294                1.0
3295            } else {
3296                (cbi / (1.0 - csi)).min(1.0)
3297            }
3298        }
3299        7 => {
3300            // ColorBurn
3301            if csi <= 0.0 {
3302                0.0
3303            } else {
3304                1.0 - ((1.0 - cbi) / csi).min(1.0)
3305            }
3306        }
3307        8 => {
3308            // HardLight
3309            if csi <= 0.5 {
3310                2.0 * cbi * csi
3311            } else {
3312                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3313            }
3314        }
3315        9 => {
3316            // SoftLight (Adobe formulation)
3317            let d = if cbi <= 0.25 {
3318                ((16.0 * cbi - 12.0) * cbi + 4.0) * cbi
3319            } else {
3320                cbi.sqrt()
3321            };
3322            if csi <= 0.5 {
3323                cbi - (1.0 - 2.0 * csi) * cbi * (1.0 - cbi)
3324            } else {
3325                cbi + (2.0 * csi - 1.0) * (d - cbi)
3326            }
3327        }
3328        10 => (cbi - csi).abs(),           // Difference
3329        11 => cbi + csi - 2.0 * cbi * csi, // Exclusion
3330        _ => csi,                          // Normal/fallback
3331    };
3332    1.0 - result_inv.clamp(0.0, 1.0)
3333}
3334
3335/// Apply a non-separable HSL-style PDF blend mode (Hue, Saturation, Color,
3336/// Luminosity) in DeviceCMYK. Per the spec, the inverted CMY components are
3337/// treated as "effective RGB" and the standard non-separable formulas are
3338/// applied; the K channel is taken from the source (it acts as the source's
3339/// luminosity contribution for the purposes of the blend).
3340fn blend_cmyk_nonseparable(cb: [f64; 4], cs: [f64; 4], mode: u8) -> [f64; 4] {
3341    fn lum(c: [f64; 3]) -> f64 {
3342        0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
3343    }
3344    fn clip_color(mut c: [f64; 3]) -> [f64; 3] {
3345        let l = lum(c);
3346        let n = c[0].min(c[1]).min(c[2]);
3347        let x = c[0].max(c[1]).max(c[2]);
3348        if n < 0.0 {
3349            for ci in c.iter_mut() {
3350                *ci = l + (*ci - l) * l / (l - n);
3351            }
3352        }
3353        if x > 1.0 {
3354            for ci in c.iter_mut() {
3355                *ci = l + (*ci - l) * (1.0 - l) / (x - l);
3356            }
3357        }
3358        c
3359    }
3360    fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
3361        let d = l - lum(c);
3362        clip_color([c[0] + d, c[1] + d, c[2] + d])
3363    }
3364    fn sat(c: [f64; 3]) -> f64 {
3365        c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
3366    }
3367    fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
3368        // Index components by rank: min, mid, max.
3369        let mut idx = [0usize, 1, 2];
3370        idx.sort_by(|a, b| {
3371            c[*a]
3372                .partial_cmp(&c[*b])
3373                .unwrap_or(std::cmp::Ordering::Equal)
3374        });
3375        let (i_min, i_mid, i_max) = (idx[0], idx[1], idx[2]);
3376        let mut out = c;
3377        if c[i_max] > c[i_min] {
3378            out[i_mid] = (c[i_mid] - c[i_min]) * s / (c[i_max] - c[i_min]);
3379            out[i_max] = s;
3380        } else {
3381            out[i_mid] = 0.0;
3382            out[i_max] = 0.0;
3383        }
3384        out[i_min] = 0.0;
3385        out
3386    }
3387
3388    let cb_rgb = [1.0 - cb[0], 1.0 - cb[1], 1.0 - cb[2]];
3389    let cs_rgb = [1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2]];
3390    let result_rgb = match mode {
3391        12 => set_lum(set_sat(cs_rgb, sat(cb_rgb)), lum(cb_rgb)), // Hue
3392        13 => set_lum(set_sat(cb_rgb, sat(cs_rgb)), lum(cb_rgb)), // Saturation
3393        14 => set_lum(cs_rgb, lum(cb_rgb)),                       // Color
3394        15 => set_lum(cb_rgb, lum(cs_rgb)),                       // Luminosity
3395        _ => cs_rgb,
3396    };
3397    // Hue/Saturation/Color preserve the backdrop's luminosity, which in CMYK
3398    // is carried primarily by the K channel. Luminosity transfers the source's
3399    // luminosity, so it takes K from the source.
3400    let result_k = if mode == 15 { cs[3] } else { cb[3] };
3401    [
3402        (1.0 - result_rgb[0]).clamp(0.0, 1.0),
3403        (1.0 - result_rgb[1]).clamp(0.0, 1.0),
3404        (1.0 - result_rgb[2]).clamp(0.0, 1.0),
3405        result_k,
3406    ]
3407}
3408
3409/// Render a transparency group into a pixmap.
3410/// Device-space axis-aligned bbox of a path, computed from its segment
3411/// endpoints and curve control points. Returned as (x0, y0, x1, y1) with
3412/// x0 ≤ x1, y0 ≤ y1. Returns `None` for an empty path.
3413fn ps_path_bbox(path: &PsPath) -> Option<(f64, f64, f64, f64)> {
3414    let mut it = path.segments.iter().filter_map(|seg| match *seg {
3415        PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => Some(vec![(x, y)]),
3416        PathSegment::CurveTo {
3417            x1,
3418            y1,
3419            x2,
3420            y2,
3421            x3,
3422            y3,
3423        } => Some(vec![(x1, y1), (x2, y2), (x3, y3)]),
3424        PathSegment::ClosePath => None,
3425    });
3426    let first = it.next()?.into_iter().next()?;
3427    let (mut x0, mut y0) = first;
3428    let (mut x1, mut y1) = first;
3429    for seg_points in std::iter::once(vec![first]).chain(it) {
3430        for (x, y) in seg_points {
3431            x0 = x0.min(x);
3432            y0 = y0.min(y);
3433            x1 = x1.max(x);
3434            y1 = y1.max(y);
3435        }
3436    }
3437    Some((x0, y0, x1, y1))
3438}
3439
3440/// True when rectangle `inner` fits inside `outer` with `tolerance` slack
3441/// (positive tolerance = inner may protrude by up to `tolerance` units).
3442fn bbox_contains(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64), tolerance: f64) -> bool {
3443    inner.0 >= outer.0 - tolerance
3444        && inner.1 >= outer.1 - tolerance
3445        && inner.2 <= outer.2 + tolerance
3446        && inner.3 <= outer.3 + tolerance
3447}
3448
3449/// Detect the GWG "reference-under-test" authoring pattern: a parent Fill
3450/// that will be fully covered by the first Fill of a following isolated
3451/// transparency group. When detected, the parent's Fill can be skipped —
3452/// its AA edges otherwise bleed into the dest under the group's partial-
3453/// alpha source during composite-back, producing a visible outline where
3454/// Acrobat shows none (see GWG 16.2 Opacity(0%) analysis in
3455/// `project_icc_profile_stability.md`).
3456///
3457/// Returns indices in `elements` that should be skipped. Safety conditions:
3458///   1. Parent fill is fully opaque, Normal blend.
3459///   2. Next paint (ignoring Clip/InitClip) is an isolated, alpha-1,
3460///      Normal-blend Group whose first paint is a Fill with matching
3461///      path (within tolerance) and the same opacity/blend conditions.
3462///   3. The group's declared bbox fully contains the parent path's bbox
3463///      — i.e. the form's own BBox clip won't carve the fill away.
3464///   4. Every Clip element between the parent fill and the group, and
3465///      every Clip between the group's start and its first fill, has a
3466///      bbox that also fully contains the parent path — so no additional
3467///      clip can cut the group's first fill to a subset of the parent's
3468///      extent.
3469///   5. PDF's isolated transparency semantics guarantee that once the
3470///      first fill establishes alpha=1 at the parent-path pixels, later
3471///      Normal-blend paints can only add colour there; alpha can't
3472///      decrease. So nothing in the group's tail can re-expose backdrop,
3473///      even without auditing those elements explicitly.
3474fn compute_obscured_fill_skips(elements: &DisplayList) -> Vec<usize> {
3475    let mut skips = Vec::new();
3476    let els = elements.elements();
3477    for i in 0..els.len() {
3478        let DisplayElement::Fill {
3479            path: parent_path,
3480            params: parent_params,
3481        } = &els[i]
3482        else {
3483            continue;
3484        };
3485        if (parent_params.alpha - 1.0).abs() > 1e-6 || parent_params.blend_mode != 0 {
3486            continue;
3487        }
3488        let Some(parent_bbox) = ps_path_bbox(parent_path) else {
3489            continue;
3490        };
3491        // Walk forward past Clip/InitClip between parent fill and the
3492        // group. Each such clip must contain the parent's extent; any
3493        // other element type ends the scan.
3494        let mut j = i + 1;
3495        let mut clips_ok = true;
3496        while j < els.len() {
3497            match &els[j] {
3498                DisplayElement::InitClip => {}
3499                DisplayElement::Clip {
3500                    path: clip_path, ..
3501                } => match ps_path_bbox(clip_path) {
3502                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3503                    _ => {
3504                        clips_ok = false;
3505                        break;
3506                    }
3507                },
3508                _ => break,
3509            }
3510            j += 1;
3511        }
3512        if !clips_ok {
3513            continue;
3514        }
3515        let Some(DisplayElement::Group {
3516            elements: group_elements,
3517            params: group_params,
3518        }) = els.get(j)
3519        else {
3520            continue;
3521        };
3522        if !group_params.isolated
3523            || (group_params.alpha - 1.0).abs() > 1e-6
3524            || group_params.blend_mode != 0
3525        {
3526            continue;
3527        }
3528        // The form's declared BBox acts as a clip inside the group; the
3529        // parent's fill must fit inside it or the group's output will be
3530        // carved away where we'd rely on coverage.
3531        let group_bbox = (
3532            group_params.bbox[0],
3533            group_params.bbox[1],
3534            group_params.bbox[2],
3535            group_params.bbox[3],
3536        );
3537        if !bbox_contains(group_bbox, parent_bbox, 0.5) {
3538            continue;
3539        }
3540        // Walk past Clip/InitClip inside the group to its first paint,
3541        // requiring each clip to contain the parent's extent.
3542        let inner_els = group_elements.elements();
3543        let mut k = 0;
3544        let mut inner_clips_ok = true;
3545        while k < inner_els.len() {
3546            match &inner_els[k] {
3547                DisplayElement::InitClip => {}
3548                DisplayElement::Clip {
3549                    path: clip_path, ..
3550                } => match ps_path_bbox(clip_path) {
3551                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3552                    _ => {
3553                        inner_clips_ok = false;
3554                        break;
3555                    }
3556                },
3557                _ => break,
3558            }
3559            k += 1;
3560        }
3561        if !inner_clips_ok {
3562            continue;
3563        }
3564        let Some(DisplayElement::Fill {
3565            path: group_path,
3566            params: group_fill_params,
3567        }) = inner_els.get(k)
3568        else {
3569            continue;
3570        };
3571        if (group_fill_params.alpha - 1.0).abs() > 1e-6 || group_fill_params.blend_mode != 0 {
3572            continue;
3573        }
3574        if paths_approximately_equal(parent_path, group_path, 0.5) {
3575            skips.push(i);
3576        }
3577    }
3578    skips
3579}
3580
3581/// True when two device-space paths have the same segment sequence and
3582/// matching endpoints within `tolerance` device pixels per coordinate.
3583/// Used by `compute_obscured_fill_skips` to recognise PDF-authored patterns
3584/// where the same logical X path is emitted twice with sub-unit rounding
3585/// differences (GWG test suite authoring style from InDesign CS6).
3586fn paths_approximately_equal(a: &PsPath, b: &PsPath, tolerance: f64) -> bool {
3587    if a.segments.len() != b.segments.len() {
3588        return false;
3589    }
3590    for (sa, sb) in a.segments.iter().zip(b.segments.iter()) {
3591        let close_pair = |(x1, y1): (f64, f64), (x2, y2): (f64, f64)| -> bool {
3592            (x1 - x2).abs() <= tolerance && (y1 - y2).abs() <= tolerance
3593        };
3594        match (sa, sb) {
3595            (PathSegment::MoveTo(x1, y1), PathSegment::MoveTo(x2, y2)) => {
3596                if !close_pair((*x1, *y1), (*x2, *y2)) {
3597                    return false;
3598                }
3599            }
3600            (PathSegment::LineTo(x1, y1), PathSegment::LineTo(x2, y2)) => {
3601                if !close_pair((*x1, *y1), (*x2, *y2)) {
3602                    return false;
3603                }
3604            }
3605            (
3606                PathSegment::CurveTo {
3607                    x1: ax1,
3608                    y1: ay1,
3609                    x2: ax2,
3610                    y2: ay2,
3611                    x3: ax3,
3612                    y3: ay3,
3613                },
3614                PathSegment::CurveTo {
3615                    x1: bx1,
3616                    y1: by1,
3617                    x2: bx2,
3618                    y2: by2,
3619                    x3: bx3,
3620                    y3: by3,
3621                },
3622            ) => {
3623                if !close_pair((*ax1, *ay1), (*bx1, *by1))
3624                    || !close_pair((*ax2, *ay2), (*bx2, *by2))
3625                    || !close_pair((*ax3, *ay3), (*bx3, *by3))
3626                {
3627                    return false;
3628                }
3629            }
3630            (PathSegment::ClosePath, PathSegment::ClosePath) => {}
3631            _ => return false,
3632        }
3633    }
3634    true
3635}
3636
3637///
3638/// Creates an offscreen pixmap, renders the group's child elements into it,
3639/// then composites back onto the parent with the group's blend mode and alpha.
3640fn render_group(
3641    pixmap: &mut Pixmap,
3642    band_state: &mut BandState,
3643    elements: &DisplayList,
3644    params: &stet_graphics::display_list::GroupParams,
3645    ctx: &RenderContext<'_>,
3646) {
3647    if params.knockout {
3648        render_knockout_group(pixmap, band_state, elements, params, ctx);
3649        return;
3650    }
3651
3652    let crop = compute_group_crop(&params.bbox, ctx);
3653
3654    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
3655        Some((cx, cy, cw, ch)) => (
3656            cw,
3657            ch,
3658            cx,
3659            cy,
3660            ctx.vp_x + cx as f32 / ctx.scale_x,
3661            ctx.vp_y + cy as f32 / ctx.scale_y,
3662        ),
3663        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
3664    };
3665
3666    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
3667        return;
3668    };
3669
3670    // Decide upfront whether the composite-back will run in CMYK. The CMYK
3671    // path needs the parent backdrop pre-loaded into the offscreen so that
3672    // per-element painting accumulates in the right starting state. The
3673    // sRGB contribution-extraction path renders against an empty offscreen
3674    // for non-Normal BMs to avoid anti-aliased clip artifacts at the BBox
3675    // edges (the diff-against-backdrop logic mishandles partially-blended
3676    // edge pixels otherwise).
3677    use stet_graphics::display_list::GroupColorSpace;
3678
3679    // Allocate a CMYK buffer for the group when:
3680    //   - it tracks overprint, OR
3681    //   - the parent already has one (CMYK context inheritance), OR
3682    //   - this group itself or one of its descendants declares an explicit
3683    //     `/CS DeviceCMYK`, meaning compositing within it needs CMYK math.
3684    let needs_group_cmyk = has_overprint_elements(elements)
3685        || band_state.cmyk_buffer.is_some()
3686        || params.color_space == GroupColorSpace::DeviceCMYK
3687        || has_cmyk_group(elements);
3688
3689    // Decide whether to run the per-pixel CMYK composite-back. The default
3690    // (gated) rule restricts it to the cases the prior rendering session
3691    // explicitly validated. The `STET_FORCE_CMYK_COMPOSITE_BACK=1` env var
3692    // bypasses both gates and switches to the principled rule that the rest
3693    // of this plan will adopt — useful for A/B-comparing the broader fix
3694    // before flipping the default in Step 9.
3695    let force_cmyk_compose =
3696        std::env::var_os("STET_FORCE_CMYK_COMPOSITE_BACK").as_deref() == Some("1".as_ref());
3697    // The knockout group's coverage pass disables CMYK composite-back so the
3698    // painter falls through to the simple sRGB draw_pixmap path. Without this,
3699    // a white-source painter (CMYK 0,0,0,0) would be skipped by the
3700    // composite-back's "source==backdrop" guard against the transparent
3701    // coverage backdrop, and pass 2 wouldn't capture the painter's coverage.
3702    //
3703    // The color pass widens the gate to all non-Normal blend modes so a
3704    // `/CS DeviceCMYK` knockout group's painters with separable blends like
3705    // Screen / ColorDodge / Overlay / SoftLight blend in CMYK math (matching
3706    // the spec) instead of in tiny-skia's sRGB blend.
3707    let plan_cmyk_compose = match ctx.knockout_painter_pass {
3708        KnockoutPainterPass::CoveragePass => false,
3709        KnockoutPainterPass::ColorPass => {
3710            !params.isolated
3711                && params.blend_mode != 0
3712                && needs_group_cmyk
3713                && band_state.cmyk_buffer.is_some()
3714                && group_content_is_native_cmyk(elements)
3715        }
3716        KnockoutPainterPass::None if force_cmyk_compose => {
3717            // Principled rule: non-isolated group with an inversion-sensitive
3718            // blend mode (Difference, Exclusion, Hue, Saturation, Color,
3719            // Luminosity) whose painters all supply native CMYK source colors.
3720            //
3721            // The blend-mode restriction is intentional: bm 10..=15 produce
3722            // visibly *wrong* results in sRGB (the GWG 16.0 transparency test
3723            // exists exactly to expose this), so CMYK math is unambiguously
3724            // correct there. The separable modes 1..=9 (Multiply, Screen, etc.)
3725            // are spec-defensible in either color space but look noticeably
3726            // different — most renderers blend them in sRGB, and PDFs authored
3727            // for that look "wrong" if we suddenly switch them to CMYK math.
3728            //
3729            // The painter-set restriction (no shadings, no non-CMYK content)
3730            // exists because the parallel CMYK buffer can only faithfully track
3731            // single-CMYK-value-per-pixel painters; gradients interpolate
3732            // differently in pixmap RGB vs buffer CMYK and the divergence makes
3733            // the composite-back read stale source values.
3734            !params.isolated
3735                && matches!(params.blend_mode, 10..=15)
3736                && needs_group_cmyk
3737                && band_state.cmyk_buffer.is_some()
3738                && group_content_is_native_cmyk(elements)
3739        }
3740        KnockoutPainterPass::None => {
3741            // Default rule: only the inversion-sensitive blend modes
3742            // (Difference, Exclusion, HSL non-separable) need CMYK math; the
3743            // separable modes 1..=9 are spec-defensible in either color space
3744            // and most sRGB-authored PDFs expect them to blend in sRGB.
3745            let inversion_sensitive = !params.isolated
3746                && matches!(params.blend_mode, 10..=15)
3747                && group_only_native_cmyk_fills(elements);
3748            // GWG 16.2 ("Transparency Basic Blend Modes — DeviceCMYK,
3749            // Isolated") nests non-isolated `/CS DeviceCMYK` painter sub-groups
3750            // inside an isolated `/CS DeviceCMYK` group, with the swatch's
3751            // blend mode applied at the inner Do. Per PDF spec §11.6.7 the
3752            // compositing for those inner groups must happen in DeviceCMYK,
3753            // not sRGB — otherwise their colored X-shape produces the wrong
3754            // color and fails to cover the painter-A black X. The explicit
3755            // `/CS DeviceCMYK` declaration plus the isolated parent are the
3756            // spec signal that the author wants CMYK-space compositing for
3757            // a fresh transparent backdrop. The `parent_group_isolated`
3758            // gate keeps the rule from firing for non-isolated parents like
3759            // 907 page 28's chart panels, where the existing sRGB
3760            // contribution-extraction path correctly preserves anti-aliased
3761            // gray strokes.
3762            let cmyk_group_blend = !params.isolated
3763                && ctx.parent_group_isolated
3764                && params.blend_mode != 0
3765                && params.color_space == GroupColorSpace::DeviceCMYK
3766                && needs_group_cmyk
3767                && band_state.cmyk_buffer.is_some()
3768                && group_content_is_native_cmyk(elements);
3769            inversion_sensitive || cmyk_group_blend
3770        }
3771    };
3772    // Non-isolated groups with non-Normal blend modes on the sRGB path
3773    // need a two-pass render: once against the backdrop (for correct
3774    // internal blending) and once against transparent (to extract the
3775    // group's shape/alpha for the proper source-contribution formula).
3776    let needs_alpha_extraction = !params.isolated
3777        && params.blend_mode != 0
3778        && !plan_cmyk_compose
3779        && !ctx.alpha_extraction_pass;
3780    let needs_backdrop_preload =
3781        !params.isolated && (params.blend_mode == 0 || plan_cmyk_compose || needs_alpha_extraction);
3782    let backdrop = if needs_backdrop_preload {
3783        let data = if crop.is_some() {
3784            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
3785        } else {
3786            pixmap.data().to_vec()
3787        };
3788        offscreen.data_mut().copy_from_slice(&data);
3789        Some(data)
3790    } else {
3791        None
3792    };
3793    let group_cmyk = if needs_group_cmyk {
3794        let buf_size = eff_w as usize * eff_h as usize * 4;
3795        let mut buf = vec![0.0f32; buf_size];
3796        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
3797            let parent_stride = ctx.out_w as usize * 4;
3798            let group_stride = eff_w as usize * 4;
3799            for gy in 0..eff_h as usize {
3800                let py = crop_y as usize + gy;
3801                if py < ctx.out_h as usize {
3802                    let p_start = py * parent_stride + crop_x as usize * 4;
3803                    let g_start = gy * group_stride;
3804                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
3805                    buf[g_start..g_start + copy_len]
3806                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
3807                }
3808            }
3809        }
3810        Some(buf)
3811    } else {
3812        None
3813    };
3814
3815    // Snapshot the pre-load CMYK so the composite-back can identify pixels
3816    // the group actually modified. Without a separate snapshot we'd have to
3817    // diff against the parent CMYK buffer, which would lose any in-place
3818    // updates to the parent across the group's lifetime.
3819    let backdrop_cmyk: Option<Vec<f32>> = if !params.isolated {
3820        group_cmyk.clone()
3821    } else {
3822        None
3823    };
3824
3825    let mut group_band = BandState {
3826        clip_region: None,
3827        spare_mask: None,
3828        clip_mask_cache: HashMap::new(),
3829        clip_mask_seen: HashSet::new(),
3830        mask_pool: Vec::new(),
3831        cmyk_buffer: group_cmyk,
3832        op_bg_snapshot: None,
3833        op_touched: None,
3834        spot_mask: None,
3835    };
3836
3837    let group_ctx = RenderContext {
3838        vp_x: eff_vp_x,
3839        vp_y: eff_vp_y,
3840        scale_x: ctx.scale_x,
3841        scale_y: ctx.scale_y,
3842        out_w: eff_w,
3843        out_h: eff_h,
3844        effective_dpi: ctx.effective_dpi,
3845        icc: ctx.icc,
3846        image_cache: None, // Group elements don't use parent image cache
3847        preprocessed: None,
3848        elem_idx: 0,
3849        no_aa: ctx.no_aa,
3850        opm_zero_transparent: ctx.opm_zero_transparent,
3851        knockout_painter_pass: ctx.knockout_painter_pass,
3852        // The children of this group see *this* group as their parent.
3853        parent_group_isolated: params.isolated,
3854        alpha_extraction_pass: ctx.alpha_extraction_pass,
3855        layer_set: ctx.layer_set,
3856    };
3857
3858    let skip_indices = compute_obscured_fill_skips(elements);
3859    for (idx, elem) in elements.elements().iter().enumerate() {
3860        if skip_indices.contains(&idx) {
3861            continue;
3862        }
3863        let elem_ctx = RenderContext {
3864            elem_idx: idx,
3865            ..group_ctx
3866        };
3867        render_element(&mut offscreen, &mut group_band, elem, &elem_ctx);
3868    }
3869
3870    // Second pass: render against transparent to extract the group's
3871    // shape/alpha.  Only needed for the sRGB two-pass composite-back
3872    // path (non-isolated, non-Normal blend, no CMYK compose).
3873    let alpha_offscreen = if needs_alpha_extraction {
3874        let mut iso = Pixmap::new(eff_w, eff_h);
3875        if let Some(ref mut iso_pm) = iso {
3876            let mut iso_band = BandState {
3877                clip_region: None,
3878                spare_mask: None,
3879                clip_mask_cache: HashMap::new(),
3880                clip_mask_seen: HashSet::new(),
3881                mask_pool: Vec::new(),
3882                cmyk_buffer: None,
3883                op_bg_snapshot: None,
3884                op_touched: None,
3885                spot_mask: None,
3886            };
3887            let iso_ctx = RenderContext {
3888                parent_group_isolated: true,
3889                alpha_extraction_pass: true,
3890                ..group_ctx
3891            };
3892            for (idx, elem) in elements.elements().iter().enumerate() {
3893                let elem_ctx = RenderContext {
3894                    elem_idx: idx,
3895                    ..iso_ctx
3896                };
3897                render_element(iso_pm, &mut iso_band, elem, &elem_ctx);
3898            }
3899        }
3900        iso
3901    } else {
3902        None
3903    };
3904
3905    let mut temp_mask = None;
3906    let mask_ref = match resolve_clip_mask(
3907        &band_state.clip_region,
3908        &mut temp_mask,
3909        ctx.out_w,
3910        ctx.out_h,
3911    ) {
3912        None => return, // empty clip → nothing visible
3913        Some(m) => m,
3914    };
3915
3916    // Coverage pass override: force opacity 1.0 + Normal blend so the
3917    // painter's shape reaches the coverage offscreen even when the
3918    // original alpha was 0 (Opacity 0% test) or the blend mode would
3919    // erase the source against the transparent coverage backdrop.
3920    let coverage_params;
3921    let effective_params: &stet_graphics::display_list::GroupParams =
3922        if ctx.knockout_painter_pass == KnockoutPainterPass::CoveragePass {
3923            coverage_params = stet_graphics::display_list::GroupParams {
3924                alpha: 1.0,
3925                blend_mode: 0,
3926                ..params.clone()
3927            };
3928            &coverage_params
3929        } else {
3930            params
3931        };
3932
3933    let mut cmyk_compose_done = false;
3934    if let Some(backdrop) = &backdrop {
3935        // Non-isolated group. For the inversion-sensitive blend modes
3936        // (Difference, Exclusion) and the HSL non-separable modes (Hue,
3937        // Saturation, Color, Luminosity), tiny-skia's sRGB blend math gives
3938        // visibly wrong results for the GWG 16.0 transparency test, where
3939        // the source colors are chosen so that, in CMYK, the blend produces
3940        // the backdrop color exactly. Run the composite-back per pixel in
3941        // CMYK for those modes when the inner content is exclusively
3942        // native-CMYK fills (so the inner CMYK buffer faithfully represents
3943        // the source). The other separable modes (Multiply / Lighten /
3944        // Darken / etc.) and non-CMYK content stay on the existing sRGB
3945        // contribution-extraction path because their CMYK pipeline currently
3946        // depends on `interpolate_cmyk_from_stops`, which derives CMYK from
3947        // sRGB via the lossy `(1−r,1−g,1−b,0)` inverse for shadings/images
3948        // and would shift their colors. Lifting that restriction requires
3949        // computing exact CMYK from each shading/image's source color space
3950        // (e.g. running the DeviceN tint transform), which is a larger
3951        // change than this fix attempts.
3952        let inner_cmyk = group_band.cmyk_buffer.as_deref();
3953        let pre_cmyk = backdrop_cmyk.as_deref();
3954        if plan_cmyk_compose && let (Some(inner), Some(pre)) = (inner_cmyk, pre_cmyk) {
3955            composite_non_isolated_cmyk(
3956                pixmap,
3957                band_state.cmyk_buffer.as_deref_mut(),
3958                &offscreen,
3959                inner,
3960                pre,
3961                backdrop,
3962                effective_params,
3963                mask_ref,
3964                crop_x,
3965                crop_y,
3966                ctx.icc,
3967            );
3968            cmyk_compose_done = true;
3969        } else if let Some(ref alpha_os) = alpha_offscreen {
3970            composite_non_isolated_extracted(
3971                pixmap,
3972                &offscreen,
3973                alpha_os,
3974                backdrop,
3975                effective_params,
3976                mask_ref,
3977                crop_x,
3978                crop_y,
3979            );
3980        } else {
3981            composite_non_isolated_group_cropped(
3982                pixmap,
3983                &offscreen,
3984                backdrop,
3985                effective_params,
3986                mask_ref,
3987                crop_x,
3988                crop_y,
3989            );
3990        }
3991    } else {
3992        let paint = stet_tiny_skia::PixmapPaint {
3993            opacity: effective_params.alpha as f32,
3994            blend_mode: u8_to_blend_mode(effective_params.blend_mode),
3995            quality: stet_tiny_skia::FilterQuality::Nearest,
3996        };
3997        pixmap.draw_pixmap(
3998            crop_x,
3999            crop_y,
4000            offscreen.as_ref(),
4001            &paint,
4002            Transform::identity(),
4003            mask_ref,
4004        );
4005    }
4006
4007    // Write group CMYK buffer back to parent. Skip when the CMYK composite-back
4008    // already wrote the blended values into the parent CMYK buffer — running
4009    // `copy_cmyk_buffer_to_parent` afterwards would overwrite those blended
4010    // values with the inner buffer's raw source colors, breaking subsequent
4011    // siblings that read the parent CMYK as their backdrop.
4012    if !cmyk_compose_done
4013        && let (Some(group_cmyk), Some(parent_cmyk)) =
4014            (&group_band.cmyk_buffer, &mut band_state.cmyk_buffer)
4015    {
4016        copy_cmyk_buffer_to_parent(
4017            parent_cmyk,
4018            group_cmyk,
4019            offscreen.data(),
4020            crop_x as usize,
4021            crop_y as usize,
4022            eff_w as usize,
4023            eff_h as usize,
4024            ctx.out_w as usize,
4025            ctx.out_h as usize,
4026        );
4027    }
4028}
4029
4030/// CMYK-aware composite-back for a non-isolated transparency group.
4031///
4032/// For each pixel in the group's region:
4033///   1. If the inner CMYK buffer matches the snapshot taken when the group
4034///      started, the group painted nothing there → leave the parent unchanged.
4035///   2. Otherwise apply the group blend mode in DeviceCMYK using the spec's
4036///      effective inversion formulas (`blend_cmyk_separable_channel` or
4037///      `blend_cmyk_nonseparable`), convert the result to sRGB through the
4038///      ICC system CMYK profile so it sits seamlessly next to the rest of the
4039///      page, and write the result to both the parent pixmap and (when
4040///      present) the parent CMYK buffer.
4041#[allow(clippy::too_many_arguments)]
4042fn composite_non_isolated_cmyk(
4043    target: &mut Pixmap,
4044    parent_cmyk: Option<&mut [f32]>,
4045    source: &Pixmap,
4046    source_cmyk: &[f32],
4047    backdrop_cmyk: &[f32],
4048    backdrop_pixels: &[u8],
4049    params: &stet_graphics::display_list::GroupParams,
4050    clip_mask: Option<&stet_tiny_skia::Mask>,
4051    crop_x: i32,
4052    crop_y: i32,
4053    icc: Option<&IccCache>,
4054) {
4055    let cw = source.width() as usize;
4056    let ch = source.height() as usize;
4057    let target_w = target.width() as usize;
4058    let target_h = target.height() as usize;
4059
4060    let opacity = params.alpha.clamp(0.0, 1.0);
4061    let blend_mode = params.blend_mode;
4062    let is_nonseparable = matches!(blend_mode, 12..=15);
4063
4064    let target_data = target.data_mut();
4065    let target_stride = target_w * 4;
4066    let group_stride = cw * 4;
4067
4068    let clip_data = clip_mask.map(|m| m.data());
4069
4070    for gy in 0..ch {
4071        let ty = crop_y + gy as i32;
4072        if ty < 0 || ty as usize >= target_h {
4073            continue;
4074        }
4075        let ty = ty as usize;
4076        let group_row = gy * group_stride;
4077        let target_row = ty * target_stride;
4078
4079        for gx in 0..cw {
4080            let tx = crop_x + gx as i32;
4081            if tx < 0 || tx as usize >= target_w {
4082                continue;
4083            }
4084            let tx = tx as usize;
4085            let gi = group_row + gx * 4;
4086            let ti = target_row + tx * 4;
4087
4088            // Did the group actually paint this pixel?
4089            let bc = backdrop_cmyk[gi] as f64;
4090            let bm = backdrop_cmyk[gi + 1] as f64;
4091            let by_ = backdrop_cmyk[gi + 2] as f64;
4092            let bk = backdrop_cmyk[gi + 3] as f64;
4093            let sc = source_cmyk[gi] as f64;
4094            let sm = source_cmyk[gi + 1] as f64;
4095            let sy_ = source_cmyk[gi + 2] as f64;
4096            let sk = source_cmyk[gi + 3] as f64;
4097            if (sc - bc).abs() < 1.0 / 255.0
4098                && (sm - bm).abs() < 1.0 / 255.0
4099                && (sy_ - by_).abs() < 1.0 / 255.0
4100                && (sk - bk).abs() < 1.0 / 255.0
4101            {
4102                continue;
4103            }
4104
4105            // Clip mask coverage in target coordinates.
4106            let cov = if let Some(cd) = clip_data {
4107                cd[ty * target_w + tx] as f64 / 255.0
4108            } else {
4109                1.0
4110            };
4111            if cov <= 0.0 {
4112                continue;
4113            }
4114
4115            // Transparent-backdrop fast path: when the backdrop pixmap's alpha
4116            // is 0 the parent group hasn't painted this pixel, so PDF spec
4117            // §11.4.6 says the blended result reduces to α_s · source — the
4118            // blend formula must NOT be applied. Without this check, formulas
4119            // like ColorBurn / ColorDodge / Lighten / Screen produce visibly
4120            // wrong colors (yellow instead of orange-yellow, white instead of
4121            // the source) because an all-zero CMYK backdrop is identical to
4122            // opaque white in CMYK terms. Using the pixmap alpha as the
4123            // sentinel correctly distinguishes "truly nothing painted"
4124            // (alpha 0) from "white painted" (alpha 1, CMYK 0,0,0,0).
4125            //
4126            // For this branch we composite the source pixmap directly via
4127            // SourceOver (rather than converting source CMYK→sRGB) so the
4128            // source's per-pixel alpha — including anti-aliased edges and
4129            // partially-transparent paint like 907 page 28's gray rules —
4130            // is preserved. The CMYK→sRGB direct path used the un-modulated
4131            // painter color and the group opacity, which forced antialiased
4132            // gray strokes to opaque black.
4133            let backdrop_alpha = backdrop_pixels[gi + 3];
4134            let backdrop_transparent = backdrop_alpha == 0;
4135
4136            let mix = cov * opacity;
4137            let dst_a = target_data[ti + 3] as f64 / 255.0;
4138
4139            if backdrop_transparent {
4140                // SourceOver of the source pixmap (already correctly rendered
4141                // for transparent-backdrop semantics) modulated by the group's
4142                // mix factor. To ensure inner-group AA edges don't leave
4143                // sliver gaps where the outer parent pixmap had previously
4144                // drawn a near-identical path (GWG 16.2 directly-drawn black
4145                // X covered by Painter B's slightly-offset colored X), we
4146                // promote any non-zero source alpha to the painter's full
4147                // unpremultiplied source CMYK converted to sRGB. This
4148                // produces fully-opaque coverage at edge pixels matching
4149                // what the inner painter would render at the path interior,
4150                // so the inner group can fully knock out the outer's AA
4151                // edge when composited back to its parent.
4152                let src_data = source.data();
4153                let src_a_pm = src_data[gi + 3] as f64 / 255.0;
4154                if src_a_pm <= 0.0 {
4155                    continue;
4156                }
4157                // Convert source CMYK directly to sRGB. The CMYK at this
4158                // pixel was written by the inner painter at its full
4159                // un-modulated value (the cmyk_buf doesn't track AA), so
4160                // this is the pure painter color regardless of AA cov.
4161                let (full_r, full_g, full_b) = icc
4162                    .and_then(|i| i.convert_cmyk_readonly(sc, sm, sy_, sk))
4163                    .unwrap_or_else(|| cmyk_to_rgb_plrm(sc, sm, sy_, sk));
4164                let alpha_s = mix;
4165                let inv_sa = 1.0 - alpha_s;
4166                let dst_r_pm = target_data[ti] as f64 / 255.0;
4167                let dst_g_pm = target_data[ti + 1] as f64 / 255.0;
4168                let dst_b_pm = target_data[ti + 2] as f64 / 255.0;
4169                let out_r = full_r * alpha_s + dst_r_pm * inv_sa;
4170                let out_g = full_g * alpha_s + dst_g_pm * inv_sa;
4171                let out_b = full_b * alpha_s + dst_b_pm * inv_sa;
4172                let out_a = alpha_s + dst_a * inv_sa;
4173                target_data[ti] = (out_r * 255.0).round().clamp(0.0, 255.0) as u8;
4174                target_data[ti + 1] = (out_g * 255.0).round().clamp(0.0, 255.0) as u8;
4175                target_data[ti + 2] = (out_b * 255.0).round().clamp(0.0, 255.0) as u8;
4176                target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4177                continue;
4178            }
4179
4180            // Apply the group's blend mode in CMYK.
4181            let (rc, rm, ry, rk) = if is_nonseparable {
4182                let r = blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4183                (r[0], r[1], r[2], r[3])
4184            } else {
4185                (
4186                    blend_cmyk_separable_channel(bc, sc, blend_mode),
4187                    blend_cmyk_separable_channel(bm, sm, blend_mode),
4188                    blend_cmyk_separable_channel(by_, sy_, blend_mode),
4189                    blend_cmyk_separable_channel(bk, sk, blend_mode),
4190                )
4191            };
4192
4193            let (new_r, new_g, new_b) = icc
4194                .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
4195                .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
4196
4197            // tiny-skia stores premultiplied sRGB. Apply the PDF
4198            // §11.4.6 result formula in straight-color form. We force the
4199            // source alpha to 1 (subject to clip + group opacity) at any
4200            // pixel where the source CMYK was written by the inner painter
4201            // — the cmyk_buf flags coverage at the path's full extent, even
4202            // at AA edges. Using full alpha here ensures the inner group
4203            // fully covers the outer parent's previously-drawn content
4204            // when both reference near-identical paths (GWG 16.2 directly-
4205            // drawn outer X path covered by Painter B's slightly-offset
4206            // colored X path). Without this, the formula's partial-cover
4207            // mix produces a 1-pixel sliver of darker color where the two
4208            // paths' rasterizations diverge sub-pixel-wise.
4209            let alpha_s = mix;
4210            let alpha_b = dst_a;
4211            let out_a = alpha_s + alpha_b * (1.0 - alpha_s);
4212            if out_a <= 0.0 {
4213                continue;
4214            }
4215            let (dst_r, dst_g, dst_b) = if alpha_b > 0.0 {
4216                let inv_a = 1.0 / alpha_b;
4217                (
4218                    (target_data[ti] as f64 / 255.0) * inv_a,
4219                    (target_data[ti + 1] as f64 / 255.0) * inv_a,
4220                    (target_data[ti + 2] as f64 / 255.0) * inv_a,
4221                )
4222            } else {
4223                (0.0, 0.0, 0.0)
4224            };
4225            // Spec §11.4.6 result computation:
4226            //   C_o = (α_s·(1−α_b)·C_s + α_s·α_b·B(C_b,C_s) + (1−α_s)·α_b·C_b) / α_o
4227            // Here we already have B(C_b,C_s) computed in CMYK and converted
4228            // to sRGB as (new_r, new_g, new_b). The "C_s" term — the source
4229            // color un-blended — uses the same value because the spec says
4230            // when α_b = 0 the formula reduces to source-as-is, which the
4231            // (1−α_b) coefficient already handles.
4232            let coef_b = alpha_s * alpha_b;
4233            let coef_s = alpha_s * (1.0 - alpha_b);
4234            let coef_d = (1.0 - alpha_s) * alpha_b;
4235            let out_r = (coef_s * new_r + coef_b * new_r + coef_d * dst_r) / out_a;
4236            let out_g = (coef_s * new_g + coef_b * new_g + coef_d * dst_g) / out_a;
4237            let out_b = (coef_s * new_b + coef_b * new_b + coef_d * dst_b) / out_a;
4238
4239            target_data[ti] = (out_r * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4240            target_data[ti + 1] = (out_g * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4241            target_data[ti + 2] = (out_b * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4242            target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4243        }
4244    }
4245
4246    // Write the blended CMYK back to the parent CMYK buffer so subsequent
4247    // sibling groups see consistent backdrop values. We re-walk the same
4248    // region — keeps the inner loop above tight (no double-borrow on the
4249    // parent buffer) and only touches pixels we actually modified.
4250    if let Some(parent_cmyk) = parent_cmyk {
4251        for gy in 0..ch {
4252            let ty = crop_y + gy as i32;
4253            if ty < 0 || ty as usize >= target_h {
4254                continue;
4255            }
4256            let ty = ty as usize;
4257            let group_row = gy * group_stride;
4258            let parent_row = ty * target_stride;
4259
4260            for gx in 0..cw {
4261                let tx = crop_x + gx as i32;
4262                if tx < 0 || tx as usize >= target_w {
4263                    continue;
4264                }
4265                let tx = tx as usize;
4266                let gi = group_row + gx * 4;
4267                let pi = parent_row + tx * 4;
4268
4269                let bc = backdrop_cmyk[gi] as f64;
4270                let bm = backdrop_cmyk[gi + 1] as f64;
4271                let by_ = backdrop_cmyk[gi + 2] as f64;
4272                let bk = backdrop_cmyk[gi + 3] as f64;
4273                let sc = source_cmyk[gi] as f64;
4274                let sm = source_cmyk[gi + 1] as f64;
4275                let sy_ = source_cmyk[gi + 2] as f64;
4276                let sk = source_cmyk[gi + 3] as f64;
4277                if (sc - bc).abs() < 1.0 / 255.0
4278                    && (sm - bm).abs() < 1.0 / 255.0
4279                    && (sy_ - by_).abs() < 1.0 / 255.0
4280                    && (sk - bk).abs() < 1.0 / 255.0
4281                {
4282                    continue;
4283                }
4284
4285                // Same transparent-backdrop fast path as above: use source
4286                // as-is. We read the original backdrop alpha from the saved
4287                // backdrop_pixels slice, NOT the live target — the live
4288                // target's alpha was already updated by the first loop's
4289                // composite-back writes.
4290                let backdrop_transparent = backdrop_pixels[gi + 3] == 0;
4291                let (rc, rm, ry, rk) = if backdrop_transparent {
4292                    (sc, sm, sy_, sk)
4293                } else if is_nonseparable {
4294                    let r =
4295                        blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4296                    (r[0], r[1], r[2], r[3])
4297                } else {
4298                    (
4299                        blend_cmyk_separable_channel(bc, sc, blend_mode),
4300                        blend_cmyk_separable_channel(bm, sm, blend_mode),
4301                        blend_cmyk_separable_channel(by_, sy_, blend_mode),
4302                        blend_cmyk_separable_channel(bk, sk, blend_mode),
4303                    )
4304                };
4305                parent_cmyk[pi] = rc as f32;
4306                parent_cmyk[pi + 1] = rm as f32;
4307                parent_cmyk[pi + 2] = ry as f32;
4308                parent_cmyk[pi + 3] = rk as f32;
4309            }
4310        }
4311    }
4312}
4313
4314/// Render a knockout transparency group into a pixmap.
4315///
4316/// In a knockout group, each element composites against the group's initial
4317/// backdrop (not the accumulated result of previous elements).
4318fn render_knockout_group(
4319    pixmap: &mut Pixmap,
4320    band_state: &mut BandState,
4321    elements: &DisplayList,
4322    params: &stet_graphics::display_list::GroupParams,
4323    ctx: &RenderContext<'_>,
4324) {
4325    let crop = compute_group_crop(&params.bbox, ctx);
4326
4327    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
4328        Some((cx, cy, cw, ch)) => (
4329            cw,
4330            ch,
4331            cx,
4332            cy,
4333            ctx.vp_x + cx as f32 / ctx.scale_x,
4334            ctx.vp_y + cy as f32 / ctx.scale_y,
4335        ),
4336        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
4337    };
4338
4339    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
4340        return;
4341    };
4342
4343    let initial_backdrop = if !params.isolated {
4344        if crop.is_some() {
4345            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
4346        } else {
4347            pixmap.data().to_vec()
4348        }
4349    } else {
4350        vec![0u8; (eff_w * eff_h * 4) as usize]
4351    };
4352
4353    let Some(mut accumulated) = Pixmap::new(eff_w, eff_h) else {
4354        return;
4355    };
4356    accumulated.data_mut().copy_from_slice(&initial_backdrop);
4357
4358    // Initial CMYK values for the knockout group
4359    let needs_cmyk = has_overprint_elements(elements) || band_state.cmyk_buffer.is_some();
4360    let initial_cmyk = if needs_cmyk {
4361        let buf_size = eff_w as usize * eff_h as usize * 4;
4362        let mut buf = vec![0.0f32; buf_size];
4363        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4364            let parent_stride = ctx.out_w as usize * 4;
4365            let group_stride = eff_w as usize * 4;
4366            for gy in 0..eff_h as usize {
4367                let py = crop_y as usize + gy;
4368                if py < ctx.out_h as usize {
4369                    let p_start = py * parent_stride + crop_x as usize * 4;
4370                    let g_start = gy * group_stride;
4371                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4372                    buf[g_start..g_start + copy_len]
4373                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
4374                }
4375            }
4376        }
4377        Some(buf)
4378    } else {
4379        None
4380    };
4381
4382    let mut accumulated_cmyk = initial_cmyk.clone();
4383
4384    // Disable anti-aliasing in knockout groups to prevent seam artifacts.
4385    // Each element composites independently against the backdrop, so adjacent
4386    // fills' AA edges don't mesh — both blend toward the backdrop color,
4387    // creating visible 1px white lines at shared boundaries.
4388    let group_ctx = RenderContext {
4389        vp_x: eff_vp_x,
4390        vp_y: eff_vp_y,
4391        scale_x: ctx.scale_x,
4392        scale_y: ctx.scale_y,
4393        out_w: eff_w,
4394        out_h: eff_h,
4395        effective_dpi: ctx.effective_dpi,
4396        icc: ctx.icc,
4397        image_cache: None,
4398        preprocessed: None,
4399        elem_idx: 0,
4400        no_aa: true,
4401        opm_zero_transparent: ctx.opm_zero_transparent,
4402        knockout_painter_pass: ctx.knockout_painter_pass,
4403        // Knockout groups composite each element against the initial backdrop;
4404        // children effectively see this group's "fresh" backdrop. Treat the
4405        // knockout group as isolated for the purposes of the inner CMYK rule.
4406        parent_group_isolated: true,
4407        alpha_extraction_pass: false,
4408        layer_set: ctx.layer_set,
4409    };
4410
4411    // Persistent band state for clip tracking — clips must accumulate across
4412    // elements in the knockout group (each paint element still composites
4413    // against the initial backdrop, but it must respect the current clip).
4414    let mut ko_band = BandState {
4415        clip_region: None,
4416        spare_mask: None,
4417        clip_mask_cache: HashMap::new(),
4418        clip_mask_seen: HashSet::new(),
4419        mask_pool: Vec::new(),
4420        cmyk_buffer: None,
4421        op_bg_snapshot: None,
4422        op_touched: None,
4423        spot_mask: None,
4424    };
4425
4426    // Coverage offscreen for two-pass painter rendering of nested transparency
4427    // groups. Reused (zeroed) across painters; allocated lazily on first need.
4428    let mut coverage_offscreen: Option<Pixmap> = None;
4429
4430    for elem in elements.elements() {
4431        match elem {
4432            // State-only elements: update persistent clip, no knockout compositing
4433            DisplayElement::Clip { .. } | DisplayElement::InitClip => {
4434                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4435            }
4436            // Group painters need two-pass rendering. Knockout semantics
4437            // require each painter to overwrite previous siblings within its
4438            // coverage area, even when the painter's blend mode happens to
4439            // produce a result that equals the initial backdrop (e.g.
4440            // Darken(red, white)=red, SoftLight(red, black)=red,
4441            // Multiply(red, magenta)=red — which is exactly what GWG 16.1
4442            // tests). The single-pass change-against-backdrop check used for
4443            // simpler painter types would miss those pixels, and earlier
4444            // siblings' contributions would bleed through.
4445            DisplayElement::Group { .. } => {
4446                // Pass 1: render painter against initial_backdrop to compute
4447                // the blended-color result (the painter's contribution).
4448                // Use ColorPass mode so any non-Normal blend mode goes through
4449                // the per-pixel CMYK composite-back — required for separable
4450                // blends like Screen / ColorDodge / Overlay / SoftLight whose
4451                // sRGB result drifts away from the CMYK-math result.
4452                let pass1_ctx = RenderContext {
4453                    knockout_painter_pass: KnockoutPainterPass::ColorPass,
4454                    ..group_ctx
4455                };
4456                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4457                ko_band.cmyk_buffer = initial_cmyk.clone();
4458                render_element(&mut offscreen, &mut ko_band, elem, &pass1_ctx);
4459                let pass1_cmyk = ko_band.cmyk_buffer.take();
4460
4461                // Pass 2: render painter into a fresh transparent offscreen so
4462                // the alpha channel captures the painter's coverage, which the
4463                // result-color comparison cannot recover when the blend mode
4464                // outputs the backdrop color exactly.
4465                let cov = match coverage_offscreen.as_mut() {
4466                    Some(p) => {
4467                        p.data_mut().fill(0);
4468                        p
4469                    }
4470                    None => {
4471                        let Some(p) = Pixmap::new(eff_w, eff_h) else {
4472                            // Out of memory for coverage buffer — fall back
4473                            // to the change-detection path so the painter
4474                            // still appears (just without proper knockout).
4475                            replace_changed_pixels(
4476                                accumulated.data_mut(),
4477                                offscreen.data(),
4478                                &initial_backdrop,
4479                            );
4480                            if let (Some(p1), Some(acc)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4481                                replace_changed_cmyk(acc, p1, offscreen.data(), &initial_backdrop);
4482                            }
4483                            continue;
4484                        };
4485                        coverage_offscreen = Some(p);
4486                        coverage_offscreen.as_mut().unwrap()
4487                    }
4488                };
4489                ko_band.cmyk_buffer = None;
4490                // Coverage pass: render through the simple sRGB path with
4491                // alpha forced to 1.0 and Normal blend so the painter's
4492                // shape reaches the coverage offscreen even for white-source
4493                // CMYK painters and zero-alpha painters (Opacity 0% test).
4494                let coverage_ctx = RenderContext {
4495                    knockout_painter_pass: KnockoutPainterPass::CoveragePass,
4496                    ..group_ctx
4497                };
4498                render_element(cov, &mut ko_band, elem, &coverage_ctx);
4499
4500                // Use the coverage offscreen's alpha as a knockout mask: the
4501                // painter's contribution from pass 1 source-overs onto
4502                // accumulated weighted by the coverage alpha.
4503                replace_with_coverage_mask(accumulated.data_mut(), offscreen.data(), cov.data());
4504
4505                if let (Some(p1_cmyk), Some(acc_cmyk)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4506                    replace_cmyk_with_coverage_mask(acc_cmyk, p1_cmyk, cov.data());
4507                }
4508                ko_band.cmyk_buffer = None;
4509            }
4510            // Other paint elements: single-pass with change-against-backdrop.
4511            // Direct path/image/shading paints always change pixels they cover,
4512            // so the simpler detection works and avoids the second-pass cost.
4513            _ => {
4514                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4515
4516                ko_band.cmyk_buffer = initial_cmyk.clone();
4517
4518                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4519
4520                if let (Some(elem_cmyk), Some(acc_cmyk)) =
4521                    (&ko_band.cmyk_buffer, &mut accumulated_cmyk)
4522                {
4523                    replace_changed_cmyk(acc_cmyk, elem_cmyk, offscreen.data(), &initial_backdrop);
4524                }
4525                ko_band.cmyk_buffer = None;
4526
4527                replace_changed_pixels(accumulated.data_mut(), offscreen.data(), &initial_backdrop);
4528            }
4529        }
4530    }
4531
4532    let mut temp_mask = None;
4533    let mask_ref = resolve_clip_mask(
4534        &band_state.clip_region,
4535        &mut temp_mask,
4536        ctx.out_w,
4537        ctx.out_h,
4538    );
4539    let mask_ref = match mask_ref {
4540        None => return,
4541        Some(m) => m,
4542    };
4543
4544    composite_non_isolated_group_cropped(
4545        pixmap,
4546        &accumulated,
4547        &initial_backdrop,
4548        params,
4549        mask_ref,
4550        crop_x,
4551        crop_y,
4552    );
4553
4554    if let (Some(acc_cmyk), Some(parent_cmyk)) = (&accumulated_cmyk, &mut band_state.cmyk_buffer) {
4555        copy_cmyk_buffer_to_parent(
4556            parent_cmyk,
4557            acc_cmyk,
4558            accumulated.data(),
4559            crop_x as usize,
4560            crop_y as usize,
4561            eff_w as usize,
4562            eff_h as usize,
4563            ctx.out_w as usize,
4564            ctx.out_h as usize,
4565        );
4566    }
4567}
4568/// Source-over `source` onto `target` weighted by `coverage`'s alpha channel.
4569/// Used for the two-pass knockout group rendering: `coverage` is rendered
4570/// into a transparent offscreen so its alpha records the painter's coverage
4571/// regardless of whether the painter's blend mode produced backdrop-equal
4572/// pixels in the color pass. Both `source` and `target` are assumed fully
4573/// opaque pixmaps (alpha=255 everywhere) since the knockout offscreens are
4574/// pre-loaded with the opaque initial backdrop.
4575fn replace_with_coverage_mask(target: &mut [u8], source: &[u8], coverage: &[u8]) {
4576    for i in (0..target.len()).step_by(4) {
4577        let cov_a = coverage[i + 3];
4578        if cov_a == 0 {
4579            continue;
4580        }
4581        if cov_a == 255 {
4582            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4583            continue;
4584        }
4585        let a = cov_a as u32;
4586        let inv = 255 - a;
4587        for c in 0..4 {
4588            let s = source[i + c] as u32;
4589            let t = target[i + c] as u32;
4590            target[i + c] = ((s * a + t * inv + 127) / 255) as u8;
4591        }
4592    }
4593}
4594
4595/// Source-over CMYK values from `source` onto `target` weighted by the
4596/// coverage offscreen's alpha channel. Companion to
4597/// `replace_with_coverage_mask` for the parallel CMYK buffer.
4598fn replace_cmyk_with_coverage_mask(target: &mut [f32], source: &[f32], coverage: &[u8]) {
4599    let pixel_count = target.len() / 4;
4600    for i in 0..pixel_count {
4601        let pi = i * 4;
4602        let cov_a = coverage[pi + 3];
4603        if cov_a == 0 {
4604            continue;
4605        }
4606        if cov_a == 255 {
4607            target[pi..pi + 4].copy_from_slice(&source[pi..pi + 4]);
4608            continue;
4609        }
4610        let a = cov_a as f32 / 255.0;
4611        let inv = 1.0 - a;
4612        for c in 0..4 {
4613            target[pi + c] = source[pi + c] * a + target[pi + c] * inv;
4614        }
4615    }
4616}
4617
4618/// Replace pixels in `target` with pixels from `source` wherever `source`
4619/// differs from `backdrop`. Used for knockout group per-element compositing
4620/// where each element replaces (not blends with) previous elements.
4621fn replace_changed_pixels(target: &mut [u8], source: &[u8], backdrop: &[u8]) {
4622    for i in (0..target.len()).step_by(4) {
4623        if source[i] != backdrop[i]
4624            || source[i + 1] != backdrop[i + 1]
4625            || source[i + 2] != backdrop[i + 2]
4626            || source[i + 3] != backdrop[i + 3]
4627        {
4628            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4629        }
4630    }
4631}
4632
4633/// Copy a group's CMYK buffer back to the parent's CMYK buffer after compositing.
4634/// Only copies values for pixels where the group offscreen has non-zero alpha,
4635/// indicating the group actually painted something at that position.
4636#[allow(clippy::too_many_arguments)]
4637fn copy_cmyk_buffer_to_parent(
4638    parent_cmyk: &mut [f32],
4639    group_cmyk: &[f32],
4640    group_pixels: &[u8],
4641    crop_x: usize,
4642    crop_y: usize,
4643    group_w: usize,
4644    group_h: usize,
4645    parent_w: usize,
4646    parent_h: usize,
4647) {
4648    let parent_stride = parent_w * 4;
4649    let group_stride = group_w * 4;
4650    for gy in 0..group_h {
4651        let py = crop_y + gy;
4652        if py >= parent_h {
4653            break;
4654        }
4655        for gx in 0..group_w {
4656            let px = crop_x + gx;
4657            if px >= parent_w {
4658                break;
4659            }
4660            // Only copy if the group pixel has non-zero alpha AND
4661            // the group's cmyk at that pixel is non-zero.
4662            // Zero cmyk means "not tracked by a CMYK fill in this group"
4663            // — writing it back would erase the parent's tracked values.
4664            let g_pixel_idx = (gy * group_w + gx) * 4;
4665            let g_cmyk_idx = gy * group_stride + gx * 4;
4666            if group_pixels[g_pixel_idx + 3] > 0
4667                && (group_cmyk[g_cmyk_idx] != 0.0
4668                    || group_cmyk[g_cmyk_idx + 1] != 0.0
4669                    || group_cmyk[g_cmyk_idx + 2] != 0.0
4670                    || group_cmyk[g_cmyk_idx + 3] != 0.0)
4671            {
4672                let p_cmyk_idx = py * parent_stride + px * 4;
4673                parent_cmyk[p_cmyk_idx..p_cmyk_idx + 4]
4674                    .copy_from_slice(&group_cmyk[g_cmyk_idx..g_cmyk_idx + 4]);
4675            }
4676        }
4677    }
4678}
4679
4680/// Copy CMYK values for pixels that changed in a knockout element.
4681/// Used alongside replace_changed_pixels to keep CMYK in sync with RGB.
4682fn replace_changed_cmyk(
4683    target_cmyk: &mut [f32],
4684    source_cmyk: &[f32],
4685    source_pixels: &[u8],
4686    backdrop_pixels: &[u8],
4687) {
4688    let pixel_count = target_cmyk.len() / 4;
4689    for i in 0..pixel_count {
4690        let pi = i * 4;
4691        if source_pixels[pi] != backdrop_pixels[pi]
4692            || source_pixels[pi + 1] != backdrop_pixels[pi + 1]
4693            || source_pixels[pi + 2] != backdrop_pixels[pi + 2]
4694            || source_pixels[pi + 3] != backdrop_pixels[pi + 3]
4695        {
4696            target_cmyk[pi..pi + 4].copy_from_slice(&source_cmyk[pi..pi + 4]);
4697        }
4698    }
4699}
4700
4701/// Render soft-masked content.
4702///
4703/// 1. Renders the mask display list to an offscreen pixmap.
4704/// 2. Extracts a grayscale mask (luminosity or alpha).
4705/// 3. Renders content into another offscreen pixmap.
4706/// 4. Multiplies content alpha by the mask values.
4707/// 5. Composites the masked content onto the parent.
4708#[allow(clippy::too_many_arguments)]
4709fn render_soft_masked(
4710    pixmap: &mut Pixmap,
4711    band_state: &mut BandState,
4712    mask_list: &DisplayList,
4713    content_list: &DisplayList,
4714    params: &stet_graphics::display_list::SoftMaskParams,
4715    mask_cache: &Arc<Mutex<Option<Option<stet_graphics::display_list::MaskRaster>>>>,
4716    ctx: &RenderContext<'_>,
4717) {
4718    // The SoftMask's display list elements are in absolute device space (page coords).
4719    // params.bbox is the SoftMasked element's compositing bounds, derived
4720    // from the form's /BBox transformed by the gs-time CTM. The mask raster
4721    // (built lazily by `rasterize_mask` and cached on the display-list
4722    // element) is anchored independently to the *actual* mask paint bounds,
4723    // which may differ from params.bbox when the form's internal `cm`
4724    // operators translated paint elements outside the form bbox.
4725    //
4726    // The cached-raster path can produce truncated output when the
4727    // SoftMasked is rendered inside an outer offscreen (a Group, an
4728    // outer SoftMasked, etc.) — the nested offscreen's coordinate
4729    // system clips the mask raster's right edge unexpectedly. Detect
4730    // "nested" via `ctx.vp_x != 0.0` (top-level banded rendering uses
4731    // vp_x = 0; nested rendering inherits the parent offscreen's vp).
4732    // For nested cases, fall back to the inline band-local mask
4733    // rendering that worked before Step 4 of cosmic-masking-bird.
4734    let use_inline_mask = ctx.vp_x != 0.0;
4735    let bbox = &params.bbox;
4736    let smask_px_x0 = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
4737    let smask_px_y0 = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
4738    let smask_px_x1 = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
4739    let smask_px_y1 = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
4740
4741    // Clip to parent output bounds
4742    let crop_x = smask_px_x0.max(0);
4743    let crop_y = smask_px_y0.max(0);
4744    let crop_x1 = smask_px_x1.min(ctx.out_w as i32);
4745    let crop_y1 = smask_px_y1.min(ctx.out_h as i32);
4746    if crop_x >= crop_x1 || crop_y >= crop_y1 {
4747        return;
4748    }
4749    let eff_w = (crop_x1 - crop_x) as u32;
4750    let eff_h = (crop_y1 - crop_y) as u32;
4751
4752    // Viewport for the content offscreen: derived from the SoftMask's bbox
4753    // position relative to the parent's viewport. The content offscreen
4754    // still uses params.bbox because params.bbox correctly bounds where
4755    // the content can paint.
4756    let eff_vp_x = ctx.vp_x + crop_x as f32 / ctx.scale_x;
4757    let eff_vp_y = ctx.vp_y + crop_y as f32 / ctx.scale_y;
4758
4759    let sub_ctx = RenderContext {
4760        vp_x: eff_vp_x,
4761        vp_y: eff_vp_y,
4762        scale_x: ctx.scale_x,
4763        scale_y: ctx.scale_y,
4764        out_w: eff_w,
4765        out_h: eff_h,
4766        effective_dpi: ctx.effective_dpi,
4767        icc: ctx.icc,
4768        image_cache: None,
4769        preprocessed: None,
4770        elem_idx: 0,
4771        no_aa: ctx.no_aa,
4772        opm_zero_transparent: ctx.opm_zero_transparent,
4773        knockout_painter_pass: ctx.knockout_painter_pass,
4774        parent_group_isolated: ctx.parent_group_isolated,
4775        // Soft masks render into their own independent offscreen and must
4776        // not inherit the alpha extraction pass — their groups need normal
4777        // backdrop preloading regardless of the outer extraction context.
4778        alpha_extraction_pass: false,
4779        layer_set: ctx.layer_set,
4780    };
4781
4782    // 1a. INLINE PATH: Mask form contains nested offscreens.
4783    // Render the mask form into a band-local offscreen sized to the
4784    // SoftMasked's bbox crop. This matches the pre-Step-4 behavior.
4785    let mut mask_values_inline: Vec<u8> = Vec::new();
4786    if use_inline_mask {
4787        let Some(mut mask_pixmap) = Pixmap::new(eff_w, eff_h) else {
4788            return;
4789        };
4790        let mut mask_band = BandState {
4791            clip_region: None,
4792            spare_mask: None,
4793            clip_mask_cache: HashMap::new(),
4794            clip_mask_seen: HashSet::new(),
4795            mask_pool: Vec::new(),
4796            cmyk_buffer: None,
4797            op_bg_snapshot: None,
4798            op_touched: None,
4799            spot_mask: None,
4800        };
4801        for (idx, elem) in mask_list.elements().iter().enumerate() {
4802            let elem_ctx = RenderContext {
4803                elem_idx: idx,
4804                ..sub_ctx
4805            };
4806            render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
4807        }
4808        if params.has_nested_mask_scope
4809            && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
4810        {
4811            let bc = params.backdrop_color.as_ref();
4812            let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4813            let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4814            let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4815            for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
4816                let a = chunk[3] as u16;
4817                if a == 255 {
4818                    continue;
4819                }
4820                let inv_a = 255 - a;
4821                chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
4822                chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
4823                chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
4824                chunk[3] = 255;
4825            }
4826        }
4827        mask_values_inline = vec![0u8; (eff_w * eff_h) as usize];
4828        extract_soft_mask_values(mask_pixmap.data(), &mut mask_values_inline, params);
4829    }
4830
4831    // 1b. CACHED RASTER PATH: simple masks (no nested offscreens).
4832    let raster_owned: Option<stet_graphics::display_list::MaskRaster> = if use_inline_mask {
4833        None
4834    } else {
4835        let mut guard = mask_cache.lock().unwrap();
4836        let needs_build = match guard.as_ref() {
4837            None => true,
4838            Some(None) => false, // memoized "no mask"
4839            Some(Some(r)) => {
4840                (r.scale_x - ctx.scale_x).abs() > 1e-4 || (r.scale_y - ctx.scale_y).abs() > 1e-4
4841            }
4842        };
4843        if needs_build {
4844            let built = rasterize_mask(
4845                mask_list,
4846                params,
4847                ctx.icc,
4848                ctx.no_aa,
4849                ctx.effective_dpi,
4850                ctx.scale_x,
4851                ctx.scale_y,
4852                ctx.layer_set,
4853            );
4854            *guard = Some(built);
4855        }
4856        guard.as_ref().and_then(|inner| inner.clone())
4857    };
4858
4859    // Default mask value for content pixels that fall outside the mask
4860    // raster (e.g. backdrop region for a Luminosity mask with non-black
4861    // /BC, or always 0 for Alpha masks).
4862    let fallback_mask = out_of_bounds_mask_value(params) as i32;
4863
4864    // 2. Render content into an offscreen, initialized with the parent's
4865    // backdrop so non-isolated groups with blend modes (e.g. Multiply) see
4866    // the correct background and produce the right composited result.
4867    let Some(mut content_pixmap) = Pixmap::new(eff_w, eff_h) else {
4868        return;
4869    };
4870    let backdrop = copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h);
4871    content_pixmap.data_mut().copy_from_slice(&backdrop);
4872
4873    let content_cmyk = if has_overprint_elements(content_list) || band_state.cmyk_buffer.is_some() {
4874        let buf_size = eff_w as usize * eff_h as usize * 4;
4875        let mut buf = vec![0.0f32; buf_size];
4876        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4877            let parent_stride = ctx.out_w as usize * 4;
4878            let group_stride = eff_w as usize * 4;
4879            for gy in 0..eff_h as usize {
4880                let py = crop_y as usize + gy;
4881                if py < ctx.out_h as usize {
4882                    let p_start = py * parent_stride + crop_x as usize * 4;
4883                    let g_start = gy * group_stride;
4884                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4885                    buf[g_start..g_start + copy_len]
4886                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
4887                }
4888            }
4889        }
4890        Some(buf)
4891    } else {
4892        None
4893    };
4894    // Snapshot the pre-content CMYK state so the mask blend can run in CMYK
4895    // space. Without this, the downstream sRGB blend interpolates between
4896    // CMYK backdrop and source after each has been ICC-converted separately,
4897    // which shifts the midtones away from the CMYK-interpolated result the
4898    // source was authored against (pink cast vs warm peach on GWG 16.10
4899    // inner-glow in PDFX-ready_Output-Test_X4.pdf).
4900    let backdrop_cmyk: Option<Vec<f32>> = content_cmyk.clone();
4901    let mut content_band = BandState {
4902        clip_region: None,
4903        spare_mask: None,
4904        clip_mask_cache: HashMap::new(),
4905        clip_mask_seen: HashSet::new(),
4906        mask_pool: Vec::new(),
4907        cmyk_buffer: content_cmyk,
4908        op_bg_snapshot: None,
4909        op_touched: None,
4910        spot_mask: None,
4911    };
4912    for (idx, elem) in content_list.elements().iter().enumerate() {
4913        let elem_ctx = RenderContext {
4914            elem_idx: idx,
4915            ..sub_ctx
4916        };
4917        render_element(&mut content_pixmap, &mut content_band, elem, &elem_ctx);
4918    }
4919
4920    // 3. Apply soft mask: compute per-pixel masked contribution and write
4921    // to parent. result[c] = parent[c] + m * (content_on_backdrop[c] - backdrop[c]) / 255
4922    //
4923    // Mask sampling: the mask raster is in page-pixel coordinates at the
4924    // current render scale, anchored at `(raster.origin_x, raster.origin_y)`.
4925    // The combine loop iterates over content pixel `(x, y)` band-local in
4926    // the content offscreen. To translate to a mask raster index:
4927    //
4928    //   page_x = vp_x_pixels + crop_x + x
4929    //   page_y = vp_y_pixels + crop_y + y
4930    //   mask_x = page_x - raster.origin_x
4931    //   mask_y = page_y - raster.origin_y
4932    //
4933    // where `vp_x_pixels = round(ctx.vp_x * ctx.scale_x)` is the page-pixel
4934    // offset of the band's top-left. For banded rendering this is exact
4935    // (vp = 0, scale = 1, so vp_x_pixels = 0). For viewport rendering with
4936    // a fractional `vp_x`, there is at most a 0.5-pixel sub-pixel offset
4937    // between the content render grid and the cached mask grid; this is
4938    // bounded and visually acceptable for nearest-neighbor sampling.
4939    let vp_x_pixels = (ctx.vp_x * ctx.scale_x).round() as i32;
4940    let vp_y_pixels = (ctx.vp_y * ctx.scale_y).round() as i32;
4941
4942    let mut temp_mask = None;
4943    let clip_ref = resolve_clip_mask(
4944        &band_state.clip_region,
4945        &mut temp_mask,
4946        ctx.out_w,
4947        ctx.out_h,
4948    );
4949    let clip_ref = match clip_ref {
4950        None => return,
4951        Some(m) => m,
4952    };
4953
4954    // Decide whether to interpolate the masked delta in CMYK (with ICC→sRGB
4955    // on the way out) instead of sRGB. The CMYK path matches Acrobat's
4956    // behaviour when the transparency group declares /CS DeviceCMYK and all
4957    // content is native CMYK — the blend color space is then CMYK, and
4958    // sRGB-space interpolation on ICC-converted endpoints loses the warm
4959    // midtone that M+Y mixing produces under a proper CMYK profile.
4960    //
4961    // Gate strictly: content_list must be a flat list of native-CMYK fills
4962    // or strokes with Normal blend and full opacity. Any nested Group,
4963    // SoftMasked, Image, or blend-mode-modulated paint means the parallel
4964    // cmyk_buffer can't be trusted to match the pixmap — running CMYK
4965    // interpolation against a mismatched CMYK snapshot produced wrong
4966    // colors on GWG 16.10 outer-glow C (Fm5 is a Screen-blend white rect
4967    // inside a Group; cmyk_buffer held raw white while pixmap held the
4968    // screen-blended light gray).
4969    let use_cmyk_blend = ctx.icc.is_some()
4970        && backdrop_cmyk.is_some()
4971        && content_band.cmyk_buffer.is_some()
4972        && content_list_is_simple_native_cmyk(content_list);
4973
4974    let content_data = content_pixmap.data();
4975    let parent_data = pixmap.data_mut();
4976    let parent_stride = ctx.out_w as usize * 4;
4977    let content_stride = eff_w as usize * 4;
4978
4979    for y in 0..eff_h as usize {
4980        let py = crop_y as usize + y;
4981        if py >= ctx.out_h as usize {
4982            break;
4983        }
4984        let ci_row = y * content_stride;
4985        let pi_row = py * parent_stride;
4986        let page_y = vp_y_pixels + crop_y + y as i32;
4987
4988        for x in 0..eff_w as usize {
4989            let px = crop_x as usize + x;
4990            if px >= ctx.out_w as usize {
4991                break;
4992            }
4993
4994            // Check clip mask (in parent coordinates)
4995            if let Some(clip) = clip_ref {
4996                if clip.data()[py * ctx.out_w as usize + px] == 0 {
4997                    continue;
4998                }
4999            }
5000
5001            // Sample the mask: inline-rendered values for masks with
5002            // nested offscreens, cached raster for simple masks.
5003            let m = if use_inline_mask {
5004                mask_values_inline[y * eff_w as usize + x] as i32
5005            } else if let Some(ref raster) = raster_owned {
5006                let page_x = vp_x_pixels + crop_x + x as i32;
5007                let mx = page_x - raster.origin_x;
5008                let my = page_y - raster.origin_y;
5009                if mx >= 0 && (mx as u32) < raster.width && my >= 0 && (my as u32) < raster.height {
5010                    raster.data[my as usize * raster.width as usize + mx as usize] as i32
5011                } else {
5012                    fallback_mask
5013                }
5014            } else {
5015                fallback_mask
5016            };
5017            if m == 0 {
5018                continue;
5019            }
5020
5021            let ci = ci_row + x * 4;
5022            let pi = pi_row + px * 4;
5023
5024            // Per-pixel gate: CMYK interpolation is only safe when both
5025            // endpoints are faithfully tracked. ICC-convert both cmyk
5026            // snapshots and compare with the sRGB endpoints; only take
5027            // the CMYK path if BOTH agree within tolerance. The backdrop
5028            // check catches image/RGB paints upstream (tile_clamp_bug.pdf
5029            // photo background) where cmyk_buffer is an approximate
5030            // reverse-transform. The content check catches cases where
5031            // non-CMYK paints inside content leave the cmyk_buffer stale
5032            // relative to the sRGB content pixmap.
5033            let ci_cmyk = (y * eff_w as usize + x) * 4;
5034            let cmyk_path_ok = use_cmyk_blend && {
5035                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5036                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5037                let icc_match = |cmyk: &[f32], rgb: &[u8]| -> bool {
5038                    let (r, g, b) = ctx
5039                        .icc
5040                        .and_then(|i| {
5041                            i.convert_cmyk_readonly(
5042                                cmyk[0] as f64,
5043                                cmyk[1] as f64,
5044                                cmyk[2] as f64,
5045                                cmyk[3] as f64,
5046                            )
5047                        })
5048                        .unwrap_or_else(|| {
5049                            cmyk_to_rgb_plrm(
5050                                cmyk[0] as f64,
5051                                cmyk[1] as f64,
5052                                cmyk[2] as f64,
5053                                cmyk[3] as f64,
5054                            )
5055                        });
5056                    let r = (r * 255.0).round() as i32;
5057                    let g = (g * 255.0).round() as i32;
5058                    let b = (b * 255.0).round() as i32;
5059                    (r - rgb[0] as i32).abs() <= 3
5060                        && (g - rgb[1] as i32).abs() <= 3
5061                        && (b - rgb[2] as i32).abs() <= 3
5062                };
5063                icc_match(bc_cmyk, &backdrop[ci..ci + 3])
5064                    && icc_match(cc_cmyk, &content_data[ci..ci + 3])
5065            };
5066
5067            if cmyk_path_ok {
5068                // CMYK-space mask blend: result_cmyk = backdrop + m*(content - backdrop)
5069                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5070                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5071                let mf = m as f64 / 255.0;
5072                let rc = bc_cmyk[0] as f64 + mf * (cc_cmyk[0] as f64 - bc_cmyk[0] as f64);
5073                let rm = bc_cmyk[1] as f64 + mf * (cc_cmyk[1] as f64 - bc_cmyk[1] as f64);
5074                let ry = bc_cmyk[2] as f64 + mf * (cc_cmyk[2] as f64 - bc_cmyk[2] as f64);
5075                let rk = bc_cmyk[3] as f64 + mf * (cc_cmyk[3] as f64 - bc_cmyk[3] as f64);
5076                let (fr, fg, fb) = ctx
5077                    .icc
5078                    .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
5079                    .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
5080                parent_data[pi] = (fr * 255.0).round().clamp(0.0, 255.0) as u8;
5081                parent_data[pi + 1] = (fg * 255.0).round().clamp(0.0, 255.0) as u8;
5082                parent_data[pi + 2] = (fb * 255.0).round().clamp(0.0, 255.0) as u8;
5083                // Alpha channel: keep sRGB delta blend.
5084                let content_a = content_data[ci + 3] as i32;
5085                let backdrop_a = backdrop[ci + 3] as i32;
5086                let delta = content_a - backdrop_a;
5087                if delta != 0 {
5088                    let masked_delta = if delta > 0 {
5089                        (delta * m + 128) / 255
5090                    } else {
5091                        (delta * m - 128) / 255
5092                    };
5093                    let result = (parent_data[pi + 3] as i32 + masked_delta).clamp(0, 255);
5094                    parent_data[pi + 3] = result as u8;
5095                }
5096                // The parent's cmyk_buffer is deliberately NOT written here.
5097                // Writing back mask-blended CMYK would overwrite backdrop
5098                // tracking that downstream CMYK consumers (outer groups,
5099                // subsequent masks) depend on and cause them to render
5100                // nearby pixels as pure CMYK channels (e.g. the outer-glow
5101                // C regression: adjacent gray pixels ICC-resolved to a
5102                // black K silhouette). The sRGB pixmap carries the mask-
5103                // blended color; parent_cmyk stays untouched.
5104            } else {
5105                for c in 0..4 {
5106                    let content_val = content_data[ci + c] as i32;
5107                    let backdrop_val = backdrop[ci + c] as i32;
5108                    let delta = content_val - backdrop_val;
5109                    if delta != 0 {
5110                        let masked_delta = if delta > 0 {
5111                            (delta * m + 128) / 255
5112                        } else {
5113                            (delta * m - 128) / 255
5114                        };
5115                        let result = (parent_data[pi + c] as i32 + masked_delta).clamp(0, 255);
5116                        parent_data[pi + c] = result as u8;
5117                    }
5118                }
5119            }
5120        }
5121    }
5122
5123    // Write content CMYK buffer back to parent. Skip when the CMYK blend
5124    // loop already updated band_state.cmyk_buffer with mask-blended values
5125    // — copying the unmodulated content CMYK here would overwrite them.
5126    if !use_cmyk_blend {
5127        if let (Some(content_cmyk), Some(parent_cmyk)) =
5128            (&content_band.cmyk_buffer, &mut band_state.cmyk_buffer)
5129        {
5130            copy_cmyk_buffer_to_parent(
5131                parent_cmyk,
5132                content_cmyk,
5133                content_pixmap.data(),
5134                crop_x as usize,
5135                crop_y as usize,
5136                eff_w as usize,
5137                eff_h as usize,
5138                ctx.out_w as usize,
5139                ctx.out_h as usize,
5140            );
5141        }
5142    }
5143}
5144/// Extract grayscale mask values from rendered RGBA pixels.
5145fn extract_soft_mask_values(
5146    rgba: &[u8],
5147    out: &mut [u8],
5148    params: &stet_graphics::display_list::SoftMaskParams,
5149) {
5150    use stet_graphics::display_list::SoftMaskSubtype;
5151    let pixel_count = out.len();
5152
5153    match params.subtype {
5154        SoftMaskSubtype::Alpha => {
5155            for i in 0..pixel_count {
5156                let a = rgba[i * 4 + 3]; // alpha channel
5157                out[i] = if params.transfer_invert { 255 - a } else { a };
5158            }
5159        }
5160        SoftMaskSubtype::Luminosity => {
5161            // Backdrop luminosity for transparent pixels
5162            let backdrop_lum = if let Some(bc) = &params.backdrop_color {
5163                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5164            } else {
5165                0.0 // black backdrop
5166            };
5167            let backdrop_byte = (backdrop_lum * 255.0 + 0.5) as u8;
5168
5169            #[allow(clippy::needless_range_loop)]
5170            for i in 0..pixel_count {
5171                let off = i * 4;
5172                let a = rgba[off + 3];
5173                let lum_byte = if a == 0 {
5174                    backdrop_byte
5175                } else if a < 255 {
5176                    // Composite premultiplied RGB onto backdrop before computing
5177                    // luminosity (PDF spec 11.6.5.3): premul_rgb + BC × (1 - α/255)
5178                    let af = a as f64;
5179                    let bd = backdrop_lum * 255.0;
5180                    let r = rgba[off] as f64 + bd * (255.0 - af) / 255.0;
5181                    let g = rgba[off + 1] as f64 + bd * (255.0 - af) / 255.0;
5182                    let b = rgba[off + 2] as f64 + bd * (255.0 - af) / 255.0;
5183                    let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
5184                    (lum + 0.5).clamp(0.0, 255.0) as u8
5185                } else {
5186                    // Fully opaque: premultiplied == straight RGB
5187                    let lum = 0.2126 * rgba[off] as f64
5188                        + 0.7152 * rgba[off + 1] as f64
5189                        + 0.0722 * rgba[off + 2] as f64;
5190                    (lum + 0.5).clamp(0.0, 255.0) as u8
5191                };
5192                // Apply transfer function inversion: {1 exch sub} → 255 - value
5193                out[i] = if params.transfer_invert {
5194                    255 - lum_byte
5195                } else {
5196                    lum_byte
5197                };
5198            }
5199        }
5200    }
5201}
5202
5203/// Compute the byte the mask sample loop should use for content pixels
5204/// that fall outside the rasterized mask raster.
5205///
5206/// For Luminosity masks, transparent pixels (no rendered mask paint)
5207/// composite onto the backdrop color, so the effective mask value is the
5208/// backdrop's luminosity. For Alpha masks, transparent = 0 = mask off.
5209/// Both subtypes apply the `/TR {1 exch sub}` transfer inversion.
5210fn out_of_bounds_mask_value(params: &stet_graphics::display_list::SoftMaskParams) -> u8 {
5211    use stet_graphics::display_list::SoftMaskSubtype;
5212    let raw = match params.subtype {
5213        SoftMaskSubtype::Alpha => 0u8,
5214        SoftMaskSubtype::Luminosity => {
5215            let lum = if let Some(bc) = &params.backdrop_color {
5216                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5217            } else {
5218                0.0
5219            };
5220            (lum * 255.0 + 0.5) as u8
5221        }
5222    };
5223    if params.transfer_invert {
5224        255 - raw
5225    } else {
5226        raw
5227    }
5228}
5229
5230/// Maximum mask raster area in pixels.  A malformed PDF that asks for a
5231/// gigantic mask form would otherwise OOM. 64 megapixels = 64 MB for
5232/// grayscale or 256 MB for RGBA — generous but bounded.  Using an area
5233/// limit instead of a per-dimension limit correctly handles narrow-but-tall
5234/// pages (e.g. infographics that exceed 8192 pixels in height while being
5235/// only ~1000 pixels wide).
5236const MAX_MASK_RASTER_PIXELS: u64 = 64 * 1024 * 1024;
5237
5238/// Rasterize a soft mask form's display list into a `MaskRaster`.
5239///
5240/// Walks the mask display list to compute its actual paint bounds (which
5241/// may differ from the SoftMasked element's `params.bbox` because the
5242/// form's internal `cm` operators may translate paint elements outside
5243/// the form's `/BBox`), allocates a pixmap that exactly covers those
5244/// bounds in device-space pixels, and renders the mask elements with the
5245/// viewport set to the bounds origin so each element rasterizes at
5246/// `(device_x - origin_x, device_y - origin_y)`.
5247///
5248/// Returns `None` when the mask paints nothing.
5249fn rasterize_mask(
5250    mask_list: &DisplayList,
5251    params: &stet_graphics::display_list::SoftMaskParams,
5252    icc: Option<&IccCache>,
5253    no_aa: bool,
5254    effective_dpi: f64,
5255    scale_x: f32,
5256    scale_y: f32,
5257    layer_set: &LayerSet,
5258) -> Option<stet_graphics::display_list::MaskRaster> {
5259    // 1. Find the actual paint bounds in device space, then cap them to
5260    // the parent gstate's clip path bbox if known. The cap is critical
5261    // for masks whose form contains an unbounded shading inside a
5262    // sentinel-sized internal clip — without it, the raster blows past
5263    // the size limit and produces no output. Pixels outside the parent
5264    // clip can never affect the final image, so the cap is safe.
5265    let mut bounds = compute_paint_bounds(mask_list, effective_dpi)?;
5266    if let Some(cap) = params.parent_clip_bbox {
5267        let cap_bbox = BBox2D {
5268            x_min: cap[0],
5269            y_min: cap[1],
5270            x_max: cap[2],
5271            y_max: cap[3],
5272        };
5273        bounds = intersect_bbox(&bounds, &cap_bbox)?;
5274    }
5275
5276    // 2. Snap to integer device pixels at the current render scale, with a
5277    // 1-pixel pad on each side to avoid antialiasing edge clipping.
5278    let px_x_min = (bounds.x_min as f32 * scale_x).floor() as i32 - 1;
5279    let px_y_min = (bounds.y_min as f32 * scale_y).floor() as i32 - 1;
5280    let px_x_max = (bounds.x_max as f32 * scale_x).ceil() as i32 + 1;
5281    let px_y_max = (bounds.y_max as f32 * scale_y).ceil() as i32 + 1;
5282    if px_x_min >= px_x_max || px_y_min >= px_y_max {
5283        return None;
5284    }
5285    let raster_w = (px_x_max - px_x_min) as u32;
5286    let raster_h = (px_y_max - px_y_min) as u32;
5287    if raster_w == 0 || raster_h == 0 {
5288        return None;
5289    }
5290    if (raster_w as u64) * (raster_h as u64) > MAX_MASK_RASTER_PIXELS {
5291        return None;
5292    }
5293
5294    // 3. Allocate the offscreen pixmap (transparent backdrop).
5295    let mut mask_pixmap = Pixmap::new(raster_w, raster_h)?;
5296
5297    // 4. Build a RenderContext that maps device pixel `(dx, dy)` to
5298    // raster pixel `(dx - px_x_min, dy - px_y_min)`. The viewport is in
5299    // device-space units (not pixels), so divide by scale.
5300    let sub_ctx = RenderContext {
5301        vp_x: px_x_min as f32 / scale_x,
5302        vp_y: px_y_min as f32 / scale_y,
5303        scale_x,
5304        scale_y,
5305        out_w: raster_w,
5306        out_h: raster_h,
5307        effective_dpi,
5308        icc,
5309        image_cache: None,
5310        preprocessed: None,
5311        elem_idx: 0,
5312        no_aa,
5313        opm_zero_transparent: false,
5314        knockout_painter_pass: KnockoutPainterPass::None,
5315        parent_group_isolated: false,
5316        alpha_extraction_pass: false,
5317        layer_set,
5318    };
5319
5320    // 5. Mask rendering doesn't participate in CMYK overprint compositing.
5321    let mut mask_band = BandState {
5322        clip_region: None,
5323        spare_mask: None,
5324        clip_mask_cache: HashMap::new(),
5325        clip_mask_seen: HashSet::new(),
5326        mask_pool: Vec::new(),
5327        cmyk_buffer: None,
5328        op_bg_snapshot: None,
5329        op_touched: None,
5330        spot_mask: None,
5331    };
5332
5333    // 6. Render every element of the mask display list into the offscreen.
5334    for (idx, elem) in mask_list.elements().iter().enumerate() {
5335        let elem_ctx = RenderContext {
5336            elem_idx: idx,
5337            ..sub_ctx
5338        };
5339        render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
5340    }
5341
5342    // 7. If the mask form contained nested gs-set SMask scopes, composite
5343    // the rendered mask onto the backdrop color before extracting
5344    // luminosity. Nested masks produce semi-transparent pixels where
5345    // alpha encodes the mask modulation; without compositing,
5346    // un-premultiplying would amplify the color and lose the modulation.
5347    // Only Luminosity: Alpha masks extract the alpha channel directly,
5348    // so forcing alpha=255 via compositing would destroy the mask info.
5349    if params.has_nested_mask_scope
5350        && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
5351    {
5352        let bc = params.backdrop_color.as_ref();
5353        let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5354        let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5355        let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5356        for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
5357            let a = chunk[3] as u16;
5358            if a == 255 {
5359                continue;
5360            }
5361            let inv_a = 255 - a;
5362            chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
5363            chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
5364            chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
5365            chunk[3] = 255;
5366        }
5367    }
5368
5369    // 8. Extract grayscale mask values into a flat single-channel buffer.
5370    let pixel_count = (raster_w * raster_h) as usize;
5371    let mut data = vec![0u8; pixel_count];
5372    extract_soft_mask_values(mask_pixmap.data(), &mut data, params);
5373
5374    Some(stet_graphics::display_list::MaskRaster {
5375        data,
5376        width: raster_w,
5377        height: raster_h,
5378        origin_x: px_x_min,
5379        origin_y: px_y_min,
5380        scale_x,
5381        scale_y,
5382    })
5383}
5384
5385/// Transform a display element's CTM through a matrix so that pattern-space
5386/// coordinates map to device space.  Recursively transforms children of
5387/// Group and SoftMasked elements, and adjusts their bboxes.
5388fn transform_element_ctm(elem: &DisplayElement, pm: &Matrix) -> DisplayElement {
5389    match elem {
5390        DisplayElement::Fill { path, params } => {
5391            let mut p = params.clone();
5392            p.ctm = pm.concat(&p.ctm);
5393            DisplayElement::Fill {
5394                path: path.clone(),
5395                params: p,
5396            }
5397        }
5398        DisplayElement::Stroke { path, params } => {
5399            let mut p = params.clone();
5400            p.ctm = pm.concat(&p.ctm);
5401            DisplayElement::Stroke {
5402                path: path.clone(),
5403                params: p,
5404            }
5405        }
5406        DisplayElement::Clip { path, params } => {
5407            let mut p = params.clone();
5408            p.ctm = pm.concat(&p.ctm);
5409            if let Some(ref mut sp) = p.stroke_params {
5410                sp.ctm = pm.concat(&sp.ctm);
5411            }
5412            DisplayElement::Clip {
5413                path: path.clone(),
5414                params: p,
5415            }
5416        }
5417        DisplayElement::Image {
5418            sample_data,
5419            params,
5420        } => {
5421            let mut p = params.clone();
5422            p.ctm = pm.concat(&p.ctm);
5423            DisplayElement::Image {
5424                sample_data: sample_data.clone(),
5425                params: p,
5426            }
5427        }
5428        DisplayElement::MeshShading { params } => {
5429            let mut p = params.clone();
5430            p.ctm = pm.concat(&p.ctm);
5431            DisplayElement::MeshShading { params: p }
5432        }
5433        DisplayElement::PatchShading { params } => {
5434            let mut p = params.clone();
5435            p.ctm = pm.concat(&p.ctm);
5436            DisplayElement::PatchShading { params: p }
5437        }
5438        DisplayElement::AxialShading { params } => {
5439            let mut p = params.clone();
5440            p.ctm = pm.concat(&p.ctm);
5441            DisplayElement::AxialShading { params: p }
5442        }
5443        DisplayElement::RadialShading { params } => {
5444            let mut p = params.clone();
5445            p.ctm = pm.concat(&p.ctm);
5446            DisplayElement::RadialShading { params: p }
5447        }
5448        DisplayElement::Group { elements, params } => {
5449            let mut t = DisplayList::new();
5450            for child in elements.elements() {
5451                t.push(transform_element_ctm(child, pm));
5452            }
5453            let mut p = params.clone();
5454            let corners = [
5455                pm.transform_point(p.bbox[0], p.bbox[1]),
5456                pm.transform_point(p.bbox[2], p.bbox[1]),
5457                pm.transform_point(p.bbox[0], p.bbox[3]),
5458                pm.transform_point(p.bbox[2], p.bbox[3]),
5459            ];
5460            p.bbox = [
5461                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5462                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5463                corners
5464                    .iter()
5465                    .map(|c| c.0)
5466                    .fold(f64::NEG_INFINITY, f64::max),
5467                corners
5468                    .iter()
5469                    .map(|c| c.1)
5470                    .fold(f64::NEG_INFINITY, f64::max),
5471            ];
5472            DisplayElement::Group {
5473                elements: t,
5474                params: p,
5475            }
5476        }
5477        DisplayElement::SoftMasked {
5478            mask,
5479            content,
5480            params,
5481            ..
5482        } => {
5483            let mut t_mask = DisplayList::new();
5484            for child in mask.elements() {
5485                t_mask.push(transform_element_ctm(child, pm));
5486            }
5487            let mut t_content = DisplayList::new();
5488            for child in content.elements() {
5489                t_content.push(transform_element_ctm(child, pm));
5490            }
5491            let mut p = params.clone();
5492            let corners = [
5493                pm.transform_point(p.bbox[0], p.bbox[1]),
5494                pm.transform_point(p.bbox[2], p.bbox[1]),
5495                pm.transform_point(p.bbox[0], p.bbox[3]),
5496                pm.transform_point(p.bbox[2], p.bbox[3]),
5497            ];
5498            p.bbox = [
5499                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5500                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5501                corners
5502                    .iter()
5503                    .map(|c| c.0)
5504                    .fold(f64::NEG_INFINITY, f64::max),
5505                corners
5506                    .iter()
5507                    .map(|c| c.1)
5508                    .fold(f64::NEG_INFINITY, f64::max),
5509            ];
5510            // parent_clip_bbox was captured in the original (pattern)
5511            // coordinate system. Transform it through pm to match the
5512            // device-space coords that mask/content elements were just
5513            // moved into; otherwise the renderer would intersect a
5514            // device-space mask bbox with a pattern-space clip and get
5515            // an empty raster.
5516            if let Some(pcb) = p.parent_clip_bbox {
5517                let pcb_corners = [
5518                    pm.transform_point(pcb[0], pcb[1]),
5519                    pm.transform_point(pcb[2], pcb[1]),
5520                    pm.transform_point(pcb[0], pcb[3]),
5521                    pm.transform_point(pcb[2], pcb[3]),
5522                ];
5523                p.parent_clip_bbox = Some([
5524                    pcb_corners
5525                        .iter()
5526                        .map(|c| c.0)
5527                        .fold(f64::INFINITY, f64::min),
5528                    pcb_corners
5529                        .iter()
5530                        .map(|c| c.1)
5531                        .fold(f64::INFINITY, f64::min),
5532                    pcb_corners
5533                        .iter()
5534                        .map(|c| c.0)
5535                        .fold(f64::NEG_INFINITY, f64::max),
5536                    pcb_corners
5537                        .iter()
5538                        .map(|c| c.1)
5539                        .fold(f64::NEG_INFINITY, f64::max),
5540                ]);
5541            }
5542            // The transformed element's coordinate system is different
5543            // from the original; the original cache (if any) is invalid.
5544            // Allocate a fresh cache cell.
5545            DisplayElement::SoftMasked {
5546                mask: t_mask,
5547                content: t_content,
5548                params: p,
5549                mask_cache: Arc::new(Mutex::new(None)),
5550            }
5551        }
5552        DisplayElement::PatternFill { params } => {
5553            let mut p = params.clone();
5554            p.pattern_matrix = pm.concat(&p.pattern_matrix);
5555            // Transform the fill path (device-space coordinates)
5556            p.path = transform_path_by_matrix(&p.path, pm);
5557            if let Some(ref mut sp) = p.stroke_params {
5558                sp.ctm = pm.concat(&sp.ctm);
5559            }
5560            DisplayElement::PatternFill { params: p }
5561        }
5562        DisplayElement::OcgGroup {
5563            elements,
5564            visibility,
5565        } => {
5566            let mut t = DisplayList::new();
5567            for child in elements.elements() {
5568                t.push(transform_element_ctm(child, pm));
5569            }
5570            DisplayElement::OcgGroup {
5571                elements: t,
5572                visibility: visibility.clone(),
5573            }
5574        }
5575        other => other.clone(),
5576    }
5577}
5578
5579/// Transform all points in a path through a matrix.
5580fn transform_path_by_matrix(path: &PsPath, m: &Matrix) -> PsPath {
5581    use stet_fonts::geometry::PathSegment;
5582    let mut out = PsPath::new();
5583    for seg in &path.segments {
5584        out.segments.push(match *seg {
5585            PathSegment::MoveTo(x, y) => {
5586                let (nx, ny) = m.transform_point(x, y);
5587                PathSegment::MoveTo(nx, ny)
5588            }
5589            PathSegment::LineTo(x, y) => {
5590                let (nx, ny) = m.transform_point(x, y);
5591                PathSegment::LineTo(nx, ny)
5592            }
5593            PathSegment::CurveTo {
5594                x1,
5595                y1,
5596                x2,
5597                y2,
5598                x3,
5599                y3,
5600            } => {
5601                let (nx1, ny1) = m.transform_point(x1, y1);
5602                let (nx2, ny2) = m.transform_point(x2, y2);
5603                let (nx3, ny3) = m.transform_point(x3, y3);
5604                PathSegment::CurveTo {
5605                    x1: nx1,
5606                    y1: ny1,
5607                    x2: nx2,
5608                    y2: ny2,
5609                    x3: nx3,
5610                    y3: ny3,
5611                }
5612            }
5613            PathSegment::ClosePath => PathSegment::ClosePath,
5614        });
5615    }
5616    out
5617}
5618
5619/// Render a tiled pattern fill.
5620/// Bilinear downscale of premultiplied RGBA image data.
5621///
5622/// Used to pre-scale pattern tile images when the device-space tile is smaller
5623/// than the image resolution, since tiny-skia's `draw_pixmap` doesn't handle
5624/// sub-1.0 scale transforms.
5625fn bilinear_prescale(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
5626    let mut dst = vec![0u8; (dw * dh * 4) as usize];
5627    for dy in 0..dh {
5628        let sy_f = (dy as f64 + 0.5) * sh as f64 / dh as f64 - 0.5;
5629        let sy0 = sy_f.floor().max(0.0) as u32;
5630        let sy1 = (sy0 + 1).min(sh - 1);
5631        let fy = (sy_f - sy0 as f64) as f32;
5632        let ify = 1.0 - fy;
5633        for dx in 0..dw {
5634            let sx_f = (dx as f64 + 0.5) * sw as f64 / dw as f64 - 0.5;
5635            let sx0 = sx_f.floor().max(0.0) as u32;
5636            let sx1 = (sx0 + 1).min(sw - 1);
5637            let fx = (sx_f - sx0 as f64) as f32;
5638            let ifx = 1.0 - fx;
5639
5640            let i00 = (sy0 * sw + sx0) as usize * 4;
5641            let i10 = (sy0 * sw + sx1) as usize * 4;
5642            let i01 = (sy1 * sw + sx0) as usize * 4;
5643            let i11 = (sy1 * sw + sx1) as usize * 4;
5644            let di = (dy * dw + dx) as usize * 4;
5645            for c in 0..4 {
5646                dst[di + c] = (src[i00 + c] as f32 * ifx * ify
5647                    + src[i10 + c] as f32 * fx * ify
5648                    + src[i01 + c] as f32 * ifx * fy
5649                    + src[i11 + c] as f32 * fx * fy)
5650                    .round() as u8;
5651            }
5652        }
5653    }
5654    dst
5655}
5656
5657fn render_pattern_fill(
5658    pixmap: &mut Pixmap,
5659    band_state: &mut BandState,
5660    params: &stet_graphics::device::PatternFillParams,
5661    ctx: &RenderContext<'_>,
5662) {
5663    let mut temp_mask = None;
5664    let Some(mask_ref) = resolve_clip_mask(
5665        &band_state.clip_region,
5666        &mut temp_mask,
5667        ctx.out_w,
5668        ctx.out_h,
5669    ) else {
5670        return;
5671    };
5672
5673    let pm = &params.pattern_matrix;
5674
5675    // Tile step vectors in device space (handles rotation/shear)
5676    let (step_ux, step_uy) = pm.transform_delta(params.xstep, 0.0);
5677    let (step_vx, step_vy) = pm.transform_delta(0.0, params.ystep);
5678
5679    let step_u_len = (step_ux * step_ux + step_uy * step_uy).sqrt();
5680    let step_v_len = (step_vx * step_vx + step_vy * step_vy).sqrt();
5681    if step_u_len < 0.01 || step_v_len < 0.01 {
5682        return;
5683    }
5684
5685    let origin_x = pm.tx;
5686    let origin_y = pm.ty;
5687
5688    // Viewport bounds in device space
5689    let dev_vp_x = ctx.vp_x as f64;
5690    let dev_vp_y = ctx.vp_y as f64;
5691    let dev_vp_w = ctx.out_w as f64 / ctx.scale_x as f64;
5692    let dev_vp_h = ctx.out_h as f64 / ctx.scale_y as f64;
5693
5694    let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
5695    for seg in &params.path.segments {
5696        let (x, y) = match seg {
5697            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => (*x, *y),
5698            PathSegment::CurveTo { x3, y3, .. } => (*x3, *y3),
5699            PathSegment::ClosePath => continue,
5700        };
5701        min_x = min_x.min(x);
5702        min_y = min_y.min(y);
5703        max_x = max_x.max(x);
5704        max_y = max_y.max(y);
5705    }
5706
5707    // For stroke patterns, the path extends beyond the centerline by half
5708    // the stroke width.  The path is in user space; transform the bbox
5709    // corners through the CTM to get device-space bounds.
5710    if let Some(ref sp) = params.stroke_params {
5711        // Transform user-space bbox corners through CTM to device space
5712        let ctm = &sp.ctm;
5713        let corners = [
5714            ctm.transform_point(min_x, min_y),
5715            ctm.transform_point(max_x, min_y),
5716            ctm.transform_point(min_x, max_y),
5717            ctm.transform_point(max_x, max_y),
5718        ];
5719        min_x = f64::MAX;
5720        min_y = f64::MAX;
5721        max_x = f64::MIN;
5722        max_y = f64::MIN;
5723        for (cx, cy) in &corners {
5724            min_x = min_x.min(*cx);
5725            min_y = min_y.min(*cy);
5726            max_x = max_x.max(*cx);
5727            max_y = max_y.max(*cy);
5728        }
5729        // Expand by half stroke width in device space
5730        let half_w = sp.line_width
5731            * 0.5
5732            * (ctm.a * ctm.a + ctm.b * ctm.b)
5733                .sqrt()
5734                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
5735        min_x -= half_w;
5736        min_y -= half_w;
5737        max_x += half_w;
5738        max_y += half_w;
5739    }
5740
5741    // Clamp to viewport bounds in device space
5742    min_x = min_x.max(dev_vp_x);
5743    min_y = min_y.max(dev_vp_y);
5744    max_x = max_x.min(dev_vp_x + dev_vp_w);
5745    max_y = max_y.min(dev_vp_y + dev_vp_h);
5746    if min_x >= max_x || min_y >= max_y {
5747        return;
5748    }
5749
5750    let det = step_ux * step_vy - step_uy * step_vx;
5751    if det.abs() < 1e-10 {
5752        return;
5753    }
5754    let inv_det = 1.0 / det;
5755
5756    let mut tu_min = f64::MAX;
5757    let mut tu_max = f64::MIN;
5758    let mut tv_min = f64::MAX;
5759    let mut tv_max = f64::MIN;
5760    for &(cx, cy) in &[
5761        (min_x, min_y),
5762        (max_x, min_y),
5763        (min_x, max_y),
5764        (max_x, max_y),
5765    ] {
5766        let dx = cx - origin_x;
5767        let dy = cy - origin_y;
5768        let tu = (dx * step_vy - dy * step_vx) * inv_det;
5769        let tv = (-dx * step_uy + dy * step_ux) * inv_det;
5770        tu_min = tu_min.min(tu);
5771        tu_max = tu_max.max(tu);
5772        tv_min = tv_min.min(tv);
5773        tv_max = tv_max.max(tv);
5774    }
5775
5776    let tile_x_start = tu_min.floor() as i32 - 1;
5777    let tile_x_end = tu_max.ceil() as i32 + 1;
5778    let tile_y_start = tv_min.floor() as i32 - 1;
5779    let tile_y_end = tv_max.ceil() as i32 + 1;
5780
5781    let tile_count = (tile_x_end - tile_x_start) as i64 * (tile_y_end - tile_y_start) as i64;
5782    if tile_count > 10000 {
5783        return;
5784    }
5785
5786    let Some(mut tile_buf) = Pixmap::new(ctx.out_w, ctx.out_h) else {
5787        return;
5788    };
5789
5790    let sx_f = ctx.scale_x as f64;
5791    let sy_f = ctx.scale_y as f64;
5792
5793    if params.device_space_tile {
5794        // Device-space tile path: tile elements have CTMs in device space
5795        // (pattern matrix baked in). Use the full render_element pipeline
5796        // which handles all element types (clips, soft masks, shadings,
5797        // groups). For each tile position, shift the viewport origin by the
5798        // tile offset in device space.
5799        for tv in tile_y_start..tile_y_end {
5800            for tu in tile_x_start..tile_x_end {
5801                let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
5802                let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
5803
5804                let tile_ctx = RenderContext {
5805                    vp_x: ctx.vp_x - offset_x as f32,
5806                    vp_y: ctx.vp_y - offset_y as f32,
5807                    scale_x: ctx.scale_x,
5808                    scale_y: ctx.scale_y,
5809                    out_w: ctx.out_w,
5810                    out_h: ctx.out_h,
5811                    effective_dpi: ctx.effective_dpi,
5812                    icc: ctx.icc,
5813                    image_cache: None,
5814                    preprocessed: None,
5815                    elem_idx: 0,
5816                    no_aa: ctx.no_aa,
5817                    opm_zero_transparent: params.overprint_mode == 1,
5818                    knockout_painter_pass: ctx.knockout_painter_pass,
5819                    parent_group_isolated: ctx.parent_group_isolated,
5820                    alpha_extraction_pass: ctx.alpha_extraction_pass,
5821                    layer_set: ctx.layer_set,
5822                };
5823
5824                let mut tile_band = BandState {
5825                    clip_region: None,
5826                    spare_mask: None,
5827                    clip_mask_cache: HashMap::new(),
5828                    clip_mask_seen: HashSet::new(),
5829                    mask_pool: Vec::new(),
5830                    cmyk_buffer: None,
5831                    op_bg_snapshot: None,
5832                    op_touched: None,
5833                    spot_mask: None,
5834                };
5835
5836                for (idx, elem) in params.tile.elements().iter().enumerate() {
5837                    let elem_ctx = RenderContext {
5838                        elem_idx: idx,
5839                        ..tile_ctx
5840                    };
5841                    render_element(&mut tile_buf, &mut tile_band, elem, &elem_ctx);
5842                }
5843            }
5844        }
5845    } else if params.tile.elements().iter().any(|e| {
5846        !matches!(
5847            e,
5848            DisplayElement::Fill { .. }
5849                | DisplayElement::Stroke { .. }
5850                | DisplayElement::Image { .. }
5851                | DisplayElement::Clip { .. }
5852                | DisplayElement::InitClip
5853        )
5854    }) {
5855        // Complex tile path: pre-render one tile into a small pixmap using
5856        // the full render_element pipeline (handles shadings, groups,
5857        // soft masks, etc.), then stamp copies at each tile position.
5858        let bbox = &params.bbox;
5859        let corners_dev = [
5860            pm.transform_point(bbox[0], bbox[1]),
5861            pm.transform_point(bbox[2], bbox[1]),
5862            pm.transform_point(bbox[0], bbox[3]),
5863            pm.transform_point(bbox[2], bbox[3]),
5864        ];
5865        let (mut td_x0, mut td_y0) = (f64::MAX, f64::MAX);
5866        let (mut td_x1, mut td_y1) = (f64::MIN, f64::MIN);
5867        for (x, y) in &corners_dev {
5868            td_x0 = td_x0.min(*x);
5869            td_y0 = td_y0.min(*y);
5870            td_x1 = td_x1.max(*x);
5871            td_y1 = td_y1.max(*y);
5872        }
5873        let tile_pw = ((td_x1 - td_x0) * sx_f).ceil().max(1.0) as u32;
5874        let tile_ph = ((td_y1 - td_y0) * sy_f).ceil().max(1.0) as u32;
5875        let tile_pw = tile_pw.min(8192);
5876        let tile_ph = tile_ph.min(8192);
5877
5878        if let Some(mut one_tile) = Pixmap::new(tile_pw, tile_ph) {
5879            let tile_render_ctx = RenderContext {
5880                vp_x: td_x0 as f32,
5881                vp_y: td_y0 as f32,
5882                scale_x: ctx.scale_x,
5883                scale_y: ctx.scale_y,
5884                out_w: tile_pw,
5885                out_h: tile_ph,
5886                effective_dpi: ctx.effective_dpi,
5887                icc: ctx.icc,
5888                image_cache: None,
5889                preprocessed: None,
5890                elem_idx: 0,
5891                no_aa: ctx.no_aa,
5892                opm_zero_transparent: params.overprint_mode == 1,
5893                knockout_painter_pass: ctx.knockout_painter_pass,
5894                parent_group_isolated: ctx.parent_group_isolated,
5895                alpha_extraction_pass: ctx.alpha_extraction_pass,
5896                layer_set: ctx.layer_set,
5897            };
5898            let mut tile_bs = BandState {
5899                clip_region: None,
5900                spare_mask: None,
5901                clip_mask_cache: HashMap::new(),
5902                clip_mask_seen: HashSet::new(),
5903                mask_pool: Vec::new(),
5904                cmyk_buffer: None,
5905                op_bg_snapshot: None,
5906                op_touched: None,
5907                spot_mask: None,
5908            };
5909            for (idx, elem) in params.tile.elements().iter().enumerate() {
5910                let transformed = transform_element_ctm(elem, pm);
5911                let elem_ctx = RenderContext {
5912                    elem_idx: idx,
5913                    ..tile_render_ctx
5914                };
5915                render_element(&mut one_tile, &mut tile_bs, &transformed, &elem_ctx);
5916            }
5917            // Stamp pre-rendered tile at each position
5918            for tv in tile_y_start..tile_y_end {
5919                for tu in tile_x_start..tile_x_end {
5920                    let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
5921                    let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
5922                    let px = ((td_x0 + offset_x - dev_vp_x) * sx_f) as i32;
5923                    let py = ((td_y0 + offset_y - dev_vp_y) * sy_f) as i32;
5924                    let paint = stet_tiny_skia::PixmapPaint {
5925                        opacity: 1.0,
5926                        blend_mode: BlendMode::SourceOver,
5927                        quality: stet_tiny_skia::FilterQuality::Nearest,
5928                    };
5929                    tile_buf.draw_pixmap(
5930                        px,
5931                        py,
5932                        one_tile.as_ref(),
5933                        &paint,
5934                        Transform::identity(),
5935                        None,
5936                    );
5937                }
5938            }
5939        }
5940    } else {
5941        // Simple tile path: tile elements have identity CTMs.
5942        // Manually apply the pattern matrix + tile offset for each element.
5943        // Only handles Fill, Stroke, Image, and Clip.
5944
5945        // Pre-process Image elements: convert to RGBA once and pre-scale if
5946        // the combined transform would require downscaling (scale < 1.0).
5947        // tiny-skia's draw_pixmap doesn't handle sub-1.0 scale transforms.
5948        struct PreprocessedImage {
5949            rgba: Vec<u8>,
5950            width: u32,
5951            height: u32,
5952            /// Transform from pixel coords to pattern space, possibly adjusted
5953            /// to account for pre-scaling.
5954            img_transform: Transform,
5955        }
5956        let tile_elements = params.tile.elements();
5957        let mut preprocessed: Vec<Option<PreprocessedImage>> =
5958            Vec::with_capacity(tile_elements.len());
5959        // Tile transform scale components (constant across all tiles)
5960        let tt_sx = (pm.a * sx_f) as f32;
5961        let tt_sy = (pm.d * sy_f) as f32;
5962        let tt_kx = (pm.c * sx_f) as f32;
5963        let tt_ky = (pm.b * sy_f) as f32;
5964        for elem in tile_elements {
5965            if let DisplayElement::Image {
5966                sample_data,
5967                params: ip,
5968            } = elem
5969            {
5970                let iw = ip.width;
5971                let ih = ip.height;
5972                if iw > 0 && ih > 0 {
5973                    let mut rgba =
5974                        samples_to_rgba(sample_data, ip, ctx.icc, ctx.opm_zero_transparent);
5975                    if ip.mask_color.is_some() {
5976                        apply_mask_color_rgba(&mut rgba, sample_data, ip);
5977                    }
5978                    let expected = (iw * ih * 4) as usize;
5979                    if rgba.len() >= expected {
5980                        if let Some(inv) = ip.image_matrix.invert() {
5981                            let combined_mat = ip.ctm.concat(&inv);
5982                            let t = to_transform(&combined_mat);
5983                            // Check effective scale: t maps image pixels → pattern space,
5984                            // tile_transform maps pattern space → device space.
5985                            let test = t.post_concat(Transform::from_row(
5986                                tt_sx, tt_ky, tt_kx, tt_sy, 0.0, 0.0,
5987                            ));
5988                            let eff_sx = (test.sx * test.sx + test.ky * test.ky).sqrt();
5989                            let eff_sy = (test.kx * test.kx + test.sy * test.sy).sqrt();
5990                            if eff_sx < 0.99 || eff_sy < 0.99 {
5991                                // Pre-scale image to avoid sub-1.0 draw_pixmap transform.
5992                                // Use floor so the scaled image is smaller than the
5993                                // device-space tile, ensuring the adjusted scale >= 1.0.
5994                                let tw = (iw as f32 * eff_sx).floor().max(1.0) as u32;
5995                                let th = (ih as f32 * eff_sy).floor().max(1.0) as u32;
5996                                let scaled = bilinear_prescale(&rgba, iw, ih, tw, th);
5997                                // Adjust transform: pre-multiply a scale that maps new
5998                                // pixel coords back to original pixel coords
5999                                let adj = Transform::from_scale(
6000                                    iw as f32 / tw as f32,
6001                                    ih as f32 / th as f32,
6002                                );
6003                                preprocessed.push(Some(PreprocessedImage {
6004                                    rgba: scaled,
6005                                    width: tw,
6006                                    height: th,
6007                                    img_transform: t.pre_concat(adj),
6008                                }));
6009                            } else {
6010                                preprocessed.push(Some(PreprocessedImage {
6011                                    rgba,
6012                                    width: iw,
6013                                    height: ih,
6014                                    img_transform: t,
6015                                }));
6016                            }
6017                        } else {
6018                            preprocessed.push(None);
6019                        }
6020                    } else {
6021                        preprocessed.push(None);
6022                    }
6023                } else {
6024                    preprocessed.push(None);
6025                }
6026                // Note: only Image elements push to preprocessed, so img_idx
6027                // in the tile loop correctly indexes this array.
6028            }
6029        }
6030
6031        for tv in tile_y_start..tile_y_end {
6032            for tu in tile_x_start..tile_x_end {
6033                let pat_offset_x = tu as f64 * params.xstep;
6034                let pat_offset_y = tv as f64 * params.ystep;
6035
6036                let tile_transform = Transform::from_row(
6037                    tt_sx,
6038                    tt_ky,
6039                    tt_kx,
6040                    tt_sy,
6041                    ((pm.a * pat_offset_x + pm.c * pat_offset_y + pm.tx - dev_vp_x) * sx_f) as f32,
6042                    ((pm.b * pat_offset_x + pm.d * pat_offset_y + pm.ty - dev_vp_y) * sy_f) as f32,
6043                );
6044
6045                // Clip tile elements to BBox (PDF spec 8.7.4.2)
6046                let bbox_clip = {
6047                    let bb = &params.bbox;
6048                    let mut bp = stet_tiny_skia::PathBuilder::new();
6049                    bp.move_to(bb[0] as f32, bb[1] as f32);
6050                    bp.line_to(bb[2] as f32, bb[1] as f32);
6051                    bp.line_to(bb[2] as f32, bb[3] as f32);
6052                    bp.line_to(bb[0] as f32, bb[3] as f32);
6053                    bp.close();
6054                    bp.finish().and_then(|sp| {
6055                        let mut m = Mask::new(ctx.out_w, ctx.out_h)?;
6056                        m.fill_path(
6057                            &sp,
6058                            stet_tiny_skia::FillRule::Winding,
6059                            false,
6060                            tile_transform,
6061                        );
6062                        Some(m)
6063                    })
6064                };
6065                let mut tile_clip: Option<Mask> = bbox_clip;
6066                let mut img_idx = 0usize;
6067                for elem in tile_elements {
6068                    let clip_ref = tile_clip.as_ref();
6069                    match elem {
6070                        DisplayElement::Clip { path, params: cp } => {
6071                            if let Some(sp) = build_skia_path(path) {
6072                                let t = to_transform(&cp.ctm);
6073                                let combined = t.post_concat(tile_transform);
6074                                let mut mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6075                                mask.fill_path(&sp, to_fill_rule(&cp.fill_rule), false, combined);
6076                                if let Some(prev) = tile_clip.take() {
6077                                    intersect_masks(&mut mask, &prev);
6078                                }
6079                                tile_clip = Some(mask);
6080                            }
6081                        }
6082                        DisplayElement::InitClip => {
6083                            tile_clip = None;
6084                        }
6085                        DisplayElement::Fill { path, params: fp } => {
6086                            if let Some(sp) = build_skia_path(path) {
6087                                let mut paint = if params.paint_type == 1 {
6088                                    to_paint(&fp.color)
6089                                } else {
6090                                    to_paint(
6091                                        params
6092                                            .underlying_color
6093                                            .as_ref()
6094                                            .unwrap_or(&DeviceColor::black()),
6095                                    )
6096                                };
6097                                paint.anti_alias = false;
6098                                let t = to_transform(&fp.ctm);
6099                                let combined = t.post_concat(tile_transform);
6100                                let fr = to_fill_rule(&fp.fill_rule);
6101                                tile_buf.fill_path(&sp, &paint, fr, combined, clip_ref);
6102                            }
6103                        }
6104                        DisplayElement::Stroke { path, params: sp } => {
6105                            if let Some(skp) = build_skia_path(path) {
6106                                // Compose element CTM with pattern matrix so
6107                                // hairline_min_width sees the real device scale,
6108                                // not the tile's identity CTM.
6109                                let effective_ctm = pm.concat(&sp.ctm);
6110                                let mut sp_adj = sp.clone();
6111                                sp_adj.ctm = effective_ctm;
6112                                let stroke = build_stroke(&sp_adj, ctx.effective_dpi);
6113                                let paint = if params.paint_type == 1 {
6114                                    to_paint(&sp.color)
6115                                } else {
6116                                    to_paint(
6117                                        params
6118                                            .underlying_color
6119                                            .as_ref()
6120                                            .unwrap_or(&DeviceColor::black()),
6121                                    )
6122                                };
6123                                let t = to_transform(&sp.ctm);
6124                                let combined = t.post_concat(tile_transform);
6125                                tile_buf.stroke_path(&skp, &paint, &stroke, combined, clip_ref);
6126                            }
6127                        }
6128                        DisplayElement::Image { .. } => {
6129                            if let Some(ref pi) = preprocessed[img_idx] {
6130                                let combined = pi.img_transform.post_concat(tile_transform);
6131                                if let Some(img_ref) = stet_tiny_skia::PixmapRef::from_bytes(
6132                                    &pi.rgba, pi.width, pi.height,
6133                                ) {
6134                                    let paint = stet_tiny_skia::PixmapPaint {
6135                                        opacity: 1.0,
6136                                        blend_mode: BlendMode::SourceOver,
6137                                        quality: stet_tiny_skia::FilterQuality::Nearest,
6138                                    };
6139                                    tile_buf.draw_pixmap(0, 0, img_ref, &paint, combined, clip_ref);
6140                                }
6141                            }
6142                            img_idx += 1;
6143                        }
6144                        _ => {}
6145                    }
6146                }
6147            }
6148        }
6149    }
6150
6151    // Composite tile_buf onto main pixmap through the fill/stroke path
6152    let Some(fill_skia_path) = build_skia_path(&params.path) else {
6153        return;
6154    };
6155    let fill_rule = to_fill_rule(&params.fill_rule);
6156    let mut fill_mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6157    let path_transform = viewport_transform(
6158        Transform::identity(),
6159        ctx.vp_x,
6160        ctx.vp_y,
6161        ctx.scale_x,
6162        ctx.scale_y,
6163    );
6164    if let Some(ref sp) = params.stroke_params {
6165        // Stroke pattern: expand the centerline path to a fill outline
6166        // using the stroke parameters (width, cap, join, miter, dash).
6167        // Apply dash pattern first (Path::stroke doesn't handle dashing).
6168        let stroke = build_stroke(sp, ctx.effective_dpi);
6169        let ctm_transform = to_transform(&sp.ctm);
6170        let combined = ctm_transform.post_concat(path_transform);
6171        let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&combined);
6172        let dashed;
6173        let stroke_path = if let Some(ref dash) = stroke.dash {
6174            dashed = fill_skia_path.dash(dash, res_scale);
6175            match dashed.as_ref() {
6176                Some(p) => p,
6177                None => &fill_skia_path,
6178            }
6179        } else {
6180            &fill_skia_path
6181        };
6182        if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6183            fill_mask.fill_path(
6184                &outline,
6185                stet_tiny_skia::FillRule::Winding,
6186                !ctx.no_aa,
6187                combined,
6188            );
6189        }
6190    } else {
6191        fill_mask.fill_path(&fill_skia_path, fill_rule, !ctx.no_aa, path_transform);
6192    }
6193
6194    if let Some(clip_mask) = mask_ref {
6195        intersect_masks(&mut fill_mask, clip_mask);
6196    }
6197
6198    let img_paint = stet_tiny_skia::PixmapPaint::default();
6199    pixmap.draw_pixmap(
6200        0,
6201        0,
6202        tile_buf.as_ref(),
6203        &img_paint,
6204        Transform::identity(),
6205        Some(&fill_mask),
6206    );
6207}
6208
6209/// Unified clip path handling for both band and viewport rendering.
6210///
6211/// For band rendering (scale=1.0), includes rect fast-path and Y-bbox early exit.
6212/// For viewport rendering (scale!=1.0), uses the general mask path.
6213fn clip_path_unified(
6214    band_state: &mut BandState,
6215    path: &PsPath,
6216    params: &ClipParams,
6217    ctx: &RenderContext<'_>,
6218) {
6219    let is_unit_scale = ctx.scale_x == 1.0 && ctx.scale_y == 1.0;
6220
6221    // Band-mode optimizations (scale=1.0): Y-bbox early exit and rect fast-path
6222    if is_unit_scale {
6223        let y_start = ctx.vp_y as u32;
6224        let x_start = ctx.vp_x as u32;
6225
6226        // Y-bbox early exit: if clip path doesn't overlap this band, set empty clip
6227        // (only valid when CTM is identity — path coords must be in device space).
6228        // Skip when stroke_params is present: the path is in user space and
6229        // needs the stroke CTM transform, so raw Y bounds are meaningless here.
6230        if x_start == 0
6231            && params.stroke_params.is_none()
6232            && params.ctm.a == 1.0
6233            && params.ctm.d == 1.0
6234            && params.ctm.tx == 0.0
6235            && params.ctm.ty == 0.0
6236            && let Some(bbox) = path_y_bbox(path)
6237            && (bbox.y_max <= y_start as f64 || bbox.y_min >= (y_start + ctx.out_h) as f64)
6238        {
6239            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
6240                band_state.recycle_mask(mask);
6241            }
6242            band_state.clip_region = Some(ClipRegion::Rect(ClipRect {
6243                x0: 0,
6244                y0: 0,
6245                x1: 0,
6246                y1: 0,
6247            }));
6248            return;
6249        }
6250
6251        // Rect fast-path (only when x_start==0 and CTM is identity —
6252        // detect_rect uses raw path coords which are only in device space
6253        // when the CTM is identity)
6254        let ctm_is_identity = params.ctm.a == 1.0
6255            && params.ctm.b == 0.0
6256            && params.ctm.c == 0.0
6257            && params.ctm.d == 1.0
6258            && params.ctm.tx == 0.0
6259            && params.ctm.ty == 0.0;
6260        if x_start == 0
6261            && ctm_is_identity
6262            && params.stroke_params.is_none()
6263            && let Some(dev_rect) = detect_rect(path, ctx.out_w, u32::MAX)
6264        {
6265            let new_rect = translate_clip_rect(&dev_rect, y_start, ctx.out_h);
6266            match band_state.clip_region.take() {
6267                None => {
6268                    band_state.clip_region = Some(ClipRegion::Rect(new_rect));
6269                }
6270                Some(ClipRegion::Rect(existing)) => {
6271                    band_state.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6272                }
6273                Some(ClipRegion::Mask(mut mask)) => {
6274                    intersect_mask_with_rect(&mut mask, &new_rect, ctx.out_w, ctx.out_h);
6275                    band_state.clip_region = Some(ClipRegion::Mask(mask));
6276                }
6277            }
6278            return;
6279        }
6280    }
6281
6282    // General path: non-rectangular clip with cache + mask reuse
6283    let fill_rule = to_fill_rule(&params.fill_rule);
6284    let path_hash = hash_clip_path(path, &params.fill_rule);
6285    let prev_region = band_state.clip_region.take();
6286
6287    let mut mask = band_state.take_mask(ctx.out_w, ctx.out_h);
6288
6289    let path_mask = if let Some(cached) = band_state.clip_mask_cache.get(&path_hash) {
6290        mask.data_mut().copy_from_slice(cached.data());
6291        mask
6292    } else {
6293        let Some(skia_path) = build_skia_path(path) else {
6294            band_state.recycle_mask(mask);
6295            band_state.clip_region = prev_region;
6296            return;
6297        };
6298        mask.data_mut().fill(0);
6299        if let Some(ref sp) = params.stroke_params {
6300            // Stroke-based clip: expand centerline to stroke outline.
6301            // Apply dash pattern first (Path::stroke doesn't handle dashing).
6302            let stroke = build_stroke(sp, ctx.effective_dpi);
6303            let transform = ctx.transform(&sp.ctm);
6304            let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&transform);
6305            let dashed;
6306            let stroke_path = if let Some(ref dash) = stroke.dash {
6307                dashed = skia_path.dash(dash, res_scale);
6308                match dashed.as_ref() {
6309                    Some(p) => p,
6310                    None => &skia_path,
6311                }
6312            } else {
6313                &skia_path
6314            };
6315            if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6316                mask.fill_path(
6317                    &outline,
6318                    stet_tiny_skia::FillRule::Winding,
6319                    false,
6320                    transform,
6321                );
6322            }
6323        } else {
6324            let transform = ctx.transform(&params.ctm);
6325            mask.fill_path(&skia_path, fill_rule, false, transform);
6326        }
6327        if !band_state.clip_mask_seen.insert(path_hash) {
6328            band_state.clip_mask_cache.insert(path_hash, mask.clone());
6329        }
6330        mask
6331    };
6332
6333    match prev_region {
6334        None => {
6335            band_state.clip_region = Some(ClipRegion::Mask(path_mask));
6336        }
6337        Some(ClipRegion::Rect(rect)) => {
6338            if rect.is_empty() {
6339                band_state.recycle_mask(path_mask);
6340                // Intersection with empty clip is still empty — preserve empty state.
6341                // Without this, clip_region stays None (= no clip = paint everything).
6342                band_state.clip_region = Some(ClipRegion::Rect(rect));
6343            } else {
6344                let mut mask = path_mask;
6345                intersect_mask_with_rect(&mut mask, &rect, ctx.out_w, ctx.out_h);
6346                band_state.clip_region = Some(ClipRegion::Mask(mask));
6347            }
6348        }
6349        Some(ClipRegion::Mask(mut existing)) => {
6350            intersect_masks(&mut existing, &path_mask);
6351            band_state.recycle_mask(path_mask);
6352            band_state.clip_region = Some(ClipRegion::Mask(existing));
6353        }
6354    }
6355}
6356impl OutputDevice for SkiaDevice {
6357    fn fill_path(&mut self, path: &PsPath, params: &FillParams) {
6358        self.ensure_full_pixmap();
6359        let Some(skia_path) = build_skia_path(path) else {
6360            return;
6361        };
6362        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6363        let mut temp_mask = None;
6364        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6365            return; // empty clip
6366        };
6367
6368        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6369        let transform = to_transform(&params.ctm);
6370        let fill_rule = to_fill_rule(&params.fill_rule);
6371
6372        self.pixmap
6373            .fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
6374    }
6375
6376    fn stroke_path(&mut self, path: &PsPath, params: &StrokeParams) {
6377        self.ensure_full_pixmap();
6378        let stroke = build_stroke(params, self.dpi);
6379        let adjusted;
6380        let draw_path =
6381            if params.stroke_adjust && stroke.width <= 2.0 && ctm_is_device_space(&params.ctm) {
6382                adjusted =
6383                    stroke_adjust_path_viewport(path, stroke.width as f64, 1.0, 1.0, 0.0, 0.0);
6384                &adjusted
6385            } else {
6386                path
6387            };
6388        let Some(skia_path) = build_skia_path(draw_path) else {
6389            return;
6390        };
6391        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6392        let transform = to_transform(&params.ctm);
6393
6394        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6395        let mut temp_mask = None;
6396        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6397            return; // empty clip
6398        };
6399
6400        self.pixmap
6401            .stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
6402    }
6403
6404    fn clip_path(&mut self, path: &PsPath, params: &ClipParams) {
6405        self.ensure_full_pixmap();
6406        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6407
6408        // Fast path: detect axis-aligned rectangle
6409        if let Some(new_rect) = detect_rect(path, w, h) {
6410            match self.clip_region.take() {
6411                None => {
6412                    self.clip_region = Some(ClipRegion::Rect(new_rect));
6413                }
6414                Some(ClipRegion::Rect(existing)) => {
6415                    // O(1) rect-rect intersection
6416                    self.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6417                }
6418                Some(ClipRegion::Mask(mut mask)) => {
6419                    // Zero mask pixels outside rect
6420                    intersect_mask_with_rect(&mut mask, &new_rect, w, h);
6421                    self.clip_region = Some(ClipRegion::Mask(mask));
6422                }
6423            }
6424            return;
6425        }
6426
6427        // Slow path: non-rectangular clip with mask caching + allocation reuse.
6428        let fill_rule = to_fill_rule(&params.fill_rule);
6429        let path_hash = hash_clip_path(path, &params.fill_rule);
6430        let prev_region = self.clip_region.take();
6431
6432        // Reuse a spare mask buffer if available (avoids alloc/dealloc per tile).
6433        macro_rules! take_spare {
6434            ($self:expr, $w:expr, $h:expr) => {
6435                $self
6436                    .spare_mask
6437                    .take()
6438                    .unwrap_or_else(|| Mask::new($w, $h).expect("Failed to create mask"))
6439            };
6440        }
6441
6442        // Try cache first; rasterize only on miss
6443        let path_mask = if let Some(cached) = self.clip_mask_cache.get(&path_hash) {
6444            // Cache hit: copy cached data into reused buffer (memcpy, no alloc)
6445            let mut mask = take_spare!(self, w, h);
6446            mask.data_mut().copy_from_slice(cached.data());
6447            mask
6448        } else {
6449            let Some(skia_path) = build_skia_path(path) else {
6450                self.clip_region = prev_region;
6451                return;
6452            };
6453            let transform = to_transform(&params.ctm);
6454            let mut mask = take_spare!(self, w, h);
6455            mask.data_mut().fill(0); // zero before rasterizing (spare may have old data)
6456            mask.fill_path(&skia_path, fill_rule, false, transform);
6457            // Cache on second sight: first time just record, second time store
6458            if !self.clip_mask_seen.insert(path_hash) {
6459                // Seen before — cache it (this clone only happens once per unique path)
6460                self.clip_mask_cache.insert(path_hash, mask.clone());
6461            }
6462            mask
6463        };
6464
6465        match prev_region {
6466            None => {
6467                self.clip_region = Some(ClipRegion::Mask(path_mask));
6468            }
6469            Some(ClipRegion::Rect(rect)) => {
6470                if rect.is_empty() {
6471                    self.spare_mask = Some(path_mask); // recycle
6472                } else {
6473                    let mut mask = path_mask;
6474                    intersect_mask_with_rect(&mut mask, &rect, w, h);
6475                    self.clip_region = Some(ClipRegion::Mask(mask));
6476                }
6477            }
6478            Some(ClipRegion::Mask(mut existing)) => {
6479                intersect_masks(&mut existing, &path_mask);
6480                self.spare_mask = Some(path_mask); // recycle the copy
6481                self.clip_region = Some(ClipRegion::Mask(existing));
6482            }
6483        }
6484    }
6485
6486    fn init_clip(&mut self) {
6487        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6488            self.spare_mask = Some(mask);
6489        }
6490        self.clip_region = None;
6491    }
6492
6493    fn erase_page(&mut self) {
6494        // Only fill the full pixmap when it's actually allocated (non-banded path).
6495        // During banding, self.pixmap is a 1×1 placeholder — filling it is harmless.
6496        self.pixmap.fill(Color::WHITE);
6497        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6498            self.spare_mask = Some(mask);
6499        }
6500        self.clip_region = None;
6501    }
6502
6503    fn show_page(&mut self, output_path: &str) -> Result<(), String> {
6504        let w = self.pixmap.width();
6505        let h = self.pixmap.height();
6506        // Composite onto white background before output
6507        composite_onto_white(self.pixmap.data_mut());
6508        let mut sink = self.sink_factory.create_sink(output_path)?;
6509        sink.begin_page(w, h)?;
6510        sink.write_rows(self.pixmap.data(), h)?;
6511        sink.end_page()
6512    }
6513
6514    fn draw_image(&mut self, sample_data: &[u8], params: &ImageParams) {
6515        self.ensure_full_pixmap();
6516        let w = params.width;
6517        let h = params.height;
6518        if w == 0 || h == 0 {
6519            return;
6520        }
6521        let mut rgba_data =
6522            samples_to_rgba(sample_data, params, self.render_icc_cache.as_ref(), false);
6523        if params.mask_color.is_some() {
6524            apply_mask_color_rgba(&mut rgba_data, sample_data, params);
6525        }
6526        let expected = (w * h * 4) as usize;
6527        if rgba_data.len() < expected {
6528            return;
6529        }
6530
6531        let Some(image_inv) = params.image_matrix.invert() else {
6532            return;
6533        };
6534        let combined = params.ctm.concat(&image_inv);
6535        let raw_transform = enforce_min_image_size(to_transform(&combined), w, h);
6536
6537        let prescaled = prescale_image(&rgba_data, w, h, raw_transform, params.interpolate);
6538        let (img_data, img_w, img_h, transform) = match &prescaled {
6539            Some((data, pw, ph, t)) => (data.as_slice(), *pw, *ph, *t),
6540            None => (rgba_data.as_slice(), w, h, raw_transform),
6541        };
6542
6543        let Some(img_pixmap) = stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h) else {
6544            return;
6545        };
6546
6547        let (pw, ph) = (self.pixmap.width(), self.pixmap.height());
6548        let mut temp_mask = None;
6549        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, pw, ph) else {
6550            return;
6551        };
6552
6553        let paint = stet_tiny_skia::PixmapPaint {
6554            quality: image_filter_quality(transform, params.interpolate),
6555            opacity: params.alpha as f32,
6556            blend_mode: u8_to_blend_mode(params.blend_mode),
6557        };
6558        self.pixmap
6559            .draw_pixmap(0, 0, img_pixmap, &paint, transform, mask_ref);
6560    }
6561
6562    fn paint_axial_shading(&mut self, params: &AxialShadingParams) {
6563        self.ensure_full_pixmap();
6564        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6565        let mut temp_mask = None;
6566        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6567            return;
6568        };
6569        render_axial_shading(
6570            &mut self.pixmap,
6571            params,
6572            0.0,
6573            0.0,
6574            1.0,
6575            1.0,
6576            mask_ref,
6577            self.no_aa,
6578            None,
6579            None,
6580        );
6581    }
6582
6583    fn paint_radial_shading(&mut self, params: &RadialShadingParams) {
6584        self.ensure_full_pixmap();
6585        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6586        let mut temp_mask = None;
6587        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6588            return;
6589        };
6590        render_radial_shading(
6591            &mut self.pixmap,
6592            params,
6593            0.0,
6594            0.0,
6595            1.0,
6596            1.0,
6597            mask_ref,
6598            self.no_aa,
6599            None,
6600            None,
6601        );
6602    }
6603
6604    fn paint_mesh_shading(&mut self, params: &MeshShadingParams) {
6605        self.ensure_full_pixmap();
6606        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6607        let mut temp_mask = None;
6608        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6609            return;
6610        };
6611        render_mesh_shading(
6612            &mut self.pixmap,
6613            params,
6614            0.0,
6615            0.0,
6616            1.0,
6617            1.0,
6618            mask_ref,
6619            None,
6620            None,
6621        );
6622    }
6623
6624    fn paint_patch_shading(&mut self, params: &PatchShadingParams) {
6625        self.ensure_full_pixmap();
6626        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6627        let mut temp_mask = None;
6628        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6629            return;
6630        };
6631        render_patch_shading(
6632            &mut self.pixmap,
6633            params,
6634            0.0,
6635            0.0,
6636            1.0,
6637            1.0,
6638            mask_ref,
6639            None,
6640            None,
6641        );
6642    }
6643
6644    fn paint_pattern_fill(&mut self, params: &stet_graphics::device::PatternFillParams) {
6645        self.ensure_full_pixmap();
6646        let w = self.pixmap.width();
6647        let h = self.pixmap.height();
6648        let mut band_state = BandState {
6649            clip_region: self.clip_region.take(),
6650            spare_mask: self.spare_mask.take(),
6651            clip_mask_cache: HashMap::new(),
6652            clip_mask_seen: HashSet::new(),
6653            mask_pool: Vec::new(),
6654            cmyk_buffer: None,
6655            op_bg_snapshot: None,
6656            op_touched: None,
6657            spot_mask: None,
6658        };
6659        {
6660            let ctx = RenderContext {
6661                vp_x: 0.0,
6662                vp_y: 0.0,
6663                scale_x: 1.0,
6664                scale_y: 1.0,
6665                out_w: w,
6666                out_h: h,
6667                effective_dpi: self.dpi,
6668                icc: None,
6669                image_cache: None,
6670                preprocessed: None,
6671                elem_idx: 0,
6672                no_aa: self.no_aa,
6673                opm_zero_transparent: false,
6674                knockout_painter_pass: KnockoutPainterPass::None,
6675                parent_group_isolated: false,
6676                alpha_extraction_pass: false,
6677                layer_set: &self.layer_set,
6678            };
6679            render_pattern_fill(&mut self.pixmap, &mut band_state, params, &ctx);
6680        }
6681        self.clip_region = band_state.clip_region.take();
6682        if let Some(mask) = band_state.spare_mask.take() {
6683            self.spare_mask = Some(mask);
6684        }
6685    }
6686
6687    fn page_size(&self) -> (u32, u32) {
6688        (self.page_w, self.page_h)
6689    }
6690
6691    fn replay_and_show(&mut self, list: DisplayList, output_path: &str) -> Result<(), String> {
6692        // Wait for any previous background render to complete
6693        self.join_pending()?;
6694
6695        let (page_w, page_h) = self.page_size();
6696
6697        // Audit mode: re-render through the viewport pipeline so visual tests
6698        // can catch viewport-only bugs against the same baselines. Same
6699        // `render_element`, same display list — differs only in how culling
6700        // and epochs are computed.
6701        if self.use_viewport_path {
6702            let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref());
6703            let rgba = render_to_rgba_viewport(
6704                &list,
6705                page_w,
6706                page_h,
6707                self.dpi,
6708                Some(&icc_cache),
6709                self.no_aa,
6710            );
6711            let mut sink = self.sink_factory.create_sink(output_path)?;
6712            sink.begin_page(page_w, page_h)?;
6713            sink.write_rows(&rgba, page_h)?;
6714            sink.end_page()?;
6715            return Ok(());
6716        }
6717
6718        let band_h = select_band_height(page_w, page_h);
6719
6720        // Build ICC cache for this page's display list
6721        let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref());
6722
6723        // If banding not worthwhile, render the full page as a single band.
6724        // This still uses render_element (same as banded path) so that Group
6725        // and SoftMasked elements get proper offscreen compositing.
6726        if band_h >= page_h {
6727            self.ensure_full_pixmap();
6728            let ctx = RenderContext {
6729                vp_x: 0.0,
6730                vp_y: 0.0,
6731                scale_x: 1.0,
6732                scale_y: 1.0,
6733                out_w: page_w,
6734                out_h: page_h,
6735                effective_dpi: self.dpi,
6736                icc: Some(&icc_cache),
6737                image_cache: None,
6738                preprocessed: None,
6739                elem_idx: 0,
6740                no_aa: self.no_aa,
6741                opm_zero_transparent: false,
6742                knockout_painter_pass: KnockoutPainterPass::None,
6743                parent_group_isolated: false,
6744                alpha_extraction_pass: false,
6745                layer_set: &self.layer_set,
6746            };
6747            let mut band_state = BandState {
6748                clip_region: None,
6749                spare_mask: None,
6750                clip_mask_cache: HashMap::new(),
6751                clip_mask_seen: HashSet::new(),
6752                mask_pool: Vec::new(),
6753                cmyk_buffer: None,
6754                op_bg_snapshot: None,
6755                op_touched: None,
6756                spot_mask: None,
6757            };
6758            for (idx, elem) in list.elements().iter().enumerate() {
6759                let elem_ctx = RenderContext {
6760                    elem_idx: idx,
6761                    ..ctx
6762                };
6763                render_element(&mut self.pixmap, &mut band_state, elem, &elem_ctx);
6764            }
6765            return self.show_page(output_path);
6766        }
6767
6768        // Banded path: shrink self.pixmap to free memory — we use a
6769        // band-sized pixmap instead. This avoids holding a multi-GB
6770        // full-page buffer during rendering.
6771        if self.pixmap.width() > 1 {
6772            self.pixmap = Pixmap::new(1, 1).expect("Failed to create placeholder pixmap");
6773        }
6774
6775        // Create the sink for this page before spawning background work
6776        let mut sink = self.sink_factory.create_sink(output_path)?;
6777        let dpi = self.dpi;
6778        let layer_set = self.layer_set.clone();
6779
6780        #[cfg(feature = "parallel")]
6781        {
6782            // Spawn banded rendering on rayon's thread pool, overlapping with
6783            // interpretation of the next page. Using rayon::spawn avoids OS thread
6784            // creation overhead and keeps work on the warmed-up pool.
6785            let no_aa = self.no_aa;
6786            let (tx, rx) = std::sync::mpsc::sync_channel(1);
6787            rayon::spawn(move || {
6788                let result = render_banded_to_sink(
6789                    page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, no_aa, &layer_set,
6790                );
6791                let _ = tx.send(result);
6792            });
6793            self.pending_render = Some(rx);
6794        }
6795        #[cfg(not(feature = "parallel"))]
6796        {
6797            render_banded_to_sink(
6798                page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, self.no_aa, &layer_set,
6799            )?;
6800        }
6801
6802        Ok(())
6803    }
6804
6805    fn finish(&mut self) -> Result<(), String> {
6806        self.join_pending()
6807    }
6808}
6809
6810impl Drop for SkiaDevice {
6811    fn drop(&mut self) {
6812        // Safety net: ensure background render completes before device is destroyed.
6813        if let Some(rx) = self.pending_render.take() {
6814            let _ = rx.recv();
6815        }
6816    }
6817}
6818
6819impl SkiaDevice {
6820    /// Wait for the pending background render to complete, if any.
6821    fn join_pending(&mut self) -> Result<(), String> {
6822        if let Some(rx) = self.pending_render.take() {
6823            match rx.recv() {
6824                Ok(result) => result?,
6825                Err(_) => return Err("Background render task failed".to_string()),
6826            }
6827        }
6828        Ok(())
6829    }
6830}
6831
6832/// Returns true if any descendant transparency group declares an explicit
6833/// `/CS DeviceCMYK`. The renderer uses this to decide whether to allocate a
6834/// parallel CMYK buffer for the band/page so that compositing inside CMYK
6835/// groups can read the exact backdrop CMYK rather than rounding-trip via sRGB.
6836fn has_cmyk_group(list: &DisplayList) -> bool {
6837    use stet_graphics::display_list::GroupColorSpace;
6838    for elem in list.elements() {
6839        match elem {
6840            DisplayElement::Group { elements, params } => {
6841                if params.color_space == GroupColorSpace::DeviceCMYK {
6842                    return true;
6843                }
6844                if has_cmyk_group(elements) {
6845                    return true;
6846                }
6847            }
6848            DisplayElement::SoftMasked { content, mask, .. } => {
6849                if has_cmyk_group(content) || has_cmyk_group(mask) {
6850                    return true;
6851                }
6852            }
6853            DisplayElement::OcgGroup { elements, .. } => {
6854                if has_cmyk_group(elements) {
6855                    return true;
6856                }
6857            }
6858            _ => {}
6859        }
6860    }
6861    false
6862}
6863
6864/// Returns true if every visible element in `elements` is a `Fill` whose
6865/// color carries `native_cmyk`. Clip and `InitClip` ops are skipped (they
6866/// don't paint). Returns `false` for any other shape (shadings, images,
6867/// patterns, nested groups, etc.) where the inner CMYK buffer would be
6868/// derived from sRGB via the lossy `interpolate_cmyk_from_stops` /
6869/// `(1-r,1-g,1-b,0)` inverse rather than tracked from the source CMYK.
6870fn group_only_native_cmyk_fills(elements: &DisplayList) -> bool {
6871    let mut found_paint = false;
6872    for elem in elements.elements() {
6873        match elem {
6874            DisplayElement::InitClip => continue,
6875            DisplayElement::Clip { .. } => continue,
6876            DisplayElement::Fill { params, .. } => {
6877                if params.color.native_cmyk.is_none() {
6878                    return false;
6879                }
6880                found_paint = true;
6881            }
6882            DisplayElement::Stroke { params, .. } => {
6883                // Strokes write a single CMYK value per painted pixel just
6884                // like fills, so the parallel CMYK buffer stays in sync with
6885                // the pixmap. Including strokes here is required by GWG 16.1
6886                // painters whose X path is both filled and stroked with the
6887                // same registration color.
6888                if params.color.native_cmyk.is_none() {
6889                    return false;
6890                }
6891                found_paint = true;
6892            }
6893            _ => return false,
6894        }
6895    }
6896    found_paint
6897}
6898
6899/// Stronger predicate: returns `true` when every paint operation in `elements`
6900/// supplies its color directly as CMYK with one CMYK value per painted pixel
6901/// — i.e. the parallel CMYK buffer is *guaranteed* to match the rendered
6902/// pixmap on a per-pixel basis. When this holds, the per-pixel CMYK
6903/// composite-back can run safely.
6904///
6905/// Importantly, this excludes **shadings** even when their declared color
6906/// space is DeviceCMYK. The pixmap rasterizer interpolates the per-stop
6907/// `.color` (RGB) linearly across the gradient via [`build_gradient_lut`],
6908/// while [`interpolate_cmyk_from_stops`] interpolates the per-stop CMYK
6909/// `raw_components` linearly. Because the system CMYK ICC profile is
6910/// non-linear, the two interpolation strategies produce different intermediate
6911/// colors at each gradient pixel — the buffer no longer represents what the
6912/// pixmap shows, and feeding that into the composite-back yields visibly
6913/// shifted colors. Until the per-pixel rasterizer is taught to interpolate
6914/// CMYK directly (or the buffer is filled by ICC-reversing the pixmap), keep
6915/// shadings on the existing sRGB compositing path.
6916///
6917/// Recurses into nested groups and soft masks. Returns `false` if the group
6918/// contains no paint operations at all (so the composite-back has no work).
6919fn group_content_is_native_cmyk(elements: &DisplayList) -> bool {
6920    let mut found_paint = false;
6921    for elem in elements.elements() {
6922        match elem {
6923            DisplayElement::InitClip => continue,
6924            DisplayElement::Clip { .. } => continue,
6925            DisplayElement::Text { .. } => continue,
6926            DisplayElement::ErasePage => continue,
6927            DisplayElement::Fill { params, .. } => {
6928                if params.color.native_cmyk.is_none() {
6929                    return false;
6930                }
6931                found_paint = true;
6932            }
6933            DisplayElement::Stroke { params, .. } => {
6934                if params.color.native_cmyk.is_none() {
6935                    return false;
6936                }
6937                found_paint = true;
6938            }
6939            DisplayElement::Image { params, .. } => {
6940                if !is_cmyk_color_space(&params.color_space) {
6941                    return false;
6942                }
6943                found_paint = true;
6944            }
6945            DisplayElement::AxialShading { .. }
6946            | DisplayElement::RadialShading { .. }
6947            | DisplayElement::MeshShading { .. }
6948            | DisplayElement::PatchShading { .. } => {
6949                // See doc comment above: shading interpolation strategies
6950                // diverge between pixmap and buffer.
6951                return false;
6952            }
6953            DisplayElement::PatternFill { .. } => {
6954                // Pattern tiles render through their own BandState with
6955                // `cmyk_buffer: None`, so the parallel CMYK buffer can't track
6956                // per-tile source CMYK. Treat patterns as non-CMYK content.
6957                return false;
6958            }
6959            DisplayElement::Group { elements: sub, .. } => {
6960                if !group_content_is_native_cmyk(sub) {
6961                    return false;
6962                }
6963                found_paint = true;
6964            }
6965            DisplayElement::SoftMasked { .. } => {
6966                // Soft masks apply a per-pixel alpha modulation that the
6967                // parallel CMYK buffer cannot represent: the buffer holds raw
6968                // source CMYK while the pixmap holds the soft-masked blend
6969                // (`backdrop * (1 − mask) + source * mask`). Running
6970                // `composite_non_isolated_cmyk` over a soft-masked region
6971                // would feed the unmodulated source CMYK into the blend
6972                // formula and produce the wrong result for any non-Normal
6973                // parent blend mode (5310.pdf phone highlight regression).
6974                // Fall back to the sRGB contribution-extraction path, which
6975                // handles soft masks correctly.
6976                return false;
6977            }
6978            DisplayElement::OcgGroup { elements: sub, .. } => {
6979                if !group_content_is_native_cmyk(sub) {
6980                    return false;
6981                }
6982                found_paint = true;
6983            }
6984            _ => return false,
6985        }
6986    }
6987    found_paint
6988}
6989
6990/// True when `list` is a flat sequence of native-CMYK Fill/Stroke paints
6991/// with Normal blend and full opacity — i.e. the cmyk_buffer's content
6992/// faithfully represents what the pixmap shows. Used by `render_soft_masked`
6993/// to decide whether to interpolate the mask blend in CMYK (ICC→sRGB).
6994/// Rejects Group/SoftMasked/Image/Shading/Pattern and any blend-mode-modulated
6995/// paint because those would diverge from the parallel CMYK snapshot.
6996fn content_list_is_simple_native_cmyk(list: &DisplayList) -> bool {
6997    let mut found_paint = false;
6998    for elem in list.elements() {
6999        match elem {
7000            DisplayElement::InitClip
7001            | DisplayElement::Clip { .. }
7002            | DisplayElement::Text { .. }
7003            | DisplayElement::ErasePage => continue,
7004            DisplayElement::Fill { params, .. } => {
7005                if params.color.native_cmyk.is_none() {
7006                    return false;
7007                }
7008                if params.blend_mode != 0 || params.alpha != 1.0 {
7009                    return false;
7010                }
7011                found_paint = true;
7012            }
7013            DisplayElement::Stroke { params, .. } => {
7014                if params.color.native_cmyk.is_none() {
7015                    return false;
7016                }
7017                if params.blend_mode != 0 || params.alpha != 1.0 {
7018                    return false;
7019                }
7020                found_paint = true;
7021            }
7022            // Recurse into a transparency Group only when the group itself is
7023            // Normal-blend / full-opacity AND its contents are themselves
7024            // simple native CMYK. This lets gradient-feather-style content
7025            // (a Group wrapping a single CMYK fill, GWG 16.11) qualify for
7026            // CMYK-domain mask blending while the prior outer-glow C
7027            // regression (a Group wrapping a Screen-blend white rect, GWG
7028            // 16.10) still gets rejected on the inner blend_mode check.
7029            DisplayElement::Group { params, elements } => {
7030                if params.blend_mode != 0 || params.alpha != 1.0 {
7031                    return false;
7032                }
7033                if !content_list_is_simple_native_cmyk(elements) {
7034                    return false;
7035                }
7036                // A Group whose contents are all clip/text without paint
7037                // adds no paint of its own; don't flip `found_paint` here —
7038                // the recursive call already counted any inner paints.
7039                if elements.elements().iter().any(|e| {
7040                    matches!(
7041                        e,
7042                        DisplayElement::Fill { .. } | DisplayElement::Stroke { .. }
7043                    )
7044                }) {
7045                    found_paint = true;
7046                }
7047            }
7048            _ => return false,
7049        }
7050    }
7051    found_paint
7052}
7053
7054/// Scan a display list for any overprint fill/stroke elements that need CMYK simulation.
7055fn has_overprint_elements(list: &DisplayList) -> bool {
7056    for elem in list.elements() {
7057        match elem {
7058            DisplayElement::Fill { params, .. } => {
7059                if params.overprint {
7060                    return true;
7061                }
7062            }
7063            DisplayElement::Stroke { params, .. } => {
7064                if params.overprint {
7065                    return true;
7066                }
7067            }
7068            DisplayElement::Image { params, .. } => {
7069                if params.overprint {
7070                    return true;
7071                }
7072            }
7073            DisplayElement::AxialShading { params } => {
7074                if params.overprint {
7075                    return true;
7076                }
7077            }
7078            DisplayElement::RadialShading { params } => {
7079                if params.overprint {
7080                    return true;
7081                }
7082            }
7083            DisplayElement::MeshShading { params } => {
7084                if params.overprint {
7085                    return true;
7086                }
7087            }
7088            DisplayElement::PatchShading { params } => {
7089                if params.overprint {
7090                    return true;
7091                }
7092            }
7093            DisplayElement::Group { elements, .. } => {
7094                if has_overprint_elements(elements) {
7095                    return true;
7096                }
7097            }
7098            DisplayElement::SoftMasked { content, mask, .. } => {
7099                if has_overprint_elements(content) || has_overprint_elements(mask) {
7100                    return true;
7101                }
7102            }
7103            DisplayElement::OcgGroup { elements, .. } => {
7104                if has_overprint_elements(elements) {
7105                    return true;
7106                }
7107            }
7108            _ => {}
7109        }
7110    }
7111    false
7112}
7113
7114/// Render an overprint fill: rasterize path to coverage mask, then composite
7115/// at the CMYK level, converting the result to RGB for the pixmap.
7116#[allow(clippy::too_many_arguments)]
7117fn render_overprint_fill(
7118    pixmap: &mut Pixmap,
7119    cmyk_buf: &mut [f32],
7120    op_bg: &mut [u8],
7121    op_touched: &mut [u8],
7122    spot_mask: &[u8],
7123    band_state: &mut BandState,
7124    path: &PsPath,
7125    params: &FillParams,
7126    vp_x: f32,
7127    vp_y: f32,
7128    scale_x: f32,
7129    scale_y: f32,
7130    out_w: u32,
7131    out_h: u32,
7132    icc: Option<&IccCache>,
7133    no_aa: bool,
7134) {
7135    let Some(skia_path) = build_skia_path(path) else {
7136        return;
7137    };
7138    let fill_rule = to_fill_rule(&params.fill_rule);
7139
7140    let mut coverage_mask = match Mask::new(out_w, out_h) {
7141        Some(m) => m,
7142        None => return,
7143    };
7144    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7145    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7146
7147    // Compute path bbox for constrained iteration
7148    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7149        path_device_bbox(&skia_path, transform, out_w, out_h);
7150
7151    // Intersect with clip mask
7152    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7153        None => None,
7154        Some(ClipRegion::Rect(r)) => {
7155            // Only zero coverage within the path bbox (not the full page)
7156            let data = coverage_mask.data_mut();
7157            let stride = out_w as usize;
7158            for y in bbox_y0..bbox_y1 {
7159                let row_start = y * stride;
7160                for x in bbox_x0..bbox_x1 {
7161                    let yu = y as u32;
7162                    let xu = x as u32;
7163                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7164                        data[row_start + x] = 0;
7165                    }
7166                }
7167            }
7168            None
7169        }
7170        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7171    };
7172
7173    let (src_c, src_m, src_y, src_k) = params.color.native_cmyk.unwrap_or_else(|| {
7174        let r = params.color.r;
7175        let g = params.color.g;
7176        let b = params.color.b;
7177        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7178    });
7179
7180    // Custom spot paints (Separation/DeviceN whose named colorants don't include
7181    // any process channel) go to a separation plate, not CMYK. In the composite
7182    // preview we layer the spot's alt-CMYK onto the pixmap via multiplicative
7183    // ink stacking and leave the cmyk_buffer untouched — otherwise a later OPM 1
7184    // overprint would see the spot's alt-CMYK as "backdrop" and knock it out.
7185    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7186
7187    let mut channels = params.painted_channels;
7188    // Non-CMYK fills (painted_channels=0, e.g. Separation spot colors, RGB, Gray)
7189    // replace all color at each pixel — update all CMYK channels to keep buffer in sync.
7190    if channels == 0 {
7191        channels = stet_graphics::device::CMYK_ALL;
7192    }
7193    // OPM 1 per-pixel zero filtering only applies to DeviceCMYK, not DeviceN/Separation
7194    if params.overprint_mode == 1
7195        && channels == stet_graphics::device::CMYK_ALL
7196        && params.is_device_cmyk
7197    {
7198        channels = 0;
7199        if src_c != 0.0 {
7200            channels |= stet_graphics::device::CMYK_C;
7201        }
7202        if src_m != 0.0 {
7203            channels |= stet_graphics::device::CMYK_M;
7204        }
7205        if src_y != 0.0 {
7206            channels |= stet_graphics::device::CMYK_Y;
7207        }
7208        if src_k != 0.0 {
7209            channels |= stet_graphics::device::CMYK_K;
7210        }
7211        // PDF 1.7 §7.6.4.5: OPM 1 with /op true preserves zero-source
7212        // components — leave `channels = 0` for an all-zero CMYK source only
7213        // when /OPM and /op (or /OP) were set together in the same ExtGState
7214        // dict, signaling the author deliberately enabled strict-spec
7215        // semantics (as Adobe Illustrator emits). When the current /op was
7216        // set standalone and OPM was merely inherited, fall back to legacy
7217        // knockout so `0 0 0 0 k` still paints white. Matches Adobe Acrobat
7218        // behavior: GWG 4.0.1 swatches g/j (paired /OPM+/op in /GS0,/GS3)
7219        // preserve the backdrop; pdf_samples/2495.pdf page 5 icon (only /op
7220        // on /R20, OPM inherited from /R11) performs the expected knockout.
7221        if channels == 0 && !params.opm_paired {
7222            channels = stet_graphics::device::CMYK_ALL;
7223        }
7224    }
7225
7226    // Bulk tiny-skia fast path for the plain CMYK_ALL replace case. Skipped
7227    // only for K-only DeviceCMYK paints under OPM 0 (C=M=Y=0, any K) because
7228    // those match the Black plate of a DeviceN [Black, spot] backdrop and
7229    // need the per-pixel no-op-delta skip to preserve spot-derived colour —
7230    // the bulk fill_path here would otherwise wipe the spot. Other CMYK
7231    // overprints (teal, full-colour, etc.) stay on the fast path to avoid
7232    // AA drift vs the non-overprint rasteriser.
7233    let is_k_only_cmyk = params.is_device_cmyk
7234        && params.overprint_mode == 0
7235        && src_c == 0.0
7236        && src_m == 0.0
7237        && src_y == 0.0;
7238    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7239        let cov_data = coverage_mask.data();
7240        let stride = out_w as usize;
7241        for y in bbox_y0..bbox_y1 {
7242            for x in bbox_x0..bbox_x1 {
7243                let mi = y * stride + x;
7244                let mut cov = cov_data[mi] as f32 / 255.0;
7245                if let Some(clip) = clip_coverage {
7246                    cov *= clip[mi] as f32 / 255.0;
7247                }
7248                if cov > 0.0 {
7249                    let ci = mi * 4;
7250                    cmyk_buf[ci] = src_c as f32;
7251                    cmyk_buf[ci + 1] = src_m as f32;
7252                    cmyk_buf[ci + 2] = src_y as f32;
7253                    cmyk_buf[ci + 3] = src_k as f32;
7254                }
7255            }
7256        }
7257        let mut temp_mask = None;
7258        let Some(mask_ref) =
7259            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
7260        else {
7261            return;
7262        };
7263        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
7264        pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
7265        return;
7266    }
7267
7268    let cov_data = coverage_mask.data();
7269    let stride = out_w as usize;
7270    let px_data = pixmap.data_mut();
7271    let px_stride = out_w as usize * 4;
7272
7273    for y in bbox_y0..bbox_y1 {
7274        for x in bbox_x0..bbox_x1 {
7275            let mi = y * stride + x;
7276            let mut cov = cov_data[mi] as f32 / 255.0;
7277            if let Some(clip) = clip_coverage {
7278                cov *= clip[mi] as f32 / 255.0;
7279            }
7280            if cov <= 0.0 {
7281                continue;
7282            }
7283
7284            let ci = mi * 4;
7285            let pi = y * px_stride + x * 4;
7286            // Snapshot-based AA blending: on the first overprint touch of a
7287            // pixel that already has a backdrop (alpha > 0), capture the
7288            // pre-paint pixmap RGBA. Subsequent overprints at the same pixel
7289            // blend against the snapshot rather than the current pixmap, so
7290            // AA edges of stacked OPM-1 overprints do not leak colour from
7291            // earlier paints into later ones.
7292            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
7293                op_bg[pi] = px_data[pi];
7294                op_bg[pi + 1] = px_data[pi + 1];
7295                op_bg[pi + 2] = px_data[pi + 2];
7296                op_bg[pi + 3] = px_data[pi + 3];
7297                op_touched[mi] = 1;
7298            }
7299            let cur_c = cmyk_buf[ci] as f64;
7300            let cur_m = cmyk_buf[ci + 1] as f64;
7301            let cur_y = cmyk_buf[ci + 2] as f64;
7302            let cur_k = cmyk_buf[ci + 3] as f64;
7303            // Switch to multiplicative ink-stacking when the pixmap carries a
7304            // contribution not reflected in cmyk_buffer: either this paint is
7305            // itself a custom spot (painted_channels=0, non-CMYK) or the
7306            // process-ink state is empty while the pixmap shows colour *and*
7307            // is actually opaque — that signals a spot (or RGB) paint landed
7308            // here and the "replace" CMYK→RGB model would erase the
7309            // contribution for the channels being overwritten. Fully
7310            // transparent pixels are stored as premultiplied (0,0,0,0), so we
7311            // must require alpha>0 before trusting the RGB — otherwise fresh
7312            // paper (alpha=0) looks like "black backdrop" and multiplicative
7313            // darkening would paint the fill pure black.
7314            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
7315            let pixmap_has_colour = px_data[pi + 3] > 0
7316                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
7317            // Multiplicative ink-stacking only when the pixmap carries a real
7318            // backdrop: either this paint is a custom spot landing on an
7319            // already-coloured pixel, or the process-ink buffer is empty but
7320            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
7321            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
7322            // the fill to pure black, so those pixels fall through to the
7323            // replace path where the source RGB paints normally.
7324            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
7325
7326            // Promoted DeviceGray on a non-spot backdrop: fall back to a
7327            // plain knockout that replaces all four CMYK plates. The
7328            // `maybe_promote_gray_fill` path describes the paint as a
7329            // K-only subset so spot-backed swatches can preserve the spot
7330            // plate (GWG 3.0 "50% gray over spot"), but on a plain CMYK
7331            // backdrop that would preserve the old CMY values and turn the
7332            // cross into the bg colour (GWG 3.0 "50% gray over CMYK" e/k).
7333            // Expanding to CMYK_ALL here restores the regular-fill result
7334            // at those pixels.
7335            //
7336            // Gate on `params.painted_channels == CMYK_K` so this only fires
7337            // for genuinely-promoted DeviceGray. A `0 0 0 0.5 k` DeviceCMYK
7338            // paint filtered to CMYK_K by OPM 1 has `params.painted_channels
7339            // = CMYK_ALL`, and must stay K-subset so its CMY=0 values do
7340            // not wipe a CMYK backdrop (GWG 3.0 "50% K over CMYK" j/d).
7341            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
7342                && channels == stet_graphics::device::CMYK_K
7343                && params.is_device_cmyk
7344                && src_c == 0.0
7345                && src_m == 0.0
7346                && src_y == 0.0;
7347            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
7348                stet_graphics::device::CMYK_ALL
7349            } else {
7350                channels
7351            };
7352
7353            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
7354                src_c
7355            } else {
7356                cur_c
7357            };
7358            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
7359                src_m
7360            } else {
7361                cur_m
7362            };
7363            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
7364                src_y
7365            } else {
7366                cur_y
7367            };
7368            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
7369                src_k
7370            } else {
7371                cur_k
7372            };
7373
7374            // Custom spot paints live on a separation plate — skip the
7375            // cmyk_buffer write so a later OPM 1 overprint still sees the
7376            // original process-ink state as backdrop.
7377            if !is_custom_spot {
7378                cmyk_buf[ci] = new_c as f32;
7379                cmyk_buf[ci + 1] = new_m as f32;
7380                cmyk_buf[ci + 2] = new_y as f32;
7381                cmyk_buf[ci + 3] = new_k as f32;
7382            }
7383
7384            // No-op overprint: the paint's effective CMYK equals the existing
7385            // process state, so no plate actually changes. Skip the pixmap
7386            // write entirely — otherwise ICC(new_cmyk) paints a plain process
7387            // composite that erases any spot-derived colour already visible
7388            // at this pixel (GWG 3.0 "50% K over spot" swatches where the
7389            // backdrop's Black component and the cross's K value match).
7390            //
7391            // Only fire when a DeviceN/Separation paint with spot colorants
7392            // actually landed on this pixel (spot_mask[mi] != 0). On plain
7393            // CMYK backdrops, ICC(cmyk_buf) == pixmap_rgb already, and
7394            // skipping vs replacing produces the same result — but making
7395            // the skip unconditional subtly drifts AA edges because prior
7396            // stroke/fill precision accumulates (regressed GWG 1.0/1.1).
7397            let delta = (new_c - cur_c)
7398                .abs()
7399                .max((new_m - cur_m).abs())
7400                .max((new_y - cur_y).abs())
7401                .max((new_k - cur_k).abs());
7402            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
7403                continue;
7404            }
7405
7406            let (r, g, b) =
7407                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
7408                    // Promoted DeviceGray collapsing to a full replace — use the
7409                    // paint's RGB directly so the pixmap matches the colour a
7410                    // regular non-overprint gray fill would paint at the same
7411                    // pixel. Going through ICC(CMYK) here would produce a
7412                    // slightly different gray (e.g. 151 vs 127) and leave a
7413                    // darker outline where a subsequent non-promoted gray
7414                    // stroke overpaints on top of it.
7415                    //
7416                    // Checked before `use_multiplicative` because a white gray
7417                    // paint (`1 g`, native CMYK (0,0,0,0)) on a coloured RGB
7418                    // backdrop (e.g. the red `Reset Form` button in 682.pdf
7419                    // page 2) would otherwise hit the multiplicative branch
7420                    // with all-zero source CMYK, which leaves the backdrop
7421                    // unchanged — hiding the white label.
7422                    (params.color.r, params.color.g, params.color.b)
7423                } else if use_multiplicative {
7424                    // Multiplicative ink stacking: each painted channel attenuates
7425                    // the corresponding RGB component; preserved channels leave
7426                    // the pixmap's existing colour untouched. This keeps any spot
7427                    // contribution already in the pixmap visible under overprints
7428                    // whose zero-valued CMYK components should not erase it.
7429                    let bg_r = px_data[pi] as f64 / 255.0;
7430                    let bg_g = px_data[pi + 1] as f64 / 255.0;
7431                    let bg_b = px_data[pi + 2] as f64 / 255.0;
7432                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
7433                        1.0 - src_c
7434                    } else {
7435                        1.0
7436                    };
7437                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
7438                        1.0 - src_m
7439                    } else {
7440                        1.0
7441                    };
7442                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
7443                        1.0 - src_y
7444                    } else {
7445                        1.0
7446                    };
7447                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
7448                        1.0 - src_k
7449                    } else {
7450                        1.0
7451                    };
7452                    (
7453                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
7454                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
7455                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
7456                    )
7457                } else if let Some(icc_cache) = icc {
7458                    icc_cache
7459                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
7460                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
7461                } else {
7462                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
7463                };
7464
7465            let a = (cov * params.alpha as f32).min(1.0);
7466            // Blend backdrop: prefer the pre-overprint snapshot only when
7467            // this paint's colour is close to the snapshot — that signals
7468            // the paint effectively returns the pixel to its original
7469            // backdrop (e.g. the almost-white cross in GWG 4.1 cancelling
7470            // the red cross's M/Y contributions). In that case blending
7471            // against the snapshot keeps AA edges clean.
7472            //
7473            // When the paint introduces colour (e.g. a magenta stroke
7474            // following a magenta fill — both lay down ink that should
7475            // stack), fall through to the current pixmap so repeated
7476            // same-colour paints keep compounding at edges instead of
7477            // snapping back to bg.
7478            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
7479                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
7480                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
7481                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
7482                let dr = (op_bg[pi] as f32 - new_r).abs();
7483                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
7484                let db = (op_bg[pi + 2] as f32 - new_b).abs();
7485                if dr.max(dg).max(db) <= 4.0 {
7486                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
7487                } else {
7488                    (
7489                        px_data[pi],
7490                        px_data[pi + 1],
7491                        px_data[pi + 2],
7492                        px_data[pi + 3],
7493                    )
7494                }
7495            } else {
7496                (
7497                    px_data[pi],
7498                    px_data[pi + 1],
7499                    px_data[pi + 2],
7500                    px_data[pi + 3],
7501                )
7502            };
7503            let dst_a = bk_a as f32 / 255.0;
7504            let one_minus_a = 1.0 - a;
7505            let out_a = a + dst_a * one_minus_a;
7506            if out_a > 0.0 {
7507                // tiny-skia stores premultiplied RGBA. Use the standard
7508                // src-over formula in premul space: result_pre = src*a + dst_pre*(1-a).
7509                // The backdrop values are already premultiplied, so no
7510                // additional divide-by-out_a step is needed.
7511                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
7512                    .clamp(0.0, 255.0)
7513                    .round() as u8;
7514                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
7515                    .clamp(0.0, 255.0)
7516                    .round() as u8;
7517                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
7518                    .clamp(0.0, 255.0)
7519                    .round() as u8;
7520                px_data[pi + 3] = (out_a * 255.0).round() as u8;
7521            }
7522        }
7523    }
7524}
7525/// PLRM CMYK-to-RGB formula fallback.
7526fn cmyk_to_rgb_plrm(c: f64, m: f64, y: f64, k: f64) -> (f64, f64, f64) {
7527    (
7528        1.0 - (c + k).min(1.0),
7529        1.0 - (m + k).min(1.0),
7530        1.0 - (y + k).min(1.0),
7531    )
7532}
7533
7534/// Update the CMYK buffer for a non-overprint fill (to track backdrop for future overprints).
7535#[allow(clippy::too_many_arguments)]
7536/// Compute the device-space bounding box of a tiny-skia path after transform,
7537/// clamped to `(0, 0, w, h)`. Returns `(x0, y0, x1, y1)` as pixel indices.
7538fn path_device_bbox(
7539    skia_path: &stet_tiny_skia::Path,
7540    transform: Transform,
7541    w: u32,
7542    h: u32,
7543) -> (usize, usize, usize, usize) {
7544    let b = skia_path.bounds();
7545    let mut corners = [
7546        stet_tiny_skia::Point {
7547            x: b.left(),
7548            y: b.top(),
7549        },
7550        stet_tiny_skia::Point {
7551            x: b.right(),
7552            y: b.top(),
7553        },
7554        stet_tiny_skia::Point {
7555            x: b.right(),
7556            y: b.bottom(),
7557        },
7558        stet_tiny_skia::Point {
7559            x: b.left(),
7560            y: b.bottom(),
7561        },
7562    ];
7563    transform.map_points(&mut corners);
7564    let min_x = corners.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
7565    let min_y = corners.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
7566    let max_x = corners
7567        .iter()
7568        .map(|p| p.x)
7569        .fold(f32::NEG_INFINITY, f32::max);
7570    let max_y = corners
7571        .iter()
7572        .map(|p| p.y)
7573        .fold(f32::NEG_INFINITY, f32::max);
7574    // Floor/ceil + clamp to output dimensions (with 1px margin for AA)
7575    let x0 = (min_x.floor() as i32 - 1).max(0) as usize;
7576    let y0 = (min_y.floor() as i32 - 1).max(0) as usize;
7577    let x1 = (max_x.ceil() as i32 + 1).clamp(0, w as i32) as usize;
7578    let y1 = (max_y.ceil() as i32 + 1).clamp(0, h as i32) as usize;
7579    (x0, y0, x1, y1)
7580}
7581
7582fn update_cmyk_buffer_for_fill(
7583    cmyk_buf: &mut [f32],
7584    spot_mask: &mut [u8],
7585    path: &PsPath,
7586    params: &FillParams,
7587    vp_x: f32,
7588    vp_y: f32,
7589    scale_x: f32,
7590    scale_y: f32,
7591    out_w: u32,
7592    out_h: u32,
7593    clip_region: &Option<ClipRegion>,
7594    no_aa: bool,
7595    icc: Option<&IccCache>,
7596) {
7597    // Custom spot paints (Separation/DeviceN naming no process channel) go to
7598    // their own separation plate — the process CMYK buffer must be zeroed
7599    // under the paint (knockout) so a later overprint sees "no process ink"
7600    // and falls into the multiplicative-blend branch that preserves the
7601    // spot's visible contribution in the pixmap.
7602    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7603
7604    // A DeviceN/Separation paint leaves "spot contribution" on the pixmap
7605    // when its full alt-CMYK (`native_cmyk`) differs from the process-only
7606    // tint (`process_cmyk`) — the extra RGB in the pixmap comes from a spot
7607    // plate that `cmyk_buf` cannot reflect. Pure DeviceCMYK paints have
7608    // `process_cmyk == None` (fall back to native), so no spot contribution.
7609    //
7610    // A "real" custom spot paint (`is_custom_spot && native_cmyk.is_some()`)
7611    // also deposits spot RGB that `cmyk_buf` loses (it's zeroed by the
7612    // custom-spot branch). Exclude DeviceRGB / DeviceGray / ICCBased-RGB
7613    // paints — those also satisfy `is_custom_spot = painted==0 &&
7614    // !is_device_cmyk` but carry no spot-plate contribution, and flagging
7615    // them would gate later OPM-1 cancel skips on a signal that doesn't
7616    // actually mean anything.
7617    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
7618        || matches!(
7619            (params.color.native_cmyk, params.color.process_cmyk),
7620            (Some(nat), Some(proc_))
7621                if (nat.0 - proc_.0).abs() > 1e-6
7622                    || (nat.1 - proc_.1).abs() > 1e-6
7623                    || (nat.2 - proc_.2).abs() > 1e-6
7624                    || (nat.3 - proc_.3).abs() > 1e-6
7625        );
7626
7627    // Source CMYK preference: process-only CMYK (from Separation/DeviceN paints
7628    // so spot-colorant tint contributions stay out of the process buffer) >
7629    // native CMYK (full alt-CMYK tint, fine for pure DeviceCMYK paints) > ICC
7630    // reverse (sRGB→CMYK via the system CMYK profile) > PLRM (1−r, 1−g, 1−b, 0)
7631    // fallback. The ICC reverse keeps non-CMYK fills (RGB/Gray/Lab/etc.)
7632    // representable as accurate CMYK in the parallel buffer so the
7633    // non-isolated CMYK composite-back can blend them correctly.
7634    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
7635        (0.0, 0.0, 0.0, 0.0)
7636    } else if let Some(c) = params.color.process_cmyk {
7637        c
7638    } else if let Some(c) = params.color.native_cmyk {
7639        c
7640    } else if let Some(cmyk) = icc.and_then(|i| {
7641        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
7642    }) {
7643        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
7644    } else {
7645        (
7646            (1.0 - params.color.r).clamp(0.0, 1.0),
7647            (1.0 - params.color.g).clamp(0.0, 1.0),
7648            (1.0 - params.color.b).clamp(0.0, 1.0),
7649            0.0,
7650        )
7651    };
7652    let Some(skia_path) = build_skia_path(path) else {
7653        return;
7654    };
7655
7656    let mut coverage_mask = match Mask::new(out_w, out_h) {
7657        Some(m) => m,
7658        None => return,
7659    };
7660    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7661    let fill_rule = to_fill_rule(&params.fill_rule);
7662    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7663
7664    let cov_data = coverage_mask.data();
7665    let clip_data: Option<&[u8]> = match clip_region {
7666        Some(ClipRegion::Mask(m)) => Some(m.data()),
7667        _ => None,
7668    };
7669
7670    // Constrain iteration to the path's device-space bounding box
7671    let (mut bx0, mut by0, mut bx1, mut by1) =
7672        path_device_bbox(&skia_path, transform, out_w, out_h);
7673    if let Some(ClipRegion::Rect(r)) = clip_region {
7674        bx0 = bx0.max(r.x0 as usize);
7675        by0 = by0.max(r.y0 as usize);
7676        bx1 = bx1.min(r.x1 as usize);
7677        by1 = by1.min(r.y1 as usize);
7678    }
7679
7680    let stride = out_w as usize;
7681    for y in by0..by1 {
7682        for x in bx0..bx1 {
7683            let mi = y * stride + x;
7684            let mut cov = cov_data[mi] as f32 / 255.0;
7685            if let Some(clip) = clip_data {
7686                cov *= clip[mi] as f32 / 255.0;
7687            }
7688            if cov > 0.0 {
7689                let ci = mi * 4;
7690                cmyk_buf[ci] = src_c as f32;
7691                cmyk_buf[ci + 1] = src_m as f32;
7692                cmyk_buf[ci + 2] = src_y as f32;
7693                cmyk_buf[ci + 3] = src_k as f32;
7694                if has_spot_contrib {
7695                    spot_mask[mi] = 1;
7696                }
7697            }
7698        }
7699    }
7700}
7701
7702/// Render an overprint stroke: convert the stroke outline to a fill path,
7703/// rasterize a coverage mask, then composite per-pixel in CMYK so the painted
7704/// channels of the stroke colour replace the matching backdrop channels and
7705/// the result lands in the pixmap as RGB. Mirrors `render_overprint_fill`.
7706#[allow(clippy::too_many_arguments)]
7707fn render_overprint_stroke(
7708    pixmap: &mut Pixmap,
7709    cmyk_buf: &mut [f32],
7710    op_bg: &mut [u8],
7711    op_touched: &mut [u8],
7712    spot_mask: &[u8],
7713    band_state: &mut BandState,
7714    skia_path: &stet_tiny_skia::Path,
7715    stroke: &Stroke,
7716    transform: Transform,
7717    params: &StrokeParams,
7718    out_w: u32,
7719    out_h: u32,
7720    icc: Option<&IccCache>,
7721    no_aa: bool,
7722) {
7723    // Convert stroke outline to fill path. Mirrors update_cmyk_buffer_for_stroke_overprint.
7724    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
7725        .sqrt()
7726        .max(1.0);
7727    let dashed_op;
7728    let stroke_src = if let Some(ref dash) = stroke.dash {
7729        dashed_op = skia_path.dash(dash, resolution_scale);
7730        match dashed_op.as_ref() {
7731            Some(p) => p,
7732            None => skia_path,
7733        }
7734    } else {
7735        skia_path
7736    };
7737    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
7738        return;
7739    };
7740    let Some(stroked) = stroked_user.transform(transform) else {
7741        return;
7742    };
7743
7744    let mut coverage_mask = match Mask::new(out_w, out_h) {
7745        Some(m) => m,
7746        None => return,
7747    };
7748    coverage_mask.fill_path(
7749        &stroked,
7750        SkiaFillRule::Winding,
7751        !no_aa,
7752        Transform::identity(),
7753    );
7754
7755    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7756        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
7757
7758    // Intersect with clip mask (same logic as render_overprint_fill).
7759    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7760        None => None,
7761        Some(ClipRegion::Rect(r)) => {
7762            let data = coverage_mask.data_mut();
7763            let stride = out_w as usize;
7764            for y in bbox_y0..bbox_y1 {
7765                let row_start = y * stride;
7766                for x in bbox_x0..bbox_x1 {
7767                    let yu = y as u32;
7768                    let xu = x as u32;
7769                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7770                        data[row_start + x] = 0;
7771                    }
7772                }
7773            }
7774            None
7775        }
7776        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7777    };
7778
7779    let (src_c, src_m, src_y, src_k) = params.color.native_cmyk.unwrap_or_else(|| {
7780        let r = params.color.r;
7781        let g = params.color.g;
7782        let b = params.color.b;
7783        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7784    });
7785
7786    // See render_overprint_fill for the rationale: a custom spot stroke must
7787    // preserve the process CMYK buffer and blend multiplicatively in RGB so
7788    // later OPM 1 overprints don't knock out the spot's visible colour.
7789    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7790
7791    let mut channels = params.painted_channels;
7792    if channels == 0 {
7793        channels = stet_graphics::device::CMYK_ALL;
7794    }
7795    if params.overprint_mode == 1
7796        && channels == stet_graphics::device::CMYK_ALL
7797        && params.is_device_cmyk
7798    {
7799        channels = 0;
7800        if src_c != 0.0 {
7801            channels |= stet_graphics::device::CMYK_C;
7802        }
7803        if src_m != 0.0 {
7804            channels |= stet_graphics::device::CMYK_M;
7805        }
7806        if src_y != 0.0 {
7807            channels |= stet_graphics::device::CMYK_Y;
7808        }
7809        if src_k != 0.0 {
7810            channels |= stet_graphics::device::CMYK_K;
7811        }
7812        // See render_overprint_fill: an all-zero CMYK source preserves the
7813        // backdrop only when /OPM and /op|/OP were set together in the same
7814        // ExtGState. Inherited-OPM cases fall back to legacy knockout.
7815        if channels == 0 && !params.opm_paired {
7816            channels = stet_graphics::device::CMYK_ALL;
7817        }
7818    }
7819
7820    let is_k_only_cmyk = params.is_device_cmyk
7821        && params.overprint_mode == 0
7822        && src_c == 0.0
7823        && src_m == 0.0
7824        && src_y == 0.0;
7825    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7826        // Full-channel replacement: write source CMYK to buffer for covered
7827        // pixels and let tiny-skia stroke the pixmap with the source colour.
7828        // Only K-only DeviceCMYK OPM 0 paints are routed to the per-pixel
7829        // path (see render_overprint_fill).
7830        let cov_data = coverage_mask.data();
7831        let stride = out_w as usize;
7832        for y in bbox_y0..bbox_y1 {
7833            for x in bbox_x0..bbox_x1 {
7834                let mi = y * stride + x;
7835                let mut cov = cov_data[mi] as f32 / 255.0;
7836                if let Some(clip) = clip_coverage {
7837                    cov *= clip[mi] as f32 / 255.0;
7838                }
7839                if cov > 0.0 {
7840                    let ci = mi * 4;
7841                    cmyk_buf[ci] = src_c as f32;
7842                    cmyk_buf[ci + 1] = src_m as f32;
7843                    cmyk_buf[ci + 2] = src_y as f32;
7844                    cmyk_buf[ci + 3] = src_k as f32;
7845                }
7846            }
7847        }
7848        let mut temp_mask = None;
7849        let Some(mask_ref) =
7850            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
7851        else {
7852            return;
7853        };
7854        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
7855        pixmap.stroke_path(skia_path, &paint, stroke, transform, mask_ref);
7856        return;
7857    }
7858
7859    let cov_data = coverage_mask.data();
7860    let stride = out_w as usize;
7861    let px_data = pixmap.data_mut();
7862    let px_stride = out_w as usize * 4;
7863
7864    for y in bbox_y0..bbox_y1 {
7865        for x in bbox_x0..bbox_x1 {
7866            let mi = y * stride + x;
7867            let mut cov = cov_data[mi] as f32 / 255.0;
7868            if let Some(clip) = clip_coverage {
7869                cov *= clip[mi] as f32 / 255.0;
7870            }
7871            if cov <= 0.0 {
7872                continue;
7873            }
7874
7875            let ci = mi * 4;
7876            let pi = y * px_stride + x * 4;
7877            // Snapshot-based AA blending — see render_overprint_fill for the
7878            // rationale. Capture the pre-paint pixmap on first overprint touch
7879            // so stacked overprints at the same pixel blend against the
7880            // original backdrop rather than each other.
7881            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
7882                op_bg[pi] = px_data[pi];
7883                op_bg[pi + 1] = px_data[pi + 1];
7884                op_bg[pi + 2] = px_data[pi + 2];
7885                op_bg[pi + 3] = px_data[pi + 3];
7886                op_touched[mi] = 1;
7887            }
7888            let cur_c = cmyk_buf[ci] as f64;
7889            let cur_m = cmyk_buf[ci + 1] as f64;
7890            let cur_y = cmyk_buf[ci + 2] as f64;
7891            let cur_k = cmyk_buf[ci + 3] as f64;
7892            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
7893            let pixmap_has_colour = px_data[pi + 3] > 0
7894                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
7895            // Multiplicative ink-stacking only when the pixmap carries a real
7896            // backdrop: either this paint is a custom spot landing on an
7897            // already-coloured pixel, or the process-ink buffer is empty but
7898            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
7899            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
7900            // the fill to pure black, so those pixels fall through to the
7901            // replace path where the source RGB paints normally.
7902            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
7903
7904            // Promoted DeviceGray on non-spot backdrop: replace all channels
7905            // (see render_overprint_fill).
7906            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
7907                && channels == stet_graphics::device::CMYK_K
7908                && params.is_device_cmyk
7909                && src_c == 0.0
7910                && src_m == 0.0
7911                && src_y == 0.0;
7912            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
7913                stet_graphics::device::CMYK_ALL
7914            } else {
7915                channels
7916            };
7917
7918            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
7919                src_c
7920            } else {
7921                cur_c
7922            };
7923            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
7924                src_m
7925            } else {
7926                cur_m
7927            };
7928            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
7929                src_y
7930            } else {
7931                cur_y
7932            };
7933            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
7934                src_k
7935            } else {
7936                cur_k
7937            };
7938
7939            if !is_custom_spot {
7940                cmyk_buf[ci] = new_c as f32;
7941                cmyk_buf[ci + 1] = new_m as f32;
7942                cmyk_buf[ci + 2] = new_y as f32;
7943                cmyk_buf[ci + 3] = new_k as f32;
7944            }
7945
7946            // No-op overprint skip — see render_overprint_fill for rationale.
7947            let delta = (new_c - cur_c)
7948                .abs()
7949                .max((new_m - cur_m).abs())
7950                .max((new_y - cur_y).abs())
7951                .max((new_k - cur_k).abs());
7952            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
7953                continue;
7954            }
7955
7956            let (r, g, b) =
7957                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
7958                    // Promoted DeviceGray collapsing to a full replace — see
7959                    // render_overprint_fill for the rationale (must run before
7960                    // the multiplicative branch so a `1 g` / `1 G` white paint
7961                    // doesn't get folded into the backdrop via zero-source
7962                    // multiplication).
7963                    (params.color.r, params.color.g, params.color.b)
7964                } else if use_multiplicative {
7965                    let bg_r = px_data[pi] as f64 / 255.0;
7966                    let bg_g = px_data[pi + 1] as f64 / 255.0;
7967                    let bg_b = px_data[pi + 2] as f64 / 255.0;
7968                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
7969                        1.0 - src_c
7970                    } else {
7971                        1.0
7972                    };
7973                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
7974                        1.0 - src_m
7975                    } else {
7976                        1.0
7977                    };
7978                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
7979                        1.0 - src_y
7980                    } else {
7981                        1.0
7982                    };
7983                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
7984                        1.0 - src_k
7985                    } else {
7986                        1.0
7987                    };
7988                    (
7989                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
7990                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
7991                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
7992                    )
7993                } else if let Some(icc_cache) = icc {
7994                    icc_cache
7995                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
7996                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
7997                } else {
7998                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
7999                };
8000
8001            let a = (cov * params.alpha as f32).min(1.0);
8002            // Blend backdrop: prefer snapshot only when this paint's colour
8003            // closely matches the snapshot — see render_overprint_fill for
8004            // the rationale (keeps aw-on-red-style cancel paints clean at
8005            // edges while preserving additive same-colour stacking).
8006            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
8007                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
8008                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
8009                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
8010                let dr = (op_bg[pi] as f32 - new_r).abs();
8011                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
8012                let db = (op_bg[pi + 2] as f32 - new_b).abs();
8013                if dr.max(dg).max(db) <= 4.0 {
8014                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
8015                } else {
8016                    (
8017                        px_data[pi],
8018                        px_data[pi + 1],
8019                        px_data[pi + 2],
8020                        px_data[pi + 3],
8021                    )
8022                }
8023            } else {
8024                (
8025                    px_data[pi],
8026                    px_data[pi + 1],
8027                    px_data[pi + 2],
8028                    px_data[pi + 3],
8029                )
8030            };
8031            let dst_a = bk_a as f32 / 255.0;
8032            let one_minus_a = 1.0 - a;
8033            let out_a = a + dst_a * one_minus_a;
8034            if out_a > 0.0 {
8035                // tiny-skia stores premultiplied RGBA (see render_overprint_fill).
8036                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
8037                    .clamp(0.0, 255.0)
8038                    .round() as u8;
8039                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
8040                    .clamp(0.0, 255.0)
8041                    .round() as u8;
8042                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
8043                    .clamp(0.0, 255.0)
8044                    .round() as u8;
8045                px_data[pi + 3] = (out_a * 255.0).round() as u8;
8046            }
8047        }
8048    }
8049}
8050
8051/// Update the CMYK buffer for a non-overprint stroke. Mirrors
8052/// [`update_cmyk_buffer_for_fill`] but rasterizes a stroked outline path
8053/// instead of a filled one. Source-CMYK selection follows the same
8054/// native_cmyk → ICC reverse → PLRM cascade.
8055#[allow(clippy::too_many_arguments)]
8056fn update_cmyk_buffer_for_stroke(
8057    cmyk_buf: &mut [f32],
8058    spot_mask: &mut [u8],
8059    path: &PsPath,
8060    params: &StrokeParams,
8061    stroke: &Stroke,
8062    transform: Transform,
8063    out_w: u32,
8064    out_h: u32,
8065    clip_region: &Option<ClipRegion>,
8066    no_aa: bool,
8067    icc: Option<&IccCache>,
8068) {
8069    // Custom spot strokes knockout the process CMYK plates — zero the buffer
8070    // under the stroke so later overprints fall into the multiplicative-blend
8071    // branch (see update_cmyk_buffer_for_fill).
8072    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
8073    // See update_cmyk_buffer_for_fill for rationale.
8074    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
8075        || matches!(
8076            (params.color.native_cmyk, params.color.process_cmyk),
8077            (Some(nat), Some(proc_))
8078                if (nat.0 - proc_.0).abs() > 1e-6
8079                    || (nat.1 - proc_.1).abs() > 1e-6
8080                    || (nat.2 - proc_.2).abs() > 1e-6
8081                    || (nat.3 - proc_.3).abs() > 1e-6
8082        );
8083
8084    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
8085        (0.0, 0.0, 0.0, 0.0)
8086    } else if let Some(c) = params.color.process_cmyk {
8087        c
8088    } else if let Some(c) = params.color.native_cmyk {
8089        c
8090    } else if let Some(cmyk) = icc.and_then(|i| {
8091        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
8092    }) {
8093        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
8094    } else {
8095        (
8096            (1.0 - params.color.r).clamp(0.0, 1.0),
8097            (1.0 - params.color.g).clamp(0.0, 1.0),
8098            (1.0 - params.color.b).clamp(0.0, 1.0),
8099            0.0,
8100        )
8101    };
8102
8103    let Some(skia_path) = build_skia_path(path) else {
8104        return;
8105    };
8106
8107    // Convert the stroke outline into a fill path so we can rasterize it via
8108    // Mask::fill_path. Mirrors the dance in the overprint stroke branch:
8109    // dash → stroke-to-outline (in user space) → device transform.
8110    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
8111        .sqrt()
8112        .max(1.0);
8113    let dashed_op;
8114    let stroke_src = if let Some(ref dash) = stroke.dash {
8115        dashed_op = skia_path.dash(dash, resolution_scale);
8116        match dashed_op.as_ref() {
8117            Some(p) => p,
8118            None => &skia_path,
8119        }
8120    } else {
8121        &skia_path
8122    };
8123    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
8124        return;
8125    };
8126    let Some(stroked) = stroked_user.transform(transform) else {
8127        return;
8128    };
8129
8130    let mut coverage_mask = match Mask::new(out_w, out_h) {
8131        Some(m) => m,
8132        None => return,
8133    };
8134    coverage_mask.fill_path(
8135        &stroked,
8136        SkiaFillRule::Winding,
8137        !no_aa,
8138        Transform::identity(),
8139    );
8140
8141    let cov_data = coverage_mask.data();
8142    let clip_data: Option<&[u8]> = match clip_region {
8143        Some(ClipRegion::Mask(m)) => Some(m.data()),
8144        _ => None,
8145    };
8146
8147    let (mut bx0, mut by0, mut bx1, mut by1) =
8148        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
8149    if let Some(ClipRegion::Rect(r)) = clip_region {
8150        bx0 = bx0.max(r.x0 as usize);
8151        by0 = by0.max(r.y0 as usize);
8152        bx1 = bx1.min(r.x1 as usize);
8153        by1 = by1.min(r.y1 as usize);
8154    }
8155
8156    let stride = out_w as usize;
8157    for y in by0..by1 {
8158        for x in bx0..bx1 {
8159            let mi = y * stride + x;
8160            let mut cov = cov_data[mi] as f32 / 255.0;
8161            if let Some(clip) = clip_data {
8162                cov *= clip[mi] as f32 / 255.0;
8163            }
8164            if cov > 0.0 {
8165                let ci = mi * 4;
8166                cmyk_buf[ci] = src_c as f32;
8167                cmyk_buf[ci + 1] = src_m as f32;
8168                cmyk_buf[ci + 2] = src_y as f32;
8169                cmyk_buf[ci + 3] = src_k as f32;
8170                if has_spot_contrib {
8171                    spot_mask[mi] = 1;
8172                }
8173            }
8174        }
8175    }
8176}
8177
8178/// Render an overprint image with viewport params.
8179#[allow(clippy::too_many_arguments)]
8180fn render_overprint_image(
8181    pixmap: &mut Pixmap,
8182    cmyk_buf: &mut [f32],
8183    op_bg: &mut [u8],
8184    op_touched: &mut [u8],
8185    band_state: &mut BandState,
8186    sample_data: &[u8],
8187    params: &ImageParams,
8188    vp_x: f32,
8189    vp_y: f32,
8190    scale_x: f32,
8191    scale_y: f32,
8192    out_w: u32,
8193    out_h: u32,
8194    icc: Option<&IccCache>,
8195) {
8196    let iw = params.width as usize;
8197    let ih = params.height as usize;
8198    let Some(image_inv) = params.image_matrix.invert() else {
8199        return;
8200    };
8201    let combined = params.ctm.concat(&image_inv);
8202    let Some(inv_combined) = combined.invert() else {
8203        return;
8204    };
8205
8206    let px_data = pixmap.data_mut();
8207    let stride = out_w as usize;
8208    let inv_sx = 1.0 / scale_x as f64;
8209    let inv_sy = 1.0 / scale_y as f64;
8210
8211    let clip_data: Option<&[u8]> = match &band_state.clip_region {
8212        Some(ClipRegion::Mask(m)) => Some(m.data()),
8213        _ => None,
8214    };
8215    let clip_rect = match &band_state.clip_region {
8216        Some(ClipRegion::Rect(r)) => Some(*r),
8217        _ => None,
8218    };
8219
8220    let mask_info = if let ImageColorSpace::Mask { color, polarity } = &params.color_space {
8221        let (src_c, src_m, src_y, src_k) = color.native_cmyk.unwrap_or_else(|| {
8222            let r = color.r;
8223            let g = color.g;
8224            let b = color.b;
8225            (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
8226        });
8227        Some((src_c, src_m, src_y, src_k, *polarity, iw.div_ceil(8)))
8228    } else {
8229        None
8230    };
8231
8232    for by in 0..out_h as usize {
8233        for bx in 0..out_w as usize {
8234            if let Some(ref r) = clip_rect
8235                && ((by as u32) < r.y0
8236                    || (by as u32) >= r.y1
8237                    || (bx as u32) < r.x0
8238                    || (bx as u32) >= r.x1)
8239            {
8240                continue;
8241            }
8242            if let Some(clip) = clip_data {
8243                let ci_clip = by * stride + bx;
8244                if clip[ci_clip] == 0 {
8245                    let bh = out_h as usize;
8246                    let has_neighbor = (bx > 0 && clip[ci_clip - 1] != 0)
8247                        || (bx + 1 < stride && clip[ci_clip + 1] != 0)
8248                        || (by > 0 && clip[ci_clip - stride] != 0)
8249                        || (by + 1 < bh && clip[ci_clip + stride] != 0)
8250                        || (bx > 0 && by > 0 && clip[ci_clip - stride - 1] != 0)
8251                        || (bx + 1 < stride && by > 0 && clip[ci_clip - stride + 1] != 0)
8252                        || (bx > 0 && by + 1 < bh && clip[ci_clip + stride - 1] != 0)
8253                        || (bx + 1 < stride && by + 1 < bh && clip[ci_clip + stride + 1] != 0);
8254                    if !has_neighbor {
8255                        continue;
8256                    }
8257                }
8258            }
8259
8260            // Map output pixel to device space, then to image space
8261            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8262            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8263            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8264            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8265
8266            let col = ix.floor() as i64;
8267            let row = iy.floor() as i64;
8268            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8269                continue;
8270            }
8271            let col = col as usize;
8272            let row = row as usize;
8273
8274            let (src_c, src_m, src_y, src_k) =
8275                if let Some((mc, mm, my, mk, polarity, bytes_per_row)) = mask_info {
8276                    let byte_idx = row * bytes_per_row + col / 8;
8277                    let bit_offset = 7 - (col % 8);
8278                    let bit = if byte_idx < sample_data.len() {
8279                        (sample_data[byte_idx] >> bit_offset) & 1
8280                    } else {
8281                        0
8282                    };
8283                    let paint = if polarity { bit == 1 } else { bit == 0 };
8284                    if !paint {
8285                        continue;
8286                    }
8287                    (mc, mm, my, mk)
8288                } else if let Some(cmyk) =
8289                    sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8290                {
8291                    cmyk
8292                } else {
8293                    continue;
8294                };
8295
8296            let mi = by * stride + bx;
8297            let ci = mi * 4;
8298            let pi = mi * 4;
8299
8300            // Spot-tint images (Separation / DeviceN with CMYK alt and at
8301            // least one non-process colorant): per PDF spec 11.7.4.5 the
8302            // image affects only the device colorants identified by its color
8303            // space.  In composite preview that means:
8304            //   * Where the CMYK buffer is empty (fresh paper or a custom
8305            //     spot painted earlier whose alt-CMYK we never tracked),
8306            //     paint the pixel directly from the image's tint output —
8307            //     the spot's full alt-CMYK contribution shows up, and a
8308            //     same-spot underlying paint (e.g. a /GWG-Green X under an
8309            //     image whose GWG-Green is zero) is knocked out because
8310            //     ICC(0,0,0,0) is white.
8311            //   * Where the CMYK buffer carries prior CMYK (a `1 0 1 0.5 k`
8312            //     ✓ underneath), REPLACE only the NAMED PROCESS plates with
8313            //     the image's tint output and PRESERVE the rest, then
8314            //     recompose the pixmap.  A duotone DeviceN [Black, Green]
8315            //     image's "no ink" pixel knocks the ✓'s K=0.5 down to 0 —
8316            //     lightening it to (C=1, M=0, Y=1, K=0) — while leaving its
8317            //     C=1, Y=1 untouched.
8318            if image_cs_has_spot_tint_transform(&params.color_space) {
8319                let cur_c = cmyk_buf[ci] as f64;
8320                let cur_m = cmyk_buf[ci + 1] as f64;
8321                let cur_y = cmyk_buf[ci + 2] as f64;
8322                let cur_k = cmyk_buf[ci + 3] as f64;
8323                let cur_is_zero = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8324                let named = params.painted_channels;
8325                // OPM=1 zero-source preservation: when the image's tint
8326                // output for a named plate is zero, the underlying value is
8327                // preserved instead of replaced.  Without this, a duotone
8328                // DeviceN [Black, GWG-Green] image's "no ink" pixel
8329                // overwrote the K=0.5 of an underlying CMYK ✓ with 0,
8330                // rendering the checkmark too light versus Adobe Acrobat.
8331                let opm1 = params.overprint_mode == 1;
8332                let (new_c, new_m, new_y, new_k) = if cur_is_zero {
8333                    (src_c, src_m, src_y, src_k)
8334                } else {
8335                    let nc =
8336                        if named & stet_graphics::device::CMYK_C != 0 && !(opm1 && src_c == 0.0) {
8337                            src_c
8338                        } else {
8339                            cur_c
8340                        };
8341                    let nm =
8342                        if named & stet_graphics::device::CMYK_M != 0 && !(opm1 && src_m == 0.0) {
8343                            src_m
8344                        } else {
8345                            cur_m
8346                        };
8347                    let ny =
8348                        if named & stet_graphics::device::CMYK_Y != 0 && !(opm1 && src_y == 0.0) {
8349                            src_y
8350                        } else {
8351                            cur_y
8352                        };
8353                    let nk =
8354                        if named & stet_graphics::device::CMYK_K != 0 && !(opm1 && src_k == 0.0) {
8355                            src_k
8356                        } else {
8357                            cur_k
8358                        };
8359                    (nc, nm, ny, nk)
8360                };
8361                cmyk_buf[ci] = new_c as f32;
8362                cmyk_buf[ci + 1] = new_m as f32;
8363                cmyk_buf[ci + 2] = new_y as f32;
8364                cmyk_buf[ci + 3] = new_k as f32;
8365                let (r, g, b) = if let Some(icc_cache) = icc {
8366                    icc_cache
8367                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8368                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8369                } else {
8370                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8371                };
8372                if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8373                    op_bg[pi] = px_data[pi];
8374                    op_bg[pi + 1] = px_data[pi + 1];
8375                    op_bg[pi + 2] = px_data[pi + 2];
8376                    op_bg[pi + 3] = px_data[pi + 3];
8377                    op_touched[mi] = 1;
8378                }
8379                px_data[pi] = (r * 255.0).round() as u8;
8380                px_data[pi + 1] = (g * 255.0).round() as u8;
8381                px_data[pi + 2] = (b * 255.0).round() as u8;
8382                px_data[pi + 3] = 255;
8383                continue;
8384            }
8385
8386            let mut channels = params.painted_channels;
8387            // Non-CMYK images (painted_channels=0, e.g. Separation/DeviceN spot colors)
8388            // replace all CMYK channels with the tinted equivalent.
8389            if channels == 0 {
8390                channels = stet_graphics::device::CMYK_ALL;
8391            }
8392            let is_direct_cmyk = matches!(
8393                &params.color_space,
8394                ImageColorSpace::DeviceCMYK
8395                    | ImageColorSpace::ICCBased { n: 4, .. }
8396                    | ImageColorSpace::Mask { .. }
8397            );
8398            // Custom spot image: process plates stay untouched and the per-pixel
8399            // sampled CMYK is the spot's alt-CMYK, which we layer multiplicatively
8400            // onto the pixmap. For image masks, the spot identity lives on the
8401            // fill color (recognise them via painted_channels=0 paired with a
8402            // native-CMYK fill color from the alt-space conversion). Indexed
8403            // images inherit the base space, so an Indexed /DeviceCMYK palette
8404            // is NOT a custom spot even when painted_channels=0. Plain DeviceCMYK
8405            // / ICCBased(4) images keep is_custom_spot=false so standard OPM 1
8406            // behaviour still applies.
8407            let is_custom_spot = params.painted_channels == 0
8408                && !is_cmyk_color_space(&params.color_space)
8409                && match &params.color_space {
8410                    ImageColorSpace::Mask { color, .. } => color.native_cmyk.is_some(),
8411                    _ => true,
8412                };
8413            if params.overprint_mode == 1
8414                && channels == stet_graphics::device::CMYK_ALL
8415                && is_direct_cmyk
8416            {
8417                channels = 0;
8418                if src_c != 0.0 {
8419                    channels |= stet_graphics::device::CMYK_C;
8420                }
8421                if src_m != 0.0 {
8422                    channels |= stet_graphics::device::CMYK_M;
8423                }
8424                if src_y != 0.0 {
8425                    channels |= stet_graphics::device::CMYK_Y;
8426                }
8427                if src_k != 0.0 {
8428                    channels |= stet_graphics::device::CMYK_K;
8429                }
8430            }
8431
8432            let cur_c = cmyk_buf[ci] as f64;
8433            let cur_m = cmyk_buf[ci + 1] as f64;
8434            let cur_y = cmyk_buf[ci + 2] as f64;
8435            let cur_k = cmyk_buf[ci + 3] as f64;
8436            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8437            let pixmap_has_colour = px_data[pi + 3] > 0
8438                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
8439            // Multiplicative ink-stacking only when the pixmap carries a real
8440            // backdrop: either this paint is a custom spot landing on an
8441            // already-coloured pixel, or the process-ink buffer is empty but
8442            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
8443            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
8444            // the fill to pure black, so those pixels fall through to the
8445            // replace path where the source RGB paints normally.
8446            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
8447
8448            let new_c = if channels & stet_graphics::device::CMYK_C != 0 {
8449                src_c
8450            } else {
8451                cur_c
8452            };
8453            let new_m = if channels & stet_graphics::device::CMYK_M != 0 {
8454                src_m
8455            } else {
8456                cur_m
8457            };
8458            let new_y = if channels & stet_graphics::device::CMYK_Y != 0 {
8459                src_y
8460            } else {
8461                cur_y
8462            };
8463            let new_k = if channels & stet_graphics::device::CMYK_K != 0 {
8464                src_k
8465            } else {
8466                cur_k
8467            };
8468
8469            if !is_custom_spot {
8470                cmyk_buf[ci] = new_c as f32;
8471                cmyk_buf[ci + 1] = new_m as f32;
8472                cmyk_buf[ci + 2] = new_y as f32;
8473                cmyk_buf[ci + 3] = new_k as f32;
8474            }
8475
8476            let (r, g, b) = if use_multiplicative {
8477                let bg_r = px_data[pi] as f64 / 255.0;
8478                let bg_g = px_data[pi + 1] as f64 / 255.0;
8479                let bg_b = px_data[pi + 2] as f64 / 255.0;
8480                let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
8481                    1.0 - src_c
8482                } else {
8483                    1.0
8484                };
8485                let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
8486                    1.0 - src_m
8487                } else {
8488                    1.0
8489                };
8490                let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
8491                    1.0 - src_y
8492                } else {
8493                    1.0
8494                };
8495                let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
8496                    1.0 - src_k
8497                } else {
8498                    1.0
8499                };
8500                (
8501                    (bg_r * over_r * k_fac).clamp(0.0, 1.0),
8502                    (bg_g * over_g * k_fac).clamp(0.0, 1.0),
8503                    (bg_b * over_b * k_fac).clamp(0.0, 1.0),
8504                )
8505            } else if let Some(icc_cache) = icc {
8506                icc_cache
8507                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8508                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8509            } else {
8510                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8511            };
8512
8513            // Snapshot the pre-paint pixmap so a later overprint fill/stroke
8514            // at this pixel can blend against it (see render_overprint_fill).
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
8523            px_data[pi] = (r * 255.0).round() as u8;
8524            px_data[pi + 1] = (g * 255.0).round() as u8;
8525            px_data[pi + 2] = (b * 255.0).round() as u8;
8526            px_data[pi + 3] = 255;
8527        }
8528    }
8529}
8530
8531/// Update CMYK buffer for a non-overprint image.
8532///
8533/// For native-CMYK image color spaces (DeviceCMYK / ICCBased(4) / Separation
8534/// or DeviceN with CMYK alt), the source CMYK is sampled directly via
8535/// `sample_pixel_cmyk`. For non-CMYK source spaces (RGB/Gray/Lab/etc.), the
8536/// already-composited pixmap pixel is read and reverse-converted to CMYK via
8537/// the system CMYK ICC profile, falling back to the PLRM formula. This keeps
8538/// the parallel CMYK buffer faithful for any image painter inside a
8539/// CMYK-tracked context.
8540#[allow(clippy::too_many_arguments)]
8541fn update_cmyk_buffer_for_image(
8542    cmyk_buf: &mut [f32],
8543    sample_data: &[u8],
8544    pixmap_rgba: &[u8],
8545    params: &ImageParams,
8546    vp_x: f32,
8547    vp_y: f32,
8548    scale_x: f32,
8549    scale_y: f32,
8550    out_w: u32,
8551    out_h: u32,
8552    clip_region: &Option<ClipRegion>,
8553    icc: Option<&IccCache>,
8554) {
8555    let iw = params.width as usize;
8556    let ih = params.height as usize;
8557    let Some(image_inv) = params.image_matrix.invert() else {
8558        return;
8559    };
8560    let combined = params.ctm.concat(&image_inv);
8561    let Some(inv_combined) = combined.invert() else {
8562        return;
8563    };
8564    let stride = out_w as usize;
8565    let inv_sx = 1.0 / scale_x as f64;
8566    let inv_sy = 1.0 / scale_y as f64;
8567
8568    let mask_info = if let ImageColorSpace::Mask { color, polarity } = &params.color_space {
8569        let Some((c, m, y, k)) = color.native_cmyk else {
8570            return;
8571        };
8572        Some((
8573            c as f32,
8574            m as f32,
8575            y as f32,
8576            k as f32,
8577            *polarity,
8578            iw.div_ceil(8),
8579        ))
8580    } else {
8581        None
8582    };
8583
8584    let clip_data: Option<&[u8]> = match clip_region {
8585        Some(ClipRegion::Mask(m)) => Some(m.data()),
8586        _ => None,
8587    };
8588    let clip_rect = match clip_region {
8589        Some(ClipRegion::Rect(r)) => Some(*r),
8590        _ => None,
8591    };
8592
8593    for by in 0..out_h as usize {
8594        for bx in 0..out_w as usize {
8595            if let Some(ref r) = clip_rect
8596                && ((by as u32) < r.y0
8597                    || (by as u32) >= r.y1
8598                    || (bx as u32) < r.x0
8599                    || (bx as u32) >= r.x1)
8600            {
8601                continue;
8602            }
8603            if let Some(clip) = clip_data
8604                && clip[by * stride + bx] == 0
8605            {
8606                continue;
8607            }
8608
8609            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8610            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8611            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8612            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8613
8614            let col = ix.floor() as i64;
8615            let row = iy.floor() as i64;
8616            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8617                continue;
8618            }
8619            let col = col as usize;
8620            let row = row as usize;
8621
8622            let ci = (by * stride + bx) * 4;
8623            if let Some((sc, sm, sy, sk, polarity, bytes_per_row)) = mask_info {
8624                let byte_idx = row * bytes_per_row + col / 8;
8625                let bit_offset = 7 - (col % 8);
8626                let bit = if byte_idx < sample_data.len() {
8627                    (sample_data[byte_idx] >> bit_offset) & 1
8628                } else {
8629                    0
8630                };
8631                let paint = if polarity { bit == 1 } else { bit == 0 };
8632                if paint {
8633                    cmyk_buf[ci] = sc;
8634                    cmyk_buf[ci + 1] = sm;
8635                    cmyk_buf[ci + 2] = sy;
8636                    cmyk_buf[ci + 3] = sk;
8637                }
8638            } else if let Some((sc, sm, sy, sk)) =
8639                sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8640            {
8641                cmyk_buf[ci] = sc as f32;
8642                cmyk_buf[ci + 1] = sm as f32;
8643                cmyk_buf[ci + 2] = sy as f32;
8644                cmyk_buf[ci + 3] = sk as f32;
8645            } else if ci + 3 < pixmap_rgba.len() && pixmap_rgba[ci + 3] > 0 {
8646                // Non-CMYK source space: reverse-convert the composited pixmap
8647                // pixel to CMYK via the system profile. Falls back to PLRM
8648                // (1 − r, 1 − g, 1 − b, 0) when no ICC reverse is available.
8649                let r = pixmap_rgba[ci] as f64 / 255.0;
8650                let g = pixmap_rgba[ci + 1] as f64 / 255.0;
8651                let b = pixmap_rgba[ci + 2] as f64 / 255.0;
8652                let cmyk =
8653                    if let Some(c) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
8654                        c
8655                    } else {
8656                        [
8657                            (1.0 - r).clamp(0.0, 1.0),
8658                            (1.0 - g).clamp(0.0, 1.0),
8659                            (1.0 - b).clamp(0.0, 1.0),
8660                            0.0,
8661                        ]
8662                    };
8663                cmyk_buf[ci] = cmyk[0] as f32;
8664                cmyk_buf[ci + 1] = cmyk[1] as f32;
8665                cmyk_buf[ci + 2] = cmyk[2] as f32;
8666                cmyk_buf[ci + 3] = cmyk[3] as f32;
8667            }
8668        }
8669    }
8670}
8671/// Check if an image color space can be rendered through the overprint path.
8672/// Image masks always work (they use the fill color's native CMYK).
8673/// Other color spaces must be CMYK-resolvable via `sample_pixel_cmyk`.
8674fn image_supports_overprint(cs: &ImageColorSpace) -> bool {
8675    match cs {
8676        ImageColorSpace::Mask { .. } => true,
8677        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => true,
8678        ImageColorSpace::Separation { alt_space, .. }
8679        | ImageColorSpace::DeviceN { alt_space, .. } => {
8680            matches!(
8681                alt_space.as_ref(),
8682                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8683            )
8684        }
8685        ImageColorSpace::Indexed { base, .. } => image_supports_overprint(base),
8686        _ => false,
8687    }
8688}
8689
8690/// Check if an image color space is CMYK-based (DeviceCMYK, ICCBased 4-component, or Indexed over CMYK).
8691fn is_cmyk_color_space(cs: &ImageColorSpace) -> bool {
8692    match cs {
8693        ImageColorSpace::DeviceCMYK => true,
8694        ImageColorSpace::ICCBased { n: 4, .. } => true,
8695        ImageColorSpace::Indexed { base, .. } => is_cmyk_color_space(base),
8696        _ => false,
8697    }
8698}
8699
8700/// True when an image's color space is a Separation/DeviceN with a CMYK
8701/// alternate AND at least one non-process spot colorant.  These images
8702/// represent paint that affects a virtual spot plate; the per-pixel CMYK
8703/// produced by the tint transform must blend multiplicatively with the
8704/// tracked CMYK buffer (rather than per-channel REPLACE) so that
8705/// underlying CMYK paints survive while same-spot underlying paints are
8706/// replaced by the image's "no ink" pixels.
8707fn image_cs_has_spot_tint_transform(cs: &ImageColorSpace) -> bool {
8708    use stet_graphics::device::cmyk_channel_for_name;
8709    match cs {
8710        ImageColorSpace::Separation {
8711            name, alt_space, ..
8712        } => {
8713            cmyk_channel_for_name(name) == 0
8714                && matches!(
8715                    alt_space.as_ref(),
8716                    ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8717                )
8718        }
8719        ImageColorSpace::DeviceN {
8720            names, alt_space, ..
8721        } => {
8722            matches!(
8723                alt_space.as_ref(),
8724                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8725            ) && names.iter().any(|n| cmyk_channel_for_name(n) == 0)
8726        }
8727        ImageColorSpace::Indexed { base, .. } => image_cs_has_spot_tint_transform(base),
8728        _ => false,
8729    }
8730}
8731
8732/// Sample a single pixel's CMYK values from image data, handling DeviceCMYK,
8733/// ICCBased(4), Separation/DeviceN with CMYK alt, and Indexed color spaces.
8734/// Returns None for non-CMYK images.
8735fn sample_pixel_cmyk(
8736    sample_data: &[u8],
8737    cs: &ImageColorSpace,
8738    iw: usize,
8739    row: usize,
8740    col: usize,
8741) -> Option<(f64, f64, f64, f64)> {
8742    match cs {
8743        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => {
8744            let si = (row * iw + col) * 4;
8745            if si + 3 < sample_data.len() {
8746                Some((
8747                    sample_data[si] as f64 / 255.0,
8748                    sample_data[si + 1] as f64 / 255.0,
8749                    sample_data[si + 2] as f64 / 255.0,
8750                    sample_data[si + 3] as f64 / 255.0,
8751                ))
8752            } else {
8753                None
8754            }
8755        }
8756        ImageColorSpace::Separation {
8757            alt_space,
8758            tint_table,
8759            ..
8760        } => {
8761            if !matches!(
8762                alt_space.as_ref(),
8763                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8764            ) {
8765                return None;
8766            }
8767            let si = row * iw + col;
8768            if si >= sample_data.len() {
8769                return None;
8770            }
8771            let tint = sample_data[si] as f32 / 255.0;
8772            let mut alt = [0.0f32; 4];
8773            tint_table.lookup_1d(tint, &mut alt);
8774            Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64))
8775        }
8776        ImageColorSpace::DeviceN {
8777            alt_space,
8778            tint_table,
8779            ..
8780        } => {
8781            if !matches!(
8782                alt_space.as_ref(),
8783                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8784            ) {
8785                return None;
8786            }
8787            let ni = tint_table.num_inputs as usize;
8788            let si = (row * iw + col) * ni;
8789            if si + ni > sample_data.len() {
8790                return None;
8791            }
8792            let mut inputs = vec![0.0f32; ni];
8793            for (c, inp) in inputs.iter_mut().enumerate() {
8794                *inp = sample_data[si + c] as f32 / 255.0;
8795            }
8796            let mut alt = [0.0f32; 4];
8797            tint_table.lookup_nd(&inputs, &mut alt);
8798            Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64))
8799        }
8800        ImageColorSpace::Indexed {
8801            base,
8802            hival,
8803            lookup,
8804        } => {
8805            let pi = row * iw + col;
8806            if pi >= sample_data.len() {
8807                return None;
8808            }
8809            let idx = sample_data[pi] as usize;
8810            let idx = idx.min(*hival as usize);
8811            let base_ncomp = base.num_components() as usize;
8812            let li = idx * base_ncomp;
8813            // For direct CMYK base (4 components): read CMYK from lookup table
8814            if is_cmyk_color_space(base) && base_ncomp == 4 && li + 3 < lookup.len() {
8815                return Some((
8816                    lookup[li] as f64 / 255.0,
8817                    lookup[li + 1] as f64 / 255.0,
8818                    lookup[li + 2] as f64 / 255.0,
8819                    lookup[li + 3] as f64 / 255.0,
8820                ));
8821            }
8822            // For Separation/DeviceN base: extract base components from lookup, then tint
8823            if li + base_ncomp <= lookup.len() {
8824                match base.as_ref() {
8825                    ImageColorSpace::Separation {
8826                        alt_space,
8827                        tint_table,
8828                        ..
8829                    } if matches!(
8830                        alt_space.as_ref(),
8831                        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8832                    ) =>
8833                    {
8834                        let tint = lookup[li] as f32 / 255.0;
8835                        let mut alt = [0.0f32; 4];
8836                        tint_table.lookup_1d(tint, &mut alt);
8837                        return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
8838                    }
8839                    ImageColorSpace::DeviceN {
8840                        alt_space,
8841                        tint_table,
8842                        ..
8843                    } if matches!(
8844                        alt_space.as_ref(),
8845                        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8846                    ) =>
8847                    {
8848                        let ni = tint_table.num_inputs as usize;
8849                        let mut inputs = vec![0.0f32; ni];
8850                        for (c, inp) in inputs.iter_mut().enumerate() {
8851                            if c < base_ncomp {
8852                                *inp = lookup[li + c] as f32 / 255.0;
8853                            }
8854                        }
8855                        let mut alt = [0.0f32; 4];
8856                        tint_table.lookup_nd(&inputs, &mut alt);
8857                        return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
8858                    }
8859                    _ => {}
8860                }
8861            }
8862            None
8863        }
8864        _ => None,
8865    }
8866}
8867/// Banded rendering as a free function — runs on a background thread.
8868///
8869/// Renders the display list in horizontal bands and streams the output
8870/// to a `PageSink`. This function is self-contained: it creates its own
8871/// band pixmaps, clip state, and streams rows to the sink.
8872#[allow(clippy::too_many_arguments)]
8873fn render_banded_to_sink(
8874    page_w: u32,
8875    page_h: u32,
8876    band_h: u32,
8877    dpi: f64,
8878    list: &DisplayList,
8879    sink: &mut dyn stet_graphics::device::PageSink,
8880    icc_cache: &IccCache,
8881    no_aa: bool,
8882    layer_set: &LayerSet,
8883) -> Result<(), String> {
8884    // Precompute Y bounding boxes for culling
8885    let bboxes = precompute_bboxes(list, dpi);
8886
8887    // Build clip epochs — groups of elements between InitClip boundaries.
8888    // Epochs whose paint elements don't overlap a band can be skipped entirely,
8889    // avoiding both the per-element iteration AND clip mask rasterization.
8890    let epochs = build_clip_epochs(list, &bboxes);
8891
8892    // Pre-populate clip_mask_seen so repeated clip paths get cached from first band
8893    let clip_seen = precompute_clip_seen(list);
8894
8895    // Allocate a CMYK buffer at the page level when CMYK math is needed:
8896    // overprint simulation, an explicit DeviceCMYK page-level transparency
8897    // group (PDF spec §11.6.7), or any descendant group that declares its own
8898    // DeviceCMYK transparency CS.
8899    use stet_graphics::display_list::GroupColorSpace;
8900    let needs_cmyk_buffer = has_overprint_elements(list)
8901        || list.page_group_color_space() == GroupColorSpace::DeviceCMYK
8902        || has_cmyk_group(list);
8903
8904    // Pre-convert and prescale images once (instead of per-band)
8905    let preprocessed_images = preprocess_images_for_bands(list, Some(icc_cache));
8906
8907    // Extra rows rendered above and below each band to provide anti-aliasing
8908    // context at band seams. Without this, tiny-skia clips geometry at the
8909    // pixmap edge, producing visible discontinuities in thin diagonal strokes.
8910    const BAND_OVERLAP: u32 = 6;
8911
8912    let render_h = band_h + 2 * BAND_OVERLAP;
8913
8914    // Initialize the sink for this page
8915    sink.begin_page(page_w, page_h)?;
8916
8917    let num_bands = page_h.div_ceil(band_h);
8918    let elements = list.elements();
8919    let row_bytes = page_w as usize * 4;
8920    let icc_ref = Some(icc_cache);
8921
8922    // Closure that renders a single band and returns its RGBA pixels.
8923    let render_band = |band_idx: u32| -> Vec<u8> {
8924        let y_start = band_idx * band_h;
8925        let actual_h = (page_h - y_start).min(band_h);
8926
8927        let render_y_start = y_start.saturating_sub(BAND_OVERLAP);
8928        let render_y_end_f = ((y_start + actual_h + BAND_OVERLAP).min(page_h)) as f64;
8929        let band_offset = y_start - render_y_start;
8930
8931        let mut band_pixmap = Pixmap::new(page_w, render_h).expect("Failed to create band pixmap");
8932        // Start transparent — white background composited after content rendering
8933        band_pixmap.as_mut().data_mut().fill(0x00);
8934
8935        let cmyk_buf = if needs_cmyk_buffer {
8936            // CMYK buffer for the render region (including overlap)
8937            Some(vec![0.0f32; page_w as usize * render_h as usize * 4])
8938        } else {
8939            None
8940        };
8941
8942        let mut band_state = BandState {
8943            clip_region: None,
8944            spare_mask: None,
8945            clip_mask_cache: HashMap::new(),
8946            clip_mask_seen: clip_seen.clone(),
8947            mask_pool: Vec::new(),
8948            cmyk_buffer: cmyk_buf,
8949            op_bg_snapshot: None,
8950            op_touched: None,
8951            spot_mask: None,
8952        };
8953
8954        // Epoch-based replay
8955        for epoch in &epochs {
8956            if !epoch.has_erase_page {
8957                match epoch.paint_bbox {
8958                    Some(ref pb)
8959                        if pb.y_max <= render_y_start as f64 || pb.y_min >= render_y_end_f =>
8960                    {
8961                        continue;
8962                    }
8963                    None => continue,
8964                    _ => {}
8965                }
8966            }
8967
8968            for i in epoch.start_idx..epoch.end_idx {
8969                // OcgGroups containing Clip/InitClip must always be
8970                // processed so their clip-state changes apply for every
8971                // band — per-element Y culling would strand clip mutations
8972                // inside a group whose paint content doesn't touch the
8973                // current band.
8974                let force_process = matches!(
8975                    &elements[i],
8976                    DisplayElement::OcgGroup { elements: inner, .. }
8977                        if contains_clip_op(inner)
8978                );
8979                if !force_process
8980                    && let Some(ref bbox) = bboxes[i]
8981                    && (bbox.y_max <= render_y_start as f64 || bbox.y_min >= render_y_end_f)
8982                {
8983                    continue;
8984                }
8985                let ctx = RenderContext {
8986                    vp_x: 0.0,
8987                    vp_y: render_y_start as f32,
8988                    scale_x: 1.0,
8989                    scale_y: 1.0,
8990                    out_w: page_w,
8991                    out_h: render_h,
8992                    effective_dpi: dpi,
8993                    icc: icc_ref,
8994                    image_cache: None,
8995                    preprocessed: Some(&preprocessed_images),
8996                    elem_idx: i,
8997                    no_aa,
8998                    opm_zero_transparent: false,
8999                    knockout_painter_pass: KnockoutPainterPass::None,
9000                    parent_group_isolated: false,
9001                    alpha_extraction_pass: false,
9002                    layer_set,
9003                };
9004                render_element(&mut band_pixmap, &mut band_state, &elements[i], &ctx);
9005            }
9006        }
9007
9008        // Composite content onto white background (premultiplied alpha)
9009        composite_onto_white(band_pixmap.data_mut());
9010
9011        // Extract only the actual band rows (skip overlap)
9012        let start_byte = band_offset as usize * row_bytes;
9013        let total_bytes = actual_h as usize * row_bytes;
9014        band_pixmap.data()[start_byte..start_byte + total_bytes].to_vec()
9015    };
9016
9017    // Render bands in parallel (when available), write to sink in order.
9018    #[cfg(feature = "parallel")]
9019    {
9020        // Process in chunks of `chunk_size` bands to limit peak memory
9021        // (each rendered band is ~band_h * page_w * 4 bytes).
9022        // Cap at 8 threads — sequential sink writing bottleneck means
9023        // additional cores yield no speedup (benchmarked: 8→7.8s plateau).
9024        let chunk_size = rayon::current_num_threads().max(1);
9025
9026        for chunk_start in (0..num_bands).step_by(chunk_size) {
9027            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
9028
9029            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
9030                .into_par_iter()
9031                .map(&render_band)
9032                .collect();
9033
9034            for (i, band_data) in rendered.iter().enumerate() {
9035                let band_idx = chunk_start + i as u32;
9036                let y_start = band_idx * band_h;
9037                let actual_h = (page_h - y_start).min(band_h);
9038                sink.write_rows(band_data, actual_h)?;
9039            }
9040        }
9041    }
9042    #[cfg(not(feature = "parallel"))]
9043    {
9044        // Sequential single-threaded rendering
9045        for band_idx in 0..num_bands {
9046            let band_data = render_band(band_idx);
9047            let y_start = band_idx * band_h;
9048            let actual_h = (page_h - y_start).min(band_h);
9049            sink.write_rows(&band_data, actual_h)?;
9050        }
9051    }
9052
9053    sink.end_page()
9054}
9055
9056/// 2D bounding box in device pixels.
9057#[derive(Clone, Copy)]
9058struct BBox2D {
9059    x_min: f64,
9060    y_min: f64,
9061    x_max: f64,
9062    y_max: f64,
9063}
9064
9065/// Compute full 2D bounding boxes for display list elements (for viewport culling).
9066fn precompute_full_bboxes(list: &DisplayList, dpi: f64) -> Vec<Option<BBox2D>> {
9067    list.elements()
9068        .iter()
9069        .map(|elem| match elem {
9070            DisplayElement::Fill { path, params } => fill_device_full_bbox(path, &params.ctm),
9071            DisplayElement::Stroke { path, params } => {
9072                path_full_bbox(path).map(|mut bbox| {
9073                    // Use effective line width: actual width or hairline minimum
9074                    let effective_lw = params.line_width.max(hairline_min_width(&params.ctm, dpi));
9075                    let expand = effective_lw * params.miter_limit * 0.5;
9076                    let m = &params.ctm;
9077                    let is_identity = m.a == 1.0
9078                        && m.b == 0.0
9079                        && m.c == 0.0
9080                        && m.d == 1.0
9081                        && m.tx == 0.0
9082                        && m.ty == 0.0;
9083                    if is_identity {
9084                        bbox.x_min -= expand;
9085                        bbox.x_max += expand;
9086                        bbox.y_min -= expand;
9087                        bbox.y_max += expand;
9088                    } else {
9089                        // Path is in user space — expand for stroke, then
9090                        // transform bbox corners through CTM to device space.
9091                        let col_x_len = (m.a * m.a + m.b * m.b).sqrt().max(1.0);
9092                        let col_y_len = (m.c * m.c + m.d * m.d).sqrt().max(1.0);
9093                        let expand_x = effective_lw * col_x_len * params.miter_limit * 0.5;
9094                        let expand_y = effective_lw * col_y_len * params.miter_limit * 0.5;
9095                        bbox.x_min -= expand_x;
9096                        bbox.x_max += expand_x;
9097                        bbox.y_min -= expand_y;
9098                        bbox.y_max += expand_y;
9099                        // Transform all 4 corners to device space
9100                        let corners = [
9101                            (
9102                                m.a * bbox.x_min + m.c * bbox.y_min + m.tx,
9103                                m.b * bbox.x_min + m.d * bbox.y_min + m.ty,
9104                            ),
9105                            (
9106                                m.a * bbox.x_max + m.c * bbox.y_min + m.tx,
9107                                m.b * bbox.x_max + m.d * bbox.y_min + m.ty,
9108                            ),
9109                            (
9110                                m.a * bbox.x_min + m.c * bbox.y_max + m.tx,
9111                                m.b * bbox.x_min + m.d * bbox.y_max + m.ty,
9112                            ),
9113                            (
9114                                m.a * bbox.x_max + m.c * bbox.y_max + m.tx,
9115                                m.b * bbox.x_max + m.d * bbox.y_max + m.ty,
9116                            ),
9117                        ];
9118                        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9119                        bbox.x_max = corners
9120                            .iter()
9121                            .map(|c| c.0)
9122                            .fold(f64::NEG_INFINITY, f64::max);
9123                        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9124                        bbox.y_max = corners
9125                            .iter()
9126                            .map(|c| c.1)
9127                            .fold(f64::NEG_INFINITY, f64::max);
9128                    }
9129                    bbox
9130                })
9131            }
9132            DisplayElement::Image { params, .. } => image_full_bbox(params),
9133            DisplayElement::AxialShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9134            DisplayElement::RadialShading { params } => {
9135                shading_full_bbox(&params.bbox, &params.ctm)
9136            }
9137            DisplayElement::MeshShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9138            DisplayElement::PatchShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9139            DisplayElement::PatternFill { params } => pattern_fill_full_bbox(params),
9140            DisplayElement::Group { params, .. } => Some(BBox2D {
9141                x_min: params.bbox[0],
9142                y_min: params.bbox[1],
9143                x_max: params.bbox[2],
9144                y_max: params.bbox[3],
9145            }),
9146            DisplayElement::SoftMasked { params, .. } => Some(BBox2D {
9147                x_min: params.bbox[0],
9148                y_min: params.bbox[1],
9149                x_max: params.bbox[2],
9150                y_max: params.bbox[3],
9151            }),
9152            DisplayElement::OcgGroup {
9153                elements,
9154                visibility,
9155            } => {
9156                // Hidden groups without clip ops contribute nothing. Hidden
9157                // + has clip ops is force-processed at the render-loop layer
9158                // (see the viewport render_region_prepared loop) so we still
9159                // return the paint bounds here for correct epoch bbox.
9160                if !visibility.default_visible() && !contains_clip_op(elements) {
9161                    return None;
9162                }
9163                let child_bboxes = precompute_full_bboxes(elements, dpi);
9164                let mut x_min = f64::INFINITY;
9165                let mut y_min = f64::INFINITY;
9166                let mut x_max = f64::NEG_INFINITY;
9167                let mut y_max = f64::NEG_INFINITY;
9168                for cb in child_bboxes.into_iter().flatten() {
9169                    x_min = x_min.min(cb.x_min);
9170                    y_min = y_min.min(cb.y_min);
9171                    x_max = x_max.max(cb.x_max);
9172                    y_max = y_max.max(cb.y_max);
9173                }
9174                if x_min <= x_max && y_min <= y_max {
9175                    Some(BBox2D {
9176                        x_min,
9177                        y_min,
9178                        x_max,
9179                        y_max,
9180                    })
9181                } else {
9182                    None
9183                }
9184            }
9185            _ => None, // Clip, InitClip, ErasePage: always process
9186        })
9187        .collect()
9188}
9189
9190/// Compute the device-space bounding box of a Clip element's path.
9191///
9192/// Clip paths emitted by the PDF reader use `ctm = identity`, so the path
9193/// segments are already in device space. For Clips that come from other
9194/// sources (PostScript, the pattern transform path), the `ctm` field may
9195/// be non-identity and the path is in user space — transform the path's
9196/// bbox corners through the CTM in that case. Stroke-clips are expanded
9197/// by half the line width.
9198fn clip_path_bbox(path: &PsPath, params: &ClipParams) -> Option<BBox2D> {
9199    let mut bbox = path_full_bbox(path)?;
9200    let ctm = &params.ctm;
9201    let is_identity = ctm.a == 1.0
9202        && ctm.b == 0.0
9203        && ctm.c == 0.0
9204        && ctm.d == 1.0
9205        && ctm.tx == 0.0
9206        && ctm.ty == 0.0;
9207    if !is_identity {
9208        let corners = [
9209            ctm.transform_point(bbox.x_min, bbox.y_min),
9210            ctm.transform_point(bbox.x_max, bbox.y_min),
9211            ctm.transform_point(bbox.x_min, bbox.y_max),
9212            ctm.transform_point(bbox.x_max, bbox.y_max),
9213        ];
9214        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9215        bbox.x_max = corners
9216            .iter()
9217            .map(|c| c.0)
9218            .fold(f64::NEG_INFINITY, f64::max);
9219        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9220        bbox.y_max = corners
9221            .iter()
9222            .map(|c| c.1)
9223            .fold(f64::NEG_INFINITY, f64::max);
9224    }
9225    if let Some(sp) = &params.stroke_params {
9226        let scale = (ctm.a * ctm.a + ctm.b * ctm.b)
9227            .sqrt()
9228            .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt())
9229            .max(1.0);
9230        let expand = sp.line_width * 0.5 * scale;
9231        bbox.x_min -= expand;
9232        bbox.x_max += expand;
9233        bbox.y_min -= expand;
9234        bbox.y_max += expand;
9235    }
9236    Some(bbox)
9237}
9238
9239/// Intersect two bboxes; returns `None` if they don't overlap.
9240fn intersect_bbox(a: &BBox2D, b: &BBox2D) -> Option<BBox2D> {
9241    let x_min = a.x_min.max(b.x_min);
9242    let y_min = a.y_min.max(b.y_min);
9243    let x_max = a.x_max.min(b.x_max);
9244    let y_max = a.y_max.min(b.y_max);
9245    if x_min < x_max && y_min < y_max {
9246        Some(BBox2D {
9247            x_min,
9248            y_min,
9249            x_max,
9250            y_max,
9251        })
9252    } else {
9253        None
9254    }
9255}
9256
9257/// Compute the union of all paint elements' device-space bounds in
9258/// `list`, with awareness of the active clip stack.
9259///
9260/// Used by the soft-mask rasterization path: a SoftMasked element's
9261/// `params.bbox` is derived from the form's `/BBox` transformed by the
9262/// gs-time CTM, but the form's internal `cm` operators may translate
9263/// individual paint elements outside that bbox. The mask raster needs to
9264/// be sized against the actual paint bounds, not the form bbox.
9265///
9266/// **Why clip-awareness matters**: a mask form may contain a shading
9267/// without an explicit `/BBox`, in which case `precompute_full_bboxes`
9268/// returns a sentinel "infinite" bbox (`shading_full_bbox` falls back to
9269/// `0..1e9`) so band rendering doesn't cull it. If `compute_paint_bounds`
9270/// just unioned that, the result would exceed the mask raster size cap
9271/// and `rasterize_mask` would return `None`, making the entire SoftMasked
9272/// element invisible. Tracking the active clip stack lets us bound those
9273/// shadings to their effective paint area.
9274///
9275/// Returns `None` when the list contains no paintable elements or when
9276/// no element survives clip culling.
9277fn compute_paint_bounds(list: &DisplayList, _dpi: f64) -> Option<BBox2D> {
9278    // Active clip stack: each entry is the intersection so far. The
9279    // current clip is `clip_stack.last()`; an empty stack means
9280    // "unbounded" (no clip established yet, or just after InitClip).
9281    let mut clip_stack: Vec<BBox2D> = Vec::new();
9282    let mut union: Option<BBox2D> = None;
9283
9284    let push_paint = |union: &mut Option<BBox2D>, clip_stack: &[BBox2D], bbox: BBox2D| {
9285        // Intersect against the active clip if any. If the clip is
9286        // tighter than the bbox, the visible region is the intersection;
9287        // if the bbox is fully clipped away, skip it.
9288        let visible = match clip_stack.last() {
9289            Some(clip) => match intersect_bbox(clip, &bbox) {
9290                Some(b) => b,
9291                None => return,
9292            },
9293            None => bbox,
9294        };
9295        *union = Some(match union.take() {
9296            None => visible,
9297            Some(u) => BBox2D {
9298                x_min: u.x_min.min(visible.x_min),
9299                y_min: u.y_min.min(visible.y_min),
9300                x_max: u.x_max.max(visible.x_max),
9301                y_max: u.y_max.max(visible.y_max),
9302            },
9303        });
9304    };
9305
9306    for elem in list.elements() {
9307        match elem {
9308            DisplayElement::Clip { path, params } => {
9309                if let Some(cb) = clip_path_bbox(path, params) {
9310                    let new_top = match clip_stack.last() {
9311                        Some(prev) => match intersect_bbox(prev, &cb) {
9312                            Some(b) => b,
9313                            // Clip cleared the visible region; push an
9314                            // empty bbox so subsequent paints are
9315                            // clipped away.
9316                            None => BBox2D {
9317                                x_min: 0.0,
9318                                y_min: 0.0,
9319                                x_max: 0.0,
9320                                y_max: 0.0,
9321                            },
9322                        },
9323                        None => cb,
9324                    };
9325                    clip_stack.push(new_top);
9326                }
9327            }
9328            DisplayElement::InitClip | DisplayElement::ErasePage => {
9329                clip_stack.clear();
9330            }
9331            DisplayElement::Fill { path, .. } => {
9332                if let Some(b) = path_full_bbox(path) {
9333                    push_paint(&mut union, &clip_stack, b);
9334                }
9335            }
9336            DisplayElement::Stroke { path, params } => {
9337                if let Some(mut b) = path_full_bbox(path) {
9338                    let expand = params.line_width * params.miter_limit * 0.5;
9339                    b.x_min -= expand;
9340                    b.x_max += expand;
9341                    b.y_min -= expand;
9342                    b.y_max += expand;
9343                    push_paint(&mut union, &clip_stack, b);
9344                }
9345            }
9346            DisplayElement::Image { params, .. } => {
9347                if let Some(b) = image_full_bbox(params) {
9348                    push_paint(&mut union, &clip_stack, b);
9349                }
9350            }
9351            DisplayElement::AxialShading { params } => {
9352                let b = match &params.bbox {
9353                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9354                    None => clip_stack.last().copied(),
9355                };
9356                if let Some(b) = b {
9357                    push_paint(&mut union, &clip_stack, b);
9358                }
9359            }
9360            DisplayElement::RadialShading { params } => {
9361                let b = match &params.bbox {
9362                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9363                    None => clip_stack.last().copied(),
9364                };
9365                if let Some(b) = b {
9366                    push_paint(&mut union, &clip_stack, b);
9367                }
9368            }
9369            DisplayElement::MeshShading { params } => {
9370                let b = match &params.bbox {
9371                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9372                    None => clip_stack.last().copied(),
9373                };
9374                if let Some(b) = b {
9375                    push_paint(&mut union, &clip_stack, b);
9376                }
9377            }
9378            DisplayElement::PatchShading { params } => {
9379                let b = match &params.bbox {
9380                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9381                    None => clip_stack.last().copied(),
9382                };
9383                if let Some(b) = b {
9384                    push_paint(&mut union, &clip_stack, b);
9385                }
9386            }
9387            DisplayElement::PatternFill { params } => {
9388                if let Some(b) = pattern_fill_full_bbox(params) {
9389                    push_paint(&mut union, &clip_stack, b);
9390                }
9391            }
9392            DisplayElement::Group { params, .. } => {
9393                push_paint(
9394                    &mut union,
9395                    &clip_stack,
9396                    BBox2D {
9397                        x_min: params.bbox[0],
9398                        y_min: params.bbox[1],
9399                        x_max: params.bbox[2],
9400                        y_max: params.bbox[3],
9401                    },
9402                );
9403            }
9404            DisplayElement::SoftMasked { params, .. } => {
9405                push_paint(
9406                    &mut union,
9407                    &clip_stack,
9408                    BBox2D {
9409                        x_min: params.bbox[0],
9410                        y_min: params.bbox[1],
9411                        x_max: params.bbox[2],
9412                        y_max: params.bbox[3],
9413                    },
9414                );
9415            }
9416            DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
9417            DisplayElement::OcgGroup { .. } => {
9418                // OCG groups have no inherent bbox; their children's bounds
9419                // are unknown without recursion. Conservative: skip here —
9420                // if the mask form contains OCG layers, the parent bbox cap
9421                // provides a sufficient upper bound.
9422            }
9423            _ => {}
9424        }
9425    }
9426    union
9427}
9428
9429/// Compute full 2D bounds from path segments.
9430/// Compute device-space 2D bounds for a Fill element, accounting for CTM.
9431/// Paths may be stored in device space (identity CTM) or user space
9432/// (non-identity CTM, e.g. synthesized annotation appearances).
9433fn fill_device_full_bbox(path: &PsPath, ctm: &Matrix) -> Option<BBox2D> {
9434    let bbox = path_full_bbox(path)?;
9435    let is_identity = ctm.a == 1.0
9436        && ctm.b == 0.0
9437        && ctm.c == 0.0
9438        && ctm.d == 1.0
9439        && ctm.tx == 0.0
9440        && ctm.ty == 0.0;
9441    if is_identity {
9442        return Some(bbox);
9443    }
9444    let corners = [
9445        (bbox.x_min, bbox.y_min),
9446        (bbox.x_max, bbox.y_min),
9447        (bbox.x_min, bbox.y_max),
9448        (bbox.x_max, bbox.y_max),
9449    ];
9450    let mut x_min = f64::INFINITY;
9451    let mut x_max = f64::NEG_INFINITY;
9452    let mut y_min = f64::INFINITY;
9453    let mut y_max = f64::NEG_INFINITY;
9454    for (x, y) in &corners {
9455        let dx = ctm.a * x + ctm.c * y + ctm.tx;
9456        let dy = ctm.b * x + ctm.d * y + ctm.ty;
9457        x_min = x_min.min(dx);
9458        x_max = x_max.max(dx);
9459        y_min = y_min.min(dy);
9460        y_max = y_max.max(dy);
9461    }
9462    Some(BBox2D {
9463        x_min,
9464        y_min,
9465        x_max,
9466        y_max,
9467    })
9468}
9469
9470fn path_full_bbox(path: &PsPath) -> Option<BBox2D> {
9471    let mut x_min = f64::INFINITY;
9472    let mut x_max = f64::NEG_INFINITY;
9473    let mut y_min = f64::INFINITY;
9474    let mut y_max = f64::NEG_INFINITY;
9475    for seg in &path.segments {
9476        match seg {
9477            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => {
9478                x_min = x_min.min(*x);
9479                x_max = x_max.max(*x);
9480                y_min = y_min.min(*y);
9481                y_max = y_max.max(*y);
9482            }
9483            PathSegment::CurveTo {
9484                x1,
9485                y1,
9486                x2,
9487                y2,
9488                x3,
9489                y3,
9490            } => {
9491                x_min = x_min.min(*x1).min(*x2).min(*x3);
9492                x_max = x_max.max(*x1).max(*x2).max(*x3);
9493                y_min = y_min.min(*y1).min(*y2).min(*y3);
9494                y_max = y_max.max(*y1).max(*y2).max(*y3);
9495            }
9496            PathSegment::ClosePath => {}
9497        }
9498    }
9499    if x_min <= x_max {
9500        Some(BBox2D {
9501            x_min,
9502            y_min,
9503            x_max,
9504            y_max,
9505        })
9506    } else {
9507        None
9508    }
9509}
9510
9511/// Compute full 2D bounds for a PatternFill element.
9512/// For stroke patterns, the path is in user space and must be transformed
9513/// through the CTM to get device-space bounds, then expanded by half
9514/// the stroke width.
9515fn pattern_fill_full_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<BBox2D> {
9516    if let Some(ref sp) = params.stroke_params {
9517        let bbox = path_full_bbox(&params.path)?;
9518        let ctm = &sp.ctm;
9519        let corners = [
9520            ctm.transform_point(bbox.x_min, bbox.y_min),
9521            ctm.transform_point(bbox.x_max, bbox.y_min),
9522            ctm.transform_point(bbox.x_min, bbox.y_max),
9523            ctm.transform_point(bbox.x_max, bbox.y_max),
9524        ];
9525        let mut dev_bbox = BBox2D {
9526            x_min: f64::INFINITY,
9527            y_min: f64::INFINITY,
9528            x_max: f64::NEG_INFINITY,
9529            y_max: f64::NEG_INFINITY,
9530        };
9531        for (x, y) in &corners {
9532            dev_bbox.x_min = dev_bbox.x_min.min(*x);
9533            dev_bbox.y_min = dev_bbox.y_min.min(*y);
9534            dev_bbox.x_max = dev_bbox.x_max.max(*x);
9535            dev_bbox.y_max = dev_bbox.y_max.max(*y);
9536        }
9537        let half_w = sp.line_width
9538            * 0.5
9539            * (ctm.a * ctm.a + ctm.b * ctm.b)
9540                .sqrt()
9541                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
9542        dev_bbox.x_min -= half_w;
9543        dev_bbox.y_min -= half_w;
9544        dev_bbox.x_max += half_w;
9545        dev_bbox.y_max += half_w;
9546        Some(dev_bbox)
9547    } else {
9548        path_full_bbox(&params.path)
9549    }
9550}
9551
9552/// Compute Y-axis bounds for a PatternFill element (banded rendering).
9553fn pattern_fill_y_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<YBBox> {
9554    let bbox = pattern_fill_full_bbox(params)?;
9555    Some(YBBox {
9556        y_min: bbox.y_min,
9557        y_max: bbox.y_max,
9558    })
9559}
9560
9561/// Compute full 2D bounds for an image from its transform.
9562fn image_full_bbox(params: &ImageParams) -> Option<BBox2D> {
9563    let m = &params.ctm;
9564    let im = &params.image_matrix;
9565    let im_inv = im.invert()?;
9566    let combined = m.concat(&im_inv);
9567    // Image occupies [0, width] × [0, height] in image space
9568    let w = params.width as f64;
9569    let h = params.height as f64;
9570    let corners = [
9571        combined.transform_point(0.0, 0.0),
9572        combined.transform_point(w, 0.0),
9573        combined.transform_point(0.0, h),
9574        combined.transform_point(w, h),
9575    ];
9576    let mut x_min = f64::INFINITY;
9577    let mut x_max = f64::NEG_INFINITY;
9578    let mut y_min = f64::INFINITY;
9579    let mut y_max = f64::NEG_INFINITY;
9580    for (x, y) in &corners {
9581        x_min = x_min.min(*x);
9582        x_max = x_max.max(*x);
9583        y_min = y_min.min(*y);
9584        y_max = y_max.max(*y);
9585    }
9586    Some(BBox2D {
9587        x_min,
9588        y_min,
9589        x_max,
9590        y_max,
9591    })
9592}
9593
9594/// Compute full 2D bounds for a shading element from its BBox.
9595fn shading_full_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<BBox2D> {
9596    if let Some(bbox) = bbox {
9597        let corners = [
9598            ctm.transform_point(bbox[0], bbox[1]),
9599            ctm.transform_point(bbox[2], bbox[1]),
9600            ctm.transform_point(bbox[0], bbox[3]),
9601            ctm.transform_point(bbox[2], bbox[3]),
9602        ];
9603        let mut x_min = f64::INFINITY;
9604        let mut x_max = f64::NEG_INFINITY;
9605        let mut y_min = f64::INFINITY;
9606        let mut y_max = f64::NEG_INFINITY;
9607        for (x, y) in &corners {
9608            x_min = x_min.min(*x);
9609            x_max = x_max.max(*x);
9610            y_min = y_min.min(*y);
9611            y_max = y_max.max(*y);
9612        }
9613        Some(BBox2D {
9614            x_min,
9615            y_min,
9616            x_max,
9617            y_max,
9618        })
9619    } else {
9620        Some(BBox2D {
9621            x_min: 0.0,
9622            y_min: 0.0,
9623            x_max: 1e9,
9624            y_max: 1e9,
9625        })
9626    }
9627}
9628
9629/// Build 2D clip epochs for viewport culling.
9630fn build_viewport_epochs(list: &DisplayList, bboxes: &[Option<BBox2D>]) -> Vec<ViewportEpoch> {
9631    let elements = list.elements();
9632    let mut epochs = Vec::new();
9633    let mut epoch_start = 0;
9634    let mut x_min = f64::INFINITY;
9635    let mut x_max = f64::NEG_INFINITY;
9636    let mut y_min = f64::INFINITY;
9637    let mut y_max = f64::NEG_INFINITY;
9638    let mut has_erase = false;
9639
9640    for (i, element) in elements.iter().enumerate() {
9641        if matches!(element, DisplayElement::InitClip) && i > epoch_start {
9642            epochs.push(ViewportEpoch {
9643                start_idx: epoch_start,
9644                end_idx: i,
9645                paint_bbox: if x_min <= x_max {
9646                    Some(BBox2D {
9647                        x_min,
9648                        y_min,
9649                        x_max,
9650                        y_max,
9651                    })
9652                } else {
9653                    None
9654                },
9655                has_erase_page: has_erase,
9656            });
9657            epoch_start = i;
9658            x_min = f64::INFINITY;
9659            x_max = f64::NEG_INFINITY;
9660            y_min = f64::INFINITY;
9661            y_max = f64::NEG_INFINITY;
9662            has_erase = false;
9663        }
9664        if matches!(element, DisplayElement::ErasePage) {
9665            has_erase = true;
9666        }
9667        if let Some(ref bbox) = bboxes[i] {
9668            x_min = x_min.min(bbox.x_min);
9669            x_max = x_max.max(bbox.x_max);
9670            y_min = y_min.min(bbox.y_min);
9671            y_max = y_max.max(bbox.y_max);
9672        }
9673    }
9674    if epoch_start < elements.len() {
9675        epochs.push(ViewportEpoch {
9676            start_idx: epoch_start,
9677            end_idx: elements.len(),
9678            paint_bbox: if x_min <= x_max {
9679                Some(BBox2D {
9680                    x_min,
9681                    y_min,
9682                    x_max,
9683                    y_max,
9684                })
9685            } else {
9686                None
9687            },
9688            has_erase_page: has_erase,
9689        });
9690    }
9691    epochs
9692}
9693
9694/// Clip epoch with full 2D bounding box for viewport culling.
9695struct ViewportEpoch {
9696    start_idx: usize,
9697    end_idx: usize,
9698    paint_bbox: Option<BBox2D>,
9699    has_erase_page: bool,
9700}
9701
9702/// Pre-computed metadata for fast viewport rendering.
9703///
9704/// Compute once per display list via [`prepare_display_list()`],
9705/// reuse across all [`render_region_prepared()`] calls. This avoids
9706/// three expensive traversals (bboxes, epochs, clip_seen) on every pan.
9707pub struct PreparedDisplayList {
9708    bboxes: Vec<Option<BBox2D>>,
9709    epochs: Vec<ViewportEpoch>,
9710    clip_seen: HashSet<u64>,
9711}
9712
9713/// Precompute display list metadata for fast viewport rendering.
9714///
9715/// Uses a conservative DPI (72.0) for hairline expansion in bounding boxes,
9716/// producing safe overestimates that work at any zoom level without recomputation.
9717pub fn prepare_display_list(list: &DisplayList) -> PreparedDisplayList {
9718    let bboxes = precompute_full_bboxes(list, 72.0);
9719    let epochs = build_viewport_epochs(list, &bboxes);
9720    let clip_seen = precompute_clip_seen(list);
9721    PreparedDisplayList {
9722        bboxes,
9723        epochs,
9724        clip_seen,
9725    }
9726}
9727
9728/// Pre-converted and prescaled image for banded rendering.
9729///
9730/// Built once per page before the band loop so that expensive RGBA conversion
9731/// and box-filter prescaling run once instead of once-per-band.
9732struct PreprocessedImage {
9733    /// RGBA pixel data (prescaled if applicable).
9734    data: Vec<u8>,
9735    /// Dimensions after prescaling.
9736    width: u32,
9737    height: u32,
9738    /// Scale/rotation part of the adjusted transform.
9739    /// Per-band rendering reconstructs the full transform by combining these
9740    /// with the band-specific translation (tx, ty).
9741    adj_sx: f32,
9742    adj_ky: f32,
9743    adj_kx: f32,
9744    adj_sy: f32,
9745    /// Filter quality for draw_pixmap.
9746    quality: stet_tiny_skia::FilterQuality,
9747}
9748
9749/// Pre-converted RGBA image data cache, indexed by display list element index.
9750///
9751/// Built once per page after display list capture. Reused across all viewport
9752/// renders so that ICC color conversion (especially CMYK→sRGB) is not repeated
9753/// on every pan/zoom.
9754pub struct ImageCache {
9755    /// RGBA data per element index. `None` for non-image elements.
9756    entries: Vec<Option<Vec<u8>>>,
9757}
9758
9759impl ImageCache {
9760    /// Build cache by pre-converting all images in the display list.
9761    pub fn build(list: &DisplayList, icc: Option<&IccCache>) -> Self {
9762        let entries = list
9763            .elements()
9764            .iter()
9765            .map(|elem| {
9766                if let DisplayElement::Image {
9767                    sample_data,
9768                    params,
9769                } = elem
9770                {
9771                    if params.width == 0 || params.height == 0 {
9772                        return None;
9773                    }
9774                    let mut rgba = samples_to_rgba(sample_data, params, icc, false);
9775                    if params.mask_color.is_some() {
9776                        apply_mask_color_rgba(&mut rgba, sample_data, params);
9777                    }
9778                    Some(rgba)
9779                } else {
9780                    None
9781                }
9782            })
9783            .collect();
9784        Self { entries }
9785    }
9786
9787    /// Get pre-converted RGBA for the element at the given index.
9788    pub fn get(&self, index: usize) -> Option<&[u8]> {
9789        self.entries.get(index).and_then(|e| e.as_deref())
9790    }
9791}
9792
9793/// Build preprocessed image cache for banded rendering.
9794///
9795/// For each Image element, converts to RGBA and prescales once.
9796/// Banded rendering then only needs `draw_pixmap` per band.
9797fn preprocess_images_for_bands(
9798    list: &DisplayList,
9799    icc: Option<&IccCache>,
9800) -> Vec<Option<PreprocessedImage>> {
9801    list.elements()
9802        .iter()
9803        .map(|elem| {
9804            let DisplayElement::Image {
9805                sample_data,
9806                params,
9807            } = elem
9808            else {
9809                return None;
9810            };
9811            let iw = params.width;
9812            let ih = params.height;
9813            if iw == 0 || ih == 0 {
9814                return None;
9815            }
9816            // Skip overprint images — they use a separate rendering path
9817            if params.overprint {
9818                return None;
9819            }
9820
9821            // Convert to RGBA
9822            let mut rgba = samples_to_rgba(sample_data, params, icc, false);
9823            if params.mask_color.is_some() {
9824                apply_mask_color_rgba(&mut rgba, sample_data, params);
9825            }
9826
9827            // Compute the device-space transform (vp_y=0, scale=1.0)
9828            let image_inv = params.image_matrix.invert()?;
9829            let combined = params.ctm.concat(&image_inv);
9830            let base_transform = enforce_min_image_size(to_transform(&combined), iw, ih);
9831
9832            // Prescale
9833            let (data, width, height, adj_t) =
9834                match prescale_image(&rgba, iw, ih, base_transform, params.interpolate) {
9835                    Some((d, w, h, t)) => {
9836                        drop(rgba); // free the full-size RGBA
9837                        (d, w, h, t)
9838                    }
9839                    None => (rgba, iw, ih, base_transform),
9840                };
9841
9842            let quality = image_filter_quality(adj_t, params.interpolate);
9843
9844            Some(PreprocessedImage {
9845                data,
9846                width,
9847                height,
9848                adj_sx: adj_t.sx,
9849                adj_ky: adj_t.ky,
9850                adj_kx: adj_t.kx,
9851                adj_sy: adj_t.sy,
9852                quality,
9853            })
9854        })
9855        .collect()
9856}
9857
9858/// Render a rectangular viewport region using precomputed metadata.
9859///
9860/// Like [`render_region()`] but skips the three precomputation passes,
9861/// using the [`PreparedDisplayList`] instead. Significantly faster for
9862/// repeated renders of the same display list (e.g., panning at a fixed zoom).
9863#[allow(clippy::too_many_arguments)]
9864pub fn render_region_prepared(
9865    list: &DisplayList,
9866    prepared: &PreparedDisplayList,
9867    vp_x: f64,
9868    vp_y: f64,
9869    vp_w: f64,
9870    vp_h: f64,
9871    pixel_w: u32,
9872    pixel_h: u32,
9873    dpi: f64,
9874    icc: Option<&IccCache>,
9875    image_cache: Option<&ImageCache>,
9876    no_aa: bool,
9877) -> Vec<u8> {
9878    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
9879        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
9880    }
9881
9882    let layer_set = LayerSet::new();
9883    let scale_x = pixel_w as f64 / vp_w;
9884    let scale_y = pixel_h as f64 / vp_h;
9885    let effective_dpi = dpi * scale_x;
9886
9887    // Allocate a pixmap with the same OVERLAP padding as the banded page
9888    // renderer. This is essential for matching the banded baseline: the page
9889    // pipeline always allocates `band_h + 2*BAND_OVERLAP` rows, even for a
9890    // single-band render. tiny-skia's `Mask::fill_path` chooses between
9891    // edge-clipped and unclipped rasterization based on whether the path
9892    // bounds fit within the mask, and the two paths produce subtly different
9893    // winding counts at some pixels. Without the OVERLAP padding here, the
9894    // viewport pipeline rasterizes clip paths into a tighter mask than the
9895    // banded pipeline does, producing 39 (and other counts) of edge-pixel
9896    // divergences on samples like 1915_1.pdf.
9897    const OVERLAP: u32 = 6;
9898    let render_h = pixel_h + 2 * OVERLAP;
9899    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
9900    // Start transparent — white background composited after content rendering
9901    pixmap.fill(Color::TRANSPARENT);
9902
9903    let cmyk_buf = if has_overprint_elements(list)
9904        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
9905        || has_cmyk_group(list)
9906    {
9907        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
9908    } else {
9909        None
9910    };
9911
9912    let mut state = BandState {
9913        clip_region: None,
9914        spare_mask: None,
9915        clip_mask_cache: HashMap::new(),
9916        clip_mask_seen: prepared.clip_seen.clone(),
9917        mask_pool: Vec::new(),
9918        cmyk_buffer: cmyk_buf,
9919        op_bg_snapshot: None,
9920        op_touched: None,
9921        spot_mask: None,
9922    };
9923
9924    let elements = list.elements();
9925    let vp_x_f = vp_x as f32;
9926    let vp_y_f = vp_y as f32;
9927    let sx = scale_x as f32;
9928    let sy = scale_y as f32;
9929    let vp_x_max = vp_x + vp_w;
9930    let vp_y_max = vp_y + vp_h;
9931
9932    for epoch in &prepared.epochs {
9933        if !epoch.has_erase_page {
9934            match epoch.paint_bbox {
9935                Some(ref pb)
9936                    if pb.x_max <= vp_x
9937                        || pb.x_min >= vp_x_max
9938                        || pb.y_max <= vp_y
9939                        || pb.y_min >= vp_y_max =>
9940                {
9941                    continue;
9942                }
9943                None => continue,
9944                _ => {}
9945            }
9946        }
9947
9948        #[allow(clippy::needless_range_loop)]
9949        for i in epoch.start_idx..epoch.end_idx {
9950            // OcgGroups with Clip/InitClip must always be processed — see
9951            // the banded renderer for the rationale.
9952            let force_process = matches!(
9953                &elements[i],
9954                DisplayElement::OcgGroup { elements: inner, .. }
9955                    if contains_clip_op(inner)
9956            );
9957            if !force_process
9958                && let Some(ref bbox) = prepared.bboxes[i]
9959                && (bbox.x_max <= vp_x
9960                    || bbox.x_min >= vp_x_max
9961                    || bbox.y_max <= vp_y
9962                    || bbox.y_min >= vp_y_max)
9963            {
9964                continue;
9965            }
9966            let ctx = RenderContext {
9967                vp_x: vp_x_f,
9968                vp_y: vp_y_f,
9969                scale_x: sx,
9970                scale_y: sy,
9971                out_w: pixel_w,
9972                out_h: render_h,
9973                effective_dpi,
9974                icc,
9975                image_cache,
9976                preprocessed: None,
9977                elem_idx: i,
9978                no_aa,
9979                opm_zero_transparent: false,
9980                knockout_painter_pass: KnockoutPainterPass::None,
9981                parent_group_isolated: false,
9982                alpha_extraction_pass: false,
9983                layer_set: &layer_set,
9984            };
9985            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
9986        }
9987    }
9988
9989    // Composite onto white background
9990    composite_onto_white(pixmap.data_mut());
9991    // Extract only the requested pixel_h rows (skip the OVERLAP padding at the bottom).
9992    let row_bytes = pixel_w as usize * 4;
9993    let end = pixel_h as usize * row_bytes;
9994    pixmap.data()[..end].to_vec()
9995}
9996
9997/// Compute the number of bands and band height for viewport banding.
9998///
9999/// Returns `(num_bands, band_height)` using the same L2-cache-budget logic
10000/// as the full-page banded renderer.
10001pub fn viewport_band_count(pixel_w: u32, pixel_h: u32) -> (u32, u32) {
10002    let band_h = select_band_height(pixel_w, pixel_h);
10003    let num_bands = if band_h >= pixel_h {
10004        1
10005    } else {
10006        pixel_h.div_ceil(band_h)
10007    };
10008    (num_bands, band_h)
10009}
10010
10011/// Render a single horizontal band of a viewport region.
10012///
10013/// This is the per-band counterpart to [`render_region_prepared()`]. The caller
10014/// loops over `band_idx` in `0..num_bands`, collecting RGBA strips that tile
10015/// vertically to form the full viewport image.
10016///
10017/// Returns RGBA pixel data for `actual_h` rows (may be less than `band_h` for
10018/// the last band).
10019#[allow(clippy::too_many_arguments)]
10020pub fn render_region_single_band(
10021    list: &DisplayList,
10022    prepared: &PreparedDisplayList,
10023    vp_x: f64,
10024    vp_y: f64,
10025    vp_w: f64,
10026    vp_h: f64,
10027    pixel_w: u32,
10028    pixel_h: u32,
10029    band_idx: u32,
10030    band_h: u32,
10031    num_bands: u32,
10032    dpi: f64,
10033    icc: Option<&IccCache>,
10034    image_cache: Option<&ImageCache>,
10035    no_aa: bool,
10036) -> Vec<u8> {
10037    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
10038        let actual_h = if band_idx < num_bands - 1 {
10039            band_h
10040        } else {
10041            pixel_h - band_idx * band_h
10042        };
10043        return vec![0xFF; pixel_w as usize * actual_h as usize * 4];
10044    }
10045
10046    let layer_set = LayerSet::new();
10047    let scale_x = pixel_w as f64 / vp_w;
10048    let scale_y = pixel_h as f64 / vp_h;
10049    let effective_dpi = dpi * scale_x;
10050
10051    // Output Y range for this band
10052    let out_y_start = band_idx * band_h;
10053    let actual_h = if band_idx < num_bands - 1 {
10054        band_h
10055    } else {
10056        pixel_h - out_y_start
10057    };
10058
10059    // Add overlap above/below for anti-aliasing at seams.
10060    //
10061    // The pixmap is always `band_h + 2*OVERLAP` rows — matching the page
10062    // renderer (`render_banded_to_sink`) — even at the bottom band, where
10063    // content rendering stops at `pixel_h`. Without this, the bottom band's
10064    // pixmap is shorter than the page renderer's, and tiny-skia's
10065    // `Mask::fill_path` rasterizes clip paths into a tighter mask, producing
10066    // edge-pixel divergences from the banded baseline (39 pixels on
10067    // 1915_1.pdf, etc.). The extra rows below `pixel_h` are unused for output
10068    // but ensure mask-size-independent rasterization.
10069    const OVERLAP: u32 = 6;
10070    let render_y_start = out_y_start.saturating_sub(OVERLAP);
10071    let render_y_end = (out_y_start + actual_h + OVERLAP).min(pixel_h);
10072    let render_h = band_h + 2 * OVERLAP;
10073    let overlap_top = out_y_start - render_y_start;
10074
10075    // Source-space Y range for culling
10076    let src_y_min = vp_y + render_y_start as f64 / scale_y;
10077    let src_y_max = vp_y + render_y_end as f64 / scale_y;
10078
10079    // Adjusted viewport offset for this band's pixmap
10080    let band_vp_y = vp_y + render_y_start as f64 / scale_y;
10081
10082    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create band pixmap");
10083    pixmap.fill(Color::TRANSPARENT);
10084
10085    let cmyk_buf = if has_overprint_elements(list)
10086        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10087        || has_cmyk_group(list)
10088    {
10089        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10090    } else {
10091        None
10092    };
10093
10094    let mut state = BandState {
10095        clip_region: None,
10096        spare_mask: None,
10097        clip_mask_cache: HashMap::new(),
10098        clip_mask_seen: prepared.clip_seen.clone(),
10099        mask_pool: Vec::new(),
10100        cmyk_buffer: cmyk_buf,
10101        op_bg_snapshot: None,
10102        op_touched: None,
10103        spot_mask: None,
10104    };
10105
10106    let elements = list.elements();
10107    let vp_x_f = vp_x as f32;
10108    let band_vp_y_f = band_vp_y as f32;
10109    let sx = scale_x as f32;
10110    let sy = scale_y as f32;
10111    let vp_x_max = vp_x + vp_w;
10112
10113    for epoch in &prepared.epochs {
10114        if !epoch.has_erase_page {
10115            match epoch.paint_bbox {
10116                Some(ref pb)
10117                    if pb.x_max <= vp_x
10118                        || pb.x_min >= vp_x_max
10119                        || pb.y_max <= src_y_min
10120                        || pb.y_min >= src_y_max =>
10121                {
10122                    continue;
10123                }
10124                None => continue,
10125                _ => {}
10126            }
10127        }
10128
10129        #[allow(clippy::needless_range_loop)]
10130        for i in epoch.start_idx..epoch.end_idx {
10131            // OcgGroups containing Clip/InitClip must always be processed
10132            // regardless of this band's bbox — see the full-page banded
10133            // renderer for the rationale.
10134            let force_process = matches!(
10135                &elements[i],
10136                DisplayElement::OcgGroup { elements: inner, .. }
10137                    if contains_clip_op(inner)
10138            );
10139            if !force_process
10140                && let Some(ref bbox) = prepared.bboxes[i]
10141                && (bbox.x_max <= vp_x
10142                    || bbox.x_min >= vp_x_max
10143                    || bbox.y_max <= src_y_min
10144                    || bbox.y_min >= src_y_max)
10145            {
10146                continue;
10147            }
10148            let ctx = RenderContext {
10149                vp_x: vp_x_f,
10150                vp_y: band_vp_y_f,
10151                scale_x: sx,
10152                scale_y: sy,
10153                out_w: pixel_w,
10154                out_h: render_h,
10155                effective_dpi,
10156                icc,
10157                image_cache,
10158                preprocessed: None,
10159                elem_idx: i,
10160                no_aa,
10161                opm_zero_transparent: false,
10162                knockout_painter_pass: KnockoutPainterPass::None,
10163                parent_group_isolated: false,
10164                alpha_extraction_pass: false,
10165                layer_set: &layer_set,
10166            };
10167            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10168        }
10169    }
10170
10171    // Composite onto white background
10172    composite_onto_white(pixmap.data_mut());
10173
10174    // Extract only the non-overlap rows
10175    let row_bytes = pixel_w as usize * 4;
10176    let start = overlap_top as usize * row_bytes;
10177    let end = start + actual_h as usize * row_bytes;
10178    pixmap.data()[start..end].to_vec()
10179}
10180
10181/// Render a viewport region using parallel banded rendering via rayon.
10182///
10183/// This is the WASM counterpart to the parallel path in `render_banded_to_sink`.
10184/// All bands are rendered in parallel using `par_iter`, then assembled into the
10185/// final RGBA buffer in order.
10186///
10187/// Requires the `parallel` feature (rayon). Falls back to sequential rendering
10188/// if `parallel` is not enabled.
10189#[allow(clippy::too_many_arguments)]
10190pub fn render_region_prepared_parallel(
10191    list: &DisplayList,
10192    prepared: &PreparedDisplayList,
10193    vp_x: f64,
10194    vp_y: f64,
10195    vp_w: f64,
10196    vp_h: f64,
10197    pixel_w: u32,
10198    pixel_h: u32,
10199    dpi: f64,
10200    icc: Option<&IccCache>,
10201    image_cache: Option<&ImageCache>,
10202    no_aa: bool,
10203) -> Vec<u8> {
10204    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10205
10206    if num_bands <= 1 {
10207        // Single band — no parallelism needed
10208        return render_region_prepared(
10209            list,
10210            prepared,
10211            vp_x,
10212            vp_y,
10213            vp_w,
10214            vp_h,
10215            pixel_w,
10216            pixel_h,
10217            dpi,
10218            icc,
10219            image_cache,
10220            no_aa,
10221        );
10222    }
10223
10224    let render_band = |band_idx: u32| -> Vec<u8> {
10225        render_region_single_band(
10226            list,
10227            prepared,
10228            vp_x,
10229            vp_y,
10230            vp_w,
10231            vp_h,
10232            pixel_w,
10233            pixel_h,
10234            band_idx,
10235            band_h,
10236            num_bands,
10237            dpi,
10238            icc,
10239            image_cache,
10240            no_aa,
10241        )
10242    };
10243
10244    let row_bytes = pixel_w as usize * 4;
10245    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10246
10247    #[cfg(feature = "parallel")]
10248    {
10249        let chunk_size = rayon::current_num_threads().max(1);
10250
10251        for chunk_start in (0..num_bands).step_by(chunk_size) {
10252            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10253
10254            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10255                .into_par_iter()
10256                .map(&render_band)
10257                .collect();
10258
10259            for (i, band_data) in rendered.iter().enumerate() {
10260                let band_idx = chunk_start + i as u32;
10261                let y_start = (band_idx * band_h) as usize;
10262                let dest_start = y_start * row_bytes;
10263                let len = band_data.len();
10264                result[dest_start..dest_start + len].copy_from_slice(band_data);
10265            }
10266        }
10267    }
10268    #[cfg(not(feature = "parallel"))]
10269    {
10270        for band_idx in 0..num_bands {
10271            let band_data = render_band(band_idx);
10272            let y_start = (band_idx * band_h) as usize;
10273            let dest_start = y_start * row_bytes;
10274            let len = band_data.len();
10275            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10276        }
10277    }
10278
10279    result
10280}
10281
10282/// Like [`render_region_prepared_parallel()`] but with an atomic progress counter.
10283///
10284/// The counter is incremented after each chunk of bands completes. The total
10285/// number of bands is returned alongside the counter via [`viewport_band_count()`].
10286#[allow(clippy::too_many_arguments)]
10287pub fn render_region_prepared_parallel_with_progress(
10288    list: &DisplayList,
10289    prepared: &PreparedDisplayList,
10290    vp_x: f64,
10291    vp_y: f64,
10292    vp_w: f64,
10293    vp_h: f64,
10294    pixel_w: u32,
10295    pixel_h: u32,
10296    dpi: f64,
10297    icc: Option<&IccCache>,
10298    image_cache: Option<&ImageCache>,
10299    no_aa: bool,
10300    progress: &std::sync::atomic::AtomicU32,
10301) -> Vec<u8> {
10302    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10303
10304    if num_bands <= 1 {
10305        let result = render_region_prepared(
10306            list,
10307            prepared,
10308            vp_x,
10309            vp_y,
10310            vp_w,
10311            vp_h,
10312            pixel_w,
10313            pixel_h,
10314            dpi,
10315            icc,
10316            image_cache,
10317            no_aa,
10318        );
10319        progress.store(1, std::sync::atomic::Ordering::Relaxed);
10320        return result;
10321    }
10322
10323    let render_band = |band_idx: u32| -> Vec<u8> {
10324        render_region_single_band(
10325            list,
10326            prepared,
10327            vp_x,
10328            vp_y,
10329            vp_w,
10330            vp_h,
10331            pixel_w,
10332            pixel_h,
10333            band_idx,
10334            band_h,
10335            num_bands,
10336            dpi,
10337            icc,
10338            image_cache,
10339            no_aa,
10340        )
10341    };
10342
10343    let row_bytes = pixel_w as usize * 4;
10344    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10345
10346    #[cfg(feature = "parallel")]
10347    {
10348        let chunk_size = rayon::current_num_threads().max(1);
10349
10350        for chunk_start in (0..num_bands).step_by(chunk_size) {
10351            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10352
10353            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10354                .into_par_iter()
10355                .map(&render_band)
10356                .collect();
10357
10358            for (i, band_data) in rendered.iter().enumerate() {
10359                let band_idx = chunk_start + i as u32;
10360                let y_start = (band_idx * band_h) as usize;
10361                let dest_start = y_start * row_bytes;
10362                let len = band_data.len();
10363                result[dest_start..dest_start + len].copy_from_slice(band_data);
10364            }
10365            progress.store(chunk_end, std::sync::atomic::Ordering::Relaxed);
10366        }
10367    }
10368    #[cfg(not(feature = "parallel"))]
10369    {
10370        for band_idx in 0..num_bands {
10371            let band_data = render_band(band_idx);
10372            let y_start = (band_idx * band_h) as usize;
10373            let dest_start = y_start * row_bytes;
10374            let len = band_data.len();
10375            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10376            progress.store(band_idx + 1, std::sync::atomic::Ordering::Relaxed);
10377        }
10378    }
10379
10380    result
10381}
10382
10383/// Like [`render_region_prepared_parallel()`] but checks a cancellation flag
10384/// between band chunks. Returns `None` if cancelled.
10385#[allow(clippy::too_many_arguments)]
10386pub fn render_region_prepared_parallel_cancellable(
10387    list: &DisplayList,
10388    prepared: &PreparedDisplayList,
10389    vp_x: f64,
10390    vp_y: f64,
10391    vp_w: f64,
10392    vp_h: f64,
10393    pixel_w: u32,
10394    pixel_h: u32,
10395    dpi: f64,
10396    icc: Option<&IccCache>,
10397    image_cache: Option<&ImageCache>,
10398    no_aa: bool,
10399    cancelled: &std::sync::atomic::AtomicBool,
10400) -> Option<Vec<u8>> {
10401    if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10402        return None;
10403    }
10404
10405    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10406
10407    if num_bands <= 1 {
10408        return Some(render_region_prepared(
10409            list,
10410            prepared,
10411            vp_x,
10412            vp_y,
10413            vp_w,
10414            vp_h,
10415            pixel_w,
10416            pixel_h,
10417            dpi,
10418            icc,
10419            image_cache,
10420            no_aa,
10421        ));
10422    }
10423
10424    let render_band = |band_idx: u32| -> Vec<u8> {
10425        render_region_single_band(
10426            list,
10427            prepared,
10428            vp_x,
10429            vp_y,
10430            vp_w,
10431            vp_h,
10432            pixel_w,
10433            pixel_h,
10434            band_idx,
10435            band_h,
10436            num_bands,
10437            dpi,
10438            icc,
10439            image_cache,
10440            no_aa,
10441        )
10442    };
10443
10444    let row_bytes = pixel_w as usize * 4;
10445    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10446
10447    #[cfg(feature = "parallel")]
10448    {
10449        let chunk_size = rayon::current_num_threads().max(1);
10450
10451        for chunk_start in (0..num_bands).step_by(chunk_size) {
10452            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10453                return None;
10454            }
10455            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10456
10457            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10458                .into_par_iter()
10459                .map(&render_band)
10460                .collect();
10461
10462            for (i, band_data) in rendered.iter().enumerate() {
10463                let band_idx = chunk_start + i as u32;
10464                let y_start = (band_idx * band_h) as usize;
10465                let dest_start = y_start * row_bytes;
10466                let len = band_data.len();
10467                result[dest_start..dest_start + len].copy_from_slice(band_data);
10468            }
10469        }
10470    }
10471    #[cfg(not(feature = "parallel"))]
10472    {
10473        for band_idx in 0..num_bands {
10474            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10475                return None;
10476            }
10477            let band_data = render_band(band_idx);
10478            let y_start = (band_idx * band_h) as usize;
10479            let dest_start = y_start * row_bytes;
10480            let len = band_data.len();
10481            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10482        }
10483    }
10484
10485    Some(result)
10486}
10487
10488/// Render a full-page display list to RGBA pixels using the banded parallel renderer.
10489///
10490/// This is the preferred way to render a complete page — it uses rayon parallelism
10491/// (when the `parallel` feature is enabled) and L2-cache-friendly band sizing.
10492/// For sub-region / zoomed viewport rendering, use `render_region` instead.
10493///
10494/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`, composited onto white.
10495pub fn render_to_rgba(
10496    list: &DisplayList,
10497    pixel_w: u32,
10498    pixel_h: u32,
10499    dpi: f64,
10500    icc: Option<&IccCache>,
10501    no_aa: bool,
10502) -> Vec<u8> {
10503    render_to_rgba_with_layers(list, pixel_w, pixel_h, dpi, icc, no_aa, &LayerSet::new())
10504}
10505
10506/// Like [`render_to_rgba`] but consults the supplied [`LayerSet`] when
10507/// evaluating each `OcgGroup`'s visibility.
10508///
10509/// Pass `&LayerSet::new()` (or use [`render_to_rgba`]) to fall back to
10510/// each OCG's `default_visible` baked from the document's default
10511/// configuration.
10512#[allow(clippy::too_many_arguments)]
10513pub fn render_to_rgba_with_layers(
10514    list: &DisplayList,
10515    pixel_w: u32,
10516    pixel_h: u32,
10517    dpi: f64,
10518    icc: Option<&IccCache>,
10519    no_aa: bool,
10520    layer_set: &LayerSet,
10521) -> Vec<u8> {
10522    if pixel_w == 0 || pixel_h == 0 {
10523        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10524    }
10525
10526    let mut icc_cache = match icc {
10527        Some(c) => c.clone(),
10528        None => IccCache::new(),
10529    };
10530    // Register any ICC profiles from shadings in the display list
10531    // (the caller's cache only has image profiles)
10532    register_shading_icc_profiles(list, &mut icc_cache);
10533
10534    let mut sink = MemorySink {
10535        data: Vec::new(),
10536        width: 0,
10537    };
10538
10539    let band_h = select_band_height(pixel_w, pixel_h);
10540    if let Err(e) = render_banded_to_sink(
10541        pixel_w, pixel_h, band_h, dpi, list, &mut sink, &icc_cache, no_aa, layer_set,
10542    ) {
10543        eprintln!("render_to_rgba: banded render failed: {e}");
10544        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10545    }
10546
10547    sink.data
10548}
10549
10550/// Render a display list to RGBA using the **viewport** code path, with
10551/// the viewport set to the full page at 1:1 scale.
10552///
10553/// This exists to audit the viewport pipeline (`render_region_prepared_*`)
10554/// against the same baselines the banded PNG path uses. The two paths share
10555/// `render_element` and the same display list, so their output should be
10556/// pixel-identical on a correctly implemented display list. Differences
10557/// indicate a bug in one of the two culling / epoch / bbox pipelines.
10558///
10559/// The CLI exposes this as `--device viewport-png`; the visual test runner
10560/// uses it to double-cover each sample without maintaining a second
10561/// baseline.
10562pub fn render_to_rgba_viewport(
10563    list: &DisplayList,
10564    pixel_w: u32,
10565    pixel_h: u32,
10566    dpi: f64,
10567    icc: Option<&IccCache>,
10568    no_aa: bool,
10569) -> Vec<u8> {
10570    if pixel_w == 0 || pixel_h == 0 {
10571        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10572    }
10573
10574    let mut icc_cache = match icc {
10575        Some(c) => c.clone(),
10576        None => IccCache::new(),
10577    };
10578    register_shading_icc_profiles(list, &mut icc_cache);
10579
10580    let prepared = prepare_display_list(list);
10581    render_region_prepared_parallel(
10582        list,
10583        &prepared,
10584        0.0,
10585        0.0,
10586        pixel_w as f64,
10587        pixel_h as f64,
10588        pixel_w,
10589        pixel_h,
10590        dpi,
10591        Some(&icc_cache),
10592        None,
10593        no_aa,
10594    )
10595}
10596
10597/// Debug helper: format both bbox precomputations side-by-side.
10598///
10599/// Returns one line per element describing its Y-only bbox (used by the
10600/// banded page pipeline) and its 2D bbox (used by the viewport pipeline).
10601/// Elements that disagree on presence, or whose 2D bbox's Y extent differs
10602/// from the Y-only bbox, are marked with `DIFF`.
10603fn debug_bbox_lines(list: &DisplayList, dpi: f64, depth: usize, out: &mut Vec<String>) {
10604    let y_bboxes = precompute_bboxes(list, dpi);
10605    let full_bboxes = precompute_full_bboxes(list, dpi);
10606    let elements = list.elements();
10607    let indent = "  ".repeat(depth);
10608    for (i, elem) in elements.iter().enumerate() {
10609        let kind = match elem {
10610            DisplayElement::Fill { .. } => "Fill",
10611            DisplayElement::Stroke { .. } => "Stroke",
10612            DisplayElement::Image { .. } => "Image",
10613            DisplayElement::AxialShading { .. } => "AxialShading",
10614            DisplayElement::RadialShading { .. } => "RadialShading",
10615            DisplayElement::MeshShading { .. } => "MeshShading",
10616            DisplayElement::PatchShading { .. } => "PatchShading",
10617            DisplayElement::PatternFill { .. } => "PatternFill",
10618            DisplayElement::Group { .. } => "Group",
10619            DisplayElement::SoftMasked { .. } => "SoftMasked",
10620            DisplayElement::OcgGroup { .. } => "OcgGroup",
10621            DisplayElement::Clip { .. } => "Clip",
10622            DisplayElement::InitClip => "InitClip",
10623            DisplayElement::ErasePage => "ErasePage",
10624            DisplayElement::Text { .. } => "Text",
10625            _ => "Unknown",
10626        };
10627        let yb = &y_bboxes[i];
10628        let fb = &full_bboxes[i];
10629        let mut diff = false;
10630        if yb.is_some() != fb.is_some() {
10631            diff = true;
10632        }
10633        if let (Some(yb), Some(fb)) = (yb, fb)
10634            && ((yb.y_min - fb.y_min).abs() > 1e-9 || (yb.y_max - fb.y_max).abs() > 1e-9)
10635        {
10636            diff = true;
10637        }
10638        let yb_s = match yb {
10639            Some(b) => format!("Y[{:8.3}..{:8.3}]", b.y_min, b.y_max),
10640            None => "Y[None]".to_string(),
10641        };
10642        let fb_s = match fb {
10643            Some(b) => format!(
10644                "2D[x {:8.3}..{:8.3} y {:8.3}..{:8.3}]",
10645                b.x_min, b.x_max, b.y_min, b.y_max
10646            ),
10647            None => "2D[None]".to_string(),
10648        };
10649        out.push(format!(
10650            "{}{:4} {:15} {:30} {:55} {}",
10651            indent,
10652            i,
10653            kind,
10654            yb_s,
10655            fb_s,
10656            if diff { "DIFF" } else { "" }
10657        ));
10658        if let DisplayElement::Stroke { path, params } = elem {
10659            let rp = path_full_bbox(path);
10660            let m = &params.ctm;
10661            out.push(format!(
10662                "{}        ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] lw={:.4} miter={:.4} raw={}",
10663                indent,
10664                m.a,
10665                m.b,
10666                m.c,
10667                m.d,
10668                m.tx,
10669                m.ty,
10670                params.line_width,
10671                params.miter_limit,
10672                match rp {
10673                    Some(b) => format!(
10674                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
10675                        b.x_min, b.x_max, b.y_min, b.y_max
10676                    ),
10677                    None => "None".to_string(),
10678                }
10679            ));
10680        }
10681        if let DisplayElement::Clip { path, params } = elem {
10682            let rp = path_full_bbox(path);
10683            let m = &params.ctm;
10684            out.push(format!(
10685                "{}        clip ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] rule={:?} raw={}",
10686                indent,
10687                m.a,
10688                m.b,
10689                m.c,
10690                m.d,
10691                m.tx,
10692                m.ty,
10693                params.fill_rule,
10694                match rp {
10695                    Some(b) => format!(
10696                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
10697                        b.x_min, b.x_max, b.y_min, b.y_max
10698                    ),
10699                    None => "None".to_string(),
10700                }
10701            ));
10702        }
10703        if let DisplayElement::PatchShading { params } = elem {
10704            out.push(format!(
10705                "{}        patch ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] bbox={:?} patches={}",
10706                indent,
10707                params.ctm.a,
10708                params.ctm.b,
10709                params.ctm.c,
10710                params.ctm.d,
10711                params.ctm.tx,
10712                params.ctm.ty,
10713                params.bbox,
10714                params.patches.len()
10715            ));
10716            if !params.patches.is_empty() {
10717                let patch = &params.patches[0];
10718                // Compute device-space bbox of patch points
10719                let mut x_min = f64::INFINITY;
10720                let mut y_min = f64::INFINITY;
10721                let mut x_max = f64::NEG_INFINITY;
10722                let mut y_max = f64::NEG_INFINITY;
10723                for &(px, py) in &patch.points {
10724                    let (dx, dy) = params.ctm.transform_point(px, py);
10725                    x_min = x_min.min(dx);
10726                    y_min = y_min.min(dy);
10727                    x_max = x_max.max(dx);
10728                    y_max = y_max.max(dy);
10729                }
10730                out.push(format!(
10731                    "{}        patch[0] pts={} dev x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
10732                    indent,
10733                    patch.points.len(),
10734                    x_min,
10735                    x_max,
10736                    y_min,
10737                    y_max
10738                ));
10739            }
10740        }
10741        if let DisplayElement::Group {
10742            elements: inner,
10743            params,
10744        } = elem
10745        {
10746            out.push(format!(
10747                "{}        group bbox={:?} iso={} ko={} alpha={} bm={} cs={:?}",
10748                indent,
10749                params.bbox,
10750                params.isolated,
10751                params.knockout,
10752                params.alpha,
10753                params.blend_mode,
10754                params.color_space
10755            ));
10756            debug_bbox_lines(inner, dpi, depth + 1, out);
10757        }
10758        if let DisplayElement::SoftMasked {
10759            content, params, ..
10760        } = elem
10761        {
10762            out.push(format!(
10763                "{}        softmasked bbox={:?}",
10764                indent, params.bbox
10765            ));
10766            debug_bbox_lines(content, dpi, depth + 1, out);
10767        }
10768        if let DisplayElement::OcgGroup {
10769            elements: inner,
10770            visibility,
10771        } = elem
10772        {
10773            out.push(format!(
10774                "{}        ocg default_visible={}",
10775                indent,
10776                visibility.default_visible()
10777            ));
10778            debug_bbox_lines(inner, dpi, depth + 1, out);
10779        }
10780    }
10781}
10782
10783pub fn debug_bbox_comparison(list: &DisplayList, dpi: f64) -> Vec<String> {
10784    let mut out = Vec::new();
10785    debug_bbox_lines(list, dpi, 0, &mut out);
10786    out
10787}
10788
10789/// In-memory page sink that collects RGBA rows into a Vec.
10790struct MemorySink {
10791    data: Vec<u8>,
10792    width: u32,
10793}
10794
10795impl stet_graphics::device::PageSink for MemorySink {
10796    fn begin_page(&mut self, width: u32, height: u32) -> Result<(), String> {
10797        self.width = width;
10798        self.data.reserve(width as usize * height as usize * 4);
10799        Ok(())
10800    }
10801
10802    fn write_rows(&mut self, rgba_rows: &[u8], _num_rows: u32) -> Result<(), String> {
10803        self.data.extend_from_slice(rgba_rows);
10804        Ok(())
10805    }
10806
10807    fn end_page(&mut self) -> Result<(), String> {
10808        Ok(())
10809    }
10810}
10811
10812/// Render a rectangular viewport region of a display list to RGBA pixels.
10813///
10814/// - `list`: The display list to render (in device-space coordinates at the reference DPI)
10815/// - `vp_x, vp_y, vp_w, vp_h`: Viewport rectangle in device-space pixels
10816/// - `pixel_w, pixel_h`: Output pixel dimensions
10817/// - `dpi`: Reference DPI (for hairline width decisions)
10818///
10819/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`.
10820#[allow(clippy::too_many_arguments)]
10821pub fn render_region(
10822    list: &DisplayList,
10823    vp_x: f64,
10824    vp_y: f64,
10825    vp_w: f64,
10826    vp_h: f64,
10827    pixel_w: u32,
10828    pixel_h: u32,
10829    dpi: f64,
10830    icc: Option<&IccCache>,
10831    image_cache: Option<&ImageCache>,
10832    no_aa: bool,
10833) -> Vec<u8> {
10834    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
10835        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10836    }
10837
10838    let layer_set = LayerSet::new();
10839    let scale_x = pixel_w as f64 / vp_w;
10840    let scale_y = pixel_h as f64 / vp_h;
10841    // Effective DPI for hairline decisions — reference DPI scaled by zoom
10842    let effective_dpi = dpi * scale_x;
10843
10844    let bboxes = precompute_full_bboxes(list, effective_dpi);
10845    let epochs = build_viewport_epochs(list, &bboxes);
10846    let clip_seen = precompute_clip_seen(list);
10847
10848    // OVERLAP padding to match `render_banded_to_sink`. See the comment in
10849    // `render_region_prepared` for why this is required for tiny-skia
10850    // mask-rasterization parity with the page renderer.
10851    const OVERLAP: u32 = 6;
10852    let render_h = pixel_h + 2 * OVERLAP;
10853
10854    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
10855    pixmap.fill(Color::TRANSPARENT);
10856
10857    let cmyk_buf = if has_overprint_elements(list)
10858        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10859        || has_cmyk_group(list)
10860    {
10861        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10862    } else {
10863        None
10864    };
10865
10866    let mut state = BandState {
10867        clip_region: None,
10868        spare_mask: None,
10869        clip_mask_cache: HashMap::new(),
10870        clip_mask_seen: clip_seen,
10871        mask_pool: Vec::new(),
10872        cmyk_buffer: cmyk_buf,
10873        op_bg_snapshot: None,
10874        op_touched: None,
10875        spot_mask: None,
10876    };
10877
10878    let elements = list.elements();
10879    let vp_x_f = vp_x as f32;
10880    let vp_y_f = vp_y as f32;
10881    let sx = scale_x as f32;
10882    let sy = scale_y as f32;
10883    let vp_x_max = vp_x + vp_w;
10884    let vp_y_max = vp_y + vp_h;
10885
10886    for epoch in &epochs {
10887        // Epoch-level culling
10888        if !epoch.has_erase_page {
10889            match epoch.paint_bbox {
10890                Some(ref pb)
10891                    if pb.x_max <= vp_x
10892                        || pb.x_min >= vp_x_max
10893                        || pb.y_max <= vp_y
10894                        || pb.y_min >= vp_y_max =>
10895                {
10896                    continue;
10897                }
10898                None => continue,
10899                _ => {}
10900            }
10901        }
10902
10903        for i in epoch.start_idx..epoch.end_idx {
10904            // OcgGroups with Clip/InitClip must always be processed — see
10905            // render_region_prepared for the rationale.
10906            let force_process = matches!(
10907                &elements[i],
10908                DisplayElement::OcgGroup { elements: inner, .. }
10909                    if contains_clip_op(inner)
10910            );
10911            // Element-level culling
10912            if !force_process
10913                && let Some(ref bbox) = bboxes[i]
10914                && (bbox.x_max <= vp_x
10915                    || bbox.x_min >= vp_x_max
10916                    || bbox.y_max <= vp_y
10917                    || bbox.y_min >= vp_y_max)
10918            {
10919                continue;
10920            }
10921            let ctx = RenderContext {
10922                vp_x: vp_x_f,
10923                vp_y: vp_y_f,
10924                scale_x: sx,
10925                scale_y: sy,
10926                out_w: pixel_w,
10927                out_h: render_h,
10928                effective_dpi,
10929                icc,
10930                image_cache,
10931                preprocessed: None,
10932                elem_idx: i,
10933                no_aa,
10934                opm_zero_transparent: false,
10935                knockout_painter_pass: KnockoutPainterPass::None,
10936                parent_group_isolated: false,
10937                alpha_extraction_pass: false,
10938                layer_set: &layer_set,
10939            };
10940            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10941        }
10942    }
10943
10944    composite_onto_white(pixmap.data_mut());
10945    // Extract only the requested pixel_h rows (skip OVERLAP padding).
10946    let row_bytes = pixel_w as usize * 4;
10947    let end = pixel_h as usize * row_bytes;
10948    pixmap.data()[..end].to_vec()
10949}
10950/// Copy a rectangular region from parent pixmap into a smaller crop pixmap.
10951fn copy_backdrop_crop(
10952    parent: &Pixmap,
10953    crop_x: i32,
10954    crop_y: i32,
10955    crop_w: u32,
10956    crop_h: u32,
10957) -> Vec<u8> {
10958    let pw = parent.width() as usize;
10959    let src = parent.data();
10960    let cw = crop_w as usize;
10961    let ch = crop_h as usize;
10962    let cx = crop_x as usize;
10963    let cy = crop_y as usize;
10964    let mut backdrop = vec![0u8; cw * ch * 4];
10965    for row in 0..ch {
10966        let src_off = ((cy + row) * pw + cx) * 4;
10967        let dst_off = row * cw * 4;
10968        backdrop[dst_off..dst_off + cw * 4].copy_from_slice(&src[src_off..src_off + cw * 4]);
10969    }
10970    backdrop
10971}
10972// ---- Shading rendering ----
10973
10974/// Sutherland-Hodgman polygon clipping against a half-plane.
10975/// Keeps the side where `nx*(x-px) + ny*(y-py) >= 0`.
10976fn clip_polygon_halfplane(
10977    poly: &[(f32, f32)],
10978    nx: f32,
10979    ny: f32,
10980    px: f32,
10981    py: f32,
10982) -> Vec<(f32, f32)> {
10983    if poly.is_empty() {
10984        return vec![];
10985    }
10986    let dot = |x: f32, y: f32| nx * (x - px) + ny * (y - py);
10987    let mut out = Vec::with_capacity(poly.len() + 1);
10988    let n = poly.len();
10989    for i in 0..n {
10990        let (ax, ay) = poly[i];
10991        let (bx, by) = poly[(i + 1) % n];
10992        let da = dot(ax, ay);
10993        let db = dot(bx, by);
10994        if da >= 0.0 {
10995            out.push((ax, ay));
10996        }
10997        if (da >= 0.0) != (db >= 0.0) {
10998            // Edge crosses the clipping line — compute intersection
10999            let t = da / (da - db);
11000            out.push((ax + t * (bx - ax), ay + t * (by - ay)));
11001        }
11002    }
11003    out
11004}
11005
11006/// Render an axial (linear) gradient shading.
11007#[allow(clippy::too_many_arguments)]
11008fn render_axial_shading(
11009    pixmap: &mut Pixmap,
11010    params: &AxialShadingParams,
11011    vp_x: f32,
11012    vp_y: f32,
11013    scale_x: f32,
11014    scale_y: f32,
11015    clip_mask: Option<&Mask>,
11016    no_aa: bool,
11017    cmyk_buf: Option<&mut [f32]>,
11018    icc: Option<&IccCache>,
11019) {
11020    let pw = pixmap.width();
11021    let ph = pixmap.height();
11022    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
11023        return;
11024    }
11025
11026    let (mut rx_min, mut ry_min, mut rx_max, mut ry_max) = if let Some(bbox) = &params.bbox {
11027        let corners = [
11028            params.ctm.transform_point(bbox[0], bbox[1]),
11029            params.ctm.transform_point(bbox[2], bbox[1]),
11030            params.ctm.transform_point(bbox[0], bbox[3]),
11031            params.ctm.transform_point(bbox[2], bbox[3]),
11032        ];
11033        let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
11034        let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
11035        let x_max = corners
11036            .iter()
11037            .map(|c| c.0)
11038            .fold(f64::NEG_INFINITY, f64::max);
11039        let y_max = corners
11040            .iter()
11041            .map(|c| c.1)
11042            .fold(f64::NEG_INFINITY, f64::max);
11043        (
11044            ((x_min as f32 - vp_x) * scale_x).max(0.0),
11045            ((y_min as f32 - vp_y) * scale_y).max(0.0),
11046            ((x_max as f32 - vp_x) * scale_x).min(pw as f32),
11047            ((y_max as f32 - vp_y) * scale_y).min(ph as f32),
11048        )
11049    } else {
11050        (0.0, 0.0, pw as f32, ph as f32)
11051    };
11052
11053    if rx_max <= rx_min || ry_max <= ry_min {
11054        return;
11055    }
11056
11057    // Transform endpoints to device space for perpendicular clipping
11058    let (dx0, dy0) = params.ctm.transform_point(params.x0, params.y0);
11059    let (dx1, dy1) = params.ctm.transform_point(params.x1, params.y1);
11060
11061    // When extend is false on a side, clip the fill area along a line
11062    // perpendicular to the gradient axis through that endpoint. For diagonal
11063    // gradients this produces a diagonal cutoff (not axis-aligned).
11064    let needs_perpendicular_clip = (!params.extend_start || !params.extend_end) && {
11065        let axis_x = dx1 - dx0;
11066        let axis_y = dy1 - dy0;
11067        axis_x.abs() > 1e-6 && axis_y.abs() > 1e-6
11068    };
11069
11070    // Detect rotated BBox: if CTM has rotation components (b or c non-zero),
11071    // the BBox is not axis-aligned in device space and needs proper polygon clipping.
11072    let bbox_is_rotated =
11073        params.bbox.is_some() && (params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10);
11074
11075    if needs_perpendicular_clip {
11076        // Diagonal gradient with non-extended side — fall back to tiny-skia
11077        // for Sutherland-Hodgman polygon clipping.
11078        let stops = build_gradient_stops(&params.color_stops);
11079        if stops.is_empty() {
11080            return;
11081        }
11082        let start = stet_tiny_skia::Point::from_xy(params.x0 as f32, params.y0 as f32);
11083        let end = stet_tiny_skia::Point::from_xy(params.x1 as f32, params.y1 as f32);
11084        let gradient_transform =
11085            viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
11086        let Some(gradient) = stet_tiny_skia::LinearGradient::new(
11087            start,
11088            end,
11089            stops,
11090            stet_tiny_skia::SpreadMode::Pad,
11091            gradient_transform,
11092        ) else {
11093            return;
11094        };
11095        let paint = Paint {
11096            shader: gradient,
11097            anti_alias: !no_aa,
11098            ..Paint::default()
11099        };
11100
11101        // Use rotated BBox polygon when CTM has rotation, otherwise axis-aligned rect
11102        let mut poly: Vec<(f32, f32)> = if bbox_is_rotated {
11103            let bbox = params.bbox.as_ref().unwrap();
11104            let corners = [
11105                params.ctm.transform_point(bbox[0], bbox[1]),
11106                params.ctm.transform_point(bbox[2], bbox[1]),
11107                params.ctm.transform_point(bbox[2], bbox[3]),
11108                params.ctm.transform_point(bbox[0], bbox[3]),
11109            ];
11110            corners
11111                .iter()
11112                .map(|(x, y)| ((*x as f32 - vp_x) * scale_x, (*y as f32 - vp_y) * scale_y))
11113                .collect()
11114        } else {
11115            vec![
11116                (rx_min, ry_min),
11117                (rx_max, ry_min),
11118                (rx_max, ry_max),
11119                (rx_min, ry_max),
11120            ]
11121        };
11122        let ax = (dx1 - dx0) as f32 * scale_x;
11123        let ay = (dy1 - dy0) as f32 * scale_y;
11124        if !params.extend_start {
11125            let px = (dx0 as f32 - vp_x) * scale_x;
11126            let py = (dy0 as f32 - vp_y) * scale_y;
11127            poly = clip_polygon_halfplane(&poly, ax, ay, px, py);
11128        }
11129        if !params.extend_end {
11130            let px = (dx1 as f32 - vp_x) * scale_x;
11131            let py = (dy1 as f32 - vp_y) * scale_y;
11132            poly = clip_polygon_halfplane(&poly, -ax, -ay, px, py);
11133        }
11134        if poly.len() >= 3 {
11135            let mut pb = PathBuilder::new();
11136            pb.move_to(poly[0].0, poly[0].1);
11137            for &(x, y) in &poly[1..] {
11138                pb.line_to(x, y);
11139            }
11140            pb.close();
11141            if let Some(path) = pb.finish() {
11142                pixmap.fill_path(
11143                    &path,
11144                    &paint,
11145                    SkiaFillRule::Winding,
11146                    Transform::identity(),
11147                    clip_mask,
11148                );
11149            }
11150        }
11151    } else {
11152        // Common case: axis-aligned or both sides extended — direct rasterization.
11153        // Clip fill rect to gradient extent when sides aren't extended.
11154        if !params.extend_start || !params.extend_end {
11155            let axis_x = dx1 - dx0;
11156            let axis_y = dy1 - dy0;
11157            let gx0 = (dx0 as f32 - vp_x) * scale_x;
11158            let gy0 = (dy0 as f32 - vp_y) * scale_y;
11159            let gx1 = (dx1 as f32 - vp_x) * scale_x;
11160            let gy1 = (dy1 as f32 - vp_y) * scale_y;
11161
11162            if axis_x.abs() >= axis_y.abs() {
11163                if !params.extend_start {
11164                    if axis_x >= 0.0 {
11165                        rx_min = rx_min.max(gx0);
11166                    } else {
11167                        rx_max = rx_max.min(gx0);
11168                    }
11169                }
11170                if !params.extend_end {
11171                    if axis_x >= 0.0 {
11172                        rx_max = rx_max.min(gx1);
11173                    } else {
11174                        rx_min = rx_min.max(gx1);
11175                    }
11176                }
11177            } else {
11178                if !params.extend_start {
11179                    if axis_y >= 0.0 {
11180                        ry_min = ry_min.max(gy0);
11181                    } else {
11182                        ry_max = ry_max.min(gy0);
11183                    }
11184                }
11185                if !params.extend_end {
11186                    if axis_y >= 0.0 {
11187                        ry_max = ry_max.min(gy1);
11188                    } else {
11189                        ry_min = ry_min.max(gy1);
11190                    }
11191                }
11192            }
11193            if rx_max <= rx_min || ry_max <= ry_min {
11194                return;
11195            }
11196        }
11197
11198        // Compute gradient axis in shading space.
11199        let ax = params.x1 - params.x0;
11200        let ay = params.y1 - params.y0;
11201        let axis_sq = ax * ax + ay * ay;
11202        if axis_sq < 1e-20 {
11203            return;
11204        }
11205
11206        // Size the LUT to the gradient's pixel span so each entry covers ≤1 pixel.
11207        // This ensures nearest-neighbor lookup produces pixel-perfect sharp edges
11208        // at stitching function discontinuities without banding in smooth gradients.
11209        let pixel_dx = (dx1 - dx0) * scale_x as f64;
11210        let pixel_dy = (dy1 - dy0) * scale_y as f64;
11211        let pixel_axis_len = (pixel_dx * pixel_dx + pixel_dy * pixel_dy).sqrt();
11212        let lut_size = (pixel_axis_len as usize)
11213            .max(params.color_stops.len())
11214            .max(256)
11215            .min(16384);
11216        let lut = build_gradient_lut(&params.color_stops, lut_size);
11217
11218        let Some(inv) = params.ctm.invert() else {
11219            return;
11220        };
11221        let inv_sx = 1.0 / scale_x as f64;
11222        let inv_sy = 1.0 / scale_y as f64;
11223        let dev_origin_x = vp_x as f64;
11224        let dev_origin_y = vp_y as f64;
11225
11226        // Shading-space coords as linear function of pixel coords:
11227        //   sx = sx_base + dsx_dx * px + dsx_dy * py
11228        //   sy = sy_base + dsy_dx * px + dsy_dy * py
11229        let sx_base = inv.a * dev_origin_x + inv.c * dev_origin_y + inv.tx;
11230        let sy_base = inv.b * dev_origin_x + inv.d * dev_origin_y + inv.ty;
11231        let dsx_dx = inv.a * inv_sx;
11232        let dsx_dy = inv.c * inv_sy;
11233        let dsy_dx = inv.b * inv_sx;
11234        let dsy_dy = inv.d * inv_sy;
11235
11236        // t = dot(P_shading - P0, axis) / dot(axis, axis)
11237        let inv_axis_sq = 1.0 / axis_sq;
11238        let t_origin = ((sx_base - params.x0) * ax + (sy_base - params.y0) * ay) * inv_axis_sq;
11239        let dt_dx = (dsx_dx * ax + dsy_dx * ay) * inv_axis_sq;
11240        let dt_dy = (dsx_dy * ax + dsy_dy * ay) * inv_axis_sq;
11241
11242        // Per-pixel rotated BBox clipping: reuse inverse CTM to map each pixel
11243        // back to shading space and check against the original BBox.
11244        let bbox_pixel_clip = if bbox_is_rotated {
11245            let bbox = params.bbox.as_ref().unwrap();
11246            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11247            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11248            Some((
11249                dsx_dx, dsx_dy, sx_base, dsy_dx, dsy_dy, sy_base, bx0, by0, bx1, by1,
11250            ))
11251        } else {
11252            None
11253        };
11254
11255        let ix_min = rx_min.floor() as u32;
11256        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11257        let iy_min = ry_min.floor() as u32;
11258        let iy_max = ry_max.ceil().min(ph as f32) as u32;
11259
11260        let stride = pw as usize * 4;
11261        let data = pixmap.data_mut();
11262        let mask_data = clip_mask.map(|m| m.data());
11263        let alpha = (params.alpha.clamp(0.0, 1.0) * 255.0 + 0.5) as u16;
11264
11265        for py in iy_min..iy_max {
11266            let t_row = t_origin + dt_dy * py as f64;
11267            let row_offset = py as usize * stride;
11268
11269            // Precompute row-base values for rotated BBox check
11270            let (ux_row, uy_row) =
11271                if let Some((_, dux_dy, ux_base, _, duy_dy, uy_base, ..)) = &bbox_pixel_clip {
11272                    (ux_base + dux_dy * py as f64, uy_base + duy_dy * py as f64)
11273                } else {
11274                    (0.0, 0.0)
11275                };
11276
11277            for px in ix_min..ix_max {
11278                // Check clip mask
11279                if let Some(md) = mask_data {
11280                    if md[py as usize * pw as usize + px as usize] == 0 {
11281                        continue;
11282                    }
11283                }
11284
11285                // Per-pixel rotated BBox clip
11286                if let Some((dux_dx, _, _, duy_dx, _, _, bx0, by0, bx1, by1)) = &bbox_pixel_clip {
11287                    let ux = ux_row + dux_dx * px as f64;
11288                    let uy = uy_row + duy_dx * px as f64;
11289                    if ux < *bx0 || ux > *bx1 || uy < *by0 || uy > *by1 {
11290                        continue;
11291                    }
11292                }
11293
11294                let t = t_row + dt_dx * px as f64;
11295                let t_clamped = t.clamp(0.0, 1.0);
11296                let idx = (t_clamped * (lut_size - 1) as f64 + 0.5) as usize;
11297                let [r, g, b, _] = lut[idx.min(lut_size - 1)];
11298
11299                let offset = row_offset + px as usize * 4;
11300                if alpha >= 255 {
11301                    data[offset] = r;
11302                    data[offset + 1] = g;
11303                    data[offset + 2] = b;
11304                    data[offset + 3] = 255;
11305                } else {
11306                    // Alpha blend: premultiply and composite over existing pixel
11307                    let a = alpha as u16;
11308                    let inv_a = 255 - a;
11309                    data[offset] = ((r as u16 * a + data[offset] as u16 * inv_a + 127) / 255) as u8;
11310                    data[offset + 1] =
11311                        ((g as u16 * a + data[offset + 1] as u16 * inv_a + 127) / 255) as u8;
11312                    data[offset + 2] =
11313                        ((b as u16 * a + data[offset + 2] as u16 * inv_a + 127) / 255) as u8;
11314                    data[offset + 3] = ((a + data[offset + 3] as u16 * inv_a / 255).min(255)) as u8;
11315                }
11316            }
11317        }
11318    }
11319
11320    // Update CMYK tracking buffer for axial shading
11321    if let Some(buf) = cmyk_buf {
11322        let pw = pixmap.width();
11323        let inv_sx = 1.0 / scale_x as f64;
11324        let inv_sy = 1.0 / scale_y as f64;
11325        let axis_x = params.x1 - params.x0;
11326        let axis_y = params.y1 - params.y0;
11327        let axis_len_sq = axis_x * axis_x + axis_y * axis_y;
11328        let Some(inv_ctm) = params.ctm.invert() else {
11329            return;
11330        };
11331
11332        let iy_min = ry_min.floor() as u32;
11333        let iy_max = ry_max.ceil().min(pixmap.height() as f32) as u32;
11334        let ix_min = rx_min.floor() as u32;
11335        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11336
11337        for py in iy_min..iy_max {
11338            let dev_y = py as f64 * inv_sy + vp_y as f64;
11339            for px in ix_min..ix_max {
11340                let dev_x = px as f64 * inv_sx + vp_x as f64;
11341                let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11342                let t = if axis_len_sq > 1e-10 {
11343                    ((ux - params.x0) * axis_x + (uy - params.y0) * axis_y) / axis_len_sq
11344                } else {
11345                    0.0
11346                };
11347                if t < 0.0 && !params.extend_start {
11348                    continue;
11349                }
11350                if t > 1.0 && !params.extend_end {
11351                    continue;
11352                }
11353                let clamped = t.clamp(0.0, 1.0);
11354
11355                if let Some(mask) = clip_mask {
11356                    let mi = py as usize * pw as usize + px as usize;
11357                    if mask.data()[mi] == 0 {
11358                        continue;
11359                    }
11360                }
11361
11362                let color = interpolate_color_stops(&params.color_stops, clamped);
11363                let cmyk = interpolate_cmyk_from_stops(
11364                    &params.color_stops,
11365                    &params.color_space,
11366                    clamped,
11367                    &color,
11368                    icc,
11369                );
11370                let ci = (py as usize * pw as usize + px as usize) * 4;
11371                if ci + 3 < buf.len() {
11372                    if params.spot_tint_blend && params.overprint {
11373                        // Per PDF spec 11.7.4.5 a Separation/DeviceN gradient
11374                        // only affects the device colorants identified by its
11375                        // color space: plates for NAMED PROCESS colorants are
11376                        // REPLACED with the gradient's CMYK value at this
11377                        // pixel, plates not tied to a named process colorant
11378                        // are PRESERVED.  The LUT-painted pixmap already
11379                        // carries the spot's full ICC-converted color, so:
11380                        //
11381                        // Gated on `overprint` because the LUT pass for
11382                        // non-overprint shadings carries the author-intended
11383                        // blend mode (e.g. 2265.pdf draws each circle wedge
11384                        // twice — Normal then Multiply — and the multiplied
11385                        // pixmap is the wedge's final color).  Recomposing
11386                        // here would overwrite the multiply-darkened result
11387                        // with a single ICC sample of the source CMYK.
11388                        //   * Where the CMYK buffer is empty (fresh paper),
11389                        //     leave the pixmap alone — re-running CMYK→RGB
11390                        //     here would round-trip through the system
11391                        //     profile and produce a perceptibly different
11392                        //     gradient curve (the snowman shading regression
11393                        //     guarded against in the original recompose
11394                        //     branch).  Just record the named-process
11395                        //     contribution to the buffer for later overprint
11396                        //     tracking.
11397                        //   * Where the CMYK buffer has prior values (a
11398                        //     CMYK fill underneath, e.g. a `1 0 1 0.5 k`
11399                        //     checkmark under the strip), the LUT-paint had
11400                        //     wiped that underlying paint from the pixmap.
11401                        //     Recompose the pixmap from the merged CMYK
11402                        //     (REPLACE named, preserve non-named) to restore
11403                        //     the checkmark with the gradient's named-plate
11404                        //     contribution layered on top.
11405                        let cur_c = buf[ci] as f64;
11406                        let cur_m = buf[ci + 1] as f64;
11407                        let cur_y = buf[ci + 2] as f64;
11408                        let cur_k = buf[ci + 3] as f64;
11409                        let cur_is_zero =
11410                            cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
11411                        let named = params.painted_channels;
11412                        if cur_is_zero {
11413                            if named & stet_graphics::device::CMYK_C != 0 {
11414                                buf[ci] = cmyk.0 as f32;
11415                            }
11416                            if named & stet_graphics::device::CMYK_M != 0 {
11417                                buf[ci + 1] = cmyk.1 as f32;
11418                            }
11419                            if named & stet_graphics::device::CMYK_Y != 0 {
11420                                buf[ci + 2] = cmyk.2 as f32;
11421                            }
11422                            if named & stet_graphics::device::CMYK_K != 0 {
11423                                buf[ci + 3] = cmyk.3 as f32;
11424                            }
11425                        } else {
11426                            let new_c = if named & stet_graphics::device::CMYK_C != 0 {
11427                                cmyk.0
11428                            } else {
11429                                cur_c
11430                            };
11431                            let new_m = if named & stet_graphics::device::CMYK_M != 0 {
11432                                cmyk.1
11433                            } else {
11434                                cur_m
11435                            };
11436                            let new_y = if named & stet_graphics::device::CMYK_Y != 0 {
11437                                cmyk.2
11438                            } else {
11439                                cur_y
11440                            };
11441                            let new_k = if named & stet_graphics::device::CMYK_K != 0 {
11442                                cmyk.3
11443                            } else {
11444                                cur_k
11445                            };
11446                            buf[ci] = new_c as f32;
11447                            buf[ci + 1] = new_m as f32;
11448                            buf[ci + 2] = new_y as f32;
11449                            buf[ci + 3] = new_k as f32;
11450                            let (rv, gv, bv) = if let Some(icc_cache) = icc {
11451                                icc_cache
11452                                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
11453                                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
11454                            } else {
11455                                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
11456                            };
11457                            let stride = pixmap.data().len() / pixmap.height() as usize;
11458                            let offset = py as usize * stride + px as usize * 4;
11459                            let data = pixmap.data_mut();
11460                            data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11461                            data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11462                            data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11463                        }
11464                    } else if params.overprint
11465                        && params.painted_channels != stet_graphics::device::CMYK_ALL
11466                    {
11467                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11468                            buf[ci] = cmyk.0 as f32;
11469                        }
11470                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11471                            buf[ci + 1] = cmyk.1 as f32;
11472                        }
11473                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11474                            buf[ci + 2] = cmyk.2 as f32;
11475                        }
11476                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
11477                            buf[ci + 3] = cmyk.3 as f32;
11478                        }
11479                        // Recomposite RGB from merged CMYK via ICC
11480                        let c = buf[ci] as f64;
11481                        let m = buf[ci + 1] as f64;
11482                        let y = buf[ci + 2] as f64;
11483                        let k = buf[ci + 3] as f64;
11484                        let (rv, gv, bv) = if let Some(icc_cache) = icc {
11485                            icc_cache
11486                                .convert_cmyk_readonly(c, m, y, k)
11487                                .unwrap_or_else(|| cmyk_to_rgb_plrm(c, m, y, k))
11488                        } else {
11489                            cmyk_to_rgb_plrm(c, m, y, k)
11490                        };
11491                        let stride = pixmap.data().len() / pixmap.height() as usize;
11492                        let offset = py as usize * stride + px as usize * 4;
11493                        let data = pixmap.data_mut();
11494                        data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11495                        data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11496                        data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11497                    } else {
11498                        // Non-overprint axial shading: write the source CMYK
11499                        // to the buffer for any consumer that needs it (e.g.
11500                        // overprint sibling tracking) but leave the pixmap
11501                        // alone — `build_gradient_lut` already painted the
11502                        // pixel with linearly-interpolated source RGB, and
11503                        // round-tripping CMYK→RGB through the ICC profile
11504                        // produces a different gradient curve (linear in
11505                        // CMYK rather than linear in RGB) that diverges
11506                        // visibly from the LUT result. The CMYK buffer is
11507                        // only consumed by `composite_non_isolated_cmyk`,
11508                        // which excludes shading-containing groups via
11509                        // `group_content_is_native_cmyk`, so the
11510                        // buffer/pixmap mismatch never reaches a consumer
11511                        // that would notice. Reintroducing the round-trip
11512                        // here was the 3000_9 / 3000_10 snowman shading
11513                        // regression in the silly-weaving-bird plan.
11514                        buf[ci] = cmyk.0 as f32;
11515                        buf[ci + 1] = cmyk.1 as f32;
11516                        buf[ci + 2] = cmyk.2 as f32;
11517                        buf[ci + 3] = cmyk.3 as f32;
11518                    }
11519                }
11520            }
11521        }
11522    }
11523}
11524
11525/// Render a radial gradient shading.
11526#[allow(clippy::too_many_arguments)]
11527fn render_radial_shading(
11528    pixmap: &mut Pixmap,
11529    params: &RadialShadingParams,
11530    vp_x: f32,
11531    vp_y: f32,
11532    scale_x: f32,
11533    scale_y: f32,
11534    clip_mask: Option<&Mask>,
11535    _no_aa: bool,
11536    mut cmyk_buf: Option<&mut [f32]>,
11537    icc: Option<&IccCache>,
11538) {
11539    let pw = pixmap.width();
11540    let ph = pixmap.height();
11541    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
11542        return;
11543    }
11544
11545    let Some(inv_ctm) = params.ctm.invert() else {
11546        return;
11547    };
11548
11549    let (px_min, py_min, px_max, py_max) = if let Some(bbox) = &params.bbox {
11550        let corners = [
11551            params.ctm.transform_point(bbox[0], bbox[1]),
11552            params.ctm.transform_point(bbox[2], bbox[1]),
11553            params.ctm.transform_point(bbox[0], bbox[3]),
11554            params.ctm.transform_point(bbox[2], bbox[3]),
11555        ];
11556        let x_min = corners
11557            .iter()
11558            .map(|c| c.0 as f32)
11559            .fold(f32::INFINITY, f32::min);
11560        let y_min = corners
11561            .iter()
11562            .map(|c| c.1 as f32)
11563            .fold(f32::INFINITY, f32::min);
11564        let x_max = corners
11565            .iter()
11566            .map(|c| c.0 as f32)
11567            .fold(f32::NEG_INFINITY, f32::max);
11568        let y_max = corners
11569            .iter()
11570            .map(|c| c.1 as f32)
11571            .fold(f32::NEG_INFINITY, f32::max);
11572        (
11573            ((x_min - vp_x) * scale_x).max(0.0) as u32,
11574            ((y_min - vp_y) * scale_y).max(0.0) as u32,
11575            (((x_max - vp_x) * scale_x).ceil() as u32).min(pw),
11576            (((y_max - vp_y) * scale_y).ceil() as u32).min(ph),
11577        )
11578    } else {
11579        (0, 0, pw, ph)
11580    };
11581
11582    let inv_sx = 1.0 / scale_x as f64;
11583    let inv_sy = 1.0 / scale_y as f64;
11584
11585    // Rotated BBox: check per-pixel user-space containment
11586    let rotated_bbox = if let Some(bbox) = &params.bbox {
11587        if params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10 {
11588            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11589            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11590            Some((bx0, by0, bx1, by1))
11591        } else {
11592            None
11593        }
11594    } else {
11595        None
11596    };
11597
11598    let data = pixmap.data_mut();
11599    let stride = pw as usize * 4;
11600
11601    for py in py_min..py_max {
11602        let dev_y = py as f64 * inv_sy + vp_y as f64;
11603        for px in px_min..px_max {
11604            let dev_x = px as f64 * inv_sx + vp_x as f64;
11605            let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11606
11607            // Per-pixel rotated BBox clip
11608            if let Some((bx0, by0, bx1, by1)) = rotated_bbox {
11609                if ux < bx0 || ux > bx1 || uy < by0 || uy > by1 {
11610                    continue;
11611                }
11612            }
11613
11614            let t = solve_radial_t(
11615                ux,
11616                uy,
11617                params.x0,
11618                params.y0,
11619                params.r0,
11620                params.x1,
11621                params.y1,
11622                params.r1,
11623                params.extend_start,
11624                params.extend_end,
11625            );
11626            if let Some(t) = t {
11627                let clamped = t.clamp(0.0, 1.0);
11628                let color = interpolate_color_stops(&params.color_stops, clamped);
11629
11630                let clipped = clip_mask
11631                    .is_some_and(|mask| mask.data()[py as usize * pw as usize + px as usize] == 0);
11632
11633                if clipped {
11634                    continue;
11635                }
11636
11637                // Decide whether this pixel should use the multiplicative
11638                // ink-stacking blend to preserve a spot backdrop. We mirror
11639                // the rule in `render_overprint_fill`: overprint + subset
11640                // painted channels + buffer effectively empty at this pixel
11641                // means the pixmap carries a non-CMYK contribution (or the
11642                // pixel is fresh), so per-channel ink-stacking gives the
11643                // correct result whether the backdrop was spot-painted or
11644                // plain.
11645                let cmyk = interpolate_cmyk_from_stops(
11646                    &params.color_stops,
11647                    &params.color_space,
11648                    clamped,
11649                    &color,
11650                    icc,
11651                );
11652                let ci = (py as usize * pw as usize + px as usize) * 4;
11653                let buffer_clean = if let Some(ref buf) = cmyk_buf {
11654                    if ci + 3 < buf.len() {
11655                        buf[ci] == 0.0
11656                            && buf[ci + 1] == 0.0
11657                            && buf[ci + 2] == 0.0
11658                            && buf[ci + 3] == 0.0
11659                    } else {
11660                        false
11661                    }
11662                } else {
11663                    false
11664                };
11665                let offset_for_check = py as usize * stride + px as usize * 4;
11666                let pixmap_has_colour = data[offset_for_check + 3] > 0
11667                    && (data[offset_for_check] < 250
11668                        || data[offset_for_check + 1] < 250
11669                        || data[offset_for_check + 2] < 250);
11670                let use_multiplicative = params.overprint
11671                    && params.painted_channels != stet_graphics::device::CMYK_ALL
11672                    && buffer_clean
11673                    && pixmap_has_colour;
11674
11675                // Write CMYK buffer at non-clipped pixels
11676                if let Some(ref mut buf) = cmyk_buf
11677                    && ci + 3 < buf.len()
11678                {
11679                    if params.overprint
11680                        && params.painted_channels != stet_graphics::device::CMYK_ALL
11681                    {
11682                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11683                            buf[ci] = cmyk.0 as f32;
11684                        }
11685                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11686                            buf[ci + 1] = cmyk.1 as f32;
11687                        }
11688                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11689                            buf[ci + 2] = cmyk.2 as f32;
11690                        }
11691                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
11692                            buf[ci + 3] = cmyk.3 as f32;
11693                        }
11694                    } else {
11695                        buf[ci] = cmyk.0 as f32;
11696                        buf[ci + 1] = cmyk.1 as f32;
11697                        buf[ci + 2] = cmyk.2 as f32;
11698                        buf[ci + 3] = cmyk.3 as f32;
11699                    }
11700                }
11701
11702                let offset = py as usize * stride + px as usize * 4;
11703                if use_multiplicative {
11704                    // Ink-stack the per-stop CMYK onto the pixmap RGB. Only
11705                    // channels named by painted_channels contribute; others
11706                    // leave the pixmap untouched, so a spot-painted backdrop
11707                    // survives with just the named inks darkening it.
11708                    let bg_r = data[offset] as f64 / 255.0;
11709                    let bg_g = data[offset + 1] as f64 / 255.0;
11710                    let bg_b = data[offset + 2] as f64 / 255.0;
11711                    let over_r = if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11712                        1.0 - cmyk.0
11713                    } else {
11714                        1.0
11715                    };
11716                    let over_g = if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11717                        1.0 - cmyk.1
11718                    } else {
11719                        1.0
11720                    };
11721                    let over_b = if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11722                        1.0 - cmyk.2
11723                    } else {
11724                        1.0
11725                    };
11726                    let k_fac = if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
11727                        1.0 - cmyk.3
11728                    } else {
11729                        1.0
11730                    };
11731                    data[offset] = ((bg_r * over_r * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
11732                    data[offset + 1] =
11733                        ((bg_g * over_g * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
11734                    data[offset + 2] =
11735                        ((bg_b * over_b * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
11736                    data[offset + 3] = 255;
11737                } else {
11738                    data[offset] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
11739                    data[offset + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
11740                    data[offset + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
11741                    data[offset + 3] = 255;
11742
11743                    // Recomposite RGB from the CMYK buffer via ICC only for
11744                    // overprint DeviceCMYK shadings on a CMYK-only backdrop,
11745                    // where the per-channel merge in the buffer means the
11746                    // displayed pixel must reflect the merged CMYK rather
11747                    // than the source's RGB. For non-overprint shadings the
11748                    // LUT-rendered pixmap (above) is already correct, and
11749                    // round-tripping CMYK→RGB through the ICC profile
11750                    // produces a different gradient curve (linear in CMYK
11751                    // rather than linear in RGB) — that drift was the
11752                    // 3000_9 / 3000_10 snowman shading regression. The CMYK
11753                    // buffer is only consumed by `composite_non_isolated_cmyk`,
11754                    // which excludes shading-containing groups via
11755                    // `group_content_is_native_cmyk`, so the buffer/pixmap
11756                    // mismatch never reaches a consumer that would notice.
11757                    if params.overprint
11758                        && params.painted_channels != stet_graphics::device::CMYK_ALL
11759                        && matches!(params.color_space, ShadingColorSpace::DeviceCMYK)
11760                        && let Some(ref mut buf) = cmyk_buf
11761                        && ci + 3 < buf.len()
11762                        && let Some(icc_cache) = icc
11763                    {
11764                        let c = buf[ci] as f64;
11765                        let m = buf[ci + 1] as f64;
11766                        let y = buf[ci + 2] as f64;
11767                        let k = buf[ci + 3] as f64;
11768                        if let Some((r, g, b)) = icc_cache.convert_cmyk_readonly(c, m, y, k) {
11769                            data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
11770                            data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
11771                            data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
11772                        }
11773                    }
11774                }
11775            }
11776        }
11777    }
11778}
11779/// Solve for the parameter t of a two-circle radial gradient at point (px, py).
11780///
11781/// Returns the largest root of the circle equation that falls within the valid
11782/// domain and has R(t) >= 0. The valid domain is [0,1], extended by extend flags.
11783#[allow(clippy::too_many_arguments)]
11784fn solve_radial_t(
11785    px: f64,
11786    py: f64,
11787    x0: f64,
11788    y0: f64,
11789    r0: f64,
11790    x1: f64,
11791    y1: f64,
11792    r1: f64,
11793    extend_start: bool,
11794    extend_end: bool,
11795) -> Option<f64> {
11796    // Parametric: C(t) = (1-t)*C0 + t*C1, R(t) = (1-t)*r0 + t*r1
11797    // Solve: (px - Cx(t))^2 + (py - Cy(t))^2 = R(t)^2
11798    let cdx = x1 - x0;
11799    let cdy = y1 - y0;
11800    let dr = r1 - r0;
11801
11802    let a = cdx * cdx + cdy * cdy - dr * dr;
11803    let dpx = px - x0;
11804    let dpy = py - y0;
11805    let b = -2.0 * (dpx * cdx + dpy * cdy + r0 * dr);
11806    let c = dpx * dpx + dpy * dpy - r0 * r0;
11807
11808    // Helper: check if a root is in the valid domain
11809    let in_domain = |t: f64| -> bool {
11810        (0.0..=1.0).contains(&t) || (t < 0.0 && extend_start) || (t > 1.0 && extend_end)
11811    };
11812
11813    if a.abs() < 1e-10 {
11814        // Linear case
11815        if b.abs() < 1e-10 {
11816            return None;
11817        }
11818        let t = -c / b;
11819        let radius = r0 + t * dr;
11820        if radius >= 0.0 && in_domain(t) {
11821            return Some(t);
11822        }
11823        return None;
11824    }
11825
11826    let discriminant = b * b - 4.0 * a * c;
11827    if discriminant < 0.0 {
11828        return None;
11829    }
11830    let sqrt_d = discriminant.sqrt();
11831    let t1 = (-b + sqrt_d) / (2.0 * a);
11832    let t2 = (-b - sqrt_d) / (2.0 * a);
11833
11834    // Pick the largest root that is in the valid domain and has R(t) >= 0
11835    let mut best: Option<f64> = None;
11836    for t in [t1, t2] {
11837        let radius = r0 + t * dr;
11838        if radius >= 0.0 && in_domain(t) {
11839            best = Some(match best {
11840                Some(prev) => prev.max(t),
11841                None => t,
11842            });
11843        }
11844    }
11845    best
11846}
11847
11848/// Render a Gouraud-shaded triangle mesh.
11849#[allow(clippy::too_many_arguments)]
11850fn render_mesh_shading(
11851    pixmap: &mut Pixmap,
11852    params: &MeshShadingParams,
11853    vp_x: f32,
11854    vp_y: f32,
11855    scale_x: f32,
11856    scale_y: f32,
11857    clip_mask: Option<&Mask>,
11858    mut cmyk_buf: Option<&mut [f32]>,
11859    icc: Option<&IccCache>,
11860) {
11861    let pw = pixmap.width() as usize;
11862    let ph = pixmap.height() as usize;
11863    if pw == 0 || ph == 0 {
11864        return;
11865    }
11866    let data = pixmap.data_mut();
11867    let stride = pw * 4;
11868
11869    let lut = params.color_lut.as_deref();
11870
11871    for tri in &params.triangles {
11872        let (dx0, dy0) = params.ctm.transform_point(tri.v0.x, tri.v0.y);
11873        let (dx1, dy1) = params.ctm.transform_point(tri.v1.x, tri.v1.y);
11874        let (dx2, dy2) = params.ctm.transform_point(tri.v2.x, tri.v2.y);
11875
11876        let x0 = (dx0 as f32 - vp_x) * scale_x;
11877        let y0 = (dy0 as f32 - vp_y) * scale_y;
11878        let x1 = (dx1 as f32 - vp_x) * scale_x;
11879        let y1 = (dy1 as f32 - vp_y) * scale_y;
11880        let x2 = (dx2 as f32 - vp_x) * scale_x;
11881        let y2 = (dy2 as f32 - vp_y) * scale_y;
11882
11883        let min_x = (x0.min(x1).min(x2).floor().max(0.0)) as usize;
11884        let max_x = (x0.max(x1).max(x2).ceil() as usize).min(pw);
11885        let min_y = (y0.min(y1).min(y2).floor().max(0.0)) as usize;
11886        let max_y = (y0.max(y1).max(y2).ceil() as usize).min(ph);
11887
11888        if min_x >= max_x || min_y >= max_y {
11889            continue;
11890        }
11891
11892        let x0 = x0 as f64;
11893        let y0 = y0 as f64;
11894        let x1 = x1 as f64;
11895        let y1 = y1 as f64;
11896        let x2 = x2 as f64;
11897        let y2 = y2 as f64;
11898        // Swap vertices 1 and 2 when the triangle has reversed winding
11899        // (from a CTM with negative determinant, e.g. X- or Y-flip).
11900        // This ensures barycentric coordinates stay positive for interior
11901        // points regardless of the CTM orientation.
11902        let denom = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2);
11903        if denom.abs() < 1e-10 {
11904            continue;
11905        }
11906        let (x1, y1, x2, y2) = if denom < 0.0 {
11907            (x2, y2, x1, y1)
11908        } else {
11909            (x1, y1, x2, y2)
11910        };
11911        let (v1_ref, v2_ref) = if denom < 0.0 {
11912            (&tri.v2, &tri.v1)
11913        } else {
11914            (&tri.v1, &tri.v2)
11915        };
11916        let denom = denom.abs();
11917        let inv_denom = 1.0 / denom;
11918
11919        for py in min_y..max_y {
11920            for px in min_x..max_x {
11921                let pxf = px as f64 + 0.5;
11922                let pyf = py as f64 + 0.5;
11923
11924                let w0 = ((y1 - y2) * (pxf - x2) + (x2 - x1) * (pyf - y2)) * inv_denom;
11925                let w1 = ((y2 - y0) * (pxf - x2) + (x0 - x2) * (pyf - y2)) * inv_denom;
11926                let w2 = 1.0 - w0 - w1;
11927
11928                if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
11929                    continue;
11930                }
11931
11932                let clipped = clip_mask.is_some_and(|mask| mask.data()[py * pw + px] == 0);
11933
11934                let w0c = w0.max(0.0);
11935                let w1c = w1.max(0.0);
11936                let w2c = w2.max(0.0);
11937                let wsum = w0c + w1c + w2c;
11938                let w0n = w0c / wsum;
11939                let w1n = w1c / wsum;
11940                let w2n = w2c / wsum;
11941
11942                // Per-pixel color: either LUT lookup (for function-based meshes)
11943                // or direct Gouraud interpolation of vertex DeviceColors.
11944                let (r, g, b) = if let Some(lut) = lut {
11945                    // Interpolate raw function input values per-pixel
11946                    let raw = w0n * tri.v0.raw_components[0]
11947                        + w1n * v1_ref.raw_components[0]
11948                        + w2n * v2_ref.raw_components[0];
11949                    let raw = raw.clamp(0.0, 1.0);
11950                    // Linear interpolation in the LUT
11951                    let fi = raw * (lut.len() - 1) as f64;
11952                    let i0 = (fi as usize).min(lut.len().saturating_sub(2));
11953                    let frac = fi - i0 as f64;
11954                    let c0 = &lut[i0];
11955                    let c1 = &lut[i0 + 1];
11956                    (
11957                        c0.r + frac * (c1.r - c0.r),
11958                        c0.g + frac * (c1.g - c0.g),
11959                        c0.b + frac * (c1.b - c0.b),
11960                    )
11961                } else {
11962                    (
11963                        w0n * tri.v0.color.r + w1n * v1_ref.color.r + w2n * v2_ref.color.r,
11964                        w0n * tri.v0.color.g + w1n * v1_ref.color.g + w2n * v2_ref.color.g,
11965                        w0n * tri.v0.color.b + w1n * v1_ref.color.b + w2n * v2_ref.color.b,
11966                    )
11967                };
11968
11969                // Write CMYK buffer
11970                if let Some(ref mut buf) = cmyk_buf {
11971                    let ci = (py * pw + px) * 4;
11972                    if ci + 3 < buf.len() {
11973                        let cmyk = interpolate_cmyk_from_vertices(
11974                            &tri.v0,
11975                            v1_ref,
11976                            v2_ref,
11977                            w0n,
11978                            w1n,
11979                            w2n,
11980                            &params.color_space,
11981                            r,
11982                            g,
11983                            b,
11984                            icc,
11985                        );
11986                        if params.overprint
11987                            && params.painted_channels != stet_graphics::device::CMYK_ALL
11988                        {
11989                            if !clipped {
11990                                if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11991                                    buf[ci] = cmyk.0 as f32;
11992                                }
11993                                if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11994                                    buf[ci + 1] = cmyk.1 as f32;
11995                                }
11996                                if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11997                                    buf[ci + 2] = cmyk.2 as f32;
11998                                }
11999                                if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12000                                    buf[ci + 3] = cmyk.3 as f32;
12001                                }
12002                            }
12003                        } else {
12004                            buf[ci] = cmyk.0 as f32;
12005                            buf[ci + 1] = cmyk.1 as f32;
12006                            buf[ci + 2] = cmyk.2 as f32;
12007                            buf[ci + 3] = cmyk.3 as f32;
12008                        }
12009                    }
12010                }
12011
12012                if clipped {
12013                    continue;
12014                }
12015
12016                let offset = py * stride + px * 4;
12017                data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
12018                data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
12019                data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
12020                data[offset + 3] = 255;
12021            }
12022        }
12023    }
12024}
12025
12026/// Render a Coons/tensor-product patch mesh by subdividing into triangles.
12027#[allow(clippy::too_many_arguments)]
12028fn render_patch_shading(
12029    pixmap: &mut Pixmap,
12030    params: &PatchShadingParams,
12031    vp_x: f32,
12032    vp_y: f32,
12033    scale_x: f32,
12034    scale_y: f32,
12035    clip_mask: Option<&Mask>,
12036    cmyk_buf: Option<&mut [f32]>,
12037    icc: Option<&IccCache>,
12038) {
12039    let mut triangles = Vec::new();
12040    let scale = scale_x.max(scale_y) as f64;
12041    for patch in &params.patches {
12042        if patch.points.len() >= 12 {
12043            // Compute device-space extent to choose subdivision level
12044            let mut x_min = f64::INFINITY;
12045            let mut y_min = f64::INFINITY;
12046            let mut x_max = f64::NEG_INFINITY;
12047            let mut y_max = f64::NEG_INFINITY;
12048            for &(px, py) in &patch.points {
12049                let (dx, dy) = params.ctm.transform_point(px, py);
12050                x_min = x_min.min(dx);
12051                y_min = y_min.min(dy);
12052                x_max = x_max.max(dx);
12053                y_max = y_max.max(dy);
12054            }
12055            let extent = (x_max - x_min).max(y_max - y_min).abs() * scale;
12056            // Target ~2 device pixels per boundary segment
12057            let n = (extent / 2.0).ceil().clamp(8.0, 64.0) as usize;
12058            // Extract ICC profile hash for per-grid-point color conversion
12059            let icc_profile_hash = match &params.color_space {
12060                stet_graphics::device::ShadingColorSpace::ICCBased { profile_hash, .. } => {
12061                    Some(profile_hash)
12062                }
12063                _ => None,
12064            };
12065            subdivide_patch_to_triangles(patch, &mut triangles, n, icc_profile_hash, icc);
12066        }
12067    }
12068    if !triangles.is_empty() {
12069        let mesh_params = MeshShadingParams {
12070            triangles,
12071            ctm: params.ctm,
12072            bbox: params.bbox,
12073            color_space: params.color_space.clone(),
12074            overprint: params.overprint,
12075            painted_channels: params.painted_channels,
12076            color_lut: params.color_lut.clone(),
12077            alpha: params.alpha,
12078            blend_mode: params.blend_mode,
12079            alpha_is_shape: params.alpha_is_shape,
12080        };
12081        render_mesh_shading(
12082            pixmap,
12083            &mesh_params,
12084            vp_x,
12085            vp_y,
12086            scale_x,
12087            scale_y,
12088            clip_mask,
12089            cmyk_buf,
12090            icc,
12091        );
12092    }
12093}
12094/// Subdivide a Coons/tensor patch into triangles via grid subdivision.
12095/// Evaluates the patch at NxN points and triangulates the resulting grid.
12096/// When an ICC profile hash and cache are provided, interpolates colors in the
12097/// source ICC color space and converts per-grid-point for accurate rendering.
12098fn subdivide_patch_to_triangles(
12099    patch: &stet_graphics::device::ShadingPatch,
12100    triangles: &mut Vec<stet_graphics::device::ShadingTriangle>,
12101    n: usize,
12102    icc_profile_hash: Option<&stet_graphics::icc::ProfileHash>,
12103    icc_cache: Option<&IccCache>,
12104) {
12105    // Evaluate patch at grid points.
12106    // Use tensor-product evaluation when 16 control points are available (Type 7),
12107    // otherwise fall back to Coons blending (Type 6, 12 points).
12108    let mut grid: Vec<(f64, f64, DeviceColor, Vec<f64>)> = Vec::with_capacity((n + 1) * (n + 1));
12109    let use_tensor = patch.points.len() >= 16;
12110    let has_raw = !patch.raw_colors[0].is_empty();
12111    // Use per-grid-point ICC conversion when profile info is available
12112    let use_icc_interp = has_raw && icc_profile_hash.is_some() && icc_cache.is_some();
12113
12114    for row in 0..=n {
12115        let v = row as f64 / n as f64;
12116        for col in 0..=n {
12117            let u = col as f64 / n as f64;
12118            let (x, y) = if use_tensor {
12119                eval_tensor_patch(patch, u, v)
12120            } else {
12121                eval_coons_patch(patch, u, v)
12122            };
12123            let raw = if has_raw {
12124                bilinear_raw(&patch.raw_colors, u, v)
12125            } else {
12126                vec![]
12127            };
12128            // When ICC profile is available, convert the interpolated raw
12129            // components at each grid point for accurate color rendering.
12130            // This interpolates in the source color space (e.g. ProPhoto RGB)
12131            // and converts per-grid-point, rather than interpolating pre-converted
12132            // sRGB values from only the 4 corners.
12133            let color = if use_icc_interp {
12134                if let Some((r, g, b)) = icc_cache
12135                    .unwrap()
12136                    .convert_color_readonly(icc_profile_hash.unwrap(), &raw)
12137                {
12138                    DeviceColor::from_rgb(r, g, b)
12139                } else {
12140                    bilinear_color(&patch.colors, u, v)
12141                }
12142            } else {
12143                bilinear_color(&patch.colors, u, v)
12144            };
12145            grid.push((x, y, color, raw));
12146        }
12147    }
12148
12149    // Triangulate grid
12150    let cols = n + 1;
12151    for row in 0..n {
12152        for col in 0..n {
12153            let i00 = row * cols + col;
12154            let i10 = i00 + 1;
12155            let i01 = i00 + cols;
12156            let i11 = i01 + 1;
12157
12158            let (x00, y00, c00, r00) = &grid[i00];
12159            let (x10, y10, c10, r10) = &grid[i10];
12160            let (x01, y01, c01, r01) = &grid[i01];
12161            let (x11, y11, c11, r11) = &grid[i11];
12162
12163            use stet_graphics::device::ShadingVertex;
12164            triangles.push(stet_graphics::device::ShadingTriangle {
12165                v0: ShadingVertex {
12166                    x: *x00,
12167                    y: *y00,
12168                    color: c00.clone(),
12169                    raw_components: r00.clone(),
12170                },
12171                v1: ShadingVertex {
12172                    x: *x10,
12173                    y: *y10,
12174                    color: c10.clone(),
12175                    raw_components: r10.clone(),
12176                },
12177                v2: ShadingVertex {
12178                    x: *x01,
12179                    y: *y01,
12180                    color: c01.clone(),
12181                    raw_components: r01.clone(),
12182                },
12183            });
12184            triangles.push(stet_graphics::device::ShadingTriangle {
12185                v0: ShadingVertex {
12186                    x: *x10,
12187                    y: *y10,
12188                    color: c10.clone(),
12189                    raw_components: r10.clone(),
12190                },
12191                v1: ShadingVertex {
12192                    x: *x11,
12193                    y: *y11,
12194                    color: c11.clone(),
12195                    raw_components: r11.clone(),
12196                },
12197                v2: ShadingVertex {
12198                    x: *x01,
12199                    y: *y01,
12200                    color: c01.clone(),
12201                    raw_components: r01.clone(),
12202                },
12203            });
12204        }
12205    }
12206}
12207
12208/// Evaluate a Coons patch at parameter (u, v).
12209/// The 12 control points define 4 cubic Bezier boundary curves.
12210fn eval_coons_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12211    let pts = &patch.points;
12212    if pts.len() < 12 {
12213        return (0.0, 0.0);
12214    }
12215
12216    // Side 0 (bottom): pts[0..4], u goes 0→1
12217    // Side 1 (right): pts[3..7], v goes 0→1
12218    // Side 2 (top): pts[6..10], u goes 1→0 (reversed)
12219    // Side 3 (left): pts[9..12] + pts[0], v goes 1→0 (reversed)
12220    let c0 = eval_cubic_bezier(pts[0], pts[1], pts[2], pts[3], u);
12221    let c2 = eval_cubic_bezier(pts[6], pts[7], pts[8], pts[9], 1.0 - u);
12222    let d0 = eval_cubic_bezier(pts[0], pts[11], pts[10], pts[9], v);
12223    let d1 = eval_cubic_bezier(pts[3], pts[4], pts[5], pts[6], v);
12224
12225    // Bilinear blending of corners
12226    let p00 = pts[0];
12227    let p10 = pts[3];
12228    let p01 = pts[9];
12229    let p11 = pts[6];
12230    let bx = (1.0 - u) * (1.0 - v) * p00.0
12231        + u * (1.0 - v) * p10.0
12232        + (1.0 - u) * v * p01.0
12233        + u * v * p11.0;
12234    let by = (1.0 - u) * (1.0 - v) * p00.1
12235        + u * (1.0 - v) * p10.1
12236        + (1.0 - u) * v * p01.1
12237        + u * v * p11.1;
12238
12239    // Coons blending: S(u,v) = c(u,v) + d(u,v) - B(u,v)
12240    let x = (1.0 - v) * c0.0 + v * c2.0 + (1.0 - u) * d0.0 + u * d1.0 - bx;
12241    let y = (1.0 - v) * c0.1 + v * c2.1 + (1.0 - u) * d0.1 + u * d1.1 - by;
12242
12243    (x, y)
12244}
12245
12246/// Evaluate a Type 7 tensor-product patch at parameter (u, v).
12247///
12248/// Uses 16 control points arranged in a 4×4 grid, evaluated as a bicubic
12249/// Bernstein surface: S(u,v) = ΣΣ B_i(u) * B_j(v) * P_ij
12250///
12251/// PDF spec (ISO 32000, Table 85) data ordering for flag=0:
12252///   p₁₁ p₁₂ p₁₃ p₁₄  p₂₁ p₂₂ p₂₃ p₂₄  p₃₁ p₃₂ p₃₃ p₃₄  p₄₁ p₄₂ p₄₃ p₄₄
12253///
12254/// In the grid (Figure 86), column index = u direction, row index = v direction:
12255///   grid[v=0][u] = p₁₁, p₂₁, p₃₁, p₄₁  = pts[0], pts[4], pts[8],  pts[12]
12256///   grid[v=⅓][u] = p₁₂, p₂₂, p₃₂, p₄₂  = pts[1], pts[5], pts[9],  pts[13]
12257///   grid[v=⅔][u] = p₁₃, p₂₃, p₃₃, p₄₃  = pts[2], pts[6], pts[10], pts[14]
12258///   grid[v=1][u] = p₁₄, p₂₄, p₃₄, p₄₄  = pts[3], pts[7], pts[11], pts[15]
12259fn eval_tensor_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12260    let pts = &patch.points;
12261
12262    // Map data indices to 4×4 grid [row][col].
12263    // pts[0..12] are boundary points around the perimeter (same as Type 6).
12264    // pts[12..16] are the 4 interior control points.
12265    let grid: [[usize; 4]; 4] = [[0, 1, 2, 3], [11, 12, 13, 4], [10, 15, 14, 5], [9, 8, 7, 6]];
12266
12267    // Cubic Bernstein basis values
12268    let su = 1.0 - u;
12269    let bu = [su * su * su, 3.0 * su * su * u, 3.0 * su * u * u, u * u * u];
12270    let sv = 1.0 - v;
12271    let bv = [sv * sv * sv, 3.0 * sv * sv * v, 3.0 * sv * v * v, v * v * v];
12272
12273    let mut x = 0.0;
12274    let mut y = 0.0;
12275    for j in 0..4 {
12276        for i in 0..4 {
12277            let w = bu[i] * bv[j];
12278            let p = pts[grid[j][i]];
12279            x += w * p.0;
12280            y += w * p.1;
12281        }
12282    }
12283    (x, y)
12284}
12285
12286/// Evaluate a cubic Bezier curve at parameter t.
12287fn eval_cubic_bezier(
12288    p0: (f64, f64),
12289    p1: (f64, f64),
12290    p2: (f64, f64),
12291    p3: (f64, f64),
12292    t: f64,
12293) -> (f64, f64) {
12294    let s = 1.0 - t;
12295    let s2 = s * s;
12296    let t2 = t * t;
12297    let b0 = s2 * s;
12298    let b1 = 3.0 * s2 * t;
12299    let b2 = 3.0 * s * t2;
12300    let b3 = t2 * t;
12301    (
12302        b0 * p0.0 + b1 * p1.0 + b2 * p2.0 + b3 * p3.0,
12303        b0 * p0.1 + b1 * p1.1 + b2 * p2.1 + b3 * p3.1,
12304    )
12305}
12306
12307/// Bilinear color interpolation across patch corners.
12308fn bilinear_color(colors: &[DeviceColor; 4], u: f64, v: f64) -> DeviceColor {
12309    let r = (1.0 - u) * (1.0 - v) * colors[0].r
12310        + u * (1.0 - v) * colors[1].r
12311        + (1.0 - u) * v * colors[3].r
12312        + u * v * colors[2].r;
12313    let g = (1.0 - u) * (1.0 - v) * colors[0].g
12314        + u * (1.0 - v) * colors[1].g
12315        + (1.0 - u) * v * colors[3].g
12316        + u * v * colors[2].g;
12317    let b = (1.0 - u) * (1.0 - v) * colors[0].b
12318        + u * (1.0 - v) * colors[1].b
12319        + (1.0 - u) * v * colors[3].b
12320        + u * v * colors[2].b;
12321    DeviceColor::from_rgb(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
12322}
12323
12324/// Bilinear interpolation of raw color components across patch corners.
12325fn bilinear_raw(raw_colors: &[Vec<f64>; 4], u: f64, v: f64) -> Vec<f64> {
12326    let n = raw_colors[0].len();
12327    let mut result = vec![0.0; n];
12328    for i in 0..n {
12329        result[i] = (1.0 - u) * (1.0 - v) * raw_colors[0][i]
12330            + u * (1.0 - v) * raw_colors[1][i]
12331            + (1.0 - u) * v * raw_colors[3][i]
12332            + u * v * raw_colors[2][i];
12333    }
12334    result
12335}
12336
12337/// Pre-rasterize color stops into a 256-entry RGBA lookup table.
12338///
12339/// Each entry is linearly interpolated from the color stops. Used by the
12340/// direct-rasterization axial shading path to replace per-pixel stop search
12341/// with a single array lookup.
12342fn build_gradient_lut(stops: &[stet_graphics::device::ColorStop], size: usize) -> Vec<[u8; 4]> {
12343    let size = size.max(2);
12344    let mut lut = vec![[0u8; 4]; size];
12345    if stops.is_empty() {
12346        return lut;
12347    }
12348    let mut si = 0usize; // current stop index
12349    let last = (size - 1) as f64;
12350    for i in 0..size {
12351        let t = i as f64 / last;
12352        // Advance stop index
12353        while si + 1 < stops.len() && stops[si + 1].position < t {
12354            si += 1;
12355        }
12356        let (r, g, b) = if si + 1 >= stops.len() {
12357            let c = &stops[stops.len() - 1].color;
12358            (c.r, c.g, c.b)
12359        } else if t <= stops[si].position {
12360            let c = &stops[si].color;
12361            (c.r, c.g, c.b)
12362        } else {
12363            let t0 = stops[si].position;
12364            let t1 = stops[si + 1].position;
12365            let frac = if (t1 - t0).abs() < 1e-10 {
12366                0.0
12367            } else {
12368                (t - t0) / (t1 - t0)
12369            };
12370            let c0 = &stops[si].color;
12371            let c1 = &stops[si + 1].color;
12372            (
12373                c0.r + frac * (c1.r - c0.r),
12374                c0.g + frac * (c1.g - c0.g),
12375                c0.b + frac * (c1.b - c0.b),
12376            )
12377        };
12378        lut[i] = [
12379            (r * 255.0).round().clamp(0.0, 255.0) as u8,
12380            (g * 255.0).round().clamp(0.0, 255.0) as u8,
12381            (b * 255.0).round().clamp(0.0, 255.0) as u8,
12382            255,
12383        ];
12384    }
12385    lut
12386}
12387
12388/// Build tiny-skia gradient stops from color stops.
12389fn build_gradient_stops(
12390    stops: &[stet_graphics::device::ColorStop],
12391) -> Vec<stet_tiny_skia::GradientStop> {
12392    let mut result = Vec::with_capacity(stops.len());
12393    for stop in stops {
12394        let r = (stop.color.r * 255.0).round().clamp(0.0, 255.0) as u8;
12395        let g = (stop.color.g * 255.0).round().clamp(0.0, 255.0) as u8;
12396        let b = (stop.color.b * 255.0).round().clamp(0.0, 255.0) as u8;
12397        result.push(stet_tiny_skia::GradientStop::new(
12398            stop.position as f32,
12399            Color::from_rgba8(r, g, b, 255),
12400        ));
12401    }
12402    result
12403}
12404
12405/// Interpolate between color stops at a given position (0.0..=1.0).
12406fn interpolate_color_stops(
12407    stops: &[stet_graphics::device::ColorStop],
12408    position: f64,
12409) -> DeviceColor {
12410    if stops.is_empty() {
12411        return DeviceColor::from_gray(0.0);
12412    }
12413    if stops.len() == 1 || position <= stops[0].position {
12414        return stops[0].color.clone();
12415    }
12416    if position >= stops.last().unwrap().position {
12417        return stops.last().unwrap().color.clone();
12418    }
12419
12420    // Find the two stops bracketing this position
12421    for i in 1..stops.len() {
12422        if position <= stops[i].position {
12423            let t0 = stops[i - 1].position;
12424            let t1 = stops[i].position;
12425            let frac = if (t1 - t0).abs() < 1e-10 {
12426                0.0
12427            } else {
12428                (position - t0) / (t1 - t0)
12429            };
12430            let c0 = &stops[i - 1].color;
12431            let c1 = &stops[i].color;
12432            return DeviceColor::from_rgb(
12433                (c0.r + frac * (c1.r - c0.r)).clamp(0.0, 1.0),
12434                (c0.g + frac * (c1.g - c0.g)).clamp(0.0, 1.0),
12435                (c0.b + frac * (c1.b - c0.b)).clamp(0.0, 1.0),
12436            );
12437        }
12438    }
12439
12440    stops.last().unwrap().color.clone()
12441}
12442
12443/// Derive CMYK values from color stops at parameter t.
12444///
12445/// For DeviceCMYK shading color spaces the per-stop `raw_components` carry the
12446/// authoritative 4-channel CMYK values (already tint-transformed for
12447/// Separation/DeviceN with a CMYK alt) — those are interpolated directly.
12448///
12449/// For non-CMYK source color spaces (DeviceRGB, DeviceGray, CalRGB, CalGray,
12450/// ICCBased non-4) the interpolated sRGB color is round-tripped to CMYK via
12451/// the system CMYK ICC profile so the parallel CMYK buffer holds an accurate
12452/// representation. Falls back to PLRM `(1−r, 1−g, 1−b, 0)` when no system
12453/// profile is registered (e.g. `--no-icc`).
12454fn interpolate_cmyk_from_stops(
12455    stops: &[stet_graphics::device::ColorStop],
12456    cs: &ShadingColorSpace,
12457    t: f64,
12458    color: &DeviceColor,
12459    icc: Option<&IccCache>,
12460) -> (f64, f64, f64, f64) {
12461    let rgb_to_cmyk = |c: &DeviceColor| -> (f64, f64, f64, f64) {
12462        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(c.r, c.g, c.b)) {
12463            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
12464        } else {
12465            (
12466                (1.0 - c.r).clamp(0.0, 1.0),
12467                (1.0 - c.g).clamp(0.0, 1.0),
12468                (1.0 - c.b).clamp(0.0, 1.0),
12469                0.0,
12470            )
12471        }
12472    };
12473
12474    match cs {
12475        ShadingColorSpace::DeviceCMYK => {
12476            // Interpolate raw CMYK components from stops
12477            if stops.len() == 1 {
12478                let rc = &stops[0].raw_components;
12479                if rc.len() >= 4 {
12480                    return (rc[0], rc[1], rc[2], rc[3]);
12481                }
12482            }
12483            // Find surrounding stops and interpolate
12484            let mut lo = &stops[0];
12485            let mut hi = stops.last().unwrap();
12486            for i in 0..stops.len() - 1 {
12487                if stops[i + 1].position >= t {
12488                    lo = &stops[i];
12489                    hi = &stops[i + 1];
12490                    break;
12491                }
12492            }
12493            let span = hi.position - lo.position;
12494            let frac = if span > 1e-10 {
12495                (t - lo.position) / span
12496            } else {
12497                0.0
12498            };
12499            let frac = frac.clamp(0.0, 1.0);
12500            if lo.raw_components.len() >= 4 && hi.raw_components.len() >= 4 {
12501                (
12502                    lo.raw_components[0] + frac * (hi.raw_components[0] - lo.raw_components[0]),
12503                    lo.raw_components[1] + frac * (hi.raw_components[1] - lo.raw_components[1]),
12504                    lo.raw_components[2] + frac * (hi.raw_components[2] - lo.raw_components[2]),
12505                    lo.raw_components[3] + frac * (hi.raw_components[3] - lo.raw_components[3]),
12506                )
12507            } else {
12508                rgb_to_cmyk(color)
12509            }
12510        }
12511        _ => rgb_to_cmyk(color),
12512    }
12513}
12514
12515/// Derive CMYK values from triangle mesh vertices using barycentric weights.
12516///
12517/// Mirrors [`interpolate_cmyk_from_stops`]: DeviceCMYK source spaces use the
12518/// per-vertex `raw_components`, non-CMYK spaces ICC-reverse the interpolated
12519/// sRGB color, and PLRM is the last-resort fallback.
12520#[allow(clippy::too_many_arguments)]
12521fn interpolate_cmyk_from_vertices(
12522    v0: &ShadingVertex,
12523    v1: &ShadingVertex,
12524    v2: &ShadingVertex,
12525    w0: f64,
12526    w1: f64,
12527    w2: f64,
12528    cs: &ShadingColorSpace,
12529    r: f64,
12530    g: f64,
12531    b: f64,
12532    icc: Option<&IccCache>,
12533) -> (f64, f64, f64, f64) {
12534    let rgb_to_cmyk = |r: f64, g: f64, b: f64| -> (f64, f64, f64, f64) {
12535        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
12536            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
12537        } else {
12538            (
12539                (1.0 - r).clamp(0.0, 1.0),
12540                (1.0 - g).clamp(0.0, 1.0),
12541                (1.0 - b).clamp(0.0, 1.0),
12542                0.0,
12543            )
12544        }
12545    };
12546
12547    match cs {
12548        ShadingColorSpace::DeviceCMYK => {
12549            if v0.raw_components.len() >= 4
12550                && v1.raw_components.len() >= 4
12551                && v2.raw_components.len() >= 4
12552            {
12553                (
12554                    w0 * v0.raw_components[0]
12555                        + w1 * v1.raw_components[0]
12556                        + w2 * v2.raw_components[0],
12557                    w0 * v0.raw_components[1]
12558                        + w1 * v1.raw_components[1]
12559                        + w2 * v2.raw_components[1],
12560                    w0 * v0.raw_components[2]
12561                        + w1 * v1.raw_components[2]
12562                        + w2 * v2.raw_components[2],
12563                    w0 * v0.raw_components[3]
12564                        + w1 * v1.raw_components[3]
12565                        + w2 * v2.raw_components[3],
12566                )
12567            } else {
12568                rgb_to_cmyk(r, g, b)
12569            }
12570        }
12571        _ => rgb_to_cmyk(r, g, b),
12572    }
12573}
12574
12575#[cfg(test)]
12576mod tests {
12577    use super::*;
12578    use stet_graphics::color::DashPattern;
12579    use stet_graphics::device::{BgUcrState, HalftoneState, TransferState};
12580
12581    #[test]
12582    fn test_create_device() {
12583        let dev = SkiaDevice::new(100, 100);
12584        assert_eq!(dev.page_size(), (100, 100));
12585    }
12586
12587    #[test]
12588    fn test_fill_rect() {
12589        let mut dev = SkiaDevice::new(100, 100);
12590        let mut path = PsPath::new();
12591        path.segments.push(PathSegment::MoveTo(10.0, 10.0));
12592        path.segments.push(PathSegment::LineTo(90.0, 10.0));
12593        path.segments.push(PathSegment::LineTo(90.0, 90.0));
12594        path.segments.push(PathSegment::LineTo(10.0, 90.0));
12595        path.segments.push(PathSegment::ClosePath);
12596
12597        let params = FillParams {
12598            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
12599            fill_rule: FillRule::NonZeroWinding,
12600            ctm: Matrix::identity(),
12601            is_text_glyph: false,
12602            overprint: false,
12603            overprint_mode: 0,
12604            opm_paired: false,
12605            painted_channels: 0,
12606            is_device_cmyk: false,
12607            spot_color: None,
12608            rendering_intent: 0,
12609            transfer: TransferState::default(),
12610            halftone: HalftoneState::default(),
12611            bg_ucr: BgUcrState::default(),
12612            alpha: 1.0,
12613            blend_mode: 0,
12614            alpha_is_shape: false,
12615        };
12616        dev.fill_path(&path, &params);
12617
12618        // Check that pixel at center is red
12619        let pixel = dev.pixmap().pixel(50, 50).unwrap();
12620        assert_eq!(pixel.red(), 255);
12621        assert_eq!(pixel.green(), 0);
12622        assert_eq!(pixel.blue(), 0);
12623    }
12624
12625    #[test]
12626    fn test_stroke_line() {
12627        let mut dev = SkiaDevice::new(100, 100);
12628        let mut path = PsPath::new();
12629        path.segments.push(PathSegment::MoveTo(10.0, 50.0));
12630        path.segments.push(PathSegment::LineTo(90.0, 50.0));
12631
12632        let params = StrokeParams {
12633            color: DeviceColor::from_rgb(0.0, 0.0, 1.0),
12634            line_width: 4.0,
12635            line_cap: LineCap::Butt,
12636            line_join: LineJoin::Miter,
12637            miter_limit: 10.0,
12638            dash_pattern: DashPattern::solid(),
12639            ctm: Matrix::identity(),
12640            stroke_adjust: false,
12641            is_text_glyph: false,
12642            overprint: false,
12643            overprint_mode: 0,
12644            opm_paired: false,
12645            painted_channels: 0,
12646            is_device_cmyk: false,
12647            spot_color: None,
12648            rendering_intent: 0,
12649            transfer: TransferState::default(),
12650            halftone: HalftoneState::default(),
12651            bg_ucr: BgUcrState::default(),
12652            alpha: 1.0,
12653            blend_mode: 0,
12654            alpha_is_shape: false,
12655        };
12656        dev.stroke_path(&path, &params);
12657
12658        // Check that pixel on the line is blue
12659        let pixel = dev.pixmap().pixel(50, 50).unwrap();
12660        assert_eq!(pixel.blue(), 255);
12661    }
12662
12663    #[test]
12664    fn test_clip() {
12665        let mut dev = SkiaDevice::new(100, 100);
12666
12667        // Set clip to left half
12668        let mut clip_path = PsPath::new();
12669        clip_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
12670        clip_path.segments.push(PathSegment::LineTo(50.0, 0.0));
12671        clip_path.segments.push(PathSegment::LineTo(50.0, 100.0));
12672        clip_path.segments.push(PathSegment::LineTo(0.0, 100.0));
12673        clip_path.segments.push(PathSegment::ClosePath);
12674
12675        let clip_params = ClipParams {
12676            fill_rule: FillRule::NonZeroWinding,
12677            ctm: Matrix::identity(),
12678            stroke_params: None,
12679        };
12680        dev.clip_path(&clip_path, &clip_params);
12681
12682        // Fill entire page with red
12683        let mut fill_path = PsPath::new();
12684        fill_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
12685        fill_path.segments.push(PathSegment::LineTo(100.0, 0.0));
12686        fill_path.segments.push(PathSegment::LineTo(100.0, 100.0));
12687        fill_path.segments.push(PathSegment::LineTo(0.0, 100.0));
12688        fill_path.segments.push(PathSegment::ClosePath);
12689
12690        let fill_params = FillParams {
12691            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
12692            fill_rule: FillRule::NonZeroWinding,
12693            ctm: Matrix::identity(),
12694            is_text_glyph: false,
12695            overprint: false,
12696            overprint_mode: 0,
12697            opm_paired: false,
12698            painted_channels: 0,
12699            is_device_cmyk: false,
12700            spot_color: None,
12701            rendering_intent: 0,
12702            transfer: TransferState::default(),
12703            halftone: HalftoneState::default(),
12704            bg_ucr: BgUcrState::default(),
12705            alpha: 1.0,
12706            blend_mode: 0,
12707            alpha_is_shape: false,
12708        };
12709        dev.fill_path(&fill_path, &fill_params);
12710
12711        // Left half should be red
12712        let left_pixel = dev.pixmap().pixel(25, 50).unwrap();
12713        assert_eq!(left_pixel.red(), 255);
12714
12715        // Right half should still be white
12716        let right_pixel = dev.pixmap().pixel(75, 50).unwrap();
12717        assert_eq!(right_pixel.red(), 255);
12718        assert_eq!(right_pixel.green(), 255); // white
12719    }
12720
12721    #[test]
12722    fn test_erase_page() {
12723        let mut dev = SkiaDevice::new(100, 100);
12724        // Fill with red
12725        let mut path = PsPath::new();
12726        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
12727        path.segments.push(PathSegment::LineTo(100.0, 0.0));
12728        path.segments.push(PathSegment::LineTo(100.0, 100.0));
12729        path.segments.push(PathSegment::LineTo(0.0, 100.0));
12730        path.segments.push(PathSegment::ClosePath);
12731        let params = FillParams {
12732            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
12733            fill_rule: FillRule::NonZeroWinding,
12734            ctm: Matrix::identity(),
12735            is_text_glyph: false,
12736            overprint: false,
12737            overprint_mode: 0,
12738            opm_paired: false,
12739            painted_channels: 0,
12740            is_device_cmyk: false,
12741            spot_color: None,
12742            rendering_intent: 0,
12743            transfer: TransferState::default(),
12744            halftone: HalftoneState::default(),
12745            bg_ucr: BgUcrState::default(),
12746            alpha: 1.0,
12747            blend_mode: 0,
12748            alpha_is_shape: false,
12749        };
12750        dev.fill_path(&path, &params);
12751
12752        dev.erase_page();
12753
12754        // Should be white again
12755        let pixel = dev.pixmap().pixel(50, 50).unwrap();
12756        assert_eq!(pixel.red(), 255);
12757        assert_eq!(pixel.green(), 255);
12758        assert_eq!(pixel.blue(), 255);
12759    }
12760
12761    #[test]
12762    fn test_show_page() {
12763        let mut dev = SkiaDevice::new(10, 10);
12764        let path = std::env::temp_dir().join("stet_test_output.png");
12765        let path_str = path.to_string_lossy();
12766        let result = dev.show_page(&path_str);
12767        assert!(result.is_ok());
12768        assert!(path.exists());
12769        std::fs::remove_file(&path).ok();
12770    }
12771
12772    #[test]
12773    fn test_transform() {
12774        let mut dev = SkiaDevice::new(200, 200);
12775        // Draw at origin with a translate transform
12776        let mut path = PsPath::new();
12777        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
12778        path.segments.push(PathSegment::LineTo(10.0, 0.0));
12779        path.segments.push(PathSegment::LineTo(10.0, 10.0));
12780        path.segments.push(PathSegment::LineTo(0.0, 10.0));
12781        path.segments.push(PathSegment::ClosePath);
12782
12783        let params = FillParams {
12784            color: DeviceColor::from_rgb(0.0, 1.0, 0.0),
12785            fill_rule: FillRule::NonZeroWinding,
12786            ctm: Matrix::translate(100.0, 100.0),
12787            is_text_glyph: false,
12788            overprint: false,
12789            overprint_mode: 0,
12790            opm_paired: false,
12791            painted_channels: 0,
12792            is_device_cmyk: false,
12793            spot_color: None,
12794            rendering_intent: 0,
12795            transfer: TransferState::default(),
12796            halftone: HalftoneState::default(),
12797            bg_ucr: BgUcrState::default(),
12798            alpha: 1.0,
12799            blend_mode: 0,
12800            alpha_is_shape: false,
12801        };
12802        dev.fill_path(&path, &params);
12803
12804        // Pixel at translated location should be green
12805        let pixel = dev.pixmap().pixel(105, 105).unwrap();
12806        assert_eq!(pixel.green(), 255);
12807        assert_eq!(pixel.red(), 0);
12808    }
12809
12810    fn make_test_fill_at(x: f64, y: f64, w: f64, h: f64) -> DisplayElement {
12811        let mut path = PsPath::new();
12812        path.segments.push(PathSegment::MoveTo(x, y));
12813        path.segments.push(PathSegment::LineTo(x + w, y));
12814        path.segments.push(PathSegment::LineTo(x + w, y + h));
12815        path.segments.push(PathSegment::LineTo(x, y + h));
12816        path.segments.push(PathSegment::ClosePath);
12817        DisplayElement::Fill {
12818            path,
12819            params: FillParams {
12820                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
12821                fill_rule: FillRule::NonZeroWinding,
12822                ctm: Matrix::identity(),
12823                is_text_glyph: false,
12824                overprint: false,
12825                overprint_mode: 0,
12826                opm_paired: false,
12827                painted_channels: 0,
12828                is_device_cmyk: false,
12829                spot_color: None,
12830                rendering_intent: 0,
12831                transfer: TransferState::default(),
12832                halftone: HalftoneState::default(),
12833                bg_ucr: BgUcrState::default(),
12834                alpha: 1.0,
12835                blend_mode: 0,
12836                alpha_is_shape: false,
12837            },
12838        }
12839    }
12840
12841    #[test]
12842    fn test_compute_paint_bounds_two_fills() {
12843        let mut list = DisplayList::new();
12844        list.push(make_test_fill_at(10.0, 20.0, 30.0, 40.0)); // [10..40, 20..60]
12845        list.push(make_test_fill_at(100.0, 50.0, 50.0, 25.0)); // [100..150, 50..75]
12846
12847        let bounds = compute_paint_bounds(&list, 72.0).expect("expected union bounds");
12848        assert!(
12849            (bounds.x_min - 10.0).abs() < 1e-9,
12850            "x_min was {}",
12851            bounds.x_min
12852        );
12853        assert!(
12854            (bounds.y_min - 20.0).abs() < 1e-9,
12855            "y_min was {}",
12856            bounds.y_min
12857        );
12858        assert!(
12859            (bounds.x_max - 150.0).abs() < 1e-9,
12860            "x_max was {}",
12861            bounds.x_max
12862        );
12863        assert!(
12864            (bounds.y_max - 75.0).abs() < 1e-9,
12865            "y_max was {}",
12866            bounds.y_max
12867        );
12868    }
12869
12870    #[test]
12871    fn test_compute_paint_bounds_empty_list() {
12872        let list = DisplayList::new();
12873        assert!(compute_paint_bounds(&list, 72.0).is_none());
12874    }
12875
12876    #[test]
12877    fn test_compute_paint_bounds_only_clip_returns_none() {
12878        let mut list = DisplayList::new();
12879        list.push(DisplayElement::InitClip);
12880        // Clip / InitClip / ErasePage are skipped (return None from
12881        // precompute_full_bboxes), so a list of only clip ops yields no bounds.
12882        assert!(compute_paint_bounds(&list, 72.0).is_none());
12883    }
12884
12885    #[test]
12886    fn test_rasterize_mask_anchors_to_paint_bounds() {
12887        use stet_graphics::display_list::{SoftMaskParams, SoftMaskSubtype};
12888
12889        // A 50×40 white fill at page coords (200, 300)..(250, 340).
12890        // Mask paint bounds in device units: x [200..250], y [300..340].
12891        let mut mask = DisplayList::new();
12892        let mut path = PsPath::new();
12893        path.segments.push(PathSegment::MoveTo(200.0, 300.0));
12894        path.segments.push(PathSegment::LineTo(250.0, 300.0));
12895        path.segments.push(PathSegment::LineTo(250.0, 340.0));
12896        path.segments.push(PathSegment::LineTo(200.0, 340.0));
12897        path.segments.push(PathSegment::ClosePath);
12898        mask.push(DisplayElement::Fill {
12899            path,
12900            params: FillParams {
12901                color: DeviceColor::from_rgb(1.0, 1.0, 1.0),
12902                fill_rule: FillRule::NonZeroWinding,
12903                ctm: Matrix::identity(),
12904                is_text_glyph: false,
12905                overprint: false,
12906                overprint_mode: 0,
12907                opm_paired: false,
12908                painted_channels: 0,
12909                is_device_cmyk: false,
12910                spot_color: None,
12911                rendering_intent: 0,
12912                transfer: TransferState::default(),
12913                halftone: HalftoneState::default(),
12914                bg_ucr: BgUcrState::default(),
12915                alpha: 1.0,
12916                blend_mode: 0,
12917                alpha_is_shape: false,
12918            },
12919        });
12920
12921        let params = SoftMaskParams {
12922            subtype: SoftMaskSubtype::Luminosity,
12923            // Form bbox; intentionally tighter than paint bounds — the
12924            // raster should follow paint bounds, not this.
12925            bbox: [0.0, 0.0, 100.0, 100.0],
12926            backdrop_color: None, // black backdrop → out-of-bounds value = 0
12927            transfer_invert: false,
12928            has_nested_mask_scope: false,
12929            parent_clip_bbox: None,
12930        };
12931
12932        let raster = rasterize_mask(
12933            &mask,
12934            &params,
12935            None,
12936            false,
12937            72.0,
12938            1.0,
12939            1.0,
12940            &LayerSet::new(),
12941        )
12942        .expect("expected raster");
12943
12944        // Origin must be at (or just before) the paint bounds, with the
12945        // 1-pixel AA pad.
12946        assert_eq!(raster.origin_x, 199);
12947        assert_eq!(raster.origin_y, 299);
12948        // Width / height = paint bounds + 2 pixels of pad (1 each side).
12949        assert_eq!(raster.width, 52);
12950        assert_eq!(raster.height, 42);
12951        assert_eq!(raster.scale_x, 1.0);
12952        assert_eq!(raster.scale_y, 1.0);
12953
12954        // The raster should be non-zero somewhere inside the painted region.
12955        // Sample the center of the painted area: page (225, 320) → mask
12956        // index (225 - 199, 320 - 299) = (26, 21).
12957        let mx = 225 - raster.origin_x;
12958        let my = 320 - raster.origin_y;
12959        assert!(mx >= 0 && (mx as u32) < raster.width);
12960        assert!(my >= 0 && (my as u32) < raster.height);
12961        let center_value = raster.data[(my as usize) * raster.width as usize + mx as usize];
12962        assert_eq!(
12963            center_value, 255,
12964            "center of painted mask should be opaque white (lum=255)"
12965        );
12966
12967        // A point outside the paint bounds (page (300, 320)) maps to mask
12968        // index (101, 21) which is outside the raster width — sampling
12969        // there should fall back to out_of_bounds_mask_value(params) = 0.
12970        let mx_out = 300 - raster.origin_x;
12971        let in_bounds = mx_out >= 0 && (mx_out as u32) < raster.width;
12972        assert!(!in_bounds, "page x=300 should be outside the mask raster");
12973        assert_eq!(
12974            out_of_bounds_mask_value(&params),
12975            0,
12976            "black backdrop → out-of-bounds = 0"
12977        );
12978    }
12979
12980    #[test]
12981    fn test_band_local_to_mask_formula() {
12982        // Verify the band-local → page-pixel → mask-index arithmetic for
12983        // several band offsets. This is the highest-risk part of Step 4
12984        // because it bridges three coordinate systems:
12985        //
12986        //   band-local pixel (x, y)
12987        //     + (crop_x, crop_y)            → soft-mask offset within band
12988        //     + (vp_x_pixels, vp_y_pixels)  → page-pixel position
12989        //     - (origin_x, origin_y)        → mask raster index
12990
12991        // Mask raster anchored at page-pixel (200, 300).
12992        let raster_origin_x = 200i32;
12993        let raster_origin_y = 300i32;
12994
12995        // Helper that runs the formula from render_soft_masked.
12996        let sample = |vp_x_dev: f32,
12997                      vp_y_dev: f32,
12998                      scale: f32,
12999                      crop_x: i32,
13000                      crop_y: i32,
13001                      x: i32,
13002                      y: i32|
13003         -> (i32, i32) {
13004            let vp_x_pixels = (vp_x_dev * scale).round() as i32;
13005            let vp_y_pixels = (vp_y_dev * scale).round() as i32;
13006            let page_x = vp_x_pixels + crop_x + x;
13007            let page_y = vp_y_pixels + crop_y + y;
13008            let mx = page_x - raster_origin_x;
13009            let my = page_y - raster_origin_y;
13010            (mx, my)
13011        };
13012
13013        // Case 1: band starts at page Y=0 (top band of page).
13014        // vp_y=0, scale=1. The soft-mask top-left page (220, 310) must
13015        // map to mask index (20, 10).
13016        // crop_x = floor((220 - 0) * 1) = 220, crop_y = floor((310 - 0) * 1) = 310
13017        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 0, 0);
13018        assert_eq!((mx, my), (20, 10), "top band: smask top-left");
13019
13020        // 5 pixels into the smask region (band-local): page (225, 315)
13021        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 5, 5);
13022        assert_eq!((mx, my), (25, 15), "top band: 5px into smask");
13023
13024        // Case 2: band starts at page Y=400. The smask region [310..340]
13025        // doesn't intersect this band — covered by the early-return path.
13026        // But test a band that DOES intersect the smask, e.g. starting at
13027        // Y=305. Then page-Y 310 is band-local Y=5.
13028        // vp_y_pixels = round(305 * 1) = 305
13029        // crop_y = floor((310 - 305) * 1) = 5  (band-local)
13030        // For content y=0 (band-local), page_y = 305 + 5 + 0 = 310 ✓
13031        let (mx, my) = sample(0.0, 305.0, 1.0, 220, 5, 0, 0);
13032        assert_eq!((mx, my), (20, 10), "mid band: smask top-left");
13033
13034        // Case 3: viewport rendering at scale 2. vp_x=100.0, vp_y=150.0,
13035        // scale=2. Page pixel offset = (200, 300). The smask region
13036        // [220..270] in device units = [440..540] in page-pixels at scale 2.
13037        // But the mask raster was built at scale 1, so this is a
13038        // SCALE-MISMATCH case — the cache would invalidate and rebuild.
13039        // We're not testing the rebuild, just that the formula computes
13040        // the right page-pixel coords:
13041        //   vp_x_pixels = round(100 * 2) = 200
13042        //   smask in band: page (440..540), band-local (240..340)
13043        //   crop_x = max(0, floor((220 - 100) * 2)) = 240
13044        //   For x=0 (band-local), page_x = 200 + 240 + 0 = 440 ✓
13045        let vp_x_pixels = (100.0_f32 * 2.0).round() as i32;
13046        let crop_x = ((220.0_f32 - 100.0) * 2.0).floor() as i32;
13047        let page_x_for_x_zero = vp_x_pixels + crop_x;
13048        assert_eq!(page_x_for_x_zero, 440, "viewport scale-2: page-x at x=0");
13049    }
13050
13051    // --- obscured-fill skip (§ GWG reference-under-test pattern) ---
13052
13053    fn x_path() -> PsPath {
13054        let mut p = PsPath::new();
13055        p.segments.push(PathSegment::MoveTo(10.0, 10.0));
13056        p.segments.push(PathSegment::LineTo(20.0, 20.0));
13057        p.segments.push(PathSegment::LineTo(30.0, 10.0));
13058        p.segments.push(PathSegment::LineTo(20.0, 0.0));
13059        p.segments.push(PathSegment::ClosePath);
13060        p
13061    }
13062
13063    fn x_path_perturbed() -> PsPath {
13064        // Same shape, sub-unit rounding — stand-in for GWG's 0.001-unit
13065        // coordinate drift between duplicated path emissions.
13066        let mut p = PsPath::new();
13067        p.segments.push(PathSegment::MoveTo(10.001, 10.0));
13068        p.segments.push(PathSegment::LineTo(20.0, 19.999));
13069        p.segments.push(PathSegment::LineTo(30.002, 10.001));
13070        p.segments.push(PathSegment::LineTo(19.999, 0.0));
13071        p.segments.push(PathSegment::ClosePath);
13072        p
13073    }
13074
13075    fn fill(path: PsPath, alpha: f64, blend: u8) -> DisplayElement {
13076        DisplayElement::Fill {
13077            path,
13078            params: FillParams {
13079                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
13080                fill_rule: FillRule::NonZeroWinding,
13081                ctm: Matrix::identity(),
13082                is_text_glyph: false,
13083                overprint: false,
13084                overprint_mode: 0,
13085                opm_paired: false,
13086                painted_channels: 0,
13087                is_device_cmyk: false,
13088                spot_color: None,
13089                rendering_intent: 0,
13090                transfer: TransferState::default(),
13091                halftone: HalftoneState::default(),
13092                bg_ucr: BgUcrState::default(),
13093                alpha,
13094                blend_mode: blend,
13095                alpha_is_shape: false,
13096            },
13097        }
13098    }
13099
13100    fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> PsPath {
13101        let mut p = PsPath::new();
13102        p.segments.push(PathSegment::MoveTo(x0, y0));
13103        p.segments.push(PathSegment::LineTo(x1, y0));
13104        p.segments.push(PathSegment::LineTo(x1, y1));
13105        p.segments.push(PathSegment::LineTo(x0, y1));
13106        p.segments.push(PathSegment::ClosePath);
13107        p
13108    }
13109
13110    fn clip_elem(path: PsPath) -> DisplayElement {
13111        DisplayElement::Clip {
13112            path,
13113            params: ClipParams {
13114                fill_rule: FillRule::NonZeroWinding,
13115                ctm: Matrix::identity(),
13116                stroke_params: None,
13117            },
13118        }
13119    }
13120
13121    fn group_elem(
13122        inner: Vec<DisplayElement>,
13123        bbox: [f64; 4],
13124        isolated: bool,
13125        alpha: f64,
13126        blend: u8,
13127    ) -> DisplayElement {
13128        let mut dl = DisplayList::new();
13129        for e in inner {
13130            dl.push(e);
13131        }
13132        DisplayElement::Group {
13133            elements: dl,
13134            params: stet_graphics::display_list::GroupParams {
13135                bbox,
13136                isolated,
13137                knockout: false,
13138                blend_mode: blend,
13139                alpha,
13140                color_space: stet_graphics::display_list::GroupColorSpace::Inherited,
13141            },
13142        }
13143    }
13144
13145    fn dl(elements: Vec<DisplayElement>) -> DisplayList {
13146        let mut d = DisplayList::new();
13147        for e in elements {
13148            d.push(e);
13149        }
13150        d
13151    }
13152
13153    #[test]
13154    fn obscured_skip_fires_on_matching_fill_plus_iso_group() {
13155        // Classic GWG pattern: parent Fill, then a clip, then an isolated
13156        // alpha-1 Group whose first paint is a matching Fill.
13157        let parent = fill(x_path(), 1.0, 0);
13158        let inner = vec![fill(x_path_perturbed(), 1.0, 0)];
13159        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13160        let d = dl(vec![
13161            parent,
13162            clip_elem(rect_path(0.0, -5.0, 40.0, 30.0)),
13163            grp,
13164        ]);
13165        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
13166    }
13167
13168    #[test]
13169    fn obscured_skip_does_not_fire_on_non_isolated_group() {
13170        let parent = fill(x_path(), 1.0, 0);
13171        let inner = vec![fill(x_path(), 1.0, 0)];
13172        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], false, 1.0, 0);
13173        let d = dl(vec![parent, grp]);
13174        assert!(compute_obscured_fill_skips(&d).is_empty());
13175    }
13176
13177    #[test]
13178    fn obscured_skip_does_not_fire_on_partial_alpha_group() {
13179        let parent = fill(x_path(), 1.0, 0);
13180        let inner = vec![fill(x_path(), 1.0, 0)];
13181        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 0.5, 0);
13182        let d = dl(vec![parent, grp]);
13183        assert!(compute_obscured_fill_skips(&d).is_empty());
13184    }
13185
13186    #[test]
13187    fn obscured_skip_does_not_fire_on_non_normal_blend() {
13188        let parent = fill(x_path(), 1.0, 0);
13189        let inner = vec![fill(x_path(), 1.0, 0)];
13190        // blend_mode = 10 (Difference) on the group — composite-back
13191        // semantics differ from Normal, so skipping parent is unsafe.
13192        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 10);
13193        let d = dl(vec![parent, grp]);
13194        assert!(compute_obscured_fill_skips(&d).is_empty());
13195    }
13196
13197    #[test]
13198    fn obscured_skip_does_not_fire_when_paths_differ() {
13199        let parent = fill(rect_path(0.0, 0.0, 5.0, 5.0), 1.0, 0);
13200        let inner = vec![fill(x_path(), 1.0, 0)];
13201        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13202        let d = dl(vec![parent, grp]);
13203        assert!(compute_obscured_fill_skips(&d).is_empty());
13204    }
13205
13206    #[test]
13207    fn obscured_skip_does_not_fire_when_group_bbox_too_small() {
13208        // Parent fills a rectangle larger than the group's declared
13209        // bbox — the form's BBox would clip the inner fill to a subset
13210        // of the parent's extent, so the parent cannot be dropped.
13211        let big = rect_path(0.0, 0.0, 100.0, 100.0);
13212        let parent = fill(big.clone(), 1.0, 0);
13213        let inner = vec![fill(big, 1.0, 0)];
13214        // Group bbox only covers [0..10, 0..10], much smaller than parent.
13215        let grp = group_elem(inner, [0.0, 0.0, 10.0, 10.0], true, 1.0, 0);
13216        let d = dl(vec![parent, grp]);
13217        assert!(compute_obscured_fill_skips(&d).is_empty());
13218    }
13219
13220    #[test]
13221    fn obscured_skip_does_not_fire_when_intervening_clip_too_small() {
13222        // A clip between the parent fill and the group is narrower than
13223        // the parent's extent — dropping the parent's fill would reveal
13224        // backdrop where the group couldn't paint.
13225        let parent = fill(x_path(), 1.0, 0);
13226        let narrow_clip = clip_elem(rect_path(12.0, 5.0, 18.0, 15.0));
13227        let inner = vec![fill(x_path(), 1.0, 0)];
13228        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13229        let d = dl(vec![parent, narrow_clip, grp]);
13230        assert!(compute_obscured_fill_skips(&d).is_empty());
13231    }
13232
13233    #[test]
13234    fn obscured_skip_does_not_fire_when_inner_clip_too_small() {
13235        // Clip *inside* the group is narrower than the parent's extent.
13236        let parent = fill(x_path(), 1.0, 0);
13237        let inner = vec![
13238            clip_elem(rect_path(12.0, 5.0, 18.0, 15.0)),
13239            fill(x_path(), 1.0, 0),
13240        ];
13241        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13242        let d = dl(vec![parent, grp]);
13243        assert!(compute_obscured_fill_skips(&d).is_empty());
13244    }
13245
13246    #[test]
13247    fn obscured_skip_fires_when_inner_clip_is_wider_than_parent_path() {
13248        // A clip inside the group that's larger than the parent's fill
13249        // doesn't threaten coverage; still safe to skip the parent.
13250        let parent = fill(x_path(), 1.0, 0);
13251        let inner = vec![
13252            clip_elem(rect_path(-10.0, -10.0, 40.0, 30.0)),
13253            fill(x_path_perturbed(), 1.0, 0),
13254        ];
13255        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13256        let d = dl(vec![parent, grp]);
13257        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
13258    }
13259
13260    #[test]
13261    fn obscured_skip_does_not_fire_on_partial_alpha_parent() {
13262        // A parent fill at alpha < 1 might blend with backdrop; dropping
13263        // it changes the visual even when the group overpaints.
13264        let parent = fill(x_path(), 0.5, 0);
13265        let inner = vec![fill(x_path(), 1.0, 0)];
13266        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13267        let d = dl(vec![parent, grp]);
13268        assert!(compute_obscured_fill_skips(&d).is_empty());
13269    }
13270}