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;
27
28/// Axis-aligned rectangle in device pixel coordinates.
29#[derive(Clone, Copy)]
30struct ClipRect {
31    x0: u32,
32    y0: u32, // top-left (inclusive)
33    x1: u32,
34    y1: u32, // bottom-right (exclusive)
35}
36
37impl ClipRect {
38    /// Intersect two rectangles. Result may be empty.
39    fn intersect(&self, other: &ClipRect) -> ClipRect {
40        ClipRect {
41            x0: self.x0.max(other.x0),
42            y0: self.y0.max(other.y0),
43            x1: self.x1.min(other.x1),
44            y1: self.y1.min(other.y1),
45        }
46    }
47
48    fn is_empty(&self) -> bool {
49        self.x0 >= self.x1 || self.y0 >= self.y1
50    }
51
52    /// True if this rect covers the entire page.
53    fn is_full_page(&self, w: u32, h: u32) -> bool {
54        self.x0 == 0 && self.y0 == 0 && self.x1 == w && self.y1 == h
55    }
56
57    /// Create a mask with 255 inside the rect, 0 outside.
58    fn make_mask(self, w: u32, h: u32) -> Option<Mask> {
59        if self.is_empty() {
60            return None;
61        }
62        let mut mask = Mask::new(w, h)?;
63        let data = mask.data_mut();
64        let stride = w as usize;
65        for y in self.y0..self.y1 {
66            let row_start = y as usize * stride + self.x0 as usize;
67            let row_end = y as usize * stride + self.x1 as usize;
68            data[row_start..row_end].fill(255);
69        }
70        Some(mask)
71    }
72}
73
74/// Clip region: either a simple rectangle (fast) or a full rasterized mask.
75enum ClipRegion {
76    Rect(ClipRect),
77    Mask(Mask),
78}
79
80/// tiny-skia based raster device.
81pub struct SkiaDevice {
82    pixmap: Pixmap,
83    /// Page dimensions in device pixels. Stored separately so we can shrink
84    /// the pixmap during banded rendering without losing page size info.
85    page_w: u32,
86    page_h: u32,
87    /// Device resolution in DPI (for hairline width decisions).
88    dpi: f64,
89    clip_region: Option<ClipRegion>,
90    /// Cache of rasterized clip masks keyed by path hash.
91    /// Only paths seen more than once are cached (cache-on-second-sight).
92    clip_mask_cache: HashMap<u64, Mask>,
93    clip_mask_seen: HashSet<u64>,
94    /// Recycled mask buffer to avoid repeated alloc/dealloc of large masks.
95    spare_mask: Option<Mask>,
96    /// Receiver for background render result (pipelined multi-page rendering).
97    /// Uses rayon::spawn + oneshot channel to avoid OS thread spawn overhead.
98    pending_render: Option<std::sync::mpsc::Receiver<Result<(), String>>>,
99    /// Factory for creating page sinks (PNG, viewer, etc.).
100    sink_factory: Box<dyn PageSinkFactory>,
101    /// Raw bytes of the system CMYK ICC profile (for building render-thread IccCaches).
102    system_cmyk_bytes: Option<std::sync::Arc<Vec<u8>>>,
103    /// Transient IccCache used during non-banded replay_to_device rendering.
104    render_icc_cache: Option<IccCache>,
105    /// Disable anti-aliasing for all fill/stroke operations (matches GhostScript).
106    no_aa: bool,
107    /// Route `replay_and_show` through the viewport code path instead of the
108    /// banded full-page path. Used by `--device viewport-png` to audit the
109    /// viewport pipeline against the banded PNG baselines — same display list,
110    /// different culling/epoch logic, same expected output.
111    use_viewport_path: bool,
112}
113
114impl SkiaDevice {
115    /// Create a new device with the given page dimensions and default PNG output.
116    ///
117    /// Defers the full-page pixmap allocation — only a 1×1 placeholder is
118    /// created here. The full pixmap is allocated lazily in `replay_and_show`
119    /// only when the non-banded rendering path is needed.
120    pub fn new(width: u32, height: u32) -> Self {
121        Self::with_sink_factory(width, height, Box::new(crate::PngSinkFactory))
122    }
123
124    /// Create a new device with a custom page sink factory.
125    pub fn with_sink_factory(
126        width: u32,
127        height: u32,
128        sink_factory: Box<dyn PageSinkFactory>,
129    ) -> Self {
130        // Estimate DPI from page height (assumes ~792pt US Letter as reference).
131        // Close enough for hairline width threshold decisions.
132        let dpi = height as f64 * 72.0 / 792.0;
133
134        // Start with a tiny placeholder. The full-page pixmap is allocated
135        // lazily only when the non-banded path is used (small pages / low DPI).
136        // For banded rendering, band-sized pixmaps are created in replay_and_show.
137        let pixmap = Pixmap::new(1, 1).expect("Failed to create placeholder pixmap");
138        Self {
139            pixmap,
140            page_w: width,
141            page_h: height,
142            dpi,
143            clip_region: None,
144            clip_mask_cache: HashMap::new(),
145            clip_mask_seen: HashSet::new(),
146            spare_mask: None,
147            pending_render: None,
148            sink_factory,
149            system_cmyk_bytes: None,
150            render_icc_cache: None,
151            no_aa: false,
152            use_viewport_path: false,
153        }
154    }
155
156    /// Route rendering through the viewport pipeline. Used by the visual
157    /// test runner's `--device viewport-png` mode.
158    pub fn set_use_viewport_path(&mut self, on: bool) {
159        self.use_viewport_path = on;
160    }
161
162    /// Ensure `self.pixmap` is allocated at full page dimensions.
163    /// Called before non-banded rendering which operates on the full pixmap.
164    fn ensure_full_pixmap(&mut self) {
165        if self.pixmap.width() != self.page_w || self.pixmap.height() != self.page_h {
166            self.pixmap =
167                Pixmap::new(self.page_w, self.page_h).expect("Failed to create page pixmap");
168            self.pixmap.fill(Color::WHITE);
169        }
170    }
171
172    /// Get the underlying pixmap (for testing).
173    pub fn pixmap(&self) -> &Pixmap {
174        &self.pixmap
175    }
176
177    /// Set the system CMYK ICC profile bytes for ICC-aware rendering.
178    pub fn set_system_cmyk_bytes(&mut self, bytes: std::sync::Arc<Vec<u8>>) {
179        self.system_cmyk_bytes = Some(bytes);
180    }
181
182    /// Disable anti-aliasing for all fill/stroke operations.
183    pub fn set_no_aa(&mut self, no_aa: bool) {
184        self.no_aa = no_aa;
185    }
186}
187
188/// Convert a PostScript `Matrix` to tiny-skia `Transform` (f32).
189fn to_transform(m: &Matrix) -> Transform {
190    Transform::from_row(
191        m.a as f32,
192        m.b as f32,
193        m.c as f32,
194        m.d as f32,
195        m.tx as f32,
196        m.ty as f32,
197    )
198}
199
200/// Convert a `DeviceColor` to tiny-skia `Paint`.
201fn to_paint(color: &DeviceColor) -> Paint<'static> {
202    to_paint_alpha(color, 1.0, 0, false)
203}
204
205/// Convert a `DeviceColor` to tiny-skia `Paint` with the given opacity and blend mode.
206fn to_paint_alpha(color: &DeviceColor, alpha: f64, blend_mode: u8, no_aa: bool) -> Paint<'static> {
207    let mut paint = Paint::default();
208    let a = (alpha * 255.0).round().clamp(0.0, 255.0) as u8;
209    paint.set_color_rgba8(
210        (color.r * 255.0).round().clamp(0.0, 255.0) as u8,
211        (color.g * 255.0).round().clamp(0.0, 255.0) as u8,
212        (color.b * 255.0).round().clamp(0.0, 255.0) as u8,
213        a,
214    );
215    paint.anti_alias = !no_aa;
216    paint.blend_mode = u8_to_blend_mode(blend_mode);
217    paint
218}
219
220/// Map a blend mode byte (0–15) to the corresponding tiny-skia `BlendMode`.
221fn u8_to_blend_mode(mode: u8) -> BlendMode {
222    match mode {
223        1 => BlendMode::Multiply,
224        2 => BlendMode::Screen,
225        3 => BlendMode::Overlay,
226        4 => BlendMode::Darken,
227        5 => BlendMode::Lighten,
228        6 => BlendMode::ColorDodge,
229        7 => BlendMode::ColorBurn,
230        8 => BlendMode::HardLight,
231        9 => BlendMode::SoftLight,
232        10 => BlendMode::Difference,
233        11 => BlendMode::Exclusion,
234        12 => BlendMode::Hue,
235        13 => BlendMode::Saturation,
236        14 => BlendMode::Color,
237        15 => BlendMode::Luminosity,
238        _ => BlendMode::SourceOver,
239    }
240}
241
242/// Convert a `PsPath` to tiny-skia `Path`.
243/// Maximum coordinate magnitude for path rasterization.
244/// Coordinates beyond this cause integer overflow in the scanline rasterizer.
245/// 1e6 is well beyond any real page (e.g. 612×792 pt at 600 DPI = ~5100×6600 px)
246/// but safely within f32 precision and fixed-point limits.
247const MAX_PATH_COORD: f32 = 1e6;
248
249fn build_skia_path(path: &PsPath) -> Option<stet_tiny_skia::Path> {
250    let mut pb = PathBuilder::new();
251
252    for seg in &path.segments {
253        match seg {
254            PathSegment::MoveTo(x, y) => {
255                pb.move_to(*x as f32, *y as f32);
256            }
257            PathSegment::LineTo(x, y) => {
258                pb.line_to(*x as f32, *y as f32);
259            }
260            PathSegment::CurveTo {
261                x1,
262                y1,
263                x2,
264                y2,
265                x3,
266                y3,
267            } => {
268                pb.cubic_to(
269                    *x1 as f32, *y1 as f32, *x2 as f32, *y2 as f32, *x3 as f32, *y3 as f32,
270                );
271            }
272            PathSegment::ClosePath => {
273                pb.close();
274            }
275        }
276    }
277
278    let result = pb.finish()?;
279
280    // Reject paths with extreme coordinates that would overflow the scanline
281    // rasterizer's integer math. This handles corrupted PDF content streams
282    // with garbled coordinates.
283    let b = result.bounds();
284    if b.left().abs() > MAX_PATH_COORD
285        || b.top().abs() > MAX_PATH_COORD
286        || b.right().abs() > MAX_PATH_COORD
287        || b.bottom().abs() > MAX_PATH_COORD
288    {
289        return None;
290    }
291
292    Some(result)
293}
294
295/// Detect degenerate fill paths that have zero extent in one dimension.
296///
297/// PDFs commonly draw table grid lines as zero-width or zero-height filled
298/// rectangles (e.g., `8 0 1031 0 re f`). Since these have no area, the
299/// fill rasterizer produces zero pixels. This function detects such paths
300/// so they can be rendered as hairline strokes instead.
301///
302/// The check is performed in the path's own coordinate space (pre-transform)
303/// using a very tight epsilon, so only paths with *exactly* zero extent in
304/// one dimension are detected. Paths containing curves are never degenerate
305/// — only MoveTo/LineTo/ClosePath segments qualify.
306fn is_degenerate_fill(path: &PsPath) -> bool {
307    let mut x_min = f64::INFINITY;
308    let mut x_max = f64::NEG_INFINITY;
309    let mut y_min = f64::INFINITY;
310    let mut y_max = f64::NEG_INFINITY;
311
312    for seg in &path.segments {
313        let (x, y) = match seg {
314            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => (*x, *y),
315            // Paths with curves are real shapes, not degenerate lines
316            PathSegment::CurveTo { .. } => return false,
317            PathSegment::ClosePath => continue,
318        };
319        x_min = x_min.min(x);
320        x_max = x_max.max(x);
321        y_min = y_min.min(y);
322        y_max = y_max.max(y);
323    }
324
325    if x_min > x_max {
326        return false; // empty path
327    }
328
329    let w = x_max - x_min;
330    let h = y_max - y_min;
331
332    // Degenerate if one dimension is exactly zero (within f64 epsilon)
333    // while the other has real extent. This catches `re` rects with
334    // zero width or height but not legitimate small shapes.
335    let eps = 1e-6;
336    (w < eps && h > eps) || (h < eps && w > eps)
337}
338
339/// Convert a tiny-skia Path back to a PsPath.
340/// Used for overprint stroke handling where we convert a stroked outline to a fill.
341
342/// Convert PostScript FillRule to tiny-skia FillRule.
343fn to_fill_rule(rule: &FillRule) -> SkiaFillRule {
344    match rule {
345        FillRule::NonZeroWinding => SkiaFillRule::Winding,
346        FillRule::EvenOdd => SkiaFillRule::EvenOdd,
347    }
348}
349
350/// Convert PostScript LineCap to tiny-skia LineCap.
351fn to_line_cap(cap: LineCap) -> SkiaLineCap {
352    match cap {
353        LineCap::Butt => SkiaLineCap::Butt,
354        LineCap::Round => SkiaLineCap::Round,
355        LineCap::Square => SkiaLineCap::Square,
356    }
357}
358
359/// Convert PostScript LineJoin to tiny-skia LineJoin.
360fn to_line_join(join: LineJoin) -> SkiaLineJoin {
361    match join {
362        LineJoin::Miter => SkiaLineJoin::Miter,
363        LineJoin::Round => SkiaLineJoin::Round,
364        LineJoin::Bevel => SkiaLineJoin::Bevel,
365    }
366}
367
368/// Detect if a path is an axis-aligned rectangle. Returns pixel-coordinate ClipRect if so.
369/// Handles both CW and CCW winding, with optional trailing ClosePath.
370fn detect_rect(path: &PsPath, page_w: u32, page_h: u32) -> Option<ClipRect> {
371    let segs = &path.segments;
372    // Expect: MoveTo + 3 LineTo + ClosePath (5 segments)
373    // or MoveTo + 3 LineTo + LineTo(back to start) + ClosePath (6 segments)
374    // or MoveTo + 3 LineTo (4 segments, implicitly closed)
375    let (move_to, lines, _has_close) = match segs.len() {
376        5 => {
377            // MoveTo + 3 LineTo + ClosePath
378            if !matches!(segs[4], PathSegment::ClosePath) {
379                return None;
380            }
381            (&segs[0], &segs[1..4], true)
382        }
383        6 => {
384            // MoveTo + 4 LineTo + ClosePath (4th LineTo returns to start)
385            if !matches!(segs[5], PathSegment::ClosePath) {
386                return None;
387            }
388            (&segs[0], &segs[1..5], true)
389        }
390        4 => {
391            // MoveTo + 3 LineTo (no explicit close)
392            (&segs[0], &segs[1..4], false)
393        }
394        _ => return None,
395    };
396
397    let PathSegment::MoveTo(mx, my) = move_to else {
398        return None;
399    };
400
401    // Collect all corner points
402    let mut pts = vec![(*mx, *my)];
403    for seg in lines {
404        match seg {
405            PathSegment::LineTo(x, y) => pts.push((*x, *y)),
406            _ => return None,
407        }
408    }
409
410    // If 5 points (4 LineTos), last must return to start
411    if pts.len() == 5 {
412        let (fx, fy) = pts[0];
413        let (lx, ly) = pts[4];
414        if (fx - lx).abs() > 0.01 || (fy - ly).abs() > 0.01 {
415            return None;
416        }
417        pts.truncate(4);
418    }
419
420    // Check axis-aligned: each edge must be horizontal or vertical
421    for i in 0..4 {
422        let (x1, y1) = pts[i];
423        let (x2, y2) = pts[(i + 1) % 4];
424        let dx = (x2 - x1).abs();
425        let dy = (y2 - y1).abs();
426        if dx > 0.01 && dy > 0.01 {
427            return None; // diagonal edge
428        }
429    }
430
431    // Compute bounding box
432    let min_x = pts.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
433    let min_y = pts.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
434    let max_x = pts.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max);
435    let max_y = pts.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
436
437    // Convert to pixel coords: floor for top-left, ceil for bottom-right, clamp to page
438    let x0 = (min_x.floor().max(0.0) as u32).min(page_w);
439    let y0 = (min_y.floor().max(0.0) as u32).min(page_h);
440    let x1 = (max_x.ceil().max(0.0) as u32).min(page_w);
441    let y1 = (max_y.ceil().max(0.0) as u32).min(page_h);
442
443    Some(ClipRect { x0, y0, x1, y1 })
444}
445
446/// Zero out mask pixels outside the given rectangle bounds.
447fn intersect_mask_with_rect(mask: &mut Mask, rect: &ClipRect, w: u32, h: u32) {
448    let data = mask.data_mut();
449    let stride = w as usize;
450
451    // Zero rows above rect
452    if rect.y0 > 0 {
453        let end = (rect.y0 as usize * stride).min(data.len());
454        data[..end].fill(0);
455    }
456
457    // Zero rows below rect
458    if rect.y1 < h {
459        let start = (rect.y1 as usize * stride).min(data.len());
460        data[start..].fill(0);
461    }
462
463    // Zero left and right margins within rect rows
464    for y in rect.y0..rect.y1.min(h) {
465        let row_start = y as usize * stride;
466        // Left margin
467        if rect.x0 > 0 {
468            let end = row_start + rect.x0 as usize;
469            data[row_start..end].fill(0);
470        }
471        // Right margin
472        if rect.x1 < w {
473            let start = row_start + rect.x1 as usize;
474            let end = row_start + stride;
475            data[start..end].fill(0);
476        }
477    }
478}
479
480/// Resolve a ClipRegion to an Option<&Mask> for paint operations.
481/// Returns `None` if the clip is empty (caller should skip painting).
482/// Returns `Some(None)` if no mask is needed (full page or no clip).
483/// Returns `Some(Some(&Mask))` if a mask should be applied.
484fn resolve_clip_mask<'a>(
485    clip_region: &'a Option<ClipRegion>,
486    temp_mask: &'a mut Option<Mask>,
487    w: u32,
488    h: u32,
489) -> Option<Option<&'a Mask>> {
490    match clip_region {
491        None => Some(None),
492        Some(ClipRegion::Mask(m)) => Some(Some(m)),
493        Some(ClipRegion::Rect(rect)) => {
494            if rect.is_empty() {
495                return None; // empty clip → skip painting
496            }
497            if rect.is_full_page(w, h) {
498                return Some(None); // full page → no mask needed
499            }
500            *temp_mask = rect.make_mask(w, h);
501            Some(temp_mask.as_ref())
502        }
503    }
504}
505
506/// Hash a PsPath's segments for clip mask caching. Uses bit-exact f64 comparison
507/// since paths are already in device space.
508fn hash_clip_path(path: &PsPath, fill_rule: &FillRule) -> u64 {
509    let mut hasher = std::collections::hash_map::DefaultHasher::new();
510    std::mem::discriminant(fill_rule).hash(&mut hasher);
511    for seg in &path.segments {
512        match seg {
513            PathSegment::MoveTo(x, y) => {
514                0u8.hash(&mut hasher);
515                x.to_bits().hash(&mut hasher);
516                y.to_bits().hash(&mut hasher);
517            }
518            PathSegment::LineTo(x, y) => {
519                1u8.hash(&mut hasher);
520                x.to_bits().hash(&mut hasher);
521                y.to_bits().hash(&mut hasher);
522            }
523            PathSegment::CurveTo {
524                x1,
525                y1,
526                x2,
527                y2,
528                x3,
529                y3,
530            } => {
531                2u8.hash(&mut hasher);
532                x1.to_bits().hash(&mut hasher);
533                y1.to_bits().hash(&mut hasher);
534                x2.to_bits().hash(&mut hasher);
535                y2.to_bits().hash(&mut hasher);
536                x3.to_bits().hash(&mut hasher);
537                y3.to_bits().hash(&mut hasher);
538            }
539            PathSegment::ClosePath => {
540                3u8.hash(&mut hasher);
541            }
542        }
543    }
544    hasher.finish()
545}
546
547/// Pixel-multiply two masks: dst[i] = dst[i] * src[i] / 255.
548fn intersect_masks(dst: &mut Mask, src: &Mask) {
549    let dst_data = dst.data_mut();
550    let src_data = src.data();
551    for (d, s) in dst_data.iter_mut().zip(src_data.iter()) {
552        *d = ((*d as u16 * *s as u16 + 127) / 255) as u8;
553    }
554}
555
556// ---- Banded rendering support ----
557
558use stet_graphics::display_list::{DisplayElement, DisplayList};
559
560/// Band-local clip state, rebuilt for each band.
561struct BandState {
562    clip_region: Option<ClipRegion>,
563    spare_mask: Option<Mask>,
564    /// Per-band cache (cleared each band since masks are band-sized).
565    clip_mask_cache: HashMap<u64, Mask>,
566    /// Persists across bands for cache-on-second-sight.
567    clip_mask_seen: HashSet<u64>,
568    /// Pool of recycled masks to avoid alloc/dealloc (mmap/munmap) per band.
569    mask_pool: Vec<Mask>,
570    /// Per-pixel CMYK tracking buffer for overprint simulation.
571    /// Only allocated when the display list contains overprint elements.
572    /// Layout: [C, M, Y, K] as f32 per pixel, band_w * band_h * 4 entries.
573    cmyk_buffer: Option<Vec<f32>>,
574    /// Per-pixel snapshot of pixmap RGBA *before* the first overprint paint
575    /// touched that pixel in this band. Subsequent overprint paints at the
576    /// same pixel blend their result against this snapshot instead of the
577    /// current (already-overprinted) pixmap, so AA edges of stacked overprints
578    /// do not leak earlier colour through later paints.
579    /// Lazily allocated on first overprint paint. 4 bytes per pixel.
580    op_bg_snapshot: Option<Vec<u8>>,
581    /// Parallel to `op_bg_snapshot`: 1 byte per pixel, non-zero iff the
582    /// snapshot for that pixel has been captured. Reset to zero over the
583    /// paint bbox on non-overprint writes so a later non-overprint fill
584    /// establishes a fresh backdrop for subsequent overprints.
585    op_touched: Option<Vec<u8>>,
586    /// Per-pixel marker for "this pixel's pixmap colour includes spot-
587    /// colorant contribution not reflected in `cmyk_buffer`". Set by
588    /// DeviceN/Separation paints that include at least one spot colorant
589    /// (i.e. `process_cmyk != native_cmyk`). Consulted by CMYK overprint
590    /// rendering so the no-op-delta skip only fires on pixels where
591    /// preserving the pixmap actually preserves spot colour — other pixels
592    /// still go through the ICC(new_cmyk) replace path.
593    spot_mask: Option<Vec<u8>>,
594}
595
596/// Maximum masks to keep in the recycling pool. Enough to avoid alloc churn
597/// without accumulating unbounded memory across bands.
598const MAX_POOL_MASKS: usize = 8;
599
600impl BandState {
601    /// Recycle all cached masks into the pool, clearing the cache for the next band.
602    #[allow(dead_code)]
603    fn recycle_cache(&mut self) {
604        for (_, mask) in self.clip_mask_cache.drain() {
605            if self.mask_pool.len() < MAX_POOL_MASKS {
606                self.mask_pool.push(mask);
607            }
608            // else: drop mask, returning memory to OS
609        }
610    }
611
612    /// Return a mask to the pool if under capacity, otherwise drop it.
613    fn recycle_mask(&mut self, mask: Mask) {
614        if self.mask_pool.len() < MAX_POOL_MASKS {
615            self.mask_pool.push(mask);
616        }
617    }
618
619    /// Get a recycled mask or allocate a new one.
620    fn take_mask(&mut self, w: u32, h: u32) -> Mask {
621        self.spare_mask
622            .take()
623            .or_else(|| self.mask_pool.pop())
624            .unwrap_or_else(|| Mask::new(w, h).expect("Failed to create mask"))
625    }
626
627    /// Take (or lazily allocate) the overprint background snapshot and
628    /// touched-flag buffers. Caller must pass them back via
629    /// `restore_op_buffers`. Layout: snapshot is 4 bytes/pixel (RGBA),
630    /// touched is 1 byte/pixel.
631    fn take_op_buffers(&mut self, w: u32, h: u32) -> (Vec<u8>, Vec<u8>) {
632        let n = w as usize * h as usize;
633        let bg = self
634            .op_bg_snapshot
635            .take()
636            .unwrap_or_else(|| vec![0u8; n * 4]);
637        let touched = self.op_touched.take().unwrap_or_else(|| vec![0u8; n]);
638        (bg, touched)
639    }
640
641    /// Put the overprint buffers back after an overprint render pass.
642    fn restore_op_buffers(&mut self, bg: Vec<u8>, touched: Vec<u8>) {
643        self.op_bg_snapshot = Some(bg);
644        self.op_touched = Some(touched);
645    }
646
647    /// Take (or lazily allocate) the spot-contribution mask (1 byte/pixel).
648    fn take_spot_mask(&mut self, w: u32, h: u32) -> Vec<u8> {
649        let n = w as usize * h as usize;
650        self.spot_mask.take().unwrap_or_else(|| vec![0u8; n])
651    }
652
653    /// Put the spot-contribution mask back after a paint.
654    fn restore_spot_mask(&mut self, mask: Vec<u8>) {
655        self.spot_mask = Some(mask);
656    }
657
658    /// Clear the overprint touched flag for pixels in the given bbox. Called
659    /// by non-overprint paints so a subsequent overprint at those pixels
660    /// captures a fresh backdrop snapshot instead of reusing a stale one.
661    #[allow(dead_code)]
662    fn invalidate_op_snapshot(
663        &mut self,
664        bbox_x0: usize,
665        bbox_y0: usize,
666        bbox_x1: usize,
667        bbox_y1: usize,
668        stride: usize,
669    ) {
670        if let Some(touched) = self.op_touched.as_mut() {
671            for y in bbox_y0..bbox_y1 {
672                let row = y * stride;
673                for x in bbox_x0..bbox_x1 {
674                    touched[row + x] = 0;
675                }
676            }
677        }
678    }
679}
680
681/// Unified rendering context that parameterizes both band and viewport rendering.
682///
683/// Band rendering is viewport rendering with `scale_x = scale_y = 1.0`.
684/// `viewport_transform(t, vp_x, vp_y, 1.0, 1.0)` == `offset_transform_xy(t, vp_x, vp_y)`.
685struct RenderContext<'a> {
686    /// Viewport/band origin X in device space.
687    vp_x: f32,
688    /// Viewport/band origin Y in device space.
689    vp_y: f32,
690    /// Horizontal scale (1.0 for band rendering, zoom for viewport).
691    scale_x: f32,
692    /// Vertical scale (1.0 for band rendering, zoom for viewport).
693    scale_y: f32,
694    /// Output pixmap width in pixels.
695    out_w: u32,
696    /// Output pixmap height in pixels.
697    out_h: u32,
698    /// Effective DPI at output scale.
699    effective_dpi: f64,
700    /// ICC color profile cache (for CMYK conversions).
701    icc: Option<&'a IccCache>,
702    /// Pre-converted image data cache (for viewport rendering).
703    image_cache: Option<&'a ImageCache>,
704    /// Pre-converted and prescaled images (for banded rendering).
705    preprocessed: Option<&'a [Option<PreprocessedImage>]>,
706    /// Element index in parent display list (for image cache lookup).
707    elem_idx: usize,
708    /// Disable anti-aliasing for all fill/stroke operations.
709    no_aa: bool,
710    /// When true, CMYK(0,0,0,0) pixels in images produce alpha=0 (OPM=1).
711    opm_zero_transparent: bool,
712    /// Knockout group painter rendering pass override. The knockout group
713    /// renders each Group painter twice — once for the blended-color result
714    /// (`ColorPass`), once for the painter's coverage mask (`CoveragePass`).
715    /// Both passes need to override `render_group`'s usual decisions:
716    ///   * `ColorPass` expands the per-pixel CMYK composite-back gate to all
717    ///     non-Normal blend modes so painters with separable blends like
718    ///     Screen / ColorDodge / Overlay / SoftLight blend in DeviceCMYK
719    ///     (matching the spec for `/CS DeviceCMYK` knockout groups) instead
720    ///     of in tiny-skia's sRGB blend.
721    ///   * `CoveragePass` disables the CMYK composite-back (its
722    ///     "source==backdrop" guard would discard white-CMYK painters
723    ///     against the transparent coverage backdrop) and forces the
724    ///     painter's alpha to 1.0 with Normal blend so the coverage offscreen
725    ///     captures the painter's *shape* even when the original alpha was 0
726    ///     (Opacity 0% test) or its blend mode would erase the source.
727    knockout_painter_pass: KnockoutPainterPass,
728    /// True when the immediately enclosing transparency group was isolated.
729    /// GWG 16.2's nested CMYK painter pattern (Painter B → Sub A/B) only
730    /// requires CMYK math at the inner non-isolated layer when Painter B
731    /// itself is isolated; for non-isolated parents (the 907 p28 financial
732    /// chart pattern) the existing sRGB compositing path produces the right
733    /// result and the new CMYK math would over-darken anti-aliased gray
734    /// strokes.
735    parent_group_isolated: bool,
736    /// True when rendering an alpha-extraction pass for a non-isolated group
737    /// with non-Normal blend mode.  Nested groups must render as isolated
738    /// (no backdrop preload, no two-pass) so the alpha channel reflects
739    /// pure element coverage rather than backdrop-blended results.
740    alpha_extraction_pass: bool,
741}
742
743/// Override mode applied to `render_group` while the knockout group renders
744/// one of its painters; see [`RenderContext::knockout_painter_pass`].
745#[derive(Clone, Copy, PartialEq, Eq)]
746enum KnockoutPainterPass {
747    /// Default rendering — no knockout overrides.
748    None,
749    /// Pass 1 (color): widen `plan_cmyk_compose` to any non-Normal blend mode.
750    ColorPass,
751    /// Pass 2 (coverage): disable CMYK composite-back, force full alpha and
752    /// Normal blend so the coverage offscreen captures the painter's shape.
753    CoveragePass,
754}
755
756impl RenderContext<'_> {
757    /// Apply viewport transform to a PostScript matrix.
758    fn transform(&self, m: &Matrix) -> Transform {
759        viewport_transform(
760            to_transform(m),
761            self.vp_x,
762            self.vp_y,
763            self.scale_x,
764            self.scale_y,
765        )
766    }
767}
768
769/// Y-axis bounding box in device pixels.
770struct YBBox {
771    y_min: f64,
772    y_max: f64,
773}
774
775/// A group of display list elements between consecutive InitClip boundaries.
776/// Each epoch starts with an InitClip (except possibly the first) and contains
777/// all elements up to the next InitClip. Epochs whose paint elements don't
778/// overlap a band can be skipped entirely.
779struct ClipEpoch {
780    /// Index of the first element in this epoch (the InitClip, or 0).
781    start_idx: usize,
782    /// One past the last element in this epoch.
783    end_idx: usize,
784    /// Y bounding box of all paint elements (Fill/Stroke/Image) in this epoch.
785    /// None if the epoch has no paint elements (pure clip setup).
786    paint_bbox: Option<YBBox>,
787    /// True if this epoch contains an ErasePage element (must process for all bands).
788    has_erase_page: bool,
789}
790
791/// Choose band height so that band pixmap + 2 clip masks fit in ~2 MB (L2 cache).
792/// Returns `page_h` when banding is not worthwhile (≤2 bands).
793fn select_band_height(w: u32, h: u32) -> u32 {
794    if w == 0 || h == 0 {
795        return h;
796    }
797    // Per-row cost: w*4 (RGBA) + w*1 (clip mask) + w*1 (spare mask) = w*6
798    let per_row = w as u64 * 6;
799    let budget = 2 * 1024 * 1024u64; // 2 MB (L2)
800    let max_rows = budget / per_row;
801
802    // Floor to power of 2, clamp to [16, h]
803    let band = if max_rows >= h as u64 {
804        h
805    } else {
806        let mut p = 1u32;
807        while (p as u64) * 2 <= max_rows {
808            p *= 2;
809        }
810        // Minimum 128 rows per band. At very high DPI the L2 budget yields
811        // tiny bands (16 rows at 2400 DPI = 1650 bands) where display list
812        // replay overhead dominates. 128-row minimum balances L3 cache fit
813        // (~15 MB working set at 2400 DPI) against per-band overhead (207 bands).
814        // Benchmarked: 16→31.3s, 64→22.5s, 128→21.8s, 256→22.1s.
815        p.clamp(128, h)
816    };
817
818    // Skip banding if ≤2 bands
819    if h.div_ceil(band) <= 2 {
820        return h;
821    }
822    band
823}
824
825/// True if this display list contains any `Clip`/`InitClip` op, recursively
826/// descending into `OcgGroup` / `Group` / `SoftMasked` children. When an
827/// `OcgGroup` wraps clip ops, Y-bbox culling would skip the whole group for
828/// bands its paint content doesn't overlap, but the clip state changes inside
829/// must still be applied — otherwise subsequent top-level elements inherit a
830/// stale clip. Use this to force such `OcgGroup`s to always be processed.
831fn contains_clip_op(list: &DisplayList) -> bool {
832    list.elements().iter().any(|e| match e {
833        DisplayElement::Clip { .. } | DisplayElement::InitClip => true,
834        DisplayElement::OcgGroup { elements, .. } => contains_clip_op(elements),
835        DisplayElement::Group { elements, .. } => contains_clip_op(elements),
836        DisplayElement::SoftMasked { content, .. } => contains_clip_op(content),
837        _ => false,
838    })
839}
840
841/// Compute conservative Y bounding boxes for display list elements.
842/// Returns `None` for elements that must always be processed (Clip, InitClip, ErasePage).
843///
844/// All returned Y values are in **device space** (pixel coordinates) so they can be
845/// compared directly against band boundaries.
846fn precompute_bboxes(list: &DisplayList, dpi: f64) -> Vec<Option<YBBox>> {
847    list.elements()
848        .iter()
849        .map(|elem| match elem {
850            DisplayElement::Fill { path, params } => fill_device_y_bbox(path, &params.ctm),
851            DisplayElement::Stroke { path, params } => stroke_device_y_bbox(path, params, dpi),
852            DisplayElement::Image { params, .. } => image_y_bbox(params),
853            DisplayElement::AxialShading { params } => {
854                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
855            }
856            DisplayElement::RadialShading { params } => {
857                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
858            }
859            DisplayElement::MeshShading { params } => {
860                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
861            }
862            DisplayElement::PatchShading { params } => {
863                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
864            }
865            DisplayElement::PatternFill { params } => pattern_fill_y_bbox(params),
866            DisplayElement::Group { params, .. } => Some(YBBox {
867                y_min: params.bbox[1],
868                y_max: params.bbox[3],
869            }),
870            DisplayElement::SoftMasked { params, .. } => Some(YBBox {
871                y_min: params.bbox[1],
872                y_max: params.bbox[3],
873            }),
874            DisplayElement::OcgGroup {
875                elements,
876                default_visible,
877                ..
878            } => {
879                // Hidden groups without clip ops contribute nothing — cull.
880                // (Hidden + has clip ops is handled below: we return paint
881                // bounds so the epoch has correct extent, and the band loop
882                // skips per-element culling for OcgGroups so the clip ops
883                // always execute.)
884                if !*default_visible && !contains_clip_op(elements) {
885                    return None;
886                }
887                let child_bboxes = precompute_bboxes(elements, dpi);
888                let mut y_min = f64::INFINITY;
889                let mut y_max = f64::NEG_INFINITY;
890                for cb in child_bboxes.into_iter().flatten() {
891                    y_min = y_min.min(cb.y_min);
892                    y_max = y_max.max(cb.y_max);
893                }
894                if y_min <= y_max {
895                    Some(YBBox { y_min, y_max })
896                } else {
897                    None
898                }
899            }
900            _ => None, // Clip, InitClip, ErasePage: always process
901        })
902        .collect()
903}
904
905/// Compute device-space Y bounding box for a shading element.
906/// Uses the BBox if present, otherwise returns a full-page sentinel
907/// (y_min=0, y_max=very large) so the element is never culled.
908fn shading_y_bbox_from_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<YBBox> {
909    if let Some(bbox) = bbox {
910        let corners = [
911            (bbox[0], bbox[1]),
912            (bbox[2], bbox[1]),
913            (bbox[0], bbox[3]),
914            (bbox[2], bbox[3]),
915        ];
916        let mut y_min = f64::INFINITY;
917        let mut y_max = f64::NEG_INFINITY;
918        for (x, y) in &corners {
919            let (_, dy) = ctm.transform_point(*x, *y);
920            y_min = y_min.min(dy);
921            y_max = y_max.max(dy);
922        }
923        Some(YBBox { y_min, y_max })
924    } else {
925        // No BBox — shading covers unbounded area; return sentinel so it's
926        // never culled by band processing.
927        Some(YBBox {
928            y_min: 0.0,
929            y_max: 1e9,
930        })
931    }
932}
933
934/// Compute device-space Y bounding box for a stroke element.
935///
936/// Isotropic strokes have paths already in device space (Identity CTM), so
937/// `path_y_bbox` gives device-space bounds directly. Anisotropic strokes have
938/// paths in user space with the full CTM — we must transform the bounding box
939/// through the CTM to get device-space bounds.
940fn stroke_device_y_bbox(path: &PsPath, params: &StrokeParams, dpi: f64) -> Option<YBBox> {
941    let m = &params.ctm;
942    let is_identity =
943        m.a == 1.0 && m.b == 0.0 && m.c == 0.0 && m.d == 1.0 && m.tx == 0.0 && m.ty == 0.0;
944
945    // Use effective line width: actual width or hairline minimum, whichever is larger
946    let effective_lw = params.line_width.max(hairline_min_width(&params.ctm, dpi));
947
948    if is_identity {
949        // Path in device space — just read Y coords and expand for stroke width.
950        return path_y_bbox(path).map(|mut bbox| {
951            let expand = effective_lw * params.miter_limit * 0.5;
952            bbox.y_min -= expand;
953            bbox.y_max += expand;
954            bbox
955        });
956    }
957
958    // Anisotropic: path in user space. Compute full XY bbox, transform corners
959    // through CTM to get device-space Y range.
960    let (mut x_min, mut x_max) = (f64::INFINITY, f64::NEG_INFINITY);
961    let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY);
962    for seg in &path.segments {
963        match seg {
964            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => {
965                x_min = x_min.min(*x);
966                x_max = x_max.max(*x);
967                y_min = y_min.min(*y);
968                y_max = y_max.max(*y);
969            }
970            PathSegment::CurveTo {
971                x1,
972                y1,
973                x2,
974                y2,
975                x3,
976                y3,
977            } => {
978                x_min = x_min.min(*x1).min(*x2).min(*x3);
979                x_max = x_max.max(*x1).max(*x2).max(*x3);
980                y_min = y_min.min(*y1).min(*y2).min(*y3);
981                y_max = y_max.max(*y1).max(*y2).max(*y3);
982            }
983            PathSegment::ClosePath => {}
984        }
985    }
986    if x_min > x_max {
987        return None;
988    }
989
990    // Transform all 4 corners of user-space bbox to device space
991    let corners = [
992        (x_min, y_min),
993        (x_max, y_min),
994        (x_min, y_max),
995        (x_max, y_max),
996    ];
997    let mut dev_y_min = f64::INFINITY;
998    let mut dev_y_max = f64::NEG_INFINITY;
999    for (x, y) in &corners {
1000        let dy = m.b * x + m.d * y + m.ty;
1001        dev_y_min = dev_y_min.min(dy);
1002        dev_y_max = dev_y_max.max(dy);
1003    }
1004
1005    // Expand for stroke width + miter in device-space units.
1006    // ||[c,d]|| converts user-space line_width to device-space Y expansion.
1007    let col_y_len = (m.c * m.c + m.d * m.d).sqrt().max(1.0);
1008    let expand = effective_lw * col_y_len * params.miter_limit * 0.5;
1009    dev_y_min -= expand;
1010    dev_y_max += expand;
1011
1012    Some(YBBox {
1013        y_min: dev_y_min,
1014        y_max: dev_y_max,
1015    })
1016}
1017
1018/// Compute device-space Y bounds for a Fill element, accounting for CTM.
1019/// Mirrors `stroke_device_y_bbox` but without stroke-width expansion.
1020/// Paths may be stored either in device space (identity CTM, content streams)
1021/// or user space (non-identity CTM, synthesized annotation appearances).
1022fn fill_device_y_bbox(path: &PsPath, ctm: &Matrix) -> Option<YBBox> {
1023    let is_identity = ctm.a == 1.0
1024        && ctm.b == 0.0
1025        && ctm.c == 0.0
1026        && ctm.d == 1.0
1027        && ctm.tx == 0.0
1028        && ctm.ty == 0.0;
1029    if is_identity {
1030        return path_y_bbox(path);
1031    }
1032    let bbox = path_full_bbox(path)?;
1033    let corners = [
1034        (bbox.x_min, bbox.y_min),
1035        (bbox.x_max, bbox.y_min),
1036        (bbox.x_min, bbox.y_max),
1037        (bbox.x_max, bbox.y_max),
1038    ];
1039    let mut dev_y_min = f64::INFINITY;
1040    let mut dev_y_max = f64::NEG_INFINITY;
1041    for (x, y) in &corners {
1042        let dy = ctm.b * x + ctm.d * y + ctm.ty;
1043        dev_y_min = dev_y_min.min(dy);
1044        dev_y_max = dev_y_max.max(dy);
1045    }
1046    Some(YBBox {
1047        y_min: dev_y_min,
1048        y_max: dev_y_max,
1049    })
1050}
1051
1052/// Compute Y bounds from path segments (conservative: uses control points for curves).
1053fn path_y_bbox(path: &PsPath) -> Option<YBBox> {
1054    let mut y_min = f64::INFINITY;
1055    let mut y_max = f64::NEG_INFINITY;
1056    for seg in &path.segments {
1057        match seg {
1058            PathSegment::MoveTo(_, y) | PathSegment::LineTo(_, y) => {
1059                y_min = y_min.min(*y);
1060                y_max = y_max.max(*y);
1061            }
1062            PathSegment::CurveTo { y1, y2, y3, .. } => {
1063                y_min = y_min.min(*y1).min(*y2).min(*y3);
1064                y_max = y_max.max(*y1).max(*y2).max(*y3);
1065            }
1066            PathSegment::ClosePath => {}
1067        }
1068    }
1069    if y_min <= y_max {
1070        Some(YBBox { y_min, y_max })
1071    } else {
1072        None
1073    }
1074}
1075
1076/// Compute Y bounds for an image element from its transform.
1077fn image_y_bbox(params: &ImageParams) -> Option<YBBox> {
1078    let image_inv = params.image_matrix.invert()?;
1079    let combined = params.ctm.concat(&image_inv);
1080    let corners = [
1081        (0.0, 0.0),
1082        (params.width as f64, 0.0),
1083        (params.width as f64, params.height as f64),
1084        (0.0, params.height as f64),
1085    ];
1086    let mut y_min = f64::INFINITY;
1087    let mut y_max = f64::NEG_INFINITY;
1088    for (x, y) in &corners {
1089        let (_, dy) = combined.transform_point(*x, *y);
1090        y_min = y_min.min(dy);
1091        y_max = y_max.max(dy);
1092    }
1093    Some(YBBox { y_min, y_max })
1094}
1095
1096/// Pre-populate clip_mask_seen with hashes of clip paths that appear ≥2 times.
1097/// This lets the first band immediately cache repeated clip paths.
1098fn precompute_clip_seen(list: &DisplayList) -> HashSet<u64> {
1099    let mut counts: HashMap<u64, u32> = HashMap::new();
1100    for elem in list.elements() {
1101        if let DisplayElement::Clip { path, params } = elem {
1102            let hash = hash_clip_path(path, &params.fill_rule);
1103            *counts.entry(hash).or_insert(0) += 1;
1104        }
1105    }
1106    counts
1107        .into_iter()
1108        .filter(|(_, c)| *c > 1)
1109        .map(|(h, _)| h)
1110        .collect()
1111}
1112
1113/// Build clip epochs — groups of elements between InitClip boundaries.
1114/// Each epoch's paint_bbox is the union of Y ranges for all paint elements in it.
1115fn build_clip_epochs(list: &DisplayList, bboxes: &[Option<YBBox>]) -> Vec<ClipEpoch> {
1116    let elements = list.elements();
1117    let mut epochs = Vec::new();
1118    let mut epoch_start = 0;
1119    let mut y_min = f64::INFINITY;
1120    let mut y_max = f64::NEG_INFINITY;
1121    let mut has_erase = false;
1122
1123    for (i, element) in elements.iter().enumerate() {
1124        // InitClip starts a new epoch (close the previous one first)
1125        if matches!(element, DisplayElement::InitClip) && i > epoch_start {
1126            epochs.push(ClipEpoch {
1127                start_idx: epoch_start,
1128                end_idx: i,
1129                paint_bbox: if y_min <= y_max {
1130                    Some(YBBox { y_min, y_max })
1131                } else {
1132                    None
1133                },
1134                has_erase_page: has_erase,
1135            });
1136            epoch_start = i;
1137            y_min = f64::INFINITY;
1138            y_max = f64::NEG_INFINITY;
1139            has_erase = false;
1140        }
1141        if matches!(element, DisplayElement::ErasePage) {
1142            has_erase = true;
1143        }
1144        if let Some(ref bbox) = bboxes[i] {
1145            y_min = y_min.min(bbox.y_min);
1146            y_max = y_max.max(bbox.y_max);
1147        }
1148    }
1149    // Final epoch
1150    if epoch_start < elements.len() {
1151        epochs.push(ClipEpoch {
1152            start_idx: epoch_start,
1153            end_idx: elements.len(),
1154            paint_bbox: if y_min <= y_max {
1155                Some(YBBox { y_min, y_max })
1156            } else {
1157                None
1158            },
1159            has_erase_page: has_erase,
1160        });
1161    }
1162    epochs
1163}
1164
1165/// Apply a device-space Y offset to a tiny-skia Transform.
1166/// The original transform maps from path space to full-page device space;
1167/// we subtract `y_offset` from `ty` so band rows [y_start, y_start+band_h)
1168/// map to pixmap rows [0, band_h).
1169/// Composite premultiplied-alpha RGBA pixels onto a white background.
1170/// After this, all pixels are fully opaque (alpha=255).
1171fn composite_onto_white(data: &mut [u8]) {
1172    for pixel in data.chunks_exact_mut(4) {
1173        let a = pixel[3] as u16;
1174        if a == 255 {
1175            continue; // fully opaque — no compositing needed
1176        }
1177        let inv_a = 255 - a;
1178        pixel[0] = (pixel[0] as u16 + inv_a).min(255) as u8;
1179        pixel[1] = (pixel[1] as u16 + inv_a).min(255) as u8;
1180        pixel[2] = (pixel[2] as u16 + inv_a).min(255) as u8;
1181        pixel[3] = 255;
1182    }
1183}
1184
1185/// Extract the contribution of a non-isolated transparency group and composite
1186/// it onto the parent using the group's blend mode and alpha.
1187///
1188/// Composite a (possibly cropped) non-isolated group offscreen onto the parent pixmap.
1189///
1190/// Like `extract_and_composite_contribution`, but the offscreen and backdrop
1191/// are crop-sized (only covering the group's bounding box region), positioned
1192/// at `(crop_x, crop_y)` in the parent's coordinate system.
1193fn composite_non_isolated_group_cropped(
1194    target: &mut Pixmap,
1195    source: &Pixmap,
1196    backdrop: &[u8],
1197    params: &stet_graphics::display_list::GroupParams,
1198    clip_mask: Option<&stet_tiny_skia::Mask>,
1199    crop_x: i32,
1200    crop_y: i32,
1201) {
1202    let cw = source.width();
1203    let ch = source.height();
1204
1205    // Build a contribution pixmap: pixels that changed vs backdrop
1206    let Some(mut contribution) = Pixmap::new(cw, ch) else {
1207        return;
1208    };
1209    let src_data = source.data();
1210    let contrib_data = contribution.data_mut();
1211
1212    for (i, chunk) in contrib_data.chunks_exact_mut(4).enumerate() {
1213        let off = i * 4;
1214        if src_data[off] != backdrop[off]
1215            || src_data[off + 1] != backdrop[off + 1]
1216            || src_data[off + 2] != backdrop[off + 2]
1217            || src_data[off + 3] != backdrop[off + 3]
1218        {
1219            chunk.copy_from_slice(&src_data[off..off + 4]);
1220        }
1221    }
1222
1223    let paint = stet_tiny_skia::PixmapPaint {
1224        opacity: params.alpha as f32,
1225        blend_mode: u8_to_blend_mode(params.blend_mode),
1226        quality: stet_tiny_skia::FilterQuality::Nearest,
1227    };
1228    target.draw_pixmap(
1229        crop_x,
1230        crop_y,
1231        contribution.as_ref(),
1232        &paint,
1233        Transform::identity(),
1234        clip_mask,
1235    );
1236}
1237
1238/// Non-isolated group composite-back using the proper source-extraction
1239/// formula (ISO 32000-1 §11.4.8).
1240///
1241/// `source` was rendered against the `backdrop`; `isolated` was rendered
1242/// against transparent.  The isolated render's alpha channel gives the
1243/// group's shape, which lets us extract the source color:
1244///
1245///   C_g_premul = R - B · (1 - α_g)      (premultiplied source color)
1246///   α_g        = isolated alpha channel
1247///
1248/// The extracted contribution is then composited onto `target` with the
1249/// group's blend mode and opacity.
1250fn composite_non_isolated_extracted(
1251    target: &mut Pixmap,
1252    source: &Pixmap,
1253    isolated: &Pixmap,
1254    backdrop: &[u8],
1255    params: &stet_graphics::display_list::GroupParams,
1256    clip_mask: Option<&stet_tiny_skia::Mask>,
1257    crop_x: i32,
1258    crop_y: i32,
1259) {
1260    let cw = source.width();
1261    let ch = source.height();
1262
1263    let Some(mut contribution) = Pixmap::new(cw, ch) else {
1264        return;
1265    };
1266    let src_data = source.data();
1267    let iso_data = isolated.data();
1268    let contrib_data = contribution.data_mut();
1269
1270    for i in 0..(cw as usize * ch as usize) {
1271        let off = i * 4;
1272        let alpha_g = iso_data[off + 3];
1273        if alpha_g == 0 {
1274            continue; // no group contribution at this pixel
1275        }
1276
1277        // Extract premultiplied source: C_g_premul = R - B · (1 - α_g/255)
1278        let inv_alpha = 255 - alpha_g as i32;
1279        for c in 0..3 {
1280            let r = src_data[off + c] as i32;
1281            let b = backdrop[off + c] as i32;
1282            let raw = r - (b * inv_alpha + 127) / 255;
1283            contrib_data[off + c] = raw.clamp(0, 255) as u8;
1284        }
1285        contrib_data[off + 3] = alpha_g;
1286    }
1287
1288    let paint = stet_tiny_skia::PixmapPaint {
1289        opacity: params.alpha as f32,
1290        blend_mode: u8_to_blend_mode(params.blend_mode),
1291        quality: stet_tiny_skia::FilterQuality::Nearest,
1292    };
1293    target.draw_pixmap(
1294        crop_x,
1295        crop_y,
1296        contribution.as_ref(),
1297        &paint,
1298        Transform::identity(),
1299        clip_mask,
1300    );
1301}
1302
1303/// Apply a combined offset + scale to a tiny-skia Transform for viewport rendering.
1304/// Maps device-space coordinates into viewport-local pixel coordinates:
1305///   output_x = (device_x - vp_x) * scale_x
1306///   output_y = (device_y - vp_y) * scale_y
1307fn viewport_transform(t: Transform, vp_x: f32, vp_y: f32, scale_x: f32, scale_y: f32) -> Transform {
1308    // Post-compose: first apply `t` (path→device), then translate(-vp_x,-vp_y), then scale
1309    Transform::from_row(
1310        t.sx * scale_x,
1311        t.ky * scale_y,
1312        t.kx * scale_x,
1313        t.sy * scale_y,
1314        (t.tx - vp_x) * scale_x,
1315        (t.ty - vp_y) * scale_y,
1316    )
1317}
1318
1319/// Fast area-average box filter resample for downscaling.
1320///
1321/// Each output pixel averages all source pixels that fall within its footprint.
1322/// Two-pass separable (horizontal then vertical) for O(src) total work regardless
1323/// of scale ratio. Produces quality equivalent to Lanczos3 for downscaling at a
1324/// fraction of the cost.
1325fn box_resample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
1326    if dw == 0 || dh == 0 {
1327        return Vec::new();
1328    }
1329    let (sw, sh, dw, dh) = (sw as usize, sh as usize, dw as usize, dh as usize);
1330
1331    // Pass 1: horizontal (sw → dw) with fractional edge weights.
1332    // Each output pixel covers [left_f, right_f] in source space. Edge source
1333    // pixels get proportional weight; interior pixels get weight 1.0.
1334    let ratio_x = sw as f32 / dw as f32;
1335    let mut tmp = vec![0.0f32; dw * sh * 4];
1336    let tmp_stride = dw * 4;
1337
1338    for y in 0..sh {
1339        let row_off = y * sw * 4;
1340        let dst_row = y * tmp_stride;
1341        for dx in 0..dw {
1342            let left_f = dx as f32 * ratio_x;
1343            let right_f = (dx + 1) as f32 * ratio_x;
1344            let left = (left_f as usize).min(sw - 1);
1345            let right = (right_f.ceil() as usize).min(sw);
1346            let inv_area = 1.0 / (right_f - left_f);
1347            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1348            for sx in left..right {
1349                // Weight: fraction of this source pixel covered by the output pixel
1350                let pixel_left = sx as f32;
1351                let pixel_right = (sx + 1) as f32;
1352                let w = pixel_right.min(right_f) - pixel_left.max(left_f);
1353                let i = row_off + sx * 4;
1354                r += src[i] as f32 * w;
1355                g += src[i + 1] as f32 * w;
1356                b += src[i + 2] as f32 * w;
1357                a += src[i + 3] as f32 * w;
1358            }
1359            let di = dst_row + dx * 4;
1360            tmp[di] = r * inv_area;
1361            tmp[di + 1] = g * inv_area;
1362            tmp[di + 2] = b * inv_area;
1363            tmp[di + 3] = a * inv_area;
1364        }
1365    }
1366
1367    // Pass 2: vertical (sh → dh) with fractional edge weights, row-major order.
1368    let ratio_y = sh as f32 / dh as f32;
1369    let mut out = vec![0u8; dw * dh * 4];
1370    let out_stride = dw * 4;
1371
1372    for dy in 0..dh {
1373        let top_f = dy as f32 * ratio_y;
1374        let bottom_f = (dy + 1) as f32 * ratio_y;
1375        let top = (top_f as usize).min(sh - 1);
1376        let bottom = (bottom_f.ceil() as usize).min(sh);
1377        let inv_area = 1.0 / (bottom_f - top_f);
1378
1379        // Pre-compute row weights
1380        let n_rows = bottom - top;
1381        let mut row_weights_buf: [(usize, f32); 8] = [(0, 0.0); 8];
1382        let row_weights_vec: Vec<(usize, f32)>;
1383        let row_weights: &[(usize, f32)] = if n_rows <= 8 {
1384            for (i, sy) in (top..bottom).enumerate() {
1385                let pixel_top = sy as f32;
1386                let pixel_bottom = (sy + 1) as f32;
1387                let w = pixel_bottom.min(bottom_f) - pixel_top.max(top_f);
1388                row_weights_buf[i] = (sy, w);
1389            }
1390            &row_weights_buf[..n_rows]
1391        } else {
1392            row_weights_vec = (top..bottom)
1393                .map(|sy| {
1394                    let pixel_top = sy as f32;
1395                    let pixel_bottom = (sy + 1) as f32;
1396                    let w = pixel_bottom.min(bottom_f) - pixel_top.max(top_f);
1397                    (sy, w)
1398                })
1399                .collect();
1400            &row_weights_vec
1401        };
1402
1403        let dst_row = dy * out_stride;
1404        for dx in 0..dw {
1405            let col = dx * 4;
1406            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1407            for &(sy, w) in row_weights {
1408                let i = sy * tmp_stride + col;
1409                r += tmp[i] * w;
1410                g += tmp[i + 1] * w;
1411                b += tmp[i + 2] * w;
1412                a += tmp[i + 3] * w;
1413            }
1414            let di = dst_row + col;
1415            out[di] = (r * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1416            out[di + 1] = (g * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1417            out[di + 2] = (b * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1418            out[di + 3] = (a * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1419        }
1420    }
1421
1422    out
1423}
1424
1425/// Bicubic (Catmull-Rom) resample for upscaling — two-pass separable.
1426///
1427/// Pass 1: horizontal resample (sw → dw) at f32 precision.
1428/// Pass 2: vertical resample (sh → dh) and quantize to u8.
1429///
1430/// Separable approach: O(dw×sh + dw×dh) × 4 taps instead of O(dw×dh) × 16 taps.
1431fn bicubic_resample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
1432    if dw == 0 || dh == 0 {
1433        return Vec::new();
1434    }
1435
1436    let (sw, sh, dw, dh) = (sw as usize, sh as usize, dw as usize, dh as usize);
1437    let ratio_x = sw as f32 / dw as f32;
1438    let ratio_y = sh as f32 / dh as f32;
1439
1440    // Pass 1: horizontal (sw → dw), keep sh rows, store as f32.
1441    let mut tmp = vec![0.0f32; dw * sh * 4];
1442    for y in 0..sh {
1443        let src_row = y * sw * 4;
1444        let dst_row = y * dw * 4;
1445        for dx in 0..dw {
1446            let sx = (dx as f32 + 0.5) * ratio_x - 0.5;
1447            let sx_floor = sx.floor() as i32;
1448            let fx = sx - sx_floor as f32;
1449            let w0 = catmull_rom(fx + 1.0);
1450            let w1 = catmull_rom(fx);
1451            let w2 = catmull_rom(1.0 - fx);
1452            let w3 = catmull_rom(2.0 - fx);
1453            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1454            for (k, w) in [
1455                (sx_floor - 1, w0),
1456                (sx_floor, w1),
1457                (sx_floor + 1, w2),
1458                (sx_floor + 2, w3),
1459            ] {
1460                let px = k.clamp(0, sw as i32 - 1) as usize;
1461                let i = src_row + px * 4;
1462                r += src[i] as f32 * w;
1463                g += src[i + 1] as f32 * w;
1464                b += src[i + 2] as f32 * w;
1465                a += src[i + 3] as f32 * w;
1466            }
1467            let di = dst_row + dx * 4;
1468            tmp[di] = r;
1469            tmp[di + 1] = g;
1470            tmp[di + 2] = b;
1471            tmp[di + 3] = a;
1472        }
1473    }
1474
1475    // Pass 2: vertical (sh → dh) on the dw-wide tmp, quantize to u8.
1476    // Row-major order for cache-friendly access.
1477    let mut out = vec![0u8; dw * dh * 4];
1478    let tmp_stride = dw * 4;
1479    let out_stride = dw * 4;
1480    for dy in 0..dh {
1481        let sy = (dy as f32 + 0.5) * ratio_y - 0.5;
1482        let sy_floor = sy.floor() as i32;
1483        let fy = sy - sy_floor as f32;
1484        let w0 = catmull_rom(fy + 1.0);
1485        let w1 = catmull_rom(fy);
1486        let w2 = catmull_rom(1.0 - fy);
1487        let w3 = catmull_rom(2.0 - fy);
1488        let py0 = (sy_floor - 1).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1489        let py1 = sy_floor.clamp(0, sh as i32 - 1) as usize * tmp_stride;
1490        let py2 = (sy_floor + 1).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1491        let py3 = (sy_floor + 2).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1492        let dst_row = dy * out_stride;
1493        for dx in 0..dw {
1494            let col = dx * 4;
1495            let r = tmp[py0 + col] * w0
1496                + tmp[py1 + col] * w1
1497                + tmp[py2 + col] * w2
1498                + tmp[py3 + col] * w3;
1499            let g = tmp[py0 + col + 1] * w0
1500                + tmp[py1 + col + 1] * w1
1501                + tmp[py2 + col + 1] * w2
1502                + tmp[py3 + col + 1] * w3;
1503            let b = tmp[py0 + col + 2] * w0
1504                + tmp[py1 + col + 2] * w1
1505                + tmp[py2 + col + 2] * w2
1506                + tmp[py3 + col + 2] * w3;
1507            let a = tmp[py0 + col + 3] * w0
1508                + tmp[py1 + col + 3] * w1
1509                + tmp[py2 + col + 3] * w2
1510                + tmp[py3 + col + 3] * w3;
1511            let di = dst_row + col;
1512            out[di] = r.round().clamp(0.0, 255.0) as u8;
1513            out[di + 1] = g.round().clamp(0.0, 255.0) as u8;
1514            out[di + 2] = b.round().clamp(0.0, 255.0) as u8;
1515            out[di + 3] = a.round().clamp(0.0, 255.0) as u8;
1516        }
1517    }
1518
1519    out
1520}
1521
1522/// Catmull-Rom spline weight (a = -0.5).
1523#[inline]
1524fn catmull_rom(t: f32) -> f32 {
1525    let t = t.abs();
1526    if t < 1.0 {
1527        (1.5 * t - 2.5) * t * t + 1.0
1528    } else if t < 2.0 {
1529        ((-0.5 * t + 2.5) * t - 4.0) * t + 2.0
1530    } else {
1531        0.0
1532    }
1533}
1534
1535/// Pre-downsample an image when the transform indicates significant downscaling.
1536///
1537/// tiny-skia's bilinear filter only samples a 2×2 neighborhood — it has no mipmap
1538/// support, so large downscale ratios cause severe aliasing (e.g., 300 DPI bitmap
1539/// fonts rendered at screen resolution).
1540///
1541/// For axis-aligned transforms: box-filter resample to the exact target dimensions.
1542///
1543/// Build an `IccCache` from ICC profiles found in a display list.
1544///
1545/// Registers all unique ICCBased profiles and optionally the system CMYK profile.
1546pub fn build_icc_cache_for_list(
1547    list: &DisplayList,
1548    system_cmyk_bytes: Option<&std::sync::Arc<Vec<u8>>>,
1549) -> IccCache {
1550    let mut cache = IccCache::new();
1551    let mut seen = HashSet::new();
1552
1553    // Register system CMYK profile first
1554    if let Some(cmyk_bytes) = system_cmyk_bytes
1555        && let Some(hash) = cache.register_profile(cmyk_bytes)
1556    {
1557        seen.insert(hash);
1558        // Set the default CMYK hash so convert_image_8bit works for DeviceCMYK
1559        cache.set_default_cmyk_hash(hash);
1560        // Pre-warm the sRGB→CMYK reverse transform so band renderers, which
1561        // only hold an `&IccCache`, can use `convert_rgb_to_cmyk_readonly`
1562        // when populating the parallel CMYK buffer for non-CMYK painters.
1563        cache.prepare_reverse_cmyk();
1564    }
1565
1566    // Scan display list for ICCBased images and shadings (recursing into Groups)
1567    fn scan_elements(
1568        elements: &[DisplayElement],
1569        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1570        cache: &mut IccCache,
1571    ) {
1572        for element in elements {
1573            // Recurse into groups
1574            if let DisplayElement::Group { elements: sub, .. } = element {
1575                scan_elements(sub.elements(), seen, cache);
1576            }
1577            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1578                scan_elements(content.elements(), seen, cache);
1579                scan_elements(mask.elements(), seen, cache);
1580            }
1581            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1582                scan_elements(sub.elements(), seen, cache);
1583            }
1584            // Shading color spaces
1585            let shading_cs = match element {
1586                DisplayElement::AxialShading { params } => Some(&params.color_space),
1587                DisplayElement::RadialShading { params } => Some(&params.color_space),
1588                DisplayElement::MeshShading { params } => Some(&params.color_space),
1589                DisplayElement::PatchShading { params } => Some(&params.color_space),
1590                _ => None,
1591            };
1592            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1593                n,
1594                profile_hash,
1595                profile_data,
1596            }) = shading_cs
1597            {
1598                if seen.insert(*profile_hash) {
1599                    cache.register_profile_with_n(profile_data, Some(*n));
1600                }
1601            }
1602            // Image color spaces
1603            if let DisplayElement::Image { params, .. } = element {
1604                match &params.color_space {
1605                    ImageColorSpace::ICCBased {
1606                        n,
1607                        profile_hash,
1608                        profile_data,
1609                    } if seen.insert(*profile_hash) => {
1610                        cache.register_profile_with_n(profile_data, Some(*n));
1611                    }
1612                    ImageColorSpace::Indexed { base, .. }
1613                        if matches!(base.as_ref(), ImageColorSpace::ICCBased { .. }) =>
1614                    {
1615                        if let ImageColorSpace::ICCBased {
1616                            n,
1617                            profile_hash,
1618                            profile_data,
1619                        } = base.as_ref()
1620                        {
1621                            if seen.insert(*profile_hash) {
1622                                cache.register_profile_with_n(profile_data, Some(*n));
1623                            }
1624                        }
1625                    }
1626                    _ => {}
1627                }
1628            }
1629        }
1630    }
1631    scan_elements(list.elements(), &mut seen, &mut cache);
1632
1633    cache
1634}
1635
1636/// Register ICC profiles from shading elements in a display list.
1637///
1638/// Recursively scans Groups and SoftMasks for ICCBased shading color spaces
1639/// and registers their profiles in the cache.
1640fn register_shading_icc_profiles(list: &DisplayList, cache: &mut IccCache) {
1641    fn register_image_iccs(
1642        cs: &ImageColorSpace,
1643        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1644        cache: &mut IccCache,
1645    ) {
1646        match cs {
1647            ImageColorSpace::ICCBased {
1648                n,
1649                profile_hash,
1650                profile_data,
1651            } => {
1652                if seen.insert(*profile_hash) {
1653                    cache.register_profile_with_n(profile_data, Some(*n));
1654                }
1655            }
1656            ImageColorSpace::Indexed { base, .. } => register_image_iccs(base, seen, cache),
1657            ImageColorSpace::Separation { alt_space, .. }
1658            | ImageColorSpace::DeviceN { alt_space, .. } => {
1659                register_image_iccs(alt_space, seen, cache)
1660            }
1661            _ => {}
1662        }
1663    }
1664    fn scan(
1665        elements: &[DisplayElement],
1666        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1667        cache: &mut IccCache,
1668    ) {
1669        for element in elements {
1670            if let DisplayElement::Group { elements: sub, .. } = element {
1671                scan(sub.elements(), seen, cache);
1672            }
1673            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1674                scan(content.elements(), seen, cache);
1675                scan(mask.elements(), seen, cache);
1676            }
1677            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1678                scan(sub.elements(), seen, cache);
1679            }
1680            let shading_cs = match element {
1681                DisplayElement::AxialShading { params } => Some(&params.color_space),
1682                DisplayElement::RadialShading { params } => Some(&params.color_space),
1683                DisplayElement::MeshShading { params } => Some(&params.color_space),
1684                DisplayElement::PatchShading { params } => Some(&params.color_space),
1685                _ => None,
1686            };
1687            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1688                n,
1689                profile_hash,
1690                profile_data,
1691            }) = shading_cs
1692                && seen.insert(*profile_hash)
1693            {
1694                cache.register_profile_with_n(profile_data, Some(*n));
1695            }
1696            if let DisplayElement::Image { params, .. } = element {
1697                register_image_iccs(&params.color_space, seen, cache);
1698            }
1699        }
1700    }
1701    let mut seen = HashSet::new();
1702    scan(list.elements(), &mut seen, cache);
1703}
1704
1705/// Convert raw image samples to RGBA for rasterization.
1706///
1707/// Handles all `ImageColorSpace` variants, producing width×height×4 RGBA bytes.
1708fn samples_to_rgba(
1709    data: &[u8],
1710    params: &ImageParams,
1711    icc: Option<&IccCache>,
1712    opm_zero_transparent: bool,
1713) -> Vec<u8> {
1714    let w = params.width as usize;
1715    let h = params.height as usize;
1716    let npixels = w * h;
1717    let bpc = params.bits_per_component;
1718    match &params.color_space {
1719        ImageColorSpace::PreconvertedRGBA => {
1720            // Already RGBA — just return as-is
1721            data.to_vec()
1722        }
1723        ImageColorSpace::DeviceGray => {
1724            let mut rgba = vec![255u8; npixels * 4];
1725            if bpc == 16 {
1726                for i in 0..npixels {
1727                    let g = data.get(i * 2).copied().unwrap_or(0);
1728                    let pi = i * 4;
1729                    rgba[pi] = g;
1730                    rgba[pi + 1] = g;
1731                    rgba[pi + 2] = g;
1732                }
1733            } else {
1734                for i in 0..npixels {
1735                    let g = data.get(i).copied().unwrap_or(0);
1736                    let pi = i * 4;
1737                    rgba[pi] = g;
1738                    rgba[pi + 1] = g;
1739                    rgba[pi + 2] = g;
1740                }
1741            }
1742            rgba
1743        }
1744        ImageColorSpace::DeviceRGB => {
1745            let mut rgba = vec![255u8; npixels * 4];
1746            if bpc == 16 {
1747                // 16 BPC: 6 bytes per pixel (R_hi R_lo G_hi G_lo B_hi B_lo)
1748                // Take high byte of each 16-bit sample
1749                for i in 0..npixels {
1750                    let si = i * 6;
1751                    let pi = i * 4;
1752                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1753                    rgba[pi + 1] = data.get(si + 2).copied().unwrap_or(0);
1754                    rgba[pi + 2] = data.get(si + 4).copied().unwrap_or(0);
1755                }
1756            } else {
1757                for i in 0..npixels {
1758                    let si = i * 3;
1759                    let pi = i * 4;
1760                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1761                    rgba[pi + 1] = data.get(si + 1).copied().unwrap_or(0);
1762                    rgba[pi + 2] = data.get(si + 2).copied().unwrap_or(0);
1763                }
1764            }
1765            rgba
1766        }
1767        ImageColorSpace::DeviceCMYK => {
1768            // Try ICC-based CMYK→RGB conversion via system CMYK profile.
1769            // Convert as many complete pixels as the data allows; PLRM-fallback
1770            // for any remaining pixels with insufficient data.
1771            if let Some(cache) = icc
1772                && let Some(cmyk_hash) = cache.default_cmyk_hash()
1773            {
1774                let avail_pixels = data.len() / 4;
1775                let icc_pixels = avail_pixels.min(npixels);
1776                if icc_pixels > 0
1777                    && let Some(rgb) = cache.convert_image_8bit(cmyk_hash, data, icc_pixels)
1778                {
1779                    let mut rgba = vec![255u8; npixels * 4];
1780                    for i in 0..icc_pixels {
1781                        rgba[i * 4] = rgb[i * 3];
1782                        rgba[i * 4 + 1] = rgb[i * 3 + 1];
1783                        rgba[i * 4 + 2] = rgb[i * 3 + 2];
1784                        // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1785                        if opm_zero_transparent {
1786                            let si = i * 4;
1787                            if data[si] == 0
1788                                && data[si + 1] == 0
1789                                && data[si + 2] == 0
1790                                && data[si + 3] == 0
1791                            {
1792                                rgba[i * 4 + 3] = 0;
1793                            }
1794                        }
1795                    }
1796                    // Remaining pixels (if data was short) stay white (0xFF)
1797                    return rgba;
1798                }
1799            }
1800            // Fallback: PLRM CMYK→RGB formula
1801            let mut rgba = vec![255u8; npixels * 4];
1802            for i in 0..npixels {
1803                let si = i * 4;
1804                let c = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1805                let m = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1806                let y = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1807                let k = data.get(si + 3).copied().unwrap_or(0) as f64 / 255.0;
1808                let r = (1.0 - c.min(1.0)) * (1.0 - k.min(1.0));
1809                let g = (1.0 - m.min(1.0)) * (1.0 - k.min(1.0));
1810                let b = (1.0 - y.min(1.0)) * (1.0 - k.min(1.0));
1811                let pi = i * 4;
1812                rgba[pi] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
1813                rgba[pi + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
1814                rgba[pi + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
1815                // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1816                if opm_zero_transparent
1817                    && data.get(si).copied().unwrap_or(0) == 0
1818                    && data.get(si + 1).copied().unwrap_or(0) == 0
1819                    && data.get(si + 2).copied().unwrap_or(0) == 0
1820                    && data.get(si + 3).copied().unwrap_or(0) == 0
1821                {
1822                    rgba[pi + 3] = 0;
1823                }
1824            }
1825            rgba
1826        }
1827        ImageColorSpace::ICCBased {
1828            n,
1829            profile_hash,
1830            profile_data,
1831        } => {
1832            // Try ICC-based conversion if cache is available
1833            if let Some(cache) = icc
1834                && cache.has_profile(profile_hash)
1835                && let Some(rgb) = cache.convert_image_8bit(profile_hash, data, npixels)
1836            {
1837                let mut rgba = vec![255u8; npixels * 4];
1838                for i in 0..npixels {
1839                    rgba[i * 4] = rgb[i * 3];
1840                    rgba[i * 4 + 1] = rgb[i * 3 + 1];
1841                    rgba[i * 4 + 2] = rgb[i * 3 + 2];
1842                    // OPM=1 on 4-component (CMYK) ICC profiles
1843                    if opm_zero_transparent && *n == 4 {
1844                        let si = i * *n as usize;
1845                        if si + 3 < data.len()
1846                            && data[si] == 0
1847                            && data[si + 1] == 0
1848                            && data[si + 2] == 0
1849                            && data[si + 3] == 0
1850                        {
1851                            rgba[i * 4 + 3] = 0;
1852                        }
1853                    }
1854                }
1855                return rgba;
1856            }
1857            // Fallback to device equivalent based on component count
1858            let _ = (profile_hash, profile_data);
1859            let fallback = match n {
1860                1 => ImageColorSpace::DeviceGray,
1861                4 => ImageColorSpace::DeviceCMYK,
1862                _ => ImageColorSpace::DeviceRGB,
1863            };
1864            let p = ImageParams {
1865                color_space: fallback,
1866                bits_per_component: 8,
1867                ..params.clone()
1868            };
1869            samples_to_rgba(data, &p, icc, opm_zero_transparent)
1870        }
1871        ImageColorSpace::Indexed {
1872            base,
1873            hival,
1874            lookup,
1875        } => {
1876            let base_ncomp = base.num_components() as usize;
1877            // Expand indexed samples to base color space, then convert
1878            let mut expanded = Vec::with_capacity(npixels * base_ncomp);
1879            for i in 0..npixels {
1880                let idx = data.get(i).copied().unwrap_or(0) as usize;
1881                let idx = idx.min(*hival as usize);
1882                let offset = idx * base_ncomp;
1883                for c in 0..base_ncomp {
1884                    expanded.push(lookup.get(offset + c).copied().unwrap_or(0));
1885                }
1886            }
1887            let p = ImageParams {
1888                color_space: *base.clone(),
1889                bits_per_component: 8,
1890                ..params.clone()
1891            };
1892            samples_to_rgba(&expanded, &p, icc, opm_zero_transparent)
1893        }
1894        ImageColorSpace::CIEBasedABC { params: cie_params } => {
1895            let mut rgba = vec![255u8; npixels * 4];
1896            for i in 0..npixels {
1897                let si = i * 3;
1898                let a = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1899                let b = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1900                let c = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1901                let color = DeviceColor::from_cie_abc(a, b, c, cie_params);
1902                let pi = i * 4;
1903                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1904                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1905                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1906            }
1907            rgba
1908        }
1909        ImageColorSpace::CIEBasedA { params: cie_params } => {
1910            let mut rgba = vec![255u8; npixels * 4];
1911            for i in 0..npixels {
1912                let val = data.get(i).copied().unwrap_or(0) as f64 / 255.0;
1913                let color = DeviceColor::from_cie_a(val, cie_params);
1914                let pi = i * 4;
1915                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1916                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1917                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1918            }
1919            rgba
1920        }
1921        ImageColorSpace::Lab { range, .. } => {
1922            let mut rgba = vec![255u8; npixels * 4];
1923            let a_span = range[1] - range[0];
1924            let b_span = range[3] - range[2];
1925            for i in 0..npixels {
1926                let si = i * 3;
1927                let l = data.get(si).copied().unwrap_or(0) as f64 / 255.0 * 100.0;
1928                let a = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0 * a_span + range[0];
1929                let b = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0 * b_span + range[2];
1930                let color = DeviceColor::from_lab(l, a, b, range);
1931                let pi = i * 4;
1932                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1933                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1934                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1935            }
1936            rgba
1937        }
1938        ImageColorSpace::Separation {
1939            alt_space,
1940            tint_table,
1941            ..
1942        } => {
1943            // 1 byte per pixel → lookup in tint table → convert alt space to RGB
1944            // For CMYK alt space with ICC, build bulk CMYK data and convert via ICC
1945            if matches!(alt_space.as_ref(), ImageColorSpace::DeviceCMYK)
1946                && let Some(rgba) = tint_separation_via_icc(data, npixels, tint_table, icc)
1947            {
1948                return rgba;
1949            }
1950            let mut rgba = vec![255u8; npixels * 4];
1951            let no = tint_table.num_outputs as usize;
1952            let mut alt_comps = vec![0.0f32; no];
1953            for i in 0..npixels {
1954                let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
1955                tint_table.lookup_1d(tint, &mut alt_comps);
1956                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
1957                let pi = i * 4;
1958                rgba[pi] = r;
1959                rgba[pi + 1] = g;
1960                rgba[pi + 2] = b;
1961            }
1962            rgba
1963        }
1964        ImageColorSpace::DeviceN {
1965            alt_space,
1966            tint_table,
1967            ..
1968        } => {
1969            let ni = tint_table.num_inputs as usize;
1970            let no = tint_table.num_outputs as usize;
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_devicen_via_icc(data, npixels, ni, tint_table, icc)
1974            {
1975                return rgba;
1976            }
1977            let mut rgba = vec![255u8; npixels * 4];
1978            let mut inputs = vec![0.0f32; ni];
1979            let mut alt_comps = vec![0.0f32; no];
1980            for i in 0..npixels {
1981                let si = i * ni;
1982                for (c, inp) in inputs.iter_mut().enumerate() {
1983                    *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
1984                }
1985                tint_table.lookup_nd(&inputs, &mut alt_comps);
1986                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
1987                let pi = i * 4;
1988                rgba[pi] = r;
1989                rgba[pi + 1] = g;
1990                rgba[pi + 2] = b;
1991            }
1992            rgba
1993        }
1994        ImageColorSpace::Mask { color, polarity } => {
1995            let mut rgba = vec![0u8; npixels * 4];
1996            let r = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1997            let g = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1998            let b = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1999            let bytes_per_row = (w).div_ceil(8);
2000            for row in 0..h {
2001                for col in 0..w {
2002                    let byte_idx = row * bytes_per_row + col / 8;
2003                    let bit_offset = 7 - (col % 8);
2004                    let bit = if byte_idx < data.len() {
2005                        (data[byte_idx] >> bit_offset) & 1
2006                    } else {
2007                        0
2008                    };
2009                    let paint = if *polarity { bit == 1 } else { bit == 0 };
2010                    if paint {
2011                        let pi = (row * w + col) * 4;
2012                        rgba[pi] = r;
2013                        rgba[pi + 1] = g;
2014                        rgba[pi + 2] = b;
2015                        rgba[pi + 3] = 255;
2016                    }
2017                }
2018            }
2019            rgba
2020        }
2021    }
2022}
2023
2024/// Convert Separation (1-input) tint table output through ICC CMYK profile.
2025/// Builds 4-byte CMYK data from tint table, then bulk-converts via ICC 8-bit transform.
2026fn tint_separation_via_icc(
2027    data: &[u8],
2028    npixels: usize,
2029    tint_table: &TintLookupTable,
2030    icc: Option<&IccCache>,
2031) -> Option<Vec<u8>> {
2032    let cache = icc?;
2033    let cmyk_hash = cache.default_cmyk_hash()?;
2034    // Build CMYK byte buffer from tint table
2035    let mut cmyk_data = vec![0u8; npixels * 4];
2036    let mut alt_comps = [0.0f32; 4];
2037    for i in 0..npixels {
2038        let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
2039        tint_table.lookup_1d(tint, &mut alt_comps);
2040        let si = i * 4;
2041        cmyk_data[si] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2042        cmyk_data[si + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2043        cmyk_data[si + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2044        cmyk_data[si + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2045    }
2046    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2047    let mut rgba = vec![255u8; npixels * 4];
2048    for i in 0..npixels {
2049        rgba[i * 4] = rgb[i * 3];
2050        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2051        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2052    }
2053    Some(rgba)
2054}
2055
2056/// Convert DeviceN (N-input) tint table output through ICC CMYK profile.
2057fn tint_devicen_via_icc(
2058    data: &[u8],
2059    npixels: usize,
2060    ni: usize,
2061    tint_table: &TintLookupTable,
2062    icc: Option<&IccCache>,
2063) -> Option<Vec<u8>> {
2064    let cache = icc?;
2065    let cmyk_hash = cache.default_cmyk_hash()?;
2066    let mut cmyk_data = vec![0u8; npixels * 4];
2067    let mut inputs = vec![0.0f32; ni];
2068    let mut alt_comps = [0.0f32; 4];
2069    for i in 0..npixels {
2070        let si = i * ni;
2071        for (c, inp) in inputs.iter_mut().enumerate() {
2072            *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
2073        }
2074        tint_table.lookup_nd(&inputs, &mut alt_comps);
2075        let di = i * 4;
2076        cmyk_data[di] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2077        cmyk_data[di + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2078        cmyk_data[di + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2079        cmyk_data[di + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2080    }
2081    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2082    let mut rgba = vec![255u8; npixels * 4];
2083    for i in 0..npixels {
2084        rgba[i * 4] = rgb[i * 3];
2085        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2086        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2087    }
2088    Some(rgba)
2089}
2090
2091/// Convert alt-space f32 component values to RGB bytes.
2092fn alt_comps_to_rgb(comps: &[f32], alt_space: &ImageColorSpace) -> (u8, u8, u8) {
2093    match alt_space {
2094        ImageColorSpace::DeviceGray => {
2095            let g = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2096            (g, g, g)
2097        }
2098        ImageColorSpace::DeviceRGB => {
2099            let r = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2100            let g = (comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2101            let b = (comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2102            (r, g, b)
2103        }
2104        ImageColorSpace::DeviceCMYK => {
2105            let c = comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0);
2106            let m = comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2107            let y = comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2108            let k = comps.get(3).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2109            let r = ((1.0 - (c + k).min(1.0)) * 255.0).round() as u8;
2110            let g = ((1.0 - (m + k).min(1.0)) * 255.0).round() as u8;
2111            let b = ((1.0 - (y + k).min(1.0)) * 255.0).round() as u8;
2112            (r, g, b)
2113        }
2114        _ => (0, 0, 0),
2115    }
2116}
2117
2118/// Apply ImageType 4 mask color transparency to RGBA data.
2119fn apply_mask_color_rgba(rgba: &mut [u8], sample_data: &[u8], params: &ImageParams) {
2120    let mask_color = match &params.mask_color {
2121        Some(mc) => mc,
2122        None => return,
2123    };
2124    let ncomp = params.color_space.num_components() as usize;
2125    let npixels = params.width as usize * params.height as usize;
2126    let is_range = mask_color.len() == 2 * ncomp;
2127
2128    for i in 0..npixels {
2129        let si = i * ncomp;
2130        let matched = if is_range {
2131            (0..ncomp).all(|c| {
2132                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2133                let min_val = mask_color.get(c * 2).copied().unwrap_or(0);
2134                let max_val = mask_color.get(c * 2 + 1).copied().unwrap_or(0);
2135                sample >= min_val && sample <= max_val
2136            })
2137        } else {
2138            (0..ncomp).all(|c| {
2139                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2140                let target = mask_color.get(c).copied().unwrap_or(0);
2141                sample == target
2142            })
2143        };
2144        if matched {
2145            let pi = i * 4;
2146            if pi + 3 < rgba.len() {
2147                rgba[pi] = 0;
2148                rgba[pi + 1] = 0;
2149                rgba[pi + 2] = 0;
2150                rgba[pi + 3] = 0;
2151            }
2152        }
2153    }
2154}
2155
2156/// Choose filter quality for image drawing.
2157///
2158/// When `interpolate` is false, use Nearest for upscaling (crisp pixel edges)
2159/// and Bilinear only for downscaling (proper area averaging). When `interpolate`
2160/// is true, use Bilinear for any scaling.
2161fn image_filter_quality(transform: Transform, interpolate: bool) -> stet_tiny_skia::FilterQuality {
2162    let eff_sx = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2163    let eff_sy = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2164    let min_scale = eff_sx.min(eff_sy);
2165    // Near-exact 1:1: Nearest is pixel-perfect and faster
2166    if (eff_sx - 1.0).abs() < 0.01 && (eff_sy - 1.0).abs() < 0.01 {
2167        stet_tiny_skia::FilterQuality::Nearest
2168    } else if !interpolate && min_scale >= 0.95 {
2169        // Non-interpolated upscaling: nearest-neighbor for crisp pixel edges
2170        stet_tiny_skia::FilterQuality::Nearest
2171    } else {
2172        stet_tiny_skia::FilterQuality::Bilinear
2173    }
2174}
2175
2176/// For rotated/sheared transforms: integer box-filter pre-downsample, leaving
2177/// the fractional remainder to tiny-skia's bilinear.
2178///
2179/// Returns `None` if no pre-scaling is needed.
2180fn prescale_image(
2181    rgba_data: &[u8],
2182    w: u32,
2183    h: u32,
2184    transform: Transform,
2185    interpolate: bool,
2186) -> Option<(Vec<u8>, u32, u32, Transform)> {
2187    // Compute effective scale factors from the 2×2 part of the transform.
2188    let scale_x = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2189    let scale_y = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2190    let min_scale = scale_x.min(scale_y);
2191
2192    // Upscaling: only apply bicubic resampling when Interpolate is true.
2193    // Per PLRM/PDF spec, non-interpolated images should use nearest-neighbor
2194    // for upscaling (crisp pixel boundaries, no smoothing).
2195    if min_scale > 1.05 {
2196        if interpolate {
2197            let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2198            if is_axis_aligned && w >= 2 && h >= 2 {
2199                let dw = (w as f32 * transform.sx.abs()).round().max(1.0) as u32;
2200                let dh = (h as f32 * transform.sy.abs()).round().max(1.0) as u32;
2201                if dw > w || dh > h {
2202                    let resampled = bicubic_resample(rgba_data, w, h, dw, dh);
2203                    let new_sx = transform.sx * w as f32 / dw as f32;
2204                    let new_sy = transform.sy * h as f32 / dh as f32;
2205                    let adjusted = Transform::from_row(
2206                        new_sx,
2207                        transform.ky,
2208                        transform.kx,
2209                        new_sy,
2210                        transform.tx,
2211                        transform.ty,
2212                    );
2213                    return Some((resampled, dw, dh, adjusted));
2214                }
2215            }
2216        }
2217        return None;
2218    }
2219
2220    // Near 1:1 — no prescaling needed.
2221    if min_scale >= 0.95 {
2222        return None;
2223    }
2224
2225    // Axis-aligned: use area-average box filter to target dimensions.
2226    // Much faster than Lanczos3 and produces equally good results for downscaling.
2227    let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2228    if is_axis_aligned && w >= 2 && h >= 2 {
2229        let dw = (w as f32 * transform.sx.abs()).ceil().max(1.0) as u32;
2230        let dh = (h as f32 * transform.sy.abs()).ceil().max(1.0) as u32;
2231        if dw < w || dh < h {
2232            let resampled = box_resample(rgba_data, w, h, dw, dh);
2233            // Adjust transform so scale ≈ ±1 (sign preserved), same translation.
2234            let new_sx = transform.sx * w as f32 / dw as f32;
2235            let new_sy = transform.sy * h as f32 / dh as f32;
2236            let adjusted = Transform::from_row(
2237                new_sx,
2238                transform.ky,
2239                transform.kx,
2240                new_sy,
2241                transform.tx,
2242                transform.ty,
2243            );
2244            return Some((resampled, dw, dh, adjusted));
2245        }
2246    }
2247
2248    // Fallback for rotated/sheared: integer box filter.
2249    let factor = (1.0 / min_scale) as u32;
2250    if factor < 2 || w < factor || h < factor {
2251        return None;
2252    }
2253    let nw = w / factor;
2254    let nh = h / factor;
2255    if nw == 0 || nh == 0 {
2256        return None;
2257    }
2258    let area = factor * factor;
2259    let half = area / 2;
2260    let stride = w as usize * 4;
2261    let mut out = vec![0u8; (nw * nh * 4) as usize];
2262    for dy in 0..nh {
2263        for dx in 0..nw {
2264            let (mut r, mut g, mut b, mut a) = (0u32, 0u32, 0u32, 0u32);
2265            let sy0 = (dy * factor) as usize;
2266            let sx0 = (dx * factor) as usize;
2267            for iy in 0..factor as usize {
2268                let row = (sy0 + iy) * stride + sx0 * 4;
2269                for ix in 0..factor as usize {
2270                    let i = row + ix * 4;
2271                    r += rgba_data[i] as u32;
2272                    g += rgba_data[i + 1] as u32;
2273                    b += rgba_data[i + 2] as u32;
2274                    a += rgba_data[i + 3] as u32;
2275                }
2276            }
2277            let di = (dy * nw + dx) as usize * 4;
2278            out[di] = ((r + half) / area) as u8;
2279            out[di + 1] = ((g + half) / area) as u8;
2280            out[di + 2] = ((b + half) / area) as u8;
2281            out[di + 3] = ((a + half) / area) as u8;
2282        }
2283    }
2284    let f = factor as f32;
2285    let adjusted = Transform::from_row(
2286        transform.sx * f,
2287        transform.ky * f,
2288        transform.kx * f,
2289        transform.sy * f,
2290        transform.tx,
2291        transform.ty,
2292    );
2293    Some((out, nw, nh, adjusted))
2294}
2295
2296/// Translate a device-space ClipRect into band-local coordinates.
2297fn translate_clip_rect(rect: &ClipRect, y_start: u32, band_h: u32) -> ClipRect {
2298    ClipRect {
2299        x0: rect.x0,
2300        y0: rect.y0.saturating_sub(y_start).min(band_h),
2301        x1: rect.x1,
2302        y1: rect.y1.saturating_sub(y_start).min(band_h),
2303    }
2304}
2305
2306/// Ensure an image transform maps to at least 1 device pixel in each dimension.
2307///
2308/// PDFs commonly draw rules and borders using tiny image masks (1×1 or 4×1 pixels)
2309/// scaled via the CTM to thin rectangles. At low DPI these can map to sub-pixel
2310/// device dimensions and vanish. This adjusts the transform's scale components
2311/// so the image covers at least 1 pixel in each direction.
2312fn enforce_min_image_size(transform: Transform, img_w: u32, img_h: u32) -> Transform {
2313    // Effective device-space dimensions
2314    let eff_w =
2315        ((transform.sx * img_w as f32).powi(2) + (transform.ky * img_w as f32).powi(2)).sqrt();
2316    let eff_h =
2317        ((transform.kx * img_h as f32).powi(2) + (transform.sy * img_h as f32).powi(2)).sqrt();
2318
2319    if eff_w >= 1.0 && eff_h >= 1.0 {
2320        return transform;
2321    }
2322
2323    // Only boost if the image is a thin rule (large aspect ratio).
2324    // Small images that are sub-pixel in both dimensions (e.g. tiny dots)
2325    // are left as-is — boosting them would create visible artifacts.
2326    let ratio = eff_w.max(eff_h) / eff_w.min(eff_h).max(0.001);
2327    if ratio < 3.0 {
2328        return transform;
2329    }
2330
2331    let mut t = transform;
2332    if eff_w < 1.0 && eff_w > 0.001 {
2333        let boost = 1.0 / eff_w;
2334        t.sx *= boost;
2335        t.ky *= boost;
2336    }
2337    if eff_h < 1.0 && eff_h > 0.001 {
2338        let boost = 1.0 / eff_h;
2339        t.kx *= boost;
2340        t.sy *= boost;
2341    }
2342    t
2343}
2344
2345/// Compute minimum line width for hairline strokes at a given DPI and CTM.
2346/// Returns the minimum width in user-space units that ensures at least
2347/// 0.5 device pixels at ≤150 DPI or 1.0 device pixel above 150 DPI.
2348fn hairline_min_width(ctm: &Matrix, dpi: f64) -> f64 {
2349    let (a, b, c, d) = (ctm.a, ctm.b, ctm.c, ctm.d);
2350    let sum_sq = a * a + b * b + c * c + d * d;
2351    let diff = ((a * a + b * b - c * c - d * d).powi(2) + 4.0 * (a * c + b * d).powi(2)).sqrt();
2352    let s_max = (0.5 * (sum_sq + diff)).max(0.0).sqrt();
2353    let min_px = if dpi <= 150.0 { 0.5 } else { 1.0 };
2354    if s_max > 1e-10 {
2355        min_px / s_max
2356    } else {
2357        min_px
2358    }
2359}
2360
2361/// True when the paint's source CMYK is K-only (C=M=Y=0, any K).
2362/// Used to route OPM 0 DeviceCMYK paints that encode "K-only" — like
2363/// `0 0 0 0.5 k` — through the per-pixel overprint path, so the no-op delta
2364/// skip can preserve a spot-painted backdrop at pixels where K already equals
2365/// the source value.
2366fn is_k_only_src(color: &DeviceColor) -> bool {
2367    if let Some((c, m, y, _k)) = color.native_cmyk {
2368        c == 0.0 && m == 0.0 && y == 0.0
2369    } else {
2370        false
2371    }
2372}
2373
2374/// Detect a DeviceGray paint that should be promoted to CMYK_K for overprint.
2375///
2376/// DeviceGray `g` sets `painted_channels = 0` and leaves `native_cmyk = None`,
2377/// so overprint dispatch can't see it as a K-ink paint. When overprint is
2378/// active we re-describe the paint as DeviceCMYK `(0, 0, 0, 1-g)` with
2379/// `painted_channels = CMYK_K`: it flows through the subset path, only the K
2380/// plate is touched, and the pixmap is updated multiplicatively so any
2381/// backdrop spot contribution survives.
2382fn needs_gray_promotion(
2383    overprint: bool,
2384    painted_channels: u8,
2385    is_device_cmyk: bool,
2386    color: &DeviceColor,
2387) -> Option<f64> {
2388    if !overprint
2389        || painted_channels != 0
2390        || is_device_cmyk
2391        || color.native_cmyk.is_some()
2392        || color.process_cmyk.is_some()
2393    {
2394        return None;
2395    }
2396    let r = color.r;
2397    if (r - color.g).abs() > f64::EPSILON || (r - color.b).abs() > f64::EPSILON {
2398        return None;
2399    }
2400    Some(r.clamp(0.0, 1.0))
2401}
2402
2403/// Promote a gray `FillParams` to a DeviceCMYK K-only overprint description if
2404/// the paint qualifies (see [`needs_gray_promotion`]).
2405fn maybe_promote_gray_fill<'a>(
2406    params: &'a FillParams,
2407    buf: &'a mut Option<FillParams>,
2408) -> &'a FillParams {
2409    if let Some(gray) = needs_gray_promotion(
2410        params.overprint,
2411        params.painted_channels,
2412        params.is_device_cmyk,
2413        &params.color,
2414    ) {
2415        let mut promoted = params.clone();
2416        promoted.is_device_cmyk = true;
2417        promoted.painted_channels = stet_graphics::device::CMYK_K;
2418        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2419        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2420        *buf = Some(promoted);
2421        return buf.as_ref().unwrap();
2422    }
2423    params
2424}
2425
2426/// Promote a gray `StrokeParams` to a DeviceCMYK K-only overprint description.
2427fn maybe_promote_gray_stroke<'a>(
2428    params: &'a StrokeParams,
2429    buf: &'a mut Option<StrokeParams>,
2430) -> &'a StrokeParams {
2431    if let Some(gray) = needs_gray_promotion(
2432        params.overprint,
2433        params.painted_channels,
2434        params.is_device_cmyk,
2435        &params.color,
2436    ) {
2437        let mut promoted = params.clone();
2438        promoted.is_device_cmyk = true;
2439        promoted.painted_channels = stet_graphics::device::CMYK_K;
2440        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2441        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2442        *buf = Some(promoted);
2443        return buf.as_ref().unwrap();
2444    }
2445    params
2446}
2447
2448/// Build a stroke with minimum line-width enforcement (shared by trait impl and band rendering).
2449/// `dpi` is the device resolution, used to select the hairline minimum width:
2450/// at ≤150 DPI use 0.6 device pixels; above 150 DPI use 1.0 device pixel.
2451fn build_stroke(params: &StrokeParams, dpi: f64) -> Stroke {
2452    let min_lw = hairline_min_width(&params.ctm, dpi);
2453    let mut stroke = Stroke {
2454        width: (params.line_width as f32).max(min_lw as f32),
2455        line_cap: to_line_cap(params.line_cap),
2456        line_join: to_line_join(params.line_join),
2457        miter_limit: params.miter_limit as f32,
2458        ..Stroke::default()
2459    };
2460    if !params.dash_pattern.array.is_empty() {
2461        let mut dash_array: Vec<f32> = params
2462            .dash_pattern
2463            .array
2464            .iter()
2465            .map(|&v| v as f32)
2466            .collect();
2467        // PostScript allows odd-length dash arrays (implicitly doubled),
2468        // but tiny-skia requires even length. Double odd arrays to match PS semantics.
2469        if dash_array.len() % 2 == 1 {
2470            let clone = dash_array.clone();
2471            dash_array.extend_from_slice(&clone);
2472        }
2473        if let Some(dash) = StrokeDash::new(dash_array, params.dash_pattern.offset as f32) {
2474            stroke.dash = Some(dash);
2475        }
2476    }
2477    stroke
2478}
2479
2480/// Apply stroke adjustment: snap axis-aligned path segments to device pixel
2481/// centers so thin strokes render with consistent weight.
2482///
2483/// For a stroke of width W in device pixels:
2484/// - Odd-integer width (1, 3, ...): snap to half-pixel (floor(x) + 0.5)
2485/// - Even-integer width or non-integer: snap to pixel edge (round(x))
2486/// - For hairlines (device width < 1.5): always snap to half-pixel
2487///
2488/// Only axis-aligned segments (horizontal/vertical lines) are snapped.
2489/// Diagonal/curved segments are left as-is since snapping would distort them.
2490///
2491/// Check whether a CTM indicates the path is already in device space (identity
2492/// or simple Y-flip/translation). Stroke adjustment snaps coordinates to pixel
2493/// boundaries, which only makes sense when path coordinates are device pixels.
2494/// PDF Form XObjects with large scale factors (e.g. [405, 0, 0, 283, ...]) would
2495/// cause catastrophic snapping if treated as device-space paths.
2496fn ctm_is_device_space(ctm: &Matrix) -> bool {
2497    (ctm.a.abs() - 1.0).abs() < 0.01
2498        && ctm.b.abs() < 0.01
2499        && ctm.c.abs() < 0.01
2500        && (ctm.d.abs() - 1.0).abs() < 0.01
2501}
2502
2503/// Apply stroke adjustment for viewport rendering.
2504///
2505/// Path coordinates are in reference-DPI device space. The viewport transform
2506/// maps them to output pixels: out = (ref - vp_origin) * scale.
2507/// We snap in output pixel space then map back to reference space.
2508fn stroke_adjust_path_viewport(
2509    path: &PsPath,
2510    device_width: f64,
2511    scale_x: f64,
2512    scale_y: f64,
2513    vp_x: f64,
2514    vp_y: f64,
2515) -> PsPath {
2516    let use_half_pixel = device_width < 1.5 || (device_width.round() as i32) % 2 == 1;
2517
2518    // Snap a reference-space coordinate to the output pixel grid, then map back
2519    let snap_x = |v: f64| -> f64 {
2520        let out = (v - vp_x) * scale_x;
2521        let snapped = if use_half_pixel {
2522            out.floor() + 0.5
2523        } else {
2524            out.round()
2525        };
2526        snapped / scale_x + vp_x
2527    };
2528    let snap_y = |v: f64| -> f64 {
2529        let out = (v - vp_y) * scale_y;
2530        let snapped = if use_half_pixel {
2531            out.floor() + 0.5
2532        } else {
2533            out.round()
2534        };
2535        snapped / scale_y + vp_y
2536    };
2537
2538    let mut result = PsPath::new();
2539    let mut prev_x = 0.0_f64;
2540    let mut prev_y = 0.0_f64;
2541
2542    for seg in &path.segments {
2543        match *seg {
2544            PathSegment::MoveTo(x, y) => {
2545                prev_x = x;
2546                prev_y = y;
2547                result.segments.push(PathSegment::MoveTo(x, y));
2548            }
2549            PathSegment::LineTo(x, y) => {
2550                let is_horizontal = (y - prev_y).abs() < 1e-6;
2551                let is_vertical = (x - prev_x).abs() < 1e-6;
2552
2553                if is_horizontal {
2554                    let snapped_y = snap_y(y);
2555                    if let Some(PathSegment::MoveTo(_, ly) | PathSegment::LineTo(_, ly)) =
2556                        result.segments.last_mut()
2557                    {
2558                        *ly = snapped_y;
2559                    }
2560                    result.segments.push(PathSegment::LineTo(x, snapped_y));
2561                    prev_x = x;
2562                    prev_y = snapped_y;
2563                } else if is_vertical {
2564                    let snapped_x = snap_x(x);
2565                    if let Some(PathSegment::MoveTo(lx, _) | PathSegment::LineTo(lx, _)) =
2566                        result.segments.last_mut()
2567                    {
2568                        *lx = snapped_x;
2569                    }
2570                    result.segments.push(PathSegment::LineTo(snapped_x, y));
2571                    prev_x = snapped_x;
2572                    prev_y = y;
2573                } else {
2574                    result.segments.push(PathSegment::LineTo(x, y));
2575                    prev_x = x;
2576                    prev_y = y;
2577                }
2578            }
2579            PathSegment::CurveTo {
2580                x1,
2581                y1,
2582                x2,
2583                y2,
2584                x3,
2585                y3,
2586            } => {
2587                result.segments.push(PathSegment::CurveTo {
2588                    x1,
2589                    y1,
2590                    x2,
2591                    y2,
2592                    x3,
2593                    y3,
2594                });
2595                prev_x = x3;
2596                prev_y = y3;
2597            }
2598            PathSegment::ClosePath => {
2599                result.segments.push(PathSegment::ClosePath);
2600            }
2601        }
2602    }
2603    result
2604}
2605
2606/// Process a single display list element into a pixmap using the given render context.
2607///
2608/// This unified function handles both band rendering (scale=1.0) and viewport
2609/// rendering (arbitrary scale). Band rendering is viewport rendering with
2610/// `scale_x = scale_y = 1.0`.
2611fn render_element(
2612    pixmap: &mut Pixmap,
2613    band_state: &mut BandState,
2614    element: &DisplayElement,
2615    ctx: &RenderContext<'_>,
2616) {
2617    match element {
2618        DisplayElement::Fill { path, params } => {
2619            // DeviceGray with overprint behaves as a K-only process paint —
2620            // promote it to DeviceCMYK (0, 0, 0, 1-gray) with painted_channels
2621            // set to CMYK_K so it flows through the overprint subset path,
2622            // preserving backdrop CMY plates and the spot-derived visual
2623            // instead of knocking the pixmap out with plain RGB gray.
2624            let mut promoted_fill: Option<FillParams> = None;
2625            let params = maybe_promote_gray_fill(params, &mut promoted_fill);
2626            // Use the overprint compositing path whenever the fill needs
2627            // per-channel CMYK rendering. Five cases trigger it:
2628            //   1. Subset painted_channels (Separation /Magenta, DeviceN, etc.)
2629            //      — only the named channels touch the buffer; the rest are
2630            //      preserved from the backdrop.
2631            //   2. DeviceCMYK + OPM 1 — zero-valued components don't paint, so
2632            //      a per-pixel filter is required.
2633            //   3. Custom spot (painted_channels=0, non-CMYK, with native_cmyk)
2634            //      under overprint — process plates must be preserved; the
2635            //      spot's alt-CMYK only contributes multiplicatively to RGB.
2636            //   4. DeviceCMYK + overprint (any OPM) with CMYK_ALL — the per-
2637            //      pixel path lets us recognise a "no-op" overprint (src CMYK
2638            //      == backdrop CMYK) and leave the pixmap untouched, which
2639            //      preserves any spot-derived colour already visible there.
2640            //   5. (Combinations of the above.)
2641            // Only fires for Normal blend; non-Normal blend modes handle zero
2642            // values through their blend math, not through overprint filtering.
2643            // Includes text glyphs: when overprint is meaningful (the test
2644            // suite's GWG 1.0 swatches f/a use Separation /Magenta + glyphs),
2645            // correctness wins over the slight AA difference vs tiny-skia.
2646            let painted = params.painted_channels;
2647            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2648            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2649            let custom_spot =
2650                painted == 0 && !params.is_device_cmyk && params.color.native_cmyk.is_some();
2651            // A "near-K-only" DeviceCMYK paint under OPM 0 — e.g. `0 0 0 0.5 k`
2652            // — matches the Black-component plate of a DeviceN [Black, spot]
2653            // backdrop exactly. Routing it through the per-pixel path lets the
2654            // no-op-delta skip preserve the spot-derived colour instead of
2655            // wiping it with plain grey (GWG 3.0 "50% K over spot").
2656            let is_k_only_cmyk =
2657                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2658            let needs_overprint = params.overprint
2659                && band_state.cmyk_buffer.is_some()
2660                && params.blend_mode == 0
2661                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2662
2663            if needs_overprint {
2664                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2665                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2666                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2667                render_overprint_fill(
2668                    pixmap,
2669                    &mut cmyk_buf,
2670                    &mut op_bg,
2671                    &mut op_touched,
2672                    &spot_mask,
2673                    band_state,
2674                    path,
2675                    params,
2676                    ctx.vp_x,
2677                    ctx.vp_y,
2678                    ctx.scale_x,
2679                    ctx.scale_y,
2680                    ctx.out_w,
2681                    ctx.out_h,
2682                    ctx.icc,
2683                    ctx.no_aa,
2684                );
2685                band_state.cmyk_buffer = Some(cmyk_buf);
2686                band_state.restore_op_buffers(op_bg, op_touched);
2687                band_state.restore_spot_mask(spot_mask);
2688            } else {
2689                let Some(skia_path) = build_skia_path(path) else {
2690                    return;
2691                };
2692                let mut temp_mask = None;
2693                let Some(mask_ref) = resolve_clip_mask(
2694                    &band_state.clip_region,
2695                    &mut temp_mask,
2696                    ctx.out_w,
2697                    ctx.out_h,
2698                ) else {
2699                    return;
2700                };
2701                let paint =
2702                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2703                let transform = ctx.transform(&params.ctm);
2704
2705                // Detect degenerate fill paths: rectangles/lines with zero extent
2706                // in one dimension. These are commonly used in PDFs to draw table
2707                // grid lines as zero-width or zero-height filled rectangles.
2708                // Since they have no area, fill_path produces nothing. Render them
2709                // as hairline strokes instead.
2710                if is_degenerate_fill(path) {
2711                    let stroke = Stroke {
2712                        width: 1.0,
2713                        ..Stroke::default()
2714                    };
2715                    pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2716                } else {
2717                    let fill_rule = to_fill_rule(&params.fill_rule);
2718                    pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
2719                }
2720
2721                // Update CMYK tracking buffer for non-overprint fills
2722                if band_state.cmyk_buffer.is_some() {
2723                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2724                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2725                    update_cmyk_buffer_for_fill(
2726                        &mut cmyk_buf,
2727                        &mut spot_mask,
2728                        path,
2729                        params,
2730                        ctx.vp_x,
2731                        ctx.vp_y,
2732                        ctx.scale_x,
2733                        ctx.scale_y,
2734                        ctx.out_w,
2735                        ctx.out_h,
2736                        &band_state.clip_region,
2737                        ctx.no_aa,
2738                        ctx.icc,
2739                    );
2740                    band_state.cmyk_buffer = Some(cmyk_buf);
2741                    band_state.restore_spot_mask(spot_mask);
2742                }
2743            }
2744        }
2745        DisplayElement::Stroke { path, params } => {
2746            let mut promoted_stroke: Option<StrokeParams> = None;
2747            let params = maybe_promote_gray_stroke(params, &mut promoted_stroke);
2748            let transform = ctx.transform(&params.ctm);
2749            // Build stroke using the composited transform so hairline width
2750            // calculations account for the actual output resolution.
2751            let vp_ctm = Matrix {
2752                a: transform.sx as f64,
2753                b: transform.ky as f64,
2754                c: transform.kx as f64,
2755                d: transform.sy as f64,
2756                tx: 0.0,
2757                ty: 0.0,
2758            };
2759            let vp_params = StrokeParams {
2760                ctm: vp_ctm,
2761                ..params.clone()
2762            };
2763            let stroke = build_stroke(&vp_params, ctx.effective_dpi);
2764
2765            // Apply stroke adjustment — snap in output device space
2766            let adjusted;
2767            let draw_path = if params.stroke_adjust
2768                && stroke.width <= 2.0
2769                && ctm_is_device_space(&params.ctm)
2770            {
2771                adjusted = stroke_adjust_path_viewport(
2772                    path,
2773                    stroke.width as f64,
2774                    ctx.scale_x as f64,
2775                    ctx.scale_y as f64,
2776                    ctx.vp_x as f64,
2777                    ctx.vp_y as f64,
2778                );
2779                &adjusted
2780            } else {
2781                path
2782            };
2783
2784            // Mirror the Fill gating: per-channel CMYK rendering kicks in for
2785            // subset painted_channels (Separation /Magenta, DeviceN, etc.), for
2786            // DeviceCMYK + OPM 1 (zero-valued source components don't paint),
2787            // or for a custom spot (painted=0, non-CMYK) under overprint — so
2788            // the spot applies multiplicatively to RGB without disturbing the
2789            // process plates. GWG 1.0 swatch a/b/f/g need this for the magenta
2790            // X stroke that overlays the same path the fill already drew.
2791            let painted = params.painted_channels;
2792            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2793            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2794            let custom_spot =
2795                painted == 0 && !params.is_device_cmyk && params.color.native_cmyk.is_some();
2796            let is_k_only_cmyk =
2797                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2798            let needs_overprint = params.overprint
2799                && band_state.cmyk_buffer.is_some()
2800                && params.blend_mode == 0
2801                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2802
2803            let Some(skia_path) = build_skia_path(draw_path) else {
2804                return;
2805            };
2806            let mut temp_mask = None;
2807            let Some(mask_ref) = resolve_clip_mask(
2808                &band_state.clip_region,
2809                &mut temp_mask,
2810                ctx.out_w,
2811                ctx.out_h,
2812            ) else {
2813                return;
2814            };
2815
2816            if needs_overprint {
2817                // Convert the stroke outline to a fill path and route it
2818                // through the same per-channel CMYK compositing logic the
2819                // fill path uses, so the post-overprint result lands in the
2820                // pixmap (not the raw source colour).
2821                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2822                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2823                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2824                render_overprint_stroke(
2825                    pixmap,
2826                    &mut cmyk_buf,
2827                    &mut op_bg,
2828                    &mut op_touched,
2829                    &spot_mask,
2830                    band_state,
2831                    &skia_path,
2832                    &stroke,
2833                    transform,
2834                    params,
2835                    ctx.out_w,
2836                    ctx.out_h,
2837                    ctx.icc,
2838                    ctx.no_aa,
2839                );
2840                band_state.cmyk_buffer = Some(cmyk_buf);
2841                band_state.restore_op_buffers(op_bg, op_touched);
2842                band_state.restore_spot_mask(spot_mask);
2843            } else {
2844                let paint =
2845                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2846                pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2847
2848                if band_state.cmyk_buffer.is_some() {
2849                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2850                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2851                    update_cmyk_buffer_for_stroke(
2852                        &mut cmyk_buf,
2853                        &mut spot_mask,
2854                        draw_path,
2855                        params,
2856                        &stroke,
2857                        transform,
2858                        ctx.out_w,
2859                        ctx.out_h,
2860                        &band_state.clip_region,
2861                        ctx.no_aa,
2862                        ctx.icc,
2863                    );
2864                    band_state.cmyk_buffer = Some(cmyk_buf);
2865                    band_state.restore_spot_mask(spot_mask);
2866                }
2867            }
2868        }
2869        DisplayElement::Clip { path, params } => {
2870            clip_path_unified(band_state, path, params, ctx);
2871        }
2872        DisplayElement::InitClip => {
2873            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2874                band_state.recycle_mask(mask);
2875            }
2876            band_state.clip_region = None;
2877        }
2878        DisplayElement::ErasePage => {
2879            pixmap.fill(Color::TRANSPARENT);
2880            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2881                band_state.recycle_mask(mask);
2882            }
2883            band_state.clip_region = None;
2884        }
2885        DisplayElement::Image {
2886            sample_data,
2887            params,
2888        } => {
2889            let iw = params.width;
2890            let ih = params.height;
2891            if iw == 0 || ih == 0 {
2892                return;
2893            }
2894
2895            let needs_overprint = params.overprint
2896                && band_state.cmyk_buffer.is_some()
2897                && image_supports_overprint(&params.color_space);
2898
2899            if needs_overprint {
2900                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2901                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2902                render_overprint_image(
2903                    pixmap,
2904                    &mut cmyk_buf,
2905                    &mut op_bg,
2906                    &mut op_touched,
2907                    band_state,
2908                    sample_data,
2909                    params,
2910                    ctx.vp_x,
2911                    ctx.vp_y,
2912                    ctx.scale_x,
2913                    ctx.scale_y,
2914                    ctx.out_w,
2915                    ctx.out_h,
2916                    ctx.icc,
2917                );
2918                band_state.cmyk_buffer = Some(cmyk_buf);
2919                band_state.restore_op_buffers(op_bg, op_touched);
2920            } else if let Some(pp) = ctx
2921                .preprocessed
2922                .and_then(|pp| pp.get(ctx.elem_idx))
2923                .and_then(|e| e.as_ref())
2924            {
2925                // Fast path: use pre-converted and prescaled image data.
2926                // Only the per-band translation differs; scale factors are cached.
2927                let Some(image_inv) = params.image_matrix.invert() else {
2928                    return;
2929                };
2930                let combined = params.ctm.concat(&image_inv);
2931                let raw_transform = ctx.transform(&combined);
2932                let transform = Transform::from_row(
2933                    pp.adj_sx,
2934                    pp.adj_ky,
2935                    pp.adj_kx,
2936                    pp.adj_sy,
2937                    raw_transform.tx,
2938                    raw_transform.ty,
2939                );
2940
2941                let Some(img_pixmap) =
2942                    stet_tiny_skia::PixmapRef::from_bytes(&pp.data, pp.width, pp.height)
2943                else {
2944                    return;
2945                };
2946                #[allow(unused_assignments)]
2947                let mut temp_mask = None;
2948                let mask_ref = match &band_state.clip_region {
2949                    None => None,
2950                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
2951                    Some(ClipRegion::Rect(rect)) => {
2952                        if rect.is_empty() {
2953                            return;
2954                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
2955                            None
2956                        } else {
2957                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
2958                            temp_mask.as_ref()
2959                        }
2960                    }
2961                };
2962                let img_paint = stet_tiny_skia::PixmapPaint {
2963                    quality: pp.quality,
2964                    opacity: params.alpha as f32,
2965                    blend_mode: u8_to_blend_mode(params.blend_mode),
2966                };
2967                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
2968
2969                // Update CMYK tracking buffer for non-overprint images on the
2970                // fast path. Reading from the post-draw pixmap means the same
2971                // helper handles native-CMYK and non-CMYK source images, even
2972                // though `pp.data` is prescaled and we no longer have a
2973                // matching native RGBA buffer.
2974                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
2975                    update_cmyk_buffer_for_image(
2976                        cmyk_buf,
2977                        sample_data,
2978                        pixmap.data(),
2979                        params,
2980                        ctx.vp_x,
2981                        ctx.vp_y,
2982                        ctx.scale_x,
2983                        ctx.scale_y,
2984                        ctx.out_w,
2985                        ctx.out_h,
2986                        &band_state.clip_region,
2987                        ctx.icc,
2988                    );
2989                }
2990            } else {
2991                // Use pre-converted RGBA from image cache when available
2992                let owned_rgba;
2993                let rgba_data: &[u8] = if let Some(cached) =
2994                    ctx.image_cache.and_then(|c| c.get(ctx.elem_idx))
2995                {
2996                    cached
2997                } else {
2998                    owned_rgba = {
2999                        let mut rgba =
3000                            samples_to_rgba(sample_data, params, ctx.icc, ctx.opm_zero_transparent);
3001                        if params.mask_color.is_some() {
3002                            apply_mask_color_rgba(&mut rgba, sample_data, params);
3003                        }
3004                        rgba
3005                    };
3006                    &owned_rgba
3007                };
3008                let expected = (iw * ih * 4) as usize;
3009                if rgba_data.len() < expected {
3010                    return;
3011                }
3012                let Some(image_inv) = params.image_matrix.invert() else {
3013                    return;
3014                };
3015                let combined = params.ctm.concat(&image_inv);
3016                let raw_transform = enforce_min_image_size(ctx.transform(&combined), iw, ih);
3017
3018                // Pre-scale images that are being downscaled. Even non-interpolated
3019                // images need proper area averaging when shrinking — "no interpolation"
3020                // means don't smooth when *upscaling*, but downscaling without averaging
3021                // produces aliased garbage.
3022                let prescaled =
3023                    prescale_image(rgba_data, iw, ih, raw_transform, params.interpolate);
3024                let (img_data, img_w, img_h, transform) = match &prescaled {
3025                    Some((data, w, h, t)) => (data.as_slice(), *w, *h, *t),
3026                    None => (rgba_data, iw, ih, raw_transform),
3027                };
3028
3029                let Some(img_pixmap) =
3030                    stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h)
3031                else {
3032                    return;
3033                };
3034                #[allow(unused_assignments)]
3035                let mut temp_mask = None;
3036                let mask_ref = match &band_state.clip_region {
3037                    None => None,
3038                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
3039                    Some(ClipRegion::Rect(rect)) => {
3040                        if rect.is_empty() {
3041                            return;
3042                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
3043                            None
3044                        } else {
3045                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
3046                            temp_mask.as_ref()
3047                        }
3048                    }
3049                };
3050                let img_paint = stet_tiny_skia::PixmapPaint {
3051                    quality: image_filter_quality(transform, params.interpolate),
3052                    opacity: params.alpha as f32,
3053                    blend_mode: u8_to_blend_mode(params.blend_mode),
3054                };
3055                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
3056
3057                // Update CMYK tracking buffer for non-overprint images. Sample
3058                // the now-composited pixmap so non-CMYK source images can be
3059                // reverse-converted to CMYK via the system profile.
3060                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
3061                    update_cmyk_buffer_for_image(
3062                        cmyk_buf,
3063                        sample_data,
3064                        pixmap.data(),
3065                        params,
3066                        ctx.vp_x,
3067                        ctx.vp_y,
3068                        ctx.scale_x,
3069                        ctx.scale_y,
3070                        ctx.out_w,
3071                        ctx.out_h,
3072                        &band_state.clip_region,
3073                        ctx.icc,
3074                    );
3075                }
3076            }
3077        }
3078        DisplayElement::AxialShading { params } => {
3079            let mut temp_mask = None;
3080            let Some(mask_ref) = resolve_clip_mask(
3081                &band_state.clip_region,
3082                &mut temp_mask,
3083                ctx.out_w,
3084                ctx.out_h,
3085            ) else {
3086                return;
3087            };
3088            render_axial_shading(
3089                pixmap,
3090                params,
3091                ctx.vp_x,
3092                ctx.vp_y,
3093                ctx.scale_x,
3094                ctx.scale_y,
3095                mask_ref,
3096                ctx.no_aa,
3097                band_state.cmyk_buffer.as_deref_mut(),
3098                ctx.icc,
3099            );
3100        }
3101        DisplayElement::RadialShading { params } => {
3102            let mut temp_mask = None;
3103            let Some(mask_ref) = resolve_clip_mask(
3104                &band_state.clip_region,
3105                &mut temp_mask,
3106                ctx.out_w,
3107                ctx.out_h,
3108            ) else {
3109                return;
3110            };
3111            render_radial_shading(
3112                pixmap,
3113                params,
3114                ctx.vp_x,
3115                ctx.vp_y,
3116                ctx.scale_x,
3117                ctx.scale_y,
3118                mask_ref,
3119                ctx.no_aa,
3120                band_state.cmyk_buffer.as_deref_mut(),
3121                ctx.icc,
3122            );
3123        }
3124        DisplayElement::MeshShading { params } => {
3125            let mut temp_mask = None;
3126            let Some(mask_ref) = resolve_clip_mask(
3127                &band_state.clip_region,
3128                &mut temp_mask,
3129                ctx.out_w,
3130                ctx.out_h,
3131            ) else {
3132                return;
3133            };
3134            render_mesh_shading(
3135                pixmap,
3136                params,
3137                ctx.vp_x,
3138                ctx.vp_y,
3139                ctx.scale_x,
3140                ctx.scale_y,
3141                mask_ref,
3142                band_state.cmyk_buffer.as_deref_mut(),
3143                ctx.icc,
3144            );
3145        }
3146        DisplayElement::PatchShading { params } => {
3147            let mut temp_mask = None;
3148            let Some(mask_ref) = resolve_clip_mask(
3149                &band_state.clip_region,
3150                &mut temp_mask,
3151                ctx.out_w,
3152                ctx.out_h,
3153            ) else {
3154                return;
3155            };
3156            render_patch_shading(
3157                pixmap,
3158                params,
3159                ctx.vp_x,
3160                ctx.vp_y,
3161                ctx.scale_x,
3162                ctx.scale_y,
3163                mask_ref,
3164                band_state.cmyk_buffer.as_deref_mut(),
3165                ctx.icc,
3166            );
3167        }
3168        DisplayElement::PatternFill { params } => {
3169            render_pattern_fill(pixmap, band_state, params, ctx);
3170        }
3171        DisplayElement::Group { elements, params } => {
3172            render_group(pixmap, band_state, elements, params, ctx);
3173        }
3174        DisplayElement::SoftMasked {
3175            mask,
3176            content,
3177            params,
3178            mask_cache,
3179        } => {
3180            render_soft_masked(pixmap, band_state, mask, content, params, mask_cache, ctx);
3181        }
3182        DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
3183        DisplayElement::OcgGroup {
3184            elements,
3185            default_visible,
3186            ..
3187        } => {
3188            // Visible groups render every child. OFF-by-default groups still
3189            // apply Clip/InitClip so the band's clip state stays in sync —
3190            // otherwise a transient clip from the previous group would leak
3191            // into the next visible one. Paint ops are skipped; that's what
3192            // "hidden layer" means.
3193            let visible = *default_visible;
3194            for (idx, elem) in elements.elements().iter().enumerate() {
3195                if !visible
3196                    && !matches!(elem, DisplayElement::Clip { .. } | DisplayElement::InitClip)
3197                {
3198                    continue;
3199                }
3200                let elem_ctx = RenderContext {
3201                    elem_idx: idx,
3202                    ..*ctx
3203                };
3204                render_element(pixmap, band_state, elem, &elem_ctx);
3205            }
3206        }
3207    }
3208}
3209
3210/// Compute the cropped output-pixel region for a group's device-space bounding box.
3211///
3212/// Returns `(crop_x, crop_y, crop_w, crop_h)` in output pixels, or `None` if
3213/// the group is entirely outside the viewport or cropping isn't worthwhile.
3214fn compute_group_crop(bbox: &[f64; 4], ctx: &RenderContext<'_>) -> Option<(i32, i32, u32, u32)> {
3215    // Transform device-space bbox to output pixel coords
3216    let px_min = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
3217    let py_min = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
3218    let px_max = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
3219    let py_max = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
3220
3221    // Clip to output bounds
3222    let x0 = px_min.max(0);
3223    let y0 = py_min.max(0);
3224    let x1 = px_max.min(ctx.out_w as i32);
3225    let y1 = py_max.min(ctx.out_h as i32);
3226
3227    if x0 >= x1 || y0 >= y1 {
3228        return None;
3229    }
3230
3231    let crop_w = (x1 - x0) as u32;
3232    let crop_h = (y1 - y0) as u32;
3233
3234    // Only crop if it saves at least 25% of pixels
3235    let crop_pixels = crop_w as u64 * crop_h as u64;
3236    let full_pixels = ctx.out_w as u64 * ctx.out_h as u64;
3237    if crop_pixels * 4 >= full_pixels * 3 {
3238        return None;
3239    }
3240
3241    Some((x0, y0, crop_w, crop_h))
3242}
3243
3244/// Apply a separable PDF blend mode in DeviceCMYK using the spec's "effective"
3245/// inversion convention (PDF 1.7 §11.3.5.2): the inverse value `1−c` is used as
3246/// input to the RGB-style blend function, and the result is inverted back.
3247fn blend_cmyk_separable_channel(cb: f64, cs: f64, mode: u8) -> f64 {
3248    let cbi = 1.0 - cb;
3249    let csi = 1.0 - cs;
3250    let result_inv = match mode {
3251        1 => cbi * csi,             // Multiply
3252        2 => cbi + csi - cbi * csi, // Screen
3253        3 => {
3254            // Overlay(b, s) = HardLight(s, b)
3255            if cbi <= 0.5 {
3256                2.0 * cbi * csi
3257            } else {
3258                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3259            }
3260        }
3261        4 => cbi.min(csi), // Darken
3262        5 => cbi.max(csi), // Lighten
3263        6 => {
3264            // ColorDodge
3265            if csi >= 1.0 {
3266                1.0
3267            } else {
3268                (cbi / (1.0 - csi)).min(1.0)
3269            }
3270        }
3271        7 => {
3272            // ColorBurn
3273            if csi <= 0.0 {
3274                0.0
3275            } else {
3276                1.0 - ((1.0 - cbi) / csi).min(1.0)
3277            }
3278        }
3279        8 => {
3280            // HardLight
3281            if csi <= 0.5 {
3282                2.0 * cbi * csi
3283            } else {
3284                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3285            }
3286        }
3287        9 => {
3288            // SoftLight (Adobe formulation)
3289            let d = if cbi <= 0.25 {
3290                ((16.0 * cbi - 12.0) * cbi + 4.0) * cbi
3291            } else {
3292                cbi.sqrt()
3293            };
3294            if csi <= 0.5 {
3295                cbi - (1.0 - 2.0 * csi) * cbi * (1.0 - cbi)
3296            } else {
3297                cbi + (2.0 * csi - 1.0) * (d - cbi)
3298            }
3299        }
3300        10 => (cbi - csi).abs(),           // Difference
3301        11 => cbi + csi - 2.0 * cbi * csi, // Exclusion
3302        _ => csi,                          // Normal/fallback
3303    };
3304    1.0 - result_inv.clamp(0.0, 1.0)
3305}
3306
3307/// Apply a non-separable HSL-style PDF blend mode (Hue, Saturation, Color,
3308/// Luminosity) in DeviceCMYK. Per the spec, the inverted CMY components are
3309/// treated as "effective RGB" and the standard non-separable formulas are
3310/// applied; the K channel is taken from the source (it acts as the source's
3311/// luminosity contribution for the purposes of the blend).
3312fn blend_cmyk_nonseparable(cb: [f64; 4], cs: [f64; 4], mode: u8) -> [f64; 4] {
3313    fn lum(c: [f64; 3]) -> f64 {
3314        0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
3315    }
3316    fn clip_color(mut c: [f64; 3]) -> [f64; 3] {
3317        let l = lum(c);
3318        let n = c[0].min(c[1]).min(c[2]);
3319        let x = c[0].max(c[1]).max(c[2]);
3320        if n < 0.0 {
3321            for ci in c.iter_mut() {
3322                *ci = l + (*ci - l) * l / (l - n);
3323            }
3324        }
3325        if x > 1.0 {
3326            for ci in c.iter_mut() {
3327                *ci = l + (*ci - l) * (1.0 - l) / (x - l);
3328            }
3329        }
3330        c
3331    }
3332    fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
3333        let d = l - lum(c);
3334        clip_color([c[0] + d, c[1] + d, c[2] + d])
3335    }
3336    fn sat(c: [f64; 3]) -> f64 {
3337        c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
3338    }
3339    fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
3340        // Index components by rank: min, mid, max.
3341        let mut idx = [0usize, 1, 2];
3342        idx.sort_by(|a, b| {
3343            c[*a]
3344                .partial_cmp(&c[*b])
3345                .unwrap_or(std::cmp::Ordering::Equal)
3346        });
3347        let (i_min, i_mid, i_max) = (idx[0], idx[1], idx[2]);
3348        let mut out = c;
3349        if c[i_max] > c[i_min] {
3350            out[i_mid] = (c[i_mid] - c[i_min]) * s / (c[i_max] - c[i_min]);
3351            out[i_max] = s;
3352        } else {
3353            out[i_mid] = 0.0;
3354            out[i_max] = 0.0;
3355        }
3356        out[i_min] = 0.0;
3357        out
3358    }
3359
3360    let cb_rgb = [1.0 - cb[0], 1.0 - cb[1], 1.0 - cb[2]];
3361    let cs_rgb = [1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2]];
3362    let result_rgb = match mode {
3363        12 => set_lum(set_sat(cs_rgb, sat(cb_rgb)), lum(cb_rgb)), // Hue
3364        13 => set_lum(set_sat(cb_rgb, sat(cs_rgb)), lum(cb_rgb)), // Saturation
3365        14 => set_lum(cs_rgb, lum(cb_rgb)),                       // Color
3366        15 => set_lum(cb_rgb, lum(cs_rgb)),                       // Luminosity
3367        _ => cs_rgb,
3368    };
3369    // Hue/Saturation/Color preserve the backdrop's luminosity, which in CMYK
3370    // is carried primarily by the K channel. Luminosity transfers the source's
3371    // luminosity, so it takes K from the source.
3372    let result_k = if mode == 15 { cs[3] } else { cb[3] };
3373    [
3374        (1.0 - result_rgb[0]).clamp(0.0, 1.0),
3375        (1.0 - result_rgb[1]).clamp(0.0, 1.0),
3376        (1.0 - result_rgb[2]).clamp(0.0, 1.0),
3377        result_k,
3378    ]
3379}
3380
3381/// Render a transparency group into a pixmap.
3382/// Device-space axis-aligned bbox of a path, computed from its segment
3383/// endpoints and curve control points. Returned as (x0, y0, x1, y1) with
3384/// x0 ≤ x1, y0 ≤ y1. Returns `None` for an empty path.
3385fn ps_path_bbox(path: &PsPath) -> Option<(f64, f64, f64, f64)> {
3386    let mut it = path.segments.iter().filter_map(|seg| match *seg {
3387        PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => Some(vec![(x, y)]),
3388        PathSegment::CurveTo {
3389            x1,
3390            y1,
3391            x2,
3392            y2,
3393            x3,
3394            y3,
3395        } => Some(vec![(x1, y1), (x2, y2), (x3, y3)]),
3396        PathSegment::ClosePath => None,
3397    });
3398    let first = it.next()?.into_iter().next()?;
3399    let (mut x0, mut y0) = first;
3400    let (mut x1, mut y1) = first;
3401    for seg_points in std::iter::once(vec![first]).chain(it) {
3402        for (x, y) in seg_points {
3403            x0 = x0.min(x);
3404            y0 = y0.min(y);
3405            x1 = x1.max(x);
3406            y1 = y1.max(y);
3407        }
3408    }
3409    Some((x0, y0, x1, y1))
3410}
3411
3412/// True when rectangle `inner` fits inside `outer` with `tolerance` slack
3413/// (positive tolerance = inner may protrude by up to `tolerance` units).
3414fn bbox_contains(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64), tolerance: f64) -> bool {
3415    inner.0 >= outer.0 - tolerance
3416        && inner.1 >= outer.1 - tolerance
3417        && inner.2 <= outer.2 + tolerance
3418        && inner.3 <= outer.3 + tolerance
3419}
3420
3421/// Detect the GWG "reference-under-test" authoring pattern: a parent Fill
3422/// that will be fully covered by the first Fill of a following isolated
3423/// transparency group. When detected, the parent's Fill can be skipped —
3424/// its AA edges otherwise bleed into the dest under the group's partial-
3425/// alpha source during composite-back, producing a visible outline where
3426/// Acrobat shows none (see GWG 16.2 Opacity(0%) analysis in
3427/// `project_icc_profile_stability.md`).
3428///
3429/// Returns indices in `elements` that should be skipped. Safety conditions:
3430///   1. Parent fill is fully opaque, Normal blend.
3431///   2. Next paint (ignoring Clip/InitClip) is an isolated, alpha-1,
3432///      Normal-blend Group whose first paint is a Fill with matching
3433///      path (within tolerance) and the same opacity/blend conditions.
3434///   3. The group's declared bbox fully contains the parent path's bbox
3435///      — i.e. the form's own BBox clip won't carve the fill away.
3436///   4. Every Clip element between the parent fill and the group, and
3437///      every Clip between the group's start and its first fill, has a
3438///      bbox that also fully contains the parent path — so no additional
3439///      clip can cut the group's first fill to a subset of the parent's
3440///      extent.
3441///   5. PDF's isolated transparency semantics guarantee that once the
3442///      first fill establishes alpha=1 at the parent-path pixels, later
3443///      Normal-blend paints can only add colour there; alpha can't
3444///      decrease. So nothing in the group's tail can re-expose backdrop,
3445///      even without auditing those elements explicitly.
3446fn compute_obscured_fill_skips(elements: &DisplayList) -> Vec<usize> {
3447    let mut skips = Vec::new();
3448    let els = elements.elements();
3449    for i in 0..els.len() {
3450        let DisplayElement::Fill {
3451            path: parent_path,
3452            params: parent_params,
3453        } = &els[i]
3454        else {
3455            continue;
3456        };
3457        if (parent_params.alpha - 1.0).abs() > 1e-6 || parent_params.blend_mode != 0 {
3458            continue;
3459        }
3460        let Some(parent_bbox) = ps_path_bbox(parent_path) else {
3461            continue;
3462        };
3463        // Walk forward past Clip/InitClip between parent fill and the
3464        // group. Each such clip must contain the parent's extent; any
3465        // other element type ends the scan.
3466        let mut j = i + 1;
3467        let mut clips_ok = true;
3468        while j < els.len() {
3469            match &els[j] {
3470                DisplayElement::InitClip => {}
3471                DisplayElement::Clip {
3472                    path: clip_path, ..
3473                } => match ps_path_bbox(clip_path) {
3474                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3475                    _ => {
3476                        clips_ok = false;
3477                        break;
3478                    }
3479                },
3480                _ => break,
3481            }
3482            j += 1;
3483        }
3484        if !clips_ok {
3485            continue;
3486        }
3487        let Some(DisplayElement::Group {
3488            elements: group_elements,
3489            params: group_params,
3490        }) = els.get(j)
3491        else {
3492            continue;
3493        };
3494        if !group_params.isolated
3495            || (group_params.alpha - 1.0).abs() > 1e-6
3496            || group_params.blend_mode != 0
3497        {
3498            continue;
3499        }
3500        // The form's declared BBox acts as a clip inside the group; the
3501        // parent's fill must fit inside it or the group's output will be
3502        // carved away where we'd rely on coverage.
3503        let group_bbox = (
3504            group_params.bbox[0],
3505            group_params.bbox[1],
3506            group_params.bbox[2],
3507            group_params.bbox[3],
3508        );
3509        if !bbox_contains(group_bbox, parent_bbox, 0.5) {
3510            continue;
3511        }
3512        // Walk past Clip/InitClip inside the group to its first paint,
3513        // requiring each clip to contain the parent's extent.
3514        let inner_els = group_elements.elements();
3515        let mut k = 0;
3516        let mut inner_clips_ok = true;
3517        while k < inner_els.len() {
3518            match &inner_els[k] {
3519                DisplayElement::InitClip => {}
3520                DisplayElement::Clip {
3521                    path: clip_path, ..
3522                } => match ps_path_bbox(clip_path) {
3523                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3524                    _ => {
3525                        inner_clips_ok = false;
3526                        break;
3527                    }
3528                },
3529                _ => break,
3530            }
3531            k += 1;
3532        }
3533        if !inner_clips_ok {
3534            continue;
3535        }
3536        let Some(DisplayElement::Fill {
3537            path: group_path,
3538            params: group_fill_params,
3539        }) = inner_els.get(k)
3540        else {
3541            continue;
3542        };
3543        if (group_fill_params.alpha - 1.0).abs() > 1e-6 || group_fill_params.blend_mode != 0 {
3544            continue;
3545        }
3546        if paths_approximately_equal(parent_path, group_path, 0.5) {
3547            skips.push(i);
3548        }
3549    }
3550    skips
3551}
3552
3553/// True when two device-space paths have the same segment sequence and
3554/// matching endpoints within `tolerance` device pixels per coordinate.
3555/// Used by `compute_obscured_fill_skips` to recognise PDF-authored patterns
3556/// where the same logical X path is emitted twice with sub-unit rounding
3557/// differences (GWG test suite authoring style from InDesign CS6).
3558fn paths_approximately_equal(a: &PsPath, b: &PsPath, tolerance: f64) -> bool {
3559    if a.segments.len() != b.segments.len() {
3560        return false;
3561    }
3562    for (sa, sb) in a.segments.iter().zip(b.segments.iter()) {
3563        let close_pair = |(x1, y1): (f64, f64), (x2, y2): (f64, f64)| -> bool {
3564            (x1 - x2).abs() <= tolerance && (y1 - y2).abs() <= tolerance
3565        };
3566        match (sa, sb) {
3567            (PathSegment::MoveTo(x1, y1), PathSegment::MoveTo(x2, y2)) => {
3568                if !close_pair((*x1, *y1), (*x2, *y2)) {
3569                    return false;
3570                }
3571            }
3572            (PathSegment::LineTo(x1, y1), PathSegment::LineTo(x2, y2)) => {
3573                if !close_pair((*x1, *y1), (*x2, *y2)) {
3574                    return false;
3575                }
3576            }
3577            (
3578                PathSegment::CurveTo {
3579                    x1: ax1,
3580                    y1: ay1,
3581                    x2: ax2,
3582                    y2: ay2,
3583                    x3: ax3,
3584                    y3: ay3,
3585                },
3586                PathSegment::CurveTo {
3587                    x1: bx1,
3588                    y1: by1,
3589                    x2: bx2,
3590                    y2: by2,
3591                    x3: bx3,
3592                    y3: by3,
3593                },
3594            ) => {
3595                if !close_pair((*ax1, *ay1), (*bx1, *by1))
3596                    || !close_pair((*ax2, *ay2), (*bx2, *by2))
3597                    || !close_pair((*ax3, *ay3), (*bx3, *by3))
3598                {
3599                    return false;
3600                }
3601            }
3602            (PathSegment::ClosePath, PathSegment::ClosePath) => {}
3603            _ => return false,
3604        }
3605    }
3606    true
3607}
3608
3609///
3610/// Creates an offscreen pixmap, renders the group's child elements into it,
3611/// then composites back onto the parent with the group's blend mode and alpha.
3612fn render_group(
3613    pixmap: &mut Pixmap,
3614    band_state: &mut BandState,
3615    elements: &DisplayList,
3616    params: &stet_graphics::display_list::GroupParams,
3617    ctx: &RenderContext<'_>,
3618) {
3619    if params.knockout {
3620        render_knockout_group(pixmap, band_state, elements, params, ctx);
3621        return;
3622    }
3623
3624    let crop = compute_group_crop(&params.bbox, ctx);
3625
3626    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
3627        Some((cx, cy, cw, ch)) => (
3628            cw,
3629            ch,
3630            cx,
3631            cy,
3632            ctx.vp_x + cx as f32 / ctx.scale_x,
3633            ctx.vp_y + cy as f32 / ctx.scale_y,
3634        ),
3635        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
3636    };
3637
3638    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
3639        return;
3640    };
3641
3642    // Decide upfront whether the composite-back will run in CMYK. The CMYK
3643    // path needs the parent backdrop pre-loaded into the offscreen so that
3644    // per-element painting accumulates in the right starting state. The
3645    // sRGB contribution-extraction path renders against an empty offscreen
3646    // for non-Normal BMs to avoid anti-aliased clip artifacts at the BBox
3647    // edges (the diff-against-backdrop logic mishandles partially-blended
3648    // edge pixels otherwise).
3649    use stet_graphics::display_list::GroupColorSpace;
3650
3651    // Allocate a CMYK buffer for the group when:
3652    //   - it tracks overprint, OR
3653    //   - the parent already has one (CMYK context inheritance), OR
3654    //   - this group itself or one of its descendants declares an explicit
3655    //     `/CS DeviceCMYK`, meaning compositing within it needs CMYK math.
3656    let needs_group_cmyk = has_overprint_elements(elements)
3657        || band_state.cmyk_buffer.is_some()
3658        || params.color_space == GroupColorSpace::DeviceCMYK
3659        || has_cmyk_group(elements);
3660
3661    // Decide whether to run the per-pixel CMYK composite-back. The default
3662    // (gated) rule restricts it to the cases the prior rendering session
3663    // explicitly validated. The `STET_FORCE_CMYK_COMPOSITE_BACK=1` env var
3664    // bypasses both gates and switches to the principled rule that the rest
3665    // of this plan will adopt — useful for A/B-comparing the broader fix
3666    // before flipping the default in Step 9.
3667    let force_cmyk_compose =
3668        std::env::var_os("STET_FORCE_CMYK_COMPOSITE_BACK").as_deref() == Some("1".as_ref());
3669    // The knockout group's coverage pass disables CMYK composite-back so the
3670    // painter falls through to the simple sRGB draw_pixmap path. Without this,
3671    // a white-source painter (CMYK 0,0,0,0) would be skipped by the
3672    // composite-back's "source==backdrop" guard against the transparent
3673    // coverage backdrop, and pass 2 wouldn't capture the painter's coverage.
3674    //
3675    // The color pass widens the gate to all non-Normal blend modes so a
3676    // `/CS DeviceCMYK` knockout group's painters with separable blends like
3677    // Screen / ColorDodge / Overlay / SoftLight blend in CMYK math (matching
3678    // the spec) instead of in tiny-skia's sRGB blend.
3679    let plan_cmyk_compose = match ctx.knockout_painter_pass {
3680        KnockoutPainterPass::CoveragePass => false,
3681        KnockoutPainterPass::ColorPass => {
3682            !params.isolated
3683                && params.blend_mode != 0
3684                && needs_group_cmyk
3685                && band_state.cmyk_buffer.is_some()
3686                && group_content_is_native_cmyk(elements)
3687        }
3688        KnockoutPainterPass::None if force_cmyk_compose => {
3689            // Principled rule: non-isolated group with an inversion-sensitive
3690            // blend mode (Difference, Exclusion, Hue, Saturation, Color,
3691            // Luminosity) whose painters all supply native CMYK source colors.
3692            //
3693            // The blend-mode restriction is intentional: bm 10..=15 produce
3694            // visibly *wrong* results in sRGB (the GWG 16.0 transparency test
3695            // exists exactly to expose this), so CMYK math is unambiguously
3696            // correct there. The separable modes 1..=9 (Multiply, Screen, etc.)
3697            // are spec-defensible in either color space but look noticeably
3698            // different — most renderers blend them in sRGB, and PDFs authored
3699            // for that look "wrong" if we suddenly switch them to CMYK math.
3700            //
3701            // The painter-set restriction (no shadings, no non-CMYK content)
3702            // exists because the parallel CMYK buffer can only faithfully track
3703            // single-CMYK-value-per-pixel painters; gradients interpolate
3704            // differently in pixmap RGB vs buffer CMYK and the divergence makes
3705            // the composite-back read stale source values.
3706            !params.isolated
3707                && matches!(params.blend_mode, 10..=15)
3708                && needs_group_cmyk
3709                && band_state.cmyk_buffer.is_some()
3710                && group_content_is_native_cmyk(elements)
3711        }
3712        KnockoutPainterPass::None => {
3713            // Default rule: only the inversion-sensitive blend modes
3714            // (Difference, Exclusion, HSL non-separable) need CMYK math; the
3715            // separable modes 1..=9 are spec-defensible in either color space
3716            // and most sRGB-authored PDFs expect them to blend in sRGB.
3717            let inversion_sensitive = !params.isolated
3718                && matches!(params.blend_mode, 10..=15)
3719                && group_only_native_cmyk_fills(elements);
3720            // GWG 16.2 ("Transparency Basic Blend Modes — DeviceCMYK,
3721            // Isolated") nests non-isolated `/CS DeviceCMYK` painter sub-groups
3722            // inside an isolated `/CS DeviceCMYK` group, with the swatch's
3723            // blend mode applied at the inner Do. Per PDF spec §11.6.7 the
3724            // compositing for those inner groups must happen in DeviceCMYK,
3725            // not sRGB — otherwise their colored X-shape produces the wrong
3726            // color and fails to cover the painter-A black X. The explicit
3727            // `/CS DeviceCMYK` declaration plus the isolated parent are the
3728            // spec signal that the author wants CMYK-space compositing for
3729            // a fresh transparent backdrop. The `parent_group_isolated`
3730            // gate keeps the rule from firing for non-isolated parents like
3731            // 907 page 28's chart panels, where the existing sRGB
3732            // contribution-extraction path correctly preserves anti-aliased
3733            // gray strokes.
3734            let cmyk_group_blend = !params.isolated
3735                && ctx.parent_group_isolated
3736                && params.blend_mode != 0
3737                && params.color_space == GroupColorSpace::DeviceCMYK
3738                && needs_group_cmyk
3739                && band_state.cmyk_buffer.is_some()
3740                && group_content_is_native_cmyk(elements);
3741            inversion_sensitive || cmyk_group_blend
3742        }
3743    };
3744    // Non-isolated groups with non-Normal blend modes on the sRGB path
3745    // need a two-pass render: once against the backdrop (for correct
3746    // internal blending) and once against transparent (to extract the
3747    // group's shape/alpha for the proper source-contribution formula).
3748    let needs_alpha_extraction = !params.isolated
3749        && params.blend_mode != 0
3750        && !plan_cmyk_compose
3751        && !ctx.alpha_extraction_pass;
3752    let needs_backdrop_preload =
3753        !params.isolated && (params.blend_mode == 0 || plan_cmyk_compose || needs_alpha_extraction);
3754    let backdrop = if needs_backdrop_preload {
3755        let data = if crop.is_some() {
3756            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
3757        } else {
3758            pixmap.data().to_vec()
3759        };
3760        offscreen.data_mut().copy_from_slice(&data);
3761        Some(data)
3762    } else {
3763        None
3764    };
3765    let group_cmyk = if needs_group_cmyk {
3766        let buf_size = eff_w as usize * eff_h as usize * 4;
3767        let mut buf = vec![0.0f32; buf_size];
3768        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
3769            let parent_stride = ctx.out_w as usize * 4;
3770            let group_stride = eff_w as usize * 4;
3771            for gy in 0..eff_h as usize {
3772                let py = crop_y as usize + gy;
3773                if py < ctx.out_h as usize {
3774                    let p_start = py * parent_stride + crop_x as usize * 4;
3775                    let g_start = gy * group_stride;
3776                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
3777                    buf[g_start..g_start + copy_len]
3778                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
3779                }
3780            }
3781        }
3782        Some(buf)
3783    } else {
3784        None
3785    };
3786
3787    // Snapshot the pre-load CMYK so the composite-back can identify pixels
3788    // the group actually modified. Without a separate snapshot we'd have to
3789    // diff against the parent CMYK buffer, which would lose any in-place
3790    // updates to the parent across the group's lifetime.
3791    let backdrop_cmyk: Option<Vec<f32>> = if !params.isolated {
3792        group_cmyk.clone()
3793    } else {
3794        None
3795    };
3796
3797    let mut group_band = BandState {
3798        clip_region: None,
3799        spare_mask: None,
3800        clip_mask_cache: HashMap::new(),
3801        clip_mask_seen: HashSet::new(),
3802        mask_pool: Vec::new(),
3803        cmyk_buffer: group_cmyk,
3804        op_bg_snapshot: None,
3805        op_touched: None,
3806        spot_mask: None,
3807    };
3808
3809    let group_ctx = RenderContext {
3810        vp_x: eff_vp_x,
3811        vp_y: eff_vp_y,
3812        scale_x: ctx.scale_x,
3813        scale_y: ctx.scale_y,
3814        out_w: eff_w,
3815        out_h: eff_h,
3816        effective_dpi: ctx.effective_dpi,
3817        icc: ctx.icc,
3818        image_cache: None, // Group elements don't use parent image cache
3819        preprocessed: None,
3820        elem_idx: 0,
3821        no_aa: ctx.no_aa,
3822        opm_zero_transparent: ctx.opm_zero_transparent,
3823        knockout_painter_pass: ctx.knockout_painter_pass,
3824        // The children of this group see *this* group as their parent.
3825        parent_group_isolated: params.isolated,
3826        alpha_extraction_pass: ctx.alpha_extraction_pass,
3827    };
3828
3829    let skip_indices = compute_obscured_fill_skips(elements);
3830    for (idx, elem) in elements.elements().iter().enumerate() {
3831        if skip_indices.contains(&idx) {
3832            continue;
3833        }
3834        let elem_ctx = RenderContext {
3835            elem_idx: idx,
3836            ..group_ctx
3837        };
3838        render_element(&mut offscreen, &mut group_band, elem, &elem_ctx);
3839    }
3840
3841    // Second pass: render against transparent to extract the group's
3842    // shape/alpha.  Only needed for the sRGB two-pass composite-back
3843    // path (non-isolated, non-Normal blend, no CMYK compose).
3844    let alpha_offscreen = if needs_alpha_extraction {
3845        let mut iso = Pixmap::new(eff_w, eff_h);
3846        if let Some(ref mut iso_pm) = iso {
3847            let mut iso_band = BandState {
3848                clip_region: None,
3849                spare_mask: None,
3850                clip_mask_cache: HashMap::new(),
3851                clip_mask_seen: HashSet::new(),
3852                mask_pool: Vec::new(),
3853                cmyk_buffer: None,
3854                op_bg_snapshot: None,
3855                op_touched: None,
3856                spot_mask: None,
3857            };
3858            let iso_ctx = RenderContext {
3859                parent_group_isolated: true,
3860                alpha_extraction_pass: true,
3861                ..group_ctx
3862            };
3863            for (idx, elem) in elements.elements().iter().enumerate() {
3864                let elem_ctx = RenderContext {
3865                    elem_idx: idx,
3866                    ..iso_ctx
3867                };
3868                render_element(iso_pm, &mut iso_band, elem, &elem_ctx);
3869            }
3870        }
3871        iso
3872    } else {
3873        None
3874    };
3875
3876    let mut temp_mask = None;
3877    let mask_ref = match resolve_clip_mask(
3878        &band_state.clip_region,
3879        &mut temp_mask,
3880        ctx.out_w,
3881        ctx.out_h,
3882    ) {
3883        None => return, // empty clip → nothing visible
3884        Some(m) => m,
3885    };
3886
3887    // Coverage pass override: force opacity 1.0 + Normal blend so the
3888    // painter's shape reaches the coverage offscreen even when the
3889    // original alpha was 0 (Opacity 0% test) or the blend mode would
3890    // erase the source against the transparent coverage backdrop.
3891    let coverage_params;
3892    let effective_params: &stet_graphics::display_list::GroupParams =
3893        if ctx.knockout_painter_pass == KnockoutPainterPass::CoveragePass {
3894            coverage_params = stet_graphics::display_list::GroupParams {
3895                alpha: 1.0,
3896                blend_mode: 0,
3897                ..params.clone()
3898            };
3899            &coverage_params
3900        } else {
3901            params
3902        };
3903
3904    let mut cmyk_compose_done = false;
3905    if let Some(backdrop) = &backdrop {
3906        // Non-isolated group. For the inversion-sensitive blend modes
3907        // (Difference, Exclusion) and the HSL non-separable modes (Hue,
3908        // Saturation, Color, Luminosity), tiny-skia's sRGB blend math gives
3909        // visibly wrong results for the GWG 16.0 transparency test, where
3910        // the source colors are chosen so that, in CMYK, the blend produces
3911        // the backdrop color exactly. Run the composite-back per pixel in
3912        // CMYK for those modes when the inner content is exclusively
3913        // native-CMYK fills (so the inner CMYK buffer faithfully represents
3914        // the source). The other separable modes (Multiply / Lighten /
3915        // Darken / etc.) and non-CMYK content stay on the existing sRGB
3916        // contribution-extraction path because their CMYK pipeline currently
3917        // depends on `interpolate_cmyk_from_stops`, which derives CMYK from
3918        // sRGB via the lossy `(1−r,1−g,1−b,0)` inverse for shadings/images
3919        // and would shift their colors. Lifting that restriction requires
3920        // computing exact CMYK from each shading/image's source color space
3921        // (e.g. running the DeviceN tint transform), which is a larger
3922        // change than this fix attempts.
3923        let inner_cmyk = group_band.cmyk_buffer.as_deref();
3924        let pre_cmyk = backdrop_cmyk.as_deref();
3925        if plan_cmyk_compose && let (Some(inner), Some(pre)) = (inner_cmyk, pre_cmyk) {
3926            composite_non_isolated_cmyk(
3927                pixmap,
3928                band_state.cmyk_buffer.as_deref_mut(),
3929                &offscreen,
3930                inner,
3931                pre,
3932                backdrop,
3933                effective_params,
3934                mask_ref,
3935                crop_x,
3936                crop_y,
3937                ctx.icc,
3938            );
3939            cmyk_compose_done = true;
3940        } else if let Some(ref alpha_os) = alpha_offscreen {
3941            composite_non_isolated_extracted(
3942                pixmap,
3943                &offscreen,
3944                alpha_os,
3945                backdrop,
3946                effective_params,
3947                mask_ref,
3948                crop_x,
3949                crop_y,
3950            );
3951        } else {
3952            composite_non_isolated_group_cropped(
3953                pixmap,
3954                &offscreen,
3955                backdrop,
3956                effective_params,
3957                mask_ref,
3958                crop_x,
3959                crop_y,
3960            );
3961        }
3962    } else {
3963        let paint = stet_tiny_skia::PixmapPaint {
3964            opacity: effective_params.alpha as f32,
3965            blend_mode: u8_to_blend_mode(effective_params.blend_mode),
3966            quality: stet_tiny_skia::FilterQuality::Nearest,
3967        };
3968        pixmap.draw_pixmap(
3969            crop_x,
3970            crop_y,
3971            offscreen.as_ref(),
3972            &paint,
3973            Transform::identity(),
3974            mask_ref,
3975        );
3976    }
3977
3978    // Write group CMYK buffer back to parent. Skip when the CMYK composite-back
3979    // already wrote the blended values into the parent CMYK buffer — running
3980    // `copy_cmyk_buffer_to_parent` afterwards would overwrite those blended
3981    // values with the inner buffer's raw source colors, breaking subsequent
3982    // siblings that read the parent CMYK as their backdrop.
3983    if !cmyk_compose_done
3984        && let (Some(group_cmyk), Some(parent_cmyk)) =
3985            (&group_band.cmyk_buffer, &mut band_state.cmyk_buffer)
3986    {
3987        copy_cmyk_buffer_to_parent(
3988            parent_cmyk,
3989            group_cmyk,
3990            offscreen.data(),
3991            crop_x as usize,
3992            crop_y as usize,
3993            eff_w as usize,
3994            eff_h as usize,
3995            ctx.out_w as usize,
3996            ctx.out_h as usize,
3997        );
3998    }
3999}
4000
4001/// CMYK-aware composite-back for a non-isolated transparency group.
4002///
4003/// For each pixel in the group's region:
4004///   1. If the inner CMYK buffer matches the snapshot taken when the group
4005///      started, the group painted nothing there → leave the parent unchanged.
4006///   2. Otherwise apply the group blend mode in DeviceCMYK using the spec's
4007///      effective inversion formulas (`blend_cmyk_separable_channel` or
4008///      `blend_cmyk_nonseparable`), convert the result to sRGB through the
4009///      ICC system CMYK profile so it sits seamlessly next to the rest of the
4010///      page, and write the result to both the parent pixmap and (when
4011///      present) the parent CMYK buffer.
4012#[allow(clippy::too_many_arguments)]
4013fn composite_non_isolated_cmyk(
4014    target: &mut Pixmap,
4015    parent_cmyk: Option<&mut [f32]>,
4016    source: &Pixmap,
4017    source_cmyk: &[f32],
4018    backdrop_cmyk: &[f32],
4019    backdrop_pixels: &[u8],
4020    params: &stet_graphics::display_list::GroupParams,
4021    clip_mask: Option<&stet_tiny_skia::Mask>,
4022    crop_x: i32,
4023    crop_y: i32,
4024    icc: Option<&IccCache>,
4025) {
4026    let cw = source.width() as usize;
4027    let ch = source.height() as usize;
4028    let target_w = target.width() as usize;
4029    let target_h = target.height() as usize;
4030
4031    let opacity = params.alpha.clamp(0.0, 1.0);
4032    let blend_mode = params.blend_mode;
4033    let is_nonseparable = matches!(blend_mode, 12..=15);
4034
4035    let target_data = target.data_mut();
4036    let target_stride = target_w * 4;
4037    let group_stride = cw * 4;
4038
4039    let clip_data = clip_mask.map(|m| m.data());
4040
4041    for gy in 0..ch {
4042        let ty = crop_y + gy as i32;
4043        if ty < 0 || ty as usize >= target_h {
4044            continue;
4045        }
4046        let ty = ty as usize;
4047        let group_row = gy * group_stride;
4048        let target_row = ty * target_stride;
4049
4050        for gx in 0..cw {
4051            let tx = crop_x + gx as i32;
4052            if tx < 0 || tx as usize >= target_w {
4053                continue;
4054            }
4055            let tx = tx as usize;
4056            let gi = group_row + gx * 4;
4057            let ti = target_row + tx * 4;
4058
4059            // Did the group actually paint this pixel?
4060            let bc = backdrop_cmyk[gi] as f64;
4061            let bm = backdrop_cmyk[gi + 1] as f64;
4062            let by_ = backdrop_cmyk[gi + 2] as f64;
4063            let bk = backdrop_cmyk[gi + 3] as f64;
4064            let sc = source_cmyk[gi] as f64;
4065            let sm = source_cmyk[gi + 1] as f64;
4066            let sy_ = source_cmyk[gi + 2] as f64;
4067            let sk = source_cmyk[gi + 3] as f64;
4068            if (sc - bc).abs() < 1.0 / 255.0
4069                && (sm - bm).abs() < 1.0 / 255.0
4070                && (sy_ - by_).abs() < 1.0 / 255.0
4071                && (sk - bk).abs() < 1.0 / 255.0
4072            {
4073                continue;
4074            }
4075
4076            // Clip mask coverage in target coordinates.
4077            let cov = if let Some(cd) = clip_data {
4078                cd[ty * target_w + tx] as f64 / 255.0
4079            } else {
4080                1.0
4081            };
4082            if cov <= 0.0 {
4083                continue;
4084            }
4085
4086            // Transparent-backdrop fast path: when the backdrop pixmap's alpha
4087            // is 0 the parent group hasn't painted this pixel, so PDF spec
4088            // §11.4.6 says the blended result reduces to α_s · source — the
4089            // blend formula must NOT be applied. Without this check, formulas
4090            // like ColorBurn / ColorDodge / Lighten / Screen produce visibly
4091            // wrong colors (yellow instead of orange-yellow, white instead of
4092            // the source) because an all-zero CMYK backdrop is identical to
4093            // opaque white in CMYK terms. Using the pixmap alpha as the
4094            // sentinel correctly distinguishes "truly nothing painted"
4095            // (alpha 0) from "white painted" (alpha 1, CMYK 0,0,0,0).
4096            //
4097            // For this branch we composite the source pixmap directly via
4098            // SourceOver (rather than converting source CMYK→sRGB) so the
4099            // source's per-pixel alpha — including anti-aliased edges and
4100            // partially-transparent paint like 907 page 28's gray rules —
4101            // is preserved. The CMYK→sRGB direct path used the un-modulated
4102            // painter color and the group opacity, which forced antialiased
4103            // gray strokes to opaque black.
4104            let backdrop_alpha = backdrop_pixels[gi + 3];
4105            let backdrop_transparent = backdrop_alpha == 0;
4106
4107            let mix = cov * opacity;
4108            let dst_a = target_data[ti + 3] as f64 / 255.0;
4109
4110            if backdrop_transparent {
4111                // SourceOver of the source pixmap (already correctly rendered
4112                // for transparent-backdrop semantics) modulated by the group's
4113                // mix factor. To ensure inner-group AA edges don't leave
4114                // sliver gaps where the outer parent pixmap had previously
4115                // drawn a near-identical path (GWG 16.2 directly-drawn black
4116                // X covered by Painter B's slightly-offset colored X), we
4117                // promote any non-zero source alpha to the painter's full
4118                // unpremultiplied source CMYK converted to sRGB. This
4119                // produces fully-opaque coverage at edge pixels matching
4120                // what the inner painter would render at the path interior,
4121                // so the inner group can fully knock out the outer's AA
4122                // edge when composited back to its parent.
4123                let src_data = source.data();
4124                let src_a_pm = src_data[gi + 3] as f64 / 255.0;
4125                if src_a_pm <= 0.0 {
4126                    continue;
4127                }
4128                // Convert source CMYK directly to sRGB. The CMYK at this
4129                // pixel was written by the inner painter at its full
4130                // un-modulated value (the cmyk_buf doesn't track AA), so
4131                // this is the pure painter color regardless of AA cov.
4132                let (full_r, full_g, full_b) = icc
4133                    .and_then(|i| i.convert_cmyk_readonly(sc, sm, sy_, sk))
4134                    .unwrap_or_else(|| cmyk_to_rgb_plrm(sc, sm, sy_, sk));
4135                let alpha_s = mix;
4136                let inv_sa = 1.0 - alpha_s;
4137                let dst_r_pm = target_data[ti] as f64 / 255.0;
4138                let dst_g_pm = target_data[ti + 1] as f64 / 255.0;
4139                let dst_b_pm = target_data[ti + 2] as f64 / 255.0;
4140                let out_r = full_r * alpha_s + dst_r_pm * inv_sa;
4141                let out_g = full_g * alpha_s + dst_g_pm * inv_sa;
4142                let out_b = full_b * alpha_s + dst_b_pm * inv_sa;
4143                let out_a = alpha_s + dst_a * inv_sa;
4144                target_data[ti] = (out_r * 255.0).round().clamp(0.0, 255.0) as u8;
4145                target_data[ti + 1] = (out_g * 255.0).round().clamp(0.0, 255.0) as u8;
4146                target_data[ti + 2] = (out_b * 255.0).round().clamp(0.0, 255.0) as u8;
4147                target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4148                continue;
4149            }
4150
4151            // Apply the group's blend mode in CMYK.
4152            let (rc, rm, ry, rk) = if is_nonseparable {
4153                let r = blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4154                (r[0], r[1], r[2], r[3])
4155            } else {
4156                (
4157                    blend_cmyk_separable_channel(bc, sc, blend_mode),
4158                    blend_cmyk_separable_channel(bm, sm, blend_mode),
4159                    blend_cmyk_separable_channel(by_, sy_, blend_mode),
4160                    blend_cmyk_separable_channel(bk, sk, blend_mode),
4161                )
4162            };
4163
4164            let (new_r, new_g, new_b) = icc
4165                .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
4166                .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
4167
4168            // tiny-skia stores premultiplied sRGB. Apply the PDF
4169            // §11.4.6 result formula in straight-color form. We force the
4170            // source alpha to 1 (subject to clip + group opacity) at any
4171            // pixel where the source CMYK was written by the inner painter
4172            // — the cmyk_buf flags coverage at the path's full extent, even
4173            // at AA edges. Using full alpha here ensures the inner group
4174            // fully covers the outer parent's previously-drawn content
4175            // when both reference near-identical paths (GWG 16.2 directly-
4176            // drawn outer X path covered by Painter B's slightly-offset
4177            // colored X path). Without this, the formula's partial-cover
4178            // mix produces a 1-pixel sliver of darker color where the two
4179            // paths' rasterizations diverge sub-pixel-wise.
4180            let alpha_s = mix;
4181            let alpha_b = dst_a;
4182            let out_a = alpha_s + alpha_b * (1.0 - alpha_s);
4183            if out_a <= 0.0 {
4184                continue;
4185            }
4186            let (dst_r, dst_g, dst_b) = if alpha_b > 0.0 {
4187                let inv_a = 1.0 / alpha_b;
4188                (
4189                    (target_data[ti] as f64 / 255.0) * inv_a,
4190                    (target_data[ti + 1] as f64 / 255.0) * inv_a,
4191                    (target_data[ti + 2] as f64 / 255.0) * inv_a,
4192                )
4193            } else {
4194                (0.0, 0.0, 0.0)
4195            };
4196            // Spec §11.4.6 result computation:
4197            //   C_o = (α_s·(1−α_b)·C_s + α_s·α_b·B(C_b,C_s) + (1−α_s)·α_b·C_b) / α_o
4198            // Here we already have B(C_b,C_s) computed in CMYK and converted
4199            // to sRGB as (new_r, new_g, new_b). The "C_s" term — the source
4200            // color un-blended — uses the same value because the spec says
4201            // when α_b = 0 the formula reduces to source-as-is, which the
4202            // (1−α_b) coefficient already handles.
4203            let coef_b = alpha_s * alpha_b;
4204            let coef_s = alpha_s * (1.0 - alpha_b);
4205            let coef_d = (1.0 - alpha_s) * alpha_b;
4206            let out_r = (coef_s * new_r + coef_b * new_r + coef_d * dst_r) / out_a;
4207            let out_g = (coef_s * new_g + coef_b * new_g + coef_d * dst_g) / out_a;
4208            let out_b = (coef_s * new_b + coef_b * new_b + coef_d * dst_b) / out_a;
4209
4210            target_data[ti] = (out_r * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4211            target_data[ti + 1] = (out_g * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4212            target_data[ti + 2] = (out_b * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4213            target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4214        }
4215    }
4216
4217    // Write the blended CMYK back to the parent CMYK buffer so subsequent
4218    // sibling groups see consistent backdrop values. We re-walk the same
4219    // region — keeps the inner loop above tight (no double-borrow on the
4220    // parent buffer) and only touches pixels we actually modified.
4221    if let Some(parent_cmyk) = parent_cmyk {
4222        for gy in 0..ch {
4223            let ty = crop_y + gy as i32;
4224            if ty < 0 || ty as usize >= target_h {
4225                continue;
4226            }
4227            let ty = ty as usize;
4228            let group_row = gy * group_stride;
4229            let parent_row = ty * target_stride;
4230
4231            for gx in 0..cw {
4232                let tx = crop_x + gx as i32;
4233                if tx < 0 || tx as usize >= target_w {
4234                    continue;
4235                }
4236                let tx = tx as usize;
4237                let gi = group_row + gx * 4;
4238                let pi = parent_row + tx * 4;
4239
4240                let bc = backdrop_cmyk[gi] as f64;
4241                let bm = backdrop_cmyk[gi + 1] as f64;
4242                let by_ = backdrop_cmyk[gi + 2] as f64;
4243                let bk = backdrop_cmyk[gi + 3] as f64;
4244                let sc = source_cmyk[gi] as f64;
4245                let sm = source_cmyk[gi + 1] as f64;
4246                let sy_ = source_cmyk[gi + 2] as f64;
4247                let sk = source_cmyk[gi + 3] as f64;
4248                if (sc - bc).abs() < 1.0 / 255.0
4249                    && (sm - bm).abs() < 1.0 / 255.0
4250                    && (sy_ - by_).abs() < 1.0 / 255.0
4251                    && (sk - bk).abs() < 1.0 / 255.0
4252                {
4253                    continue;
4254                }
4255
4256                // Same transparent-backdrop fast path as above: use source
4257                // as-is. We read the original backdrop alpha from the saved
4258                // backdrop_pixels slice, NOT the live target — the live
4259                // target's alpha was already updated by the first loop's
4260                // composite-back writes.
4261                let backdrop_transparent = backdrop_pixels[gi + 3] == 0;
4262                let (rc, rm, ry, rk) = if backdrop_transparent {
4263                    (sc, sm, sy_, sk)
4264                } else if is_nonseparable {
4265                    let r =
4266                        blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4267                    (r[0], r[1], r[2], r[3])
4268                } else {
4269                    (
4270                        blend_cmyk_separable_channel(bc, sc, blend_mode),
4271                        blend_cmyk_separable_channel(bm, sm, blend_mode),
4272                        blend_cmyk_separable_channel(by_, sy_, blend_mode),
4273                        blend_cmyk_separable_channel(bk, sk, blend_mode),
4274                    )
4275                };
4276                parent_cmyk[pi] = rc as f32;
4277                parent_cmyk[pi + 1] = rm as f32;
4278                parent_cmyk[pi + 2] = ry as f32;
4279                parent_cmyk[pi + 3] = rk as f32;
4280            }
4281        }
4282    }
4283}
4284
4285/// Render a knockout transparency group into a pixmap.
4286///
4287/// In a knockout group, each element composites against the group's initial
4288/// backdrop (not the accumulated result of previous elements).
4289fn render_knockout_group(
4290    pixmap: &mut Pixmap,
4291    band_state: &mut BandState,
4292    elements: &DisplayList,
4293    params: &stet_graphics::display_list::GroupParams,
4294    ctx: &RenderContext<'_>,
4295) {
4296    let crop = compute_group_crop(&params.bbox, ctx);
4297
4298    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
4299        Some((cx, cy, cw, ch)) => (
4300            cw,
4301            ch,
4302            cx,
4303            cy,
4304            ctx.vp_x + cx as f32 / ctx.scale_x,
4305            ctx.vp_y + cy as f32 / ctx.scale_y,
4306        ),
4307        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
4308    };
4309
4310    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
4311        return;
4312    };
4313
4314    let initial_backdrop = if !params.isolated {
4315        if crop.is_some() {
4316            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
4317        } else {
4318            pixmap.data().to_vec()
4319        }
4320    } else {
4321        vec![0u8; (eff_w * eff_h * 4) as usize]
4322    };
4323
4324    let Some(mut accumulated) = Pixmap::new(eff_w, eff_h) else {
4325        return;
4326    };
4327    accumulated.data_mut().copy_from_slice(&initial_backdrop);
4328
4329    // Initial CMYK values for the knockout group
4330    let needs_cmyk = has_overprint_elements(elements) || band_state.cmyk_buffer.is_some();
4331    let initial_cmyk = if needs_cmyk {
4332        let buf_size = eff_w as usize * eff_h as usize * 4;
4333        let mut buf = vec![0.0f32; buf_size];
4334        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4335            let parent_stride = ctx.out_w as usize * 4;
4336            let group_stride = eff_w as usize * 4;
4337            for gy in 0..eff_h as usize {
4338                let py = crop_y as usize + gy;
4339                if py < ctx.out_h as usize {
4340                    let p_start = py * parent_stride + crop_x as usize * 4;
4341                    let g_start = gy * group_stride;
4342                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4343                    buf[g_start..g_start + copy_len]
4344                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
4345                }
4346            }
4347        }
4348        Some(buf)
4349    } else {
4350        None
4351    };
4352
4353    let mut accumulated_cmyk = initial_cmyk.clone();
4354
4355    // Disable anti-aliasing in knockout groups to prevent seam artifacts.
4356    // Each element composites independently against the backdrop, so adjacent
4357    // fills' AA edges don't mesh — both blend toward the backdrop color,
4358    // creating visible 1px white lines at shared boundaries.
4359    let group_ctx = RenderContext {
4360        vp_x: eff_vp_x,
4361        vp_y: eff_vp_y,
4362        scale_x: ctx.scale_x,
4363        scale_y: ctx.scale_y,
4364        out_w: eff_w,
4365        out_h: eff_h,
4366        effective_dpi: ctx.effective_dpi,
4367        icc: ctx.icc,
4368        image_cache: None,
4369        preprocessed: None,
4370        elem_idx: 0,
4371        no_aa: true,
4372        opm_zero_transparent: ctx.opm_zero_transparent,
4373        knockout_painter_pass: ctx.knockout_painter_pass,
4374        // Knockout groups composite each element against the initial backdrop;
4375        // children effectively see this group's "fresh" backdrop. Treat the
4376        // knockout group as isolated for the purposes of the inner CMYK rule.
4377        parent_group_isolated: true,
4378        alpha_extraction_pass: false,
4379    };
4380
4381    // Persistent band state for clip tracking — clips must accumulate across
4382    // elements in the knockout group (each paint element still composites
4383    // against the initial backdrop, but it must respect the current clip).
4384    let mut ko_band = BandState {
4385        clip_region: None,
4386        spare_mask: None,
4387        clip_mask_cache: HashMap::new(),
4388        clip_mask_seen: HashSet::new(),
4389        mask_pool: Vec::new(),
4390        cmyk_buffer: None,
4391        op_bg_snapshot: None,
4392        op_touched: None,
4393        spot_mask: None,
4394    };
4395
4396    // Coverage offscreen for two-pass painter rendering of nested transparency
4397    // groups. Reused (zeroed) across painters; allocated lazily on first need.
4398    let mut coverage_offscreen: Option<Pixmap> = None;
4399
4400    for elem in elements.elements() {
4401        match elem {
4402            // State-only elements: update persistent clip, no knockout compositing
4403            DisplayElement::Clip { .. } | DisplayElement::InitClip => {
4404                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4405            }
4406            // Group painters need two-pass rendering. Knockout semantics
4407            // require each painter to overwrite previous siblings within its
4408            // coverage area, even when the painter's blend mode happens to
4409            // produce a result that equals the initial backdrop (e.g.
4410            // Darken(red, white)=red, SoftLight(red, black)=red,
4411            // Multiply(red, magenta)=red — which is exactly what GWG 16.1
4412            // tests). The single-pass change-against-backdrop check used for
4413            // simpler painter types would miss those pixels, and earlier
4414            // siblings' contributions would bleed through.
4415            DisplayElement::Group { .. } => {
4416                // Pass 1: render painter against initial_backdrop to compute
4417                // the blended-color result (the painter's contribution).
4418                // Use ColorPass mode so any non-Normal blend mode goes through
4419                // the per-pixel CMYK composite-back — required for separable
4420                // blends like Screen / ColorDodge / Overlay / SoftLight whose
4421                // sRGB result drifts away from the CMYK-math result.
4422                let pass1_ctx = RenderContext {
4423                    knockout_painter_pass: KnockoutPainterPass::ColorPass,
4424                    ..group_ctx
4425                };
4426                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4427                ko_band.cmyk_buffer = initial_cmyk.clone();
4428                render_element(&mut offscreen, &mut ko_band, elem, &pass1_ctx);
4429                let pass1_cmyk = ko_band.cmyk_buffer.take();
4430
4431                // Pass 2: render painter into a fresh transparent offscreen so
4432                // the alpha channel captures the painter's coverage, which the
4433                // result-color comparison cannot recover when the blend mode
4434                // outputs the backdrop color exactly.
4435                let cov = match coverage_offscreen.as_mut() {
4436                    Some(p) => {
4437                        p.data_mut().fill(0);
4438                        p
4439                    }
4440                    None => {
4441                        let Some(p) = Pixmap::new(eff_w, eff_h) else {
4442                            // Out of memory for coverage buffer — fall back
4443                            // to the change-detection path so the painter
4444                            // still appears (just without proper knockout).
4445                            replace_changed_pixels(
4446                                accumulated.data_mut(),
4447                                offscreen.data(),
4448                                &initial_backdrop,
4449                            );
4450                            if let (Some(p1), Some(acc)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4451                                replace_changed_cmyk(acc, p1, offscreen.data(), &initial_backdrop);
4452                            }
4453                            continue;
4454                        };
4455                        coverage_offscreen = Some(p);
4456                        coverage_offscreen.as_mut().unwrap()
4457                    }
4458                };
4459                ko_band.cmyk_buffer = None;
4460                // Coverage pass: render through the simple sRGB path with
4461                // alpha forced to 1.0 and Normal blend so the painter's
4462                // shape reaches the coverage offscreen even for white-source
4463                // CMYK painters and zero-alpha painters (Opacity 0% test).
4464                let coverage_ctx = RenderContext {
4465                    knockout_painter_pass: KnockoutPainterPass::CoveragePass,
4466                    ..group_ctx
4467                };
4468                render_element(cov, &mut ko_band, elem, &coverage_ctx);
4469
4470                // Use the coverage offscreen's alpha as a knockout mask: the
4471                // painter's contribution from pass 1 source-overs onto
4472                // accumulated weighted by the coverage alpha.
4473                replace_with_coverage_mask(accumulated.data_mut(), offscreen.data(), cov.data());
4474
4475                if let (Some(p1_cmyk), Some(acc_cmyk)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4476                    replace_cmyk_with_coverage_mask(acc_cmyk, p1_cmyk, cov.data());
4477                }
4478                ko_band.cmyk_buffer = None;
4479            }
4480            // Other paint elements: single-pass with change-against-backdrop.
4481            // Direct path/image/shading paints always change pixels they cover,
4482            // so the simpler detection works and avoids the second-pass cost.
4483            _ => {
4484                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4485
4486                ko_band.cmyk_buffer = initial_cmyk.clone();
4487
4488                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4489
4490                if let (Some(elem_cmyk), Some(acc_cmyk)) =
4491                    (&ko_band.cmyk_buffer, &mut accumulated_cmyk)
4492                {
4493                    replace_changed_cmyk(acc_cmyk, elem_cmyk, offscreen.data(), &initial_backdrop);
4494                }
4495                ko_band.cmyk_buffer = None;
4496
4497                replace_changed_pixels(accumulated.data_mut(), offscreen.data(), &initial_backdrop);
4498            }
4499        }
4500    }
4501
4502    let mut temp_mask = None;
4503    let mask_ref = resolve_clip_mask(
4504        &band_state.clip_region,
4505        &mut temp_mask,
4506        ctx.out_w,
4507        ctx.out_h,
4508    );
4509    let mask_ref = match mask_ref {
4510        None => return,
4511        Some(m) => m,
4512    };
4513
4514    composite_non_isolated_group_cropped(
4515        pixmap,
4516        &accumulated,
4517        &initial_backdrop,
4518        params,
4519        mask_ref,
4520        crop_x,
4521        crop_y,
4522    );
4523
4524    if let (Some(acc_cmyk), Some(parent_cmyk)) = (&accumulated_cmyk, &mut band_state.cmyk_buffer) {
4525        copy_cmyk_buffer_to_parent(
4526            parent_cmyk,
4527            acc_cmyk,
4528            accumulated.data(),
4529            crop_x as usize,
4530            crop_y as usize,
4531            eff_w as usize,
4532            eff_h as usize,
4533            ctx.out_w as usize,
4534            ctx.out_h as usize,
4535        );
4536    }
4537}
4538/// Source-over `source` onto `target` weighted by `coverage`'s alpha channel.
4539/// Used for the two-pass knockout group rendering: `coverage` is rendered
4540/// into a transparent offscreen so its alpha records the painter's coverage
4541/// regardless of whether the painter's blend mode produced backdrop-equal
4542/// pixels in the color pass. Both `source` and `target` are assumed fully
4543/// opaque pixmaps (alpha=255 everywhere) since the knockout offscreens are
4544/// pre-loaded with the opaque initial backdrop.
4545fn replace_with_coverage_mask(target: &mut [u8], source: &[u8], coverage: &[u8]) {
4546    for i in (0..target.len()).step_by(4) {
4547        let cov_a = coverage[i + 3];
4548        if cov_a == 0 {
4549            continue;
4550        }
4551        if cov_a == 255 {
4552            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4553            continue;
4554        }
4555        let a = cov_a as u32;
4556        let inv = 255 - a;
4557        for c in 0..4 {
4558            let s = source[i + c] as u32;
4559            let t = target[i + c] as u32;
4560            target[i + c] = ((s * a + t * inv + 127) / 255) as u8;
4561        }
4562    }
4563}
4564
4565/// Source-over CMYK values from `source` onto `target` weighted by the
4566/// coverage offscreen's alpha channel. Companion to
4567/// `replace_with_coverage_mask` for the parallel CMYK buffer.
4568fn replace_cmyk_with_coverage_mask(target: &mut [f32], source: &[f32], coverage: &[u8]) {
4569    let pixel_count = target.len() / 4;
4570    for i in 0..pixel_count {
4571        let pi = i * 4;
4572        let cov_a = coverage[pi + 3];
4573        if cov_a == 0 {
4574            continue;
4575        }
4576        if cov_a == 255 {
4577            target[pi..pi + 4].copy_from_slice(&source[pi..pi + 4]);
4578            continue;
4579        }
4580        let a = cov_a as f32 / 255.0;
4581        let inv = 1.0 - a;
4582        for c in 0..4 {
4583            target[pi + c] = source[pi + c] * a + target[pi + c] * inv;
4584        }
4585    }
4586}
4587
4588/// Replace pixels in `target` with pixels from `source` wherever `source`
4589/// differs from `backdrop`. Used for knockout group per-element compositing
4590/// where each element replaces (not blends with) previous elements.
4591fn replace_changed_pixels(target: &mut [u8], source: &[u8], backdrop: &[u8]) {
4592    for i in (0..target.len()).step_by(4) {
4593        if source[i] != backdrop[i]
4594            || source[i + 1] != backdrop[i + 1]
4595            || source[i + 2] != backdrop[i + 2]
4596            || source[i + 3] != backdrop[i + 3]
4597        {
4598            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4599        }
4600    }
4601}
4602
4603/// Copy a group's CMYK buffer back to the parent's CMYK buffer after compositing.
4604/// Only copies values for pixels where the group offscreen has non-zero alpha,
4605/// indicating the group actually painted something at that position.
4606#[allow(clippy::too_many_arguments)]
4607fn copy_cmyk_buffer_to_parent(
4608    parent_cmyk: &mut [f32],
4609    group_cmyk: &[f32],
4610    group_pixels: &[u8],
4611    crop_x: usize,
4612    crop_y: usize,
4613    group_w: usize,
4614    group_h: usize,
4615    parent_w: usize,
4616    parent_h: usize,
4617) {
4618    let parent_stride = parent_w * 4;
4619    let group_stride = group_w * 4;
4620    for gy in 0..group_h {
4621        let py = crop_y + gy;
4622        if py >= parent_h {
4623            break;
4624        }
4625        for gx in 0..group_w {
4626            let px = crop_x + gx;
4627            if px >= parent_w {
4628                break;
4629            }
4630            // Only copy if the group pixel has non-zero alpha AND
4631            // the group's cmyk at that pixel is non-zero.
4632            // Zero cmyk means "not tracked by a CMYK fill in this group"
4633            // — writing it back would erase the parent's tracked values.
4634            let g_pixel_idx = (gy * group_w + gx) * 4;
4635            let g_cmyk_idx = gy * group_stride + gx * 4;
4636            if group_pixels[g_pixel_idx + 3] > 0
4637                && (group_cmyk[g_cmyk_idx] != 0.0
4638                    || group_cmyk[g_cmyk_idx + 1] != 0.0
4639                    || group_cmyk[g_cmyk_idx + 2] != 0.0
4640                    || group_cmyk[g_cmyk_idx + 3] != 0.0)
4641            {
4642                let p_cmyk_idx = py * parent_stride + px * 4;
4643                parent_cmyk[p_cmyk_idx..p_cmyk_idx + 4]
4644                    .copy_from_slice(&group_cmyk[g_cmyk_idx..g_cmyk_idx + 4]);
4645            }
4646        }
4647    }
4648}
4649
4650/// Copy CMYK values for pixels that changed in a knockout element.
4651/// Used alongside replace_changed_pixels to keep CMYK in sync with RGB.
4652fn replace_changed_cmyk(
4653    target_cmyk: &mut [f32],
4654    source_cmyk: &[f32],
4655    source_pixels: &[u8],
4656    backdrop_pixels: &[u8],
4657) {
4658    let pixel_count = target_cmyk.len() / 4;
4659    for i in 0..pixel_count {
4660        let pi = i * 4;
4661        if source_pixels[pi] != backdrop_pixels[pi]
4662            || source_pixels[pi + 1] != backdrop_pixels[pi + 1]
4663            || source_pixels[pi + 2] != backdrop_pixels[pi + 2]
4664            || source_pixels[pi + 3] != backdrop_pixels[pi + 3]
4665        {
4666            target_cmyk[pi..pi + 4].copy_from_slice(&source_cmyk[pi..pi + 4]);
4667        }
4668    }
4669}
4670
4671/// Render soft-masked content.
4672///
4673/// 1. Renders the mask display list to an offscreen pixmap.
4674/// 2. Extracts a grayscale mask (luminosity or alpha).
4675/// 3. Renders content into another offscreen pixmap.
4676/// 4. Multiplies content alpha by the mask values.
4677/// 5. Composites the masked content onto the parent.
4678#[allow(clippy::too_many_arguments)]
4679fn render_soft_masked(
4680    pixmap: &mut Pixmap,
4681    band_state: &mut BandState,
4682    mask_list: &DisplayList,
4683    content_list: &DisplayList,
4684    params: &stet_graphics::display_list::SoftMaskParams,
4685    mask_cache: &Arc<Mutex<Option<Option<stet_graphics::display_list::MaskRaster>>>>,
4686    ctx: &RenderContext<'_>,
4687) {
4688    // The SoftMask's display list elements are in absolute device space (page coords).
4689    // params.bbox is the SoftMasked element's compositing bounds, derived
4690    // from the form's /BBox transformed by the gs-time CTM. The mask raster
4691    // (built lazily by `rasterize_mask` and cached on the display-list
4692    // element) is anchored independently to the *actual* mask paint bounds,
4693    // which may differ from params.bbox when the form's internal `cm`
4694    // operators translated paint elements outside the form bbox.
4695    //
4696    // The cached-raster path can produce truncated output when the
4697    // SoftMasked is rendered inside an outer offscreen (a Group, an
4698    // outer SoftMasked, etc.) — the nested offscreen's coordinate
4699    // system clips the mask raster's right edge unexpectedly. Detect
4700    // "nested" via `ctx.vp_x != 0.0` (top-level banded rendering uses
4701    // vp_x = 0; nested rendering inherits the parent offscreen's vp).
4702    // For nested cases, fall back to the inline band-local mask
4703    // rendering that worked before Step 4 of cosmic-masking-bird.
4704    let use_inline_mask = ctx.vp_x != 0.0;
4705    let bbox = &params.bbox;
4706    let smask_px_x0 = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
4707    let smask_px_y0 = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
4708    let smask_px_x1 = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
4709    let smask_px_y1 = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
4710
4711    // Clip to parent output bounds
4712    let crop_x = smask_px_x0.max(0);
4713    let crop_y = smask_px_y0.max(0);
4714    let crop_x1 = smask_px_x1.min(ctx.out_w as i32);
4715    let crop_y1 = smask_px_y1.min(ctx.out_h as i32);
4716    if crop_x >= crop_x1 || crop_y >= crop_y1 {
4717        return;
4718    }
4719    let eff_w = (crop_x1 - crop_x) as u32;
4720    let eff_h = (crop_y1 - crop_y) as u32;
4721
4722    // Viewport for the content offscreen: derived from the SoftMask's bbox
4723    // position relative to the parent's viewport. The content offscreen
4724    // still uses params.bbox because params.bbox correctly bounds where
4725    // the content can paint.
4726    let eff_vp_x = ctx.vp_x + crop_x as f32 / ctx.scale_x;
4727    let eff_vp_y = ctx.vp_y + crop_y as f32 / ctx.scale_y;
4728
4729    let sub_ctx = RenderContext {
4730        vp_x: eff_vp_x,
4731        vp_y: eff_vp_y,
4732        scale_x: ctx.scale_x,
4733        scale_y: ctx.scale_y,
4734        out_w: eff_w,
4735        out_h: eff_h,
4736        effective_dpi: ctx.effective_dpi,
4737        icc: ctx.icc,
4738        image_cache: None,
4739        preprocessed: None,
4740        elem_idx: 0,
4741        no_aa: ctx.no_aa,
4742        opm_zero_transparent: ctx.opm_zero_transparent,
4743        knockout_painter_pass: ctx.knockout_painter_pass,
4744        parent_group_isolated: ctx.parent_group_isolated,
4745        // Soft masks render into their own independent offscreen and must
4746        // not inherit the alpha extraction pass — their groups need normal
4747        // backdrop preloading regardless of the outer extraction context.
4748        alpha_extraction_pass: false,
4749    };
4750
4751    // 1a. INLINE PATH: Mask form contains nested offscreens.
4752    // Render the mask form into a band-local offscreen sized to the
4753    // SoftMasked's bbox crop. This matches the pre-Step-4 behavior.
4754    let mut mask_values_inline: Vec<u8> = Vec::new();
4755    if use_inline_mask {
4756        let Some(mut mask_pixmap) = Pixmap::new(eff_w, eff_h) else {
4757            return;
4758        };
4759        let mut mask_band = BandState {
4760            clip_region: None,
4761            spare_mask: None,
4762            clip_mask_cache: HashMap::new(),
4763            clip_mask_seen: HashSet::new(),
4764            mask_pool: Vec::new(),
4765            cmyk_buffer: None,
4766            op_bg_snapshot: None,
4767            op_touched: None,
4768            spot_mask: None,
4769        };
4770        for (idx, elem) in mask_list.elements().iter().enumerate() {
4771            let elem_ctx = RenderContext {
4772                elem_idx: idx,
4773                ..sub_ctx
4774            };
4775            render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
4776        }
4777        if params.has_nested_mask_scope
4778            && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
4779        {
4780            let bc = params.backdrop_color.as_ref();
4781            let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4782            let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4783            let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4784            for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
4785                let a = chunk[3] as u16;
4786                if a == 255 {
4787                    continue;
4788                }
4789                let inv_a = 255 - a;
4790                chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
4791                chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
4792                chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
4793                chunk[3] = 255;
4794            }
4795        }
4796        mask_values_inline = vec![0u8; (eff_w * eff_h) as usize];
4797        extract_soft_mask_values(mask_pixmap.data(), &mut mask_values_inline, params);
4798    }
4799
4800    // 1b. CACHED RASTER PATH: simple masks (no nested offscreens).
4801    let raster_owned: Option<stet_graphics::display_list::MaskRaster> = if use_inline_mask {
4802        None
4803    } else {
4804        let mut guard = mask_cache.lock().unwrap();
4805        let needs_build = match guard.as_ref() {
4806            None => true,
4807            Some(None) => false, // memoized "no mask"
4808            Some(Some(r)) => {
4809                (r.scale_x - ctx.scale_x).abs() > 1e-4 || (r.scale_y - ctx.scale_y).abs() > 1e-4
4810            }
4811        };
4812        if needs_build {
4813            let built = rasterize_mask(
4814                mask_list,
4815                params,
4816                ctx.icc,
4817                ctx.no_aa,
4818                ctx.effective_dpi,
4819                ctx.scale_x,
4820                ctx.scale_y,
4821            );
4822            *guard = Some(built);
4823        }
4824        guard.as_ref().and_then(|inner| inner.clone())
4825    };
4826
4827    // Default mask value for content pixels that fall outside the mask
4828    // raster (e.g. backdrop region for a Luminosity mask with non-black
4829    // /BC, or always 0 for Alpha masks).
4830    let fallback_mask = out_of_bounds_mask_value(params) as i32;
4831
4832    // 2. Render content into an offscreen, initialized with the parent's
4833    // backdrop so non-isolated groups with blend modes (e.g. Multiply) see
4834    // the correct background and produce the right composited result.
4835    let Some(mut content_pixmap) = Pixmap::new(eff_w, eff_h) else {
4836        return;
4837    };
4838    let backdrop = copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h);
4839    content_pixmap.data_mut().copy_from_slice(&backdrop);
4840
4841    let content_cmyk = if has_overprint_elements(content_list) || band_state.cmyk_buffer.is_some() {
4842        let buf_size = eff_w as usize * eff_h as usize * 4;
4843        let mut buf = vec![0.0f32; buf_size];
4844        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4845            let parent_stride = ctx.out_w as usize * 4;
4846            let group_stride = eff_w as usize * 4;
4847            for gy in 0..eff_h as usize {
4848                let py = crop_y as usize + gy;
4849                if py < ctx.out_h as usize {
4850                    let p_start = py * parent_stride + crop_x as usize * 4;
4851                    let g_start = gy * group_stride;
4852                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4853                    buf[g_start..g_start + copy_len]
4854                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
4855                }
4856            }
4857        }
4858        Some(buf)
4859    } else {
4860        None
4861    };
4862    // Snapshot the pre-content CMYK state so the mask blend can run in CMYK
4863    // space. Without this, the downstream sRGB blend interpolates between
4864    // CMYK backdrop and source after each has been ICC-converted separately,
4865    // which shifts the midtones away from the CMYK-interpolated result the
4866    // source was authored against (pink cast vs warm peach on GWG 16.10
4867    // inner-glow in PDFX-ready_Output-Test_X4.pdf).
4868    let backdrop_cmyk: Option<Vec<f32>> = content_cmyk.clone();
4869    let mut content_band = BandState {
4870        clip_region: None,
4871        spare_mask: None,
4872        clip_mask_cache: HashMap::new(),
4873        clip_mask_seen: HashSet::new(),
4874        mask_pool: Vec::new(),
4875        cmyk_buffer: content_cmyk,
4876        op_bg_snapshot: None,
4877        op_touched: None,
4878        spot_mask: None,
4879    };
4880    for (idx, elem) in content_list.elements().iter().enumerate() {
4881        let elem_ctx = RenderContext {
4882            elem_idx: idx,
4883            ..sub_ctx
4884        };
4885        render_element(&mut content_pixmap, &mut content_band, elem, &elem_ctx);
4886    }
4887
4888    // 3. Apply soft mask: compute per-pixel masked contribution and write
4889    // to parent. result[c] = parent[c] + m * (content_on_backdrop[c] - backdrop[c]) / 255
4890    //
4891    // Mask sampling: the mask raster is in page-pixel coordinates at the
4892    // current render scale, anchored at `(raster.origin_x, raster.origin_y)`.
4893    // The combine loop iterates over content pixel `(x, y)` band-local in
4894    // the content offscreen. To translate to a mask raster index:
4895    //
4896    //   page_x = vp_x_pixels + crop_x + x
4897    //   page_y = vp_y_pixels + crop_y + y
4898    //   mask_x = page_x - raster.origin_x
4899    //   mask_y = page_y - raster.origin_y
4900    //
4901    // where `vp_x_pixels = round(ctx.vp_x * ctx.scale_x)` is the page-pixel
4902    // offset of the band's top-left. For banded rendering this is exact
4903    // (vp = 0, scale = 1, so vp_x_pixels = 0). For viewport rendering with
4904    // a fractional `vp_x`, there is at most a 0.5-pixel sub-pixel offset
4905    // between the content render grid and the cached mask grid; this is
4906    // bounded and visually acceptable for nearest-neighbor sampling.
4907    let vp_x_pixels = (ctx.vp_x * ctx.scale_x).round() as i32;
4908    let vp_y_pixels = (ctx.vp_y * ctx.scale_y).round() as i32;
4909
4910    let mut temp_mask = None;
4911    let clip_ref = resolve_clip_mask(
4912        &band_state.clip_region,
4913        &mut temp_mask,
4914        ctx.out_w,
4915        ctx.out_h,
4916    );
4917    let clip_ref = match clip_ref {
4918        None => return,
4919        Some(m) => m,
4920    };
4921
4922    // Decide whether to interpolate the masked delta in CMYK (with ICC→sRGB
4923    // on the way out) instead of sRGB. The CMYK path matches Acrobat's
4924    // behaviour when the transparency group declares /CS DeviceCMYK and all
4925    // content is native CMYK — the blend color space is then CMYK, and
4926    // sRGB-space interpolation on ICC-converted endpoints loses the warm
4927    // midtone that M+Y mixing produces under a proper CMYK profile.
4928    //
4929    // Gate strictly: content_list must be a flat list of native-CMYK fills
4930    // or strokes with Normal blend and full opacity. Any nested Group,
4931    // SoftMasked, Image, or blend-mode-modulated paint means the parallel
4932    // cmyk_buffer can't be trusted to match the pixmap — running CMYK
4933    // interpolation against a mismatched CMYK snapshot produced wrong
4934    // colors on GWG 16.10 outer-glow C (Fm5 is a Screen-blend white rect
4935    // inside a Group; cmyk_buffer held raw white while pixmap held the
4936    // screen-blended light gray).
4937    let use_cmyk_blend = ctx.icc.is_some()
4938        && backdrop_cmyk.is_some()
4939        && content_band.cmyk_buffer.is_some()
4940        && content_list_is_simple_native_cmyk(content_list);
4941
4942    let content_data = content_pixmap.data();
4943    let parent_data = pixmap.data_mut();
4944    let parent_stride = ctx.out_w as usize * 4;
4945    let content_stride = eff_w as usize * 4;
4946
4947    for y in 0..eff_h as usize {
4948        let py = crop_y as usize + y;
4949        if py >= ctx.out_h as usize {
4950            break;
4951        }
4952        let ci_row = y * content_stride;
4953        let pi_row = py * parent_stride;
4954        let page_y = vp_y_pixels + crop_y + y as i32;
4955
4956        for x in 0..eff_w as usize {
4957            let px = crop_x as usize + x;
4958            if px >= ctx.out_w as usize {
4959                break;
4960            }
4961
4962            // Check clip mask (in parent coordinates)
4963            if let Some(clip) = clip_ref {
4964                if clip.data()[py * ctx.out_w as usize + px] == 0 {
4965                    continue;
4966                }
4967            }
4968
4969            // Sample the mask: inline-rendered values for masks with
4970            // nested offscreens, cached raster for simple masks.
4971            let m = if use_inline_mask {
4972                mask_values_inline[y * eff_w as usize + x] as i32
4973            } else if let Some(ref raster) = raster_owned {
4974                let page_x = vp_x_pixels + crop_x + x as i32;
4975                let mx = page_x - raster.origin_x;
4976                let my = page_y - raster.origin_y;
4977                if mx >= 0 && (mx as u32) < raster.width && my >= 0 && (my as u32) < raster.height {
4978                    raster.data[my as usize * raster.width as usize + mx as usize] as i32
4979                } else {
4980                    fallback_mask
4981                }
4982            } else {
4983                fallback_mask
4984            };
4985            if m == 0 {
4986                continue;
4987            }
4988
4989            let ci = ci_row + x * 4;
4990            let pi = pi_row + px * 4;
4991
4992            // Per-pixel gate: CMYK interpolation is only safe when both
4993            // endpoints are faithfully tracked. ICC-convert both cmyk
4994            // snapshots and compare with the sRGB endpoints; only take
4995            // the CMYK path if BOTH agree within tolerance. The backdrop
4996            // check catches image/RGB paints upstream (tile_clamp_bug.pdf
4997            // photo background) where cmyk_buffer is an approximate
4998            // reverse-transform. The content check catches cases where
4999            // non-CMYK paints inside content leave the cmyk_buffer stale
5000            // relative to the sRGB content pixmap.
5001            let ci_cmyk = (y * eff_w as usize + x) * 4;
5002            let cmyk_path_ok = use_cmyk_blend && {
5003                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5004                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5005                let icc_match = |cmyk: &[f32], rgb: &[u8]| -> bool {
5006                    let (r, g, b) = ctx
5007                        .icc
5008                        .and_then(|i| {
5009                            i.convert_cmyk_readonly(
5010                                cmyk[0] as f64,
5011                                cmyk[1] as f64,
5012                                cmyk[2] as f64,
5013                                cmyk[3] as f64,
5014                            )
5015                        })
5016                        .unwrap_or_else(|| {
5017                            cmyk_to_rgb_plrm(
5018                                cmyk[0] as f64,
5019                                cmyk[1] as f64,
5020                                cmyk[2] as f64,
5021                                cmyk[3] as f64,
5022                            )
5023                        });
5024                    let r = (r * 255.0).round() as i32;
5025                    let g = (g * 255.0).round() as i32;
5026                    let b = (b * 255.0).round() as i32;
5027                    (r - rgb[0] as i32).abs() <= 3
5028                        && (g - rgb[1] as i32).abs() <= 3
5029                        && (b - rgb[2] as i32).abs() <= 3
5030                };
5031                icc_match(bc_cmyk, &backdrop[ci..ci + 3])
5032                    && icc_match(cc_cmyk, &content_data[ci..ci + 3])
5033            };
5034
5035            if cmyk_path_ok {
5036                // CMYK-space mask blend: result_cmyk = backdrop + m*(content - backdrop)
5037                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5038                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5039                let mf = m as f64 / 255.0;
5040                let rc = bc_cmyk[0] as f64 + mf * (cc_cmyk[0] as f64 - bc_cmyk[0] as f64);
5041                let rm = bc_cmyk[1] as f64 + mf * (cc_cmyk[1] as f64 - bc_cmyk[1] as f64);
5042                let ry = bc_cmyk[2] as f64 + mf * (cc_cmyk[2] as f64 - bc_cmyk[2] as f64);
5043                let rk = bc_cmyk[3] as f64 + mf * (cc_cmyk[3] as f64 - bc_cmyk[3] as f64);
5044                let (fr, fg, fb) = ctx
5045                    .icc
5046                    .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
5047                    .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
5048                parent_data[pi] = (fr * 255.0).round().clamp(0.0, 255.0) as u8;
5049                parent_data[pi + 1] = (fg * 255.0).round().clamp(0.0, 255.0) as u8;
5050                parent_data[pi + 2] = (fb * 255.0).round().clamp(0.0, 255.0) as u8;
5051                // Alpha channel: keep sRGB delta blend.
5052                let content_a = content_data[ci + 3] as i32;
5053                let backdrop_a = backdrop[ci + 3] as i32;
5054                let delta = content_a - backdrop_a;
5055                if delta != 0 {
5056                    let masked_delta = if delta > 0 {
5057                        (delta * m + 128) / 255
5058                    } else {
5059                        (delta * m - 128) / 255
5060                    };
5061                    let result = (parent_data[pi + 3] as i32 + masked_delta).clamp(0, 255);
5062                    parent_data[pi + 3] = result as u8;
5063                }
5064                // The parent's cmyk_buffer is deliberately NOT written here.
5065                // Writing back mask-blended CMYK would overwrite backdrop
5066                // tracking that downstream CMYK consumers (outer groups,
5067                // subsequent masks) depend on and cause them to render
5068                // nearby pixels as pure CMYK channels (e.g. the outer-glow
5069                // C regression: adjacent gray pixels ICC-resolved to a
5070                // black K silhouette). The sRGB pixmap carries the mask-
5071                // blended color; parent_cmyk stays untouched.
5072            } else {
5073                for c in 0..4 {
5074                    let content_val = content_data[ci + c] as i32;
5075                    let backdrop_val = backdrop[ci + c] as i32;
5076                    let delta = content_val - backdrop_val;
5077                    if delta != 0 {
5078                        let masked_delta = if delta > 0 {
5079                            (delta * m + 128) / 255
5080                        } else {
5081                            (delta * m - 128) / 255
5082                        };
5083                        let result = (parent_data[pi + c] as i32 + masked_delta).clamp(0, 255);
5084                        parent_data[pi + c] = result as u8;
5085                    }
5086                }
5087            }
5088        }
5089    }
5090
5091    // Write content CMYK buffer back to parent. Skip when the CMYK blend
5092    // loop already updated band_state.cmyk_buffer with mask-blended values
5093    // — copying the unmodulated content CMYK here would overwrite them.
5094    if !use_cmyk_blend {
5095        if let (Some(content_cmyk), Some(parent_cmyk)) =
5096            (&content_band.cmyk_buffer, &mut band_state.cmyk_buffer)
5097        {
5098            copy_cmyk_buffer_to_parent(
5099                parent_cmyk,
5100                content_cmyk,
5101                content_pixmap.data(),
5102                crop_x as usize,
5103                crop_y as usize,
5104                eff_w as usize,
5105                eff_h as usize,
5106                ctx.out_w as usize,
5107                ctx.out_h as usize,
5108            );
5109        }
5110    }
5111}
5112/// Extract grayscale mask values from rendered RGBA pixels.
5113fn extract_soft_mask_values(
5114    rgba: &[u8],
5115    out: &mut [u8],
5116    params: &stet_graphics::display_list::SoftMaskParams,
5117) {
5118    use stet_graphics::display_list::SoftMaskSubtype;
5119    let pixel_count = out.len();
5120
5121    match params.subtype {
5122        SoftMaskSubtype::Alpha => {
5123            for i in 0..pixel_count {
5124                let a = rgba[i * 4 + 3]; // alpha channel
5125                out[i] = if params.transfer_invert { 255 - a } else { a };
5126            }
5127        }
5128        SoftMaskSubtype::Luminosity => {
5129            // Backdrop luminosity for transparent pixels
5130            let backdrop_lum = if let Some(bc) = &params.backdrop_color {
5131                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5132            } else {
5133                0.0 // black backdrop
5134            };
5135            let backdrop_byte = (backdrop_lum * 255.0 + 0.5) as u8;
5136
5137            #[allow(clippy::needless_range_loop)]
5138            for i in 0..pixel_count {
5139                let off = i * 4;
5140                let a = rgba[off + 3];
5141                let lum_byte = if a == 0 {
5142                    backdrop_byte
5143                } else if a < 255 {
5144                    // Composite premultiplied RGB onto backdrop before computing
5145                    // luminosity (PDF spec 11.6.5.3): premul_rgb + BC × (1 - α/255)
5146                    let af = a as f64;
5147                    let bd = backdrop_lum * 255.0;
5148                    let r = rgba[off] as f64 + bd * (255.0 - af) / 255.0;
5149                    let g = rgba[off + 1] as f64 + bd * (255.0 - af) / 255.0;
5150                    let b = rgba[off + 2] as f64 + bd * (255.0 - af) / 255.0;
5151                    let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
5152                    (lum + 0.5).clamp(0.0, 255.0) as u8
5153                } else {
5154                    // Fully opaque: premultiplied == straight RGB
5155                    let lum = 0.2126 * rgba[off] as f64
5156                        + 0.7152 * rgba[off + 1] as f64
5157                        + 0.0722 * rgba[off + 2] as f64;
5158                    (lum + 0.5).clamp(0.0, 255.0) as u8
5159                };
5160                // Apply transfer function inversion: {1 exch sub} → 255 - value
5161                out[i] = if params.transfer_invert {
5162                    255 - lum_byte
5163                } else {
5164                    lum_byte
5165                };
5166            }
5167        }
5168    }
5169}
5170
5171/// Compute the byte the mask sample loop should use for content pixels
5172/// that fall outside the rasterized mask raster.
5173///
5174/// For Luminosity masks, transparent pixels (no rendered mask paint)
5175/// composite onto the backdrop color, so the effective mask value is the
5176/// backdrop's luminosity. For Alpha masks, transparent = 0 = mask off.
5177/// Both subtypes apply the `/TR {1 exch sub}` transfer inversion.
5178fn out_of_bounds_mask_value(params: &stet_graphics::display_list::SoftMaskParams) -> u8 {
5179    use stet_graphics::display_list::SoftMaskSubtype;
5180    let raw = match params.subtype {
5181        SoftMaskSubtype::Alpha => 0u8,
5182        SoftMaskSubtype::Luminosity => {
5183            let lum = if let Some(bc) = &params.backdrop_color {
5184                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5185            } else {
5186                0.0
5187            };
5188            (lum * 255.0 + 0.5) as u8
5189        }
5190    };
5191    if params.transfer_invert {
5192        255 - raw
5193    } else {
5194        raw
5195    }
5196}
5197
5198/// Maximum mask raster area in pixels.  A malformed PDF that asks for a
5199/// gigantic mask form would otherwise OOM. 64 megapixels = 64 MB for
5200/// grayscale or 256 MB for RGBA — generous but bounded.  Using an area
5201/// limit instead of a per-dimension limit correctly handles narrow-but-tall
5202/// pages (e.g. infographics that exceed 8192 pixels in height while being
5203/// only ~1000 pixels wide).
5204const MAX_MASK_RASTER_PIXELS: u64 = 64 * 1024 * 1024;
5205
5206/// Rasterize a soft mask form's display list into a `MaskRaster`.
5207///
5208/// Walks the mask display list to compute its actual paint bounds (which
5209/// may differ from the SoftMasked element's `params.bbox` because the
5210/// form's internal `cm` operators may translate paint elements outside
5211/// the form's `/BBox`), allocates a pixmap that exactly covers those
5212/// bounds in device-space pixels, and renders the mask elements with the
5213/// viewport set to the bounds origin so each element rasterizes at
5214/// `(device_x - origin_x, device_y - origin_y)`.
5215///
5216/// Returns `None` when the mask paints nothing.
5217fn rasterize_mask(
5218    mask_list: &DisplayList,
5219    params: &stet_graphics::display_list::SoftMaskParams,
5220    icc: Option<&IccCache>,
5221    no_aa: bool,
5222    effective_dpi: f64,
5223    scale_x: f32,
5224    scale_y: f32,
5225) -> Option<stet_graphics::display_list::MaskRaster> {
5226    // 1. Find the actual paint bounds in device space, then cap them to
5227    // the parent gstate's clip path bbox if known. The cap is critical
5228    // for masks whose form contains an unbounded shading inside a
5229    // sentinel-sized internal clip — without it, the raster blows past
5230    // the size limit and produces no output. Pixels outside the parent
5231    // clip can never affect the final image, so the cap is safe.
5232    let mut bounds = compute_paint_bounds(mask_list, effective_dpi)?;
5233    if let Some(cap) = params.parent_clip_bbox {
5234        let cap_bbox = BBox2D {
5235            x_min: cap[0],
5236            y_min: cap[1],
5237            x_max: cap[2],
5238            y_max: cap[3],
5239        };
5240        bounds = intersect_bbox(&bounds, &cap_bbox)?;
5241    }
5242
5243    // 2. Snap to integer device pixels at the current render scale, with a
5244    // 1-pixel pad on each side to avoid antialiasing edge clipping.
5245    let px_x_min = (bounds.x_min as f32 * scale_x).floor() as i32 - 1;
5246    let px_y_min = (bounds.y_min as f32 * scale_y).floor() as i32 - 1;
5247    let px_x_max = (bounds.x_max as f32 * scale_x).ceil() as i32 + 1;
5248    let px_y_max = (bounds.y_max as f32 * scale_y).ceil() as i32 + 1;
5249    if px_x_min >= px_x_max || px_y_min >= px_y_max {
5250        return None;
5251    }
5252    let raster_w = (px_x_max - px_x_min) as u32;
5253    let raster_h = (px_y_max - px_y_min) as u32;
5254    if raster_w == 0 || raster_h == 0 {
5255        return None;
5256    }
5257    if (raster_w as u64) * (raster_h as u64) > MAX_MASK_RASTER_PIXELS {
5258        return None;
5259    }
5260
5261    // 3. Allocate the offscreen pixmap (transparent backdrop).
5262    let mut mask_pixmap = Pixmap::new(raster_w, raster_h)?;
5263
5264    // 4. Build a RenderContext that maps device pixel `(dx, dy)` to
5265    // raster pixel `(dx - px_x_min, dy - px_y_min)`. The viewport is in
5266    // device-space units (not pixels), so divide by scale.
5267    let sub_ctx = RenderContext {
5268        vp_x: px_x_min as f32 / scale_x,
5269        vp_y: px_y_min as f32 / scale_y,
5270        scale_x,
5271        scale_y,
5272        out_w: raster_w,
5273        out_h: raster_h,
5274        effective_dpi,
5275        icc,
5276        image_cache: None,
5277        preprocessed: None,
5278        elem_idx: 0,
5279        no_aa,
5280        opm_zero_transparent: false,
5281        knockout_painter_pass: KnockoutPainterPass::None,
5282        parent_group_isolated: false,
5283        alpha_extraction_pass: false,
5284    };
5285
5286    // 5. Mask rendering doesn't participate in CMYK overprint compositing.
5287    let mut mask_band = BandState {
5288        clip_region: None,
5289        spare_mask: None,
5290        clip_mask_cache: HashMap::new(),
5291        clip_mask_seen: HashSet::new(),
5292        mask_pool: Vec::new(),
5293        cmyk_buffer: None,
5294        op_bg_snapshot: None,
5295        op_touched: None,
5296        spot_mask: None,
5297    };
5298
5299    // 6. Render every element of the mask display list into the offscreen.
5300    for (idx, elem) in mask_list.elements().iter().enumerate() {
5301        let elem_ctx = RenderContext {
5302            elem_idx: idx,
5303            ..sub_ctx
5304        };
5305        render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
5306    }
5307
5308    // 7. If the mask form contained nested gs-set SMask scopes, composite
5309    // the rendered mask onto the backdrop color before extracting
5310    // luminosity. Nested masks produce semi-transparent pixels where
5311    // alpha encodes the mask modulation; without compositing,
5312    // un-premultiplying would amplify the color and lose the modulation.
5313    // Only Luminosity: Alpha masks extract the alpha channel directly,
5314    // so forcing alpha=255 via compositing would destroy the mask info.
5315    if params.has_nested_mask_scope
5316        && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
5317    {
5318        let bc = params.backdrop_color.as_ref();
5319        let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5320        let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5321        let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5322        for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
5323            let a = chunk[3] as u16;
5324            if a == 255 {
5325                continue;
5326            }
5327            let inv_a = 255 - a;
5328            chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
5329            chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
5330            chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
5331            chunk[3] = 255;
5332        }
5333    }
5334
5335    // 8. Extract grayscale mask values into a flat single-channel buffer.
5336    let pixel_count = (raster_w * raster_h) as usize;
5337    let mut data = vec![0u8; pixel_count];
5338    extract_soft_mask_values(mask_pixmap.data(), &mut data, params);
5339
5340    Some(stet_graphics::display_list::MaskRaster {
5341        data,
5342        width: raster_w,
5343        height: raster_h,
5344        origin_x: px_x_min,
5345        origin_y: px_y_min,
5346        scale_x,
5347        scale_y,
5348    })
5349}
5350
5351/// Transform a display element's CTM through a matrix so that pattern-space
5352/// coordinates map to device space.  Recursively transforms children of
5353/// Group and SoftMasked elements, and adjusts their bboxes.
5354fn transform_element_ctm(elem: &DisplayElement, pm: &Matrix) -> DisplayElement {
5355    match elem {
5356        DisplayElement::Fill { path, params } => {
5357            let mut p = params.clone();
5358            p.ctm = pm.concat(&p.ctm);
5359            DisplayElement::Fill {
5360                path: path.clone(),
5361                params: p,
5362            }
5363        }
5364        DisplayElement::Stroke { path, params } => {
5365            let mut p = params.clone();
5366            p.ctm = pm.concat(&p.ctm);
5367            DisplayElement::Stroke {
5368                path: path.clone(),
5369                params: p,
5370            }
5371        }
5372        DisplayElement::Clip { path, params } => {
5373            let mut p = params.clone();
5374            p.ctm = pm.concat(&p.ctm);
5375            if let Some(ref mut sp) = p.stroke_params {
5376                sp.ctm = pm.concat(&sp.ctm);
5377            }
5378            DisplayElement::Clip {
5379                path: path.clone(),
5380                params: p,
5381            }
5382        }
5383        DisplayElement::Image {
5384            sample_data,
5385            params,
5386        } => {
5387            let mut p = params.clone();
5388            p.ctm = pm.concat(&p.ctm);
5389            DisplayElement::Image {
5390                sample_data: sample_data.clone(),
5391                params: p,
5392            }
5393        }
5394        DisplayElement::MeshShading { params } => {
5395            let mut p = params.clone();
5396            p.ctm = pm.concat(&p.ctm);
5397            DisplayElement::MeshShading { params: p }
5398        }
5399        DisplayElement::PatchShading { params } => {
5400            let mut p = params.clone();
5401            p.ctm = pm.concat(&p.ctm);
5402            DisplayElement::PatchShading { params: p }
5403        }
5404        DisplayElement::AxialShading { params } => {
5405            let mut p = params.clone();
5406            p.ctm = pm.concat(&p.ctm);
5407            DisplayElement::AxialShading { params: p }
5408        }
5409        DisplayElement::RadialShading { params } => {
5410            let mut p = params.clone();
5411            p.ctm = pm.concat(&p.ctm);
5412            DisplayElement::RadialShading { params: p }
5413        }
5414        DisplayElement::Group { elements, params } => {
5415            let mut t = DisplayList::new();
5416            for child in elements.elements() {
5417                t.push(transform_element_ctm(child, pm));
5418            }
5419            let mut p = params.clone();
5420            let corners = [
5421                pm.transform_point(p.bbox[0], p.bbox[1]),
5422                pm.transform_point(p.bbox[2], p.bbox[1]),
5423                pm.transform_point(p.bbox[0], p.bbox[3]),
5424                pm.transform_point(p.bbox[2], p.bbox[3]),
5425            ];
5426            p.bbox = [
5427                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5428                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5429                corners
5430                    .iter()
5431                    .map(|c| c.0)
5432                    .fold(f64::NEG_INFINITY, f64::max),
5433                corners
5434                    .iter()
5435                    .map(|c| c.1)
5436                    .fold(f64::NEG_INFINITY, f64::max),
5437            ];
5438            DisplayElement::Group {
5439                elements: t,
5440                params: p,
5441            }
5442        }
5443        DisplayElement::SoftMasked {
5444            mask,
5445            content,
5446            params,
5447            ..
5448        } => {
5449            let mut t_mask = DisplayList::new();
5450            for child in mask.elements() {
5451                t_mask.push(transform_element_ctm(child, pm));
5452            }
5453            let mut t_content = DisplayList::new();
5454            for child in content.elements() {
5455                t_content.push(transform_element_ctm(child, pm));
5456            }
5457            let mut p = params.clone();
5458            let corners = [
5459                pm.transform_point(p.bbox[0], p.bbox[1]),
5460                pm.transform_point(p.bbox[2], p.bbox[1]),
5461                pm.transform_point(p.bbox[0], p.bbox[3]),
5462                pm.transform_point(p.bbox[2], p.bbox[3]),
5463            ];
5464            p.bbox = [
5465                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5466                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5467                corners
5468                    .iter()
5469                    .map(|c| c.0)
5470                    .fold(f64::NEG_INFINITY, f64::max),
5471                corners
5472                    .iter()
5473                    .map(|c| c.1)
5474                    .fold(f64::NEG_INFINITY, f64::max),
5475            ];
5476            // parent_clip_bbox was captured in the original (pattern)
5477            // coordinate system. Transform it through pm to match the
5478            // device-space coords that mask/content elements were just
5479            // moved into; otherwise the renderer would intersect a
5480            // device-space mask bbox with a pattern-space clip and get
5481            // an empty raster.
5482            if let Some(pcb) = p.parent_clip_bbox {
5483                let pcb_corners = [
5484                    pm.transform_point(pcb[0], pcb[1]),
5485                    pm.transform_point(pcb[2], pcb[1]),
5486                    pm.transform_point(pcb[0], pcb[3]),
5487                    pm.transform_point(pcb[2], pcb[3]),
5488                ];
5489                p.parent_clip_bbox = Some([
5490                    pcb_corners
5491                        .iter()
5492                        .map(|c| c.0)
5493                        .fold(f64::INFINITY, f64::min),
5494                    pcb_corners
5495                        .iter()
5496                        .map(|c| c.1)
5497                        .fold(f64::INFINITY, f64::min),
5498                    pcb_corners
5499                        .iter()
5500                        .map(|c| c.0)
5501                        .fold(f64::NEG_INFINITY, f64::max),
5502                    pcb_corners
5503                        .iter()
5504                        .map(|c| c.1)
5505                        .fold(f64::NEG_INFINITY, f64::max),
5506                ]);
5507            }
5508            // The transformed element's coordinate system is different
5509            // from the original; the original cache (if any) is invalid.
5510            // Allocate a fresh cache cell.
5511            DisplayElement::SoftMasked {
5512                mask: t_mask,
5513                content: t_content,
5514                params: p,
5515                mask_cache: Arc::new(Mutex::new(None)),
5516            }
5517        }
5518        DisplayElement::PatternFill { params } => {
5519            let mut p = params.clone();
5520            p.pattern_matrix = pm.concat(&p.pattern_matrix);
5521            // Transform the fill path (device-space coordinates)
5522            p.path = transform_path_by_matrix(&p.path, pm);
5523            if let Some(ref mut sp) = p.stroke_params {
5524                sp.ctm = pm.concat(&sp.ctm);
5525            }
5526            DisplayElement::PatternFill { params: p }
5527        }
5528        DisplayElement::OcgGroup {
5529            elements,
5530            ocg_id,
5531            default_visible,
5532        } => {
5533            let mut t = DisplayList::new();
5534            for child in elements.elements() {
5535                t.push(transform_element_ctm(child, pm));
5536            }
5537            DisplayElement::OcgGroup {
5538                elements: t,
5539                ocg_id: *ocg_id,
5540                default_visible: *default_visible,
5541            }
5542        }
5543        other => other.clone(),
5544    }
5545}
5546
5547/// Transform all points in a path through a matrix.
5548fn transform_path_by_matrix(path: &PsPath, m: &Matrix) -> PsPath {
5549    use stet_fonts::geometry::PathSegment;
5550    let mut out = PsPath::new();
5551    for seg in &path.segments {
5552        out.segments.push(match *seg {
5553            PathSegment::MoveTo(x, y) => {
5554                let (nx, ny) = m.transform_point(x, y);
5555                PathSegment::MoveTo(nx, ny)
5556            }
5557            PathSegment::LineTo(x, y) => {
5558                let (nx, ny) = m.transform_point(x, y);
5559                PathSegment::LineTo(nx, ny)
5560            }
5561            PathSegment::CurveTo {
5562                x1,
5563                y1,
5564                x2,
5565                y2,
5566                x3,
5567                y3,
5568            } => {
5569                let (nx1, ny1) = m.transform_point(x1, y1);
5570                let (nx2, ny2) = m.transform_point(x2, y2);
5571                let (nx3, ny3) = m.transform_point(x3, y3);
5572                PathSegment::CurveTo {
5573                    x1: nx1,
5574                    y1: ny1,
5575                    x2: nx2,
5576                    y2: ny2,
5577                    x3: nx3,
5578                    y3: ny3,
5579                }
5580            }
5581            PathSegment::ClosePath => PathSegment::ClosePath,
5582        });
5583    }
5584    out
5585}
5586
5587/// Render a tiled pattern fill.
5588/// Bilinear downscale of premultiplied RGBA image data.
5589///
5590/// Used to pre-scale pattern tile images when the device-space tile is smaller
5591/// than the image resolution, since tiny-skia's `draw_pixmap` doesn't handle
5592/// sub-1.0 scale transforms.
5593fn bilinear_prescale(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
5594    let mut dst = vec![0u8; (dw * dh * 4) as usize];
5595    for dy in 0..dh {
5596        let sy_f = (dy as f64 + 0.5) * sh as f64 / dh as f64 - 0.5;
5597        let sy0 = sy_f.floor().max(0.0) as u32;
5598        let sy1 = (sy0 + 1).min(sh - 1);
5599        let fy = (sy_f - sy0 as f64) as f32;
5600        let ify = 1.0 - fy;
5601        for dx in 0..dw {
5602            let sx_f = (dx as f64 + 0.5) * sw as f64 / dw as f64 - 0.5;
5603            let sx0 = sx_f.floor().max(0.0) as u32;
5604            let sx1 = (sx0 + 1).min(sw - 1);
5605            let fx = (sx_f - sx0 as f64) as f32;
5606            let ifx = 1.0 - fx;
5607
5608            let i00 = (sy0 * sw + sx0) as usize * 4;
5609            let i10 = (sy0 * sw + sx1) as usize * 4;
5610            let i01 = (sy1 * sw + sx0) as usize * 4;
5611            let i11 = (sy1 * sw + sx1) as usize * 4;
5612            let di = (dy * dw + dx) as usize * 4;
5613            for c in 0..4 {
5614                dst[di + c] = (src[i00 + c] as f32 * ifx * ify
5615                    + src[i10 + c] as f32 * fx * ify
5616                    + src[i01 + c] as f32 * ifx * fy
5617                    + src[i11 + c] as f32 * fx * fy)
5618                    .round() as u8;
5619            }
5620        }
5621    }
5622    dst
5623}
5624
5625fn render_pattern_fill(
5626    pixmap: &mut Pixmap,
5627    band_state: &mut BandState,
5628    params: &stet_graphics::device::PatternFillParams,
5629    ctx: &RenderContext<'_>,
5630) {
5631    let mut temp_mask = None;
5632    let Some(mask_ref) = resolve_clip_mask(
5633        &band_state.clip_region,
5634        &mut temp_mask,
5635        ctx.out_w,
5636        ctx.out_h,
5637    ) else {
5638        return;
5639    };
5640
5641    let pm = &params.pattern_matrix;
5642
5643    // Tile step vectors in device space (handles rotation/shear)
5644    let (step_ux, step_uy) = pm.transform_delta(params.xstep, 0.0);
5645    let (step_vx, step_vy) = pm.transform_delta(0.0, params.ystep);
5646
5647    let step_u_len = (step_ux * step_ux + step_uy * step_uy).sqrt();
5648    let step_v_len = (step_vx * step_vx + step_vy * step_vy).sqrt();
5649    if step_u_len < 0.01 || step_v_len < 0.01 {
5650        return;
5651    }
5652
5653    let origin_x = pm.tx;
5654    let origin_y = pm.ty;
5655
5656    // Viewport bounds in device space
5657    let dev_vp_x = ctx.vp_x as f64;
5658    let dev_vp_y = ctx.vp_y as f64;
5659    let dev_vp_w = ctx.out_w as f64 / ctx.scale_x as f64;
5660    let dev_vp_h = ctx.out_h as f64 / ctx.scale_y as f64;
5661
5662    let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
5663    for seg in &params.path.segments {
5664        let (x, y) = match seg {
5665            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => (*x, *y),
5666            PathSegment::CurveTo { x3, y3, .. } => (*x3, *y3),
5667            PathSegment::ClosePath => continue,
5668        };
5669        min_x = min_x.min(x);
5670        min_y = min_y.min(y);
5671        max_x = max_x.max(x);
5672        max_y = max_y.max(y);
5673    }
5674
5675    // For stroke patterns, the path extends beyond the centerline by half
5676    // the stroke width.  The path is in user space; transform the bbox
5677    // corners through the CTM to get device-space bounds.
5678    if let Some(ref sp) = params.stroke_params {
5679        // Transform user-space bbox corners through CTM to device space
5680        let ctm = &sp.ctm;
5681        let corners = [
5682            ctm.transform_point(min_x, min_y),
5683            ctm.transform_point(max_x, min_y),
5684            ctm.transform_point(min_x, max_y),
5685            ctm.transform_point(max_x, max_y),
5686        ];
5687        min_x = f64::MAX;
5688        min_y = f64::MAX;
5689        max_x = f64::MIN;
5690        max_y = f64::MIN;
5691        for (cx, cy) in &corners {
5692            min_x = min_x.min(*cx);
5693            min_y = min_y.min(*cy);
5694            max_x = max_x.max(*cx);
5695            max_y = max_y.max(*cy);
5696        }
5697        // Expand by half stroke width in device space
5698        let half_w = sp.line_width
5699            * 0.5
5700            * (ctm.a * ctm.a + ctm.b * ctm.b)
5701                .sqrt()
5702                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
5703        min_x -= half_w;
5704        min_y -= half_w;
5705        max_x += half_w;
5706        max_y += half_w;
5707    }
5708
5709    // Clamp to viewport bounds in device space
5710    min_x = min_x.max(dev_vp_x);
5711    min_y = min_y.max(dev_vp_y);
5712    max_x = max_x.min(dev_vp_x + dev_vp_w);
5713    max_y = max_y.min(dev_vp_y + dev_vp_h);
5714    if min_x >= max_x || min_y >= max_y {
5715        return;
5716    }
5717
5718    let det = step_ux * step_vy - step_uy * step_vx;
5719    if det.abs() < 1e-10 {
5720        return;
5721    }
5722    let inv_det = 1.0 / det;
5723
5724    let mut tu_min = f64::MAX;
5725    let mut tu_max = f64::MIN;
5726    let mut tv_min = f64::MAX;
5727    let mut tv_max = f64::MIN;
5728    for &(cx, cy) in &[
5729        (min_x, min_y),
5730        (max_x, min_y),
5731        (min_x, max_y),
5732        (max_x, max_y),
5733    ] {
5734        let dx = cx - origin_x;
5735        let dy = cy - origin_y;
5736        let tu = (dx * step_vy - dy * step_vx) * inv_det;
5737        let tv = (-dx * step_uy + dy * step_ux) * inv_det;
5738        tu_min = tu_min.min(tu);
5739        tu_max = tu_max.max(tu);
5740        tv_min = tv_min.min(tv);
5741        tv_max = tv_max.max(tv);
5742    }
5743
5744    let tile_x_start = tu_min.floor() as i32 - 1;
5745    let tile_x_end = tu_max.ceil() as i32 + 1;
5746    let tile_y_start = tv_min.floor() as i32 - 1;
5747    let tile_y_end = tv_max.ceil() as i32 + 1;
5748
5749    let tile_count = (tile_x_end - tile_x_start) as i64 * (tile_y_end - tile_y_start) as i64;
5750    if tile_count > 10000 {
5751        return;
5752    }
5753
5754    let Some(mut tile_buf) = Pixmap::new(ctx.out_w, ctx.out_h) else {
5755        return;
5756    };
5757
5758    let sx_f = ctx.scale_x as f64;
5759    let sy_f = ctx.scale_y as f64;
5760
5761    if params.device_space_tile {
5762        // Device-space tile path: tile elements have CTMs in device space
5763        // (pattern matrix baked in). Use the full render_element pipeline
5764        // which handles all element types (clips, soft masks, shadings,
5765        // groups). For each tile position, shift the viewport origin by the
5766        // tile offset in device space.
5767        for tv in tile_y_start..tile_y_end {
5768            for tu in tile_x_start..tile_x_end {
5769                let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
5770                let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
5771
5772                let tile_ctx = RenderContext {
5773                    vp_x: ctx.vp_x - offset_x as f32,
5774                    vp_y: ctx.vp_y - offset_y as f32,
5775                    scale_x: ctx.scale_x,
5776                    scale_y: ctx.scale_y,
5777                    out_w: ctx.out_w,
5778                    out_h: ctx.out_h,
5779                    effective_dpi: ctx.effective_dpi,
5780                    icc: ctx.icc,
5781                    image_cache: None,
5782                    preprocessed: None,
5783                    elem_idx: 0,
5784                    no_aa: ctx.no_aa,
5785                    opm_zero_transparent: params.overprint_mode == 1,
5786                    knockout_painter_pass: ctx.knockout_painter_pass,
5787                    parent_group_isolated: ctx.parent_group_isolated,
5788                    alpha_extraction_pass: ctx.alpha_extraction_pass,
5789                };
5790
5791                let mut tile_band = BandState {
5792                    clip_region: None,
5793                    spare_mask: None,
5794                    clip_mask_cache: HashMap::new(),
5795                    clip_mask_seen: HashSet::new(),
5796                    mask_pool: Vec::new(),
5797                    cmyk_buffer: None,
5798                    op_bg_snapshot: None,
5799                    op_touched: None,
5800                    spot_mask: None,
5801                };
5802
5803                for (idx, elem) in params.tile.elements().iter().enumerate() {
5804                    let elem_ctx = RenderContext {
5805                        elem_idx: idx,
5806                        ..tile_ctx
5807                    };
5808                    render_element(&mut tile_buf, &mut tile_band, elem, &elem_ctx);
5809                }
5810            }
5811        }
5812    } else if params.tile.elements().iter().any(|e| {
5813        !matches!(
5814            e,
5815            DisplayElement::Fill { .. }
5816                | DisplayElement::Stroke { .. }
5817                | DisplayElement::Image { .. }
5818                | DisplayElement::Clip { .. }
5819                | DisplayElement::InitClip
5820        )
5821    }) {
5822        // Complex tile path: pre-render one tile into a small pixmap using
5823        // the full render_element pipeline (handles shadings, groups,
5824        // soft masks, etc.), then stamp copies at each tile position.
5825        let bbox = &params.bbox;
5826        let corners_dev = [
5827            pm.transform_point(bbox[0], bbox[1]),
5828            pm.transform_point(bbox[2], bbox[1]),
5829            pm.transform_point(bbox[0], bbox[3]),
5830            pm.transform_point(bbox[2], bbox[3]),
5831        ];
5832        let (mut td_x0, mut td_y0) = (f64::MAX, f64::MAX);
5833        let (mut td_x1, mut td_y1) = (f64::MIN, f64::MIN);
5834        for (x, y) in &corners_dev {
5835            td_x0 = td_x0.min(*x);
5836            td_y0 = td_y0.min(*y);
5837            td_x1 = td_x1.max(*x);
5838            td_y1 = td_y1.max(*y);
5839        }
5840        let tile_pw = ((td_x1 - td_x0) * sx_f).ceil().max(1.0) as u32;
5841        let tile_ph = ((td_y1 - td_y0) * sy_f).ceil().max(1.0) as u32;
5842        let tile_pw = tile_pw.min(8192);
5843        let tile_ph = tile_ph.min(8192);
5844
5845        if let Some(mut one_tile) = Pixmap::new(tile_pw, tile_ph) {
5846            let tile_render_ctx = RenderContext {
5847                vp_x: td_x0 as f32,
5848                vp_y: td_y0 as f32,
5849                scale_x: ctx.scale_x,
5850                scale_y: ctx.scale_y,
5851                out_w: tile_pw,
5852                out_h: tile_ph,
5853                effective_dpi: ctx.effective_dpi,
5854                icc: ctx.icc,
5855                image_cache: None,
5856                preprocessed: None,
5857                elem_idx: 0,
5858                no_aa: ctx.no_aa,
5859                opm_zero_transparent: params.overprint_mode == 1,
5860                knockout_painter_pass: ctx.knockout_painter_pass,
5861                parent_group_isolated: ctx.parent_group_isolated,
5862                alpha_extraction_pass: ctx.alpha_extraction_pass,
5863            };
5864            let mut tile_bs = BandState {
5865                clip_region: None,
5866                spare_mask: None,
5867                clip_mask_cache: HashMap::new(),
5868                clip_mask_seen: HashSet::new(),
5869                mask_pool: Vec::new(),
5870                cmyk_buffer: None,
5871                op_bg_snapshot: None,
5872                op_touched: None,
5873                spot_mask: None,
5874            };
5875            for (idx, elem) in params.tile.elements().iter().enumerate() {
5876                let transformed = transform_element_ctm(elem, pm);
5877                let elem_ctx = RenderContext {
5878                    elem_idx: idx,
5879                    ..tile_render_ctx
5880                };
5881                render_element(&mut one_tile, &mut tile_bs, &transformed, &elem_ctx);
5882            }
5883            // Stamp pre-rendered tile at each position
5884            for tv in tile_y_start..tile_y_end {
5885                for tu in tile_x_start..tile_x_end {
5886                    let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
5887                    let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
5888                    let px = ((td_x0 + offset_x - dev_vp_x) * sx_f) as i32;
5889                    let py = ((td_y0 + offset_y - dev_vp_y) * sy_f) as i32;
5890                    let paint = stet_tiny_skia::PixmapPaint {
5891                        opacity: 1.0,
5892                        blend_mode: BlendMode::SourceOver,
5893                        quality: stet_tiny_skia::FilterQuality::Nearest,
5894                    };
5895                    tile_buf.draw_pixmap(
5896                        px,
5897                        py,
5898                        one_tile.as_ref(),
5899                        &paint,
5900                        Transform::identity(),
5901                        None,
5902                    );
5903                }
5904            }
5905        }
5906    } else {
5907        // Simple tile path: tile elements have identity CTMs.
5908        // Manually apply the pattern matrix + tile offset for each element.
5909        // Only handles Fill, Stroke, Image, and Clip.
5910
5911        // Pre-process Image elements: convert to RGBA once and pre-scale if
5912        // the combined transform would require downscaling (scale < 1.0).
5913        // tiny-skia's draw_pixmap doesn't handle sub-1.0 scale transforms.
5914        struct PreprocessedImage {
5915            rgba: Vec<u8>,
5916            width: u32,
5917            height: u32,
5918            /// Transform from pixel coords to pattern space, possibly adjusted
5919            /// to account for pre-scaling.
5920            img_transform: Transform,
5921        }
5922        let tile_elements = params.tile.elements();
5923        let mut preprocessed: Vec<Option<PreprocessedImage>> =
5924            Vec::with_capacity(tile_elements.len());
5925        // Tile transform scale components (constant across all tiles)
5926        let tt_sx = (pm.a * sx_f) as f32;
5927        let tt_sy = (pm.d * sy_f) as f32;
5928        let tt_kx = (pm.c * sx_f) as f32;
5929        let tt_ky = (pm.b * sy_f) as f32;
5930        for elem in tile_elements {
5931            if let DisplayElement::Image {
5932                sample_data,
5933                params: ip,
5934            } = elem
5935            {
5936                let iw = ip.width;
5937                let ih = ip.height;
5938                if iw > 0 && ih > 0 {
5939                    let mut rgba =
5940                        samples_to_rgba(sample_data, ip, ctx.icc, ctx.opm_zero_transparent);
5941                    if ip.mask_color.is_some() {
5942                        apply_mask_color_rgba(&mut rgba, sample_data, ip);
5943                    }
5944                    let expected = (iw * ih * 4) as usize;
5945                    if rgba.len() >= expected {
5946                        if let Some(inv) = ip.image_matrix.invert() {
5947                            let combined_mat = ip.ctm.concat(&inv);
5948                            let t = to_transform(&combined_mat);
5949                            // Check effective scale: t maps image pixels → pattern space,
5950                            // tile_transform maps pattern space → device space.
5951                            let test = t.post_concat(Transform::from_row(
5952                                tt_sx, tt_ky, tt_kx, tt_sy, 0.0, 0.0,
5953                            ));
5954                            let eff_sx = (test.sx * test.sx + test.ky * test.ky).sqrt();
5955                            let eff_sy = (test.kx * test.kx + test.sy * test.sy).sqrt();
5956                            if eff_sx < 0.99 || eff_sy < 0.99 {
5957                                // Pre-scale image to avoid sub-1.0 draw_pixmap transform.
5958                                // Use floor so the scaled image is smaller than the
5959                                // device-space tile, ensuring the adjusted scale >= 1.0.
5960                                let tw = (iw as f32 * eff_sx).floor().max(1.0) as u32;
5961                                let th = (ih as f32 * eff_sy).floor().max(1.0) as u32;
5962                                let scaled = bilinear_prescale(&rgba, iw, ih, tw, th);
5963                                // Adjust transform: pre-multiply a scale that maps new
5964                                // pixel coords back to original pixel coords
5965                                let adj = Transform::from_scale(
5966                                    iw as f32 / tw as f32,
5967                                    ih as f32 / th as f32,
5968                                );
5969                                preprocessed.push(Some(PreprocessedImage {
5970                                    rgba: scaled,
5971                                    width: tw,
5972                                    height: th,
5973                                    img_transform: t.pre_concat(adj),
5974                                }));
5975                            } else {
5976                                preprocessed.push(Some(PreprocessedImage {
5977                                    rgba,
5978                                    width: iw,
5979                                    height: ih,
5980                                    img_transform: t,
5981                                }));
5982                            }
5983                        } else {
5984                            preprocessed.push(None);
5985                        }
5986                    } else {
5987                        preprocessed.push(None);
5988                    }
5989                } else {
5990                    preprocessed.push(None);
5991                }
5992                // Note: only Image elements push to preprocessed, so img_idx
5993                // in the tile loop correctly indexes this array.
5994            }
5995        }
5996
5997        for tv in tile_y_start..tile_y_end {
5998            for tu in tile_x_start..tile_x_end {
5999                let pat_offset_x = tu as f64 * params.xstep;
6000                let pat_offset_y = tv as f64 * params.ystep;
6001
6002                let tile_transform = Transform::from_row(
6003                    tt_sx,
6004                    tt_ky,
6005                    tt_kx,
6006                    tt_sy,
6007                    ((pm.a * pat_offset_x + pm.c * pat_offset_y + pm.tx - dev_vp_x) * sx_f) as f32,
6008                    ((pm.b * pat_offset_x + pm.d * pat_offset_y + pm.ty - dev_vp_y) * sy_f) as f32,
6009                );
6010
6011                // Clip tile elements to BBox (PDF spec 8.7.4.2)
6012                let bbox_clip = {
6013                    let bb = &params.bbox;
6014                    let mut bp = stet_tiny_skia::PathBuilder::new();
6015                    bp.move_to(bb[0] as f32, bb[1] as f32);
6016                    bp.line_to(bb[2] as f32, bb[1] as f32);
6017                    bp.line_to(bb[2] as f32, bb[3] as f32);
6018                    bp.line_to(bb[0] as f32, bb[3] as f32);
6019                    bp.close();
6020                    bp.finish().and_then(|sp| {
6021                        let mut m = Mask::new(ctx.out_w, ctx.out_h)?;
6022                        m.fill_path(
6023                            &sp,
6024                            stet_tiny_skia::FillRule::Winding,
6025                            false,
6026                            tile_transform,
6027                        );
6028                        Some(m)
6029                    })
6030                };
6031                let mut tile_clip: Option<Mask> = bbox_clip;
6032                let mut img_idx = 0usize;
6033                for elem in tile_elements {
6034                    let clip_ref = tile_clip.as_ref();
6035                    match elem {
6036                        DisplayElement::Clip { path, params: cp } => {
6037                            if let Some(sp) = build_skia_path(path) {
6038                                let t = to_transform(&cp.ctm);
6039                                let combined = t.post_concat(tile_transform);
6040                                let mut mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6041                                mask.fill_path(&sp, to_fill_rule(&cp.fill_rule), false, combined);
6042                                if let Some(prev) = tile_clip.take() {
6043                                    intersect_masks(&mut mask, &prev);
6044                                }
6045                                tile_clip = Some(mask);
6046                            }
6047                        }
6048                        DisplayElement::InitClip => {
6049                            tile_clip = None;
6050                        }
6051                        DisplayElement::Fill { path, params: fp } => {
6052                            if let Some(sp) = build_skia_path(path) {
6053                                let mut paint = if params.paint_type == 1 {
6054                                    to_paint(&fp.color)
6055                                } else {
6056                                    to_paint(
6057                                        params
6058                                            .underlying_color
6059                                            .as_ref()
6060                                            .unwrap_or(&DeviceColor::black()),
6061                                    )
6062                                };
6063                                paint.anti_alias = false;
6064                                let t = to_transform(&fp.ctm);
6065                                let combined = t.post_concat(tile_transform);
6066                                let fr = to_fill_rule(&fp.fill_rule);
6067                                tile_buf.fill_path(&sp, &paint, fr, combined, clip_ref);
6068                            }
6069                        }
6070                        DisplayElement::Stroke { path, params: sp } => {
6071                            if let Some(skp) = build_skia_path(path) {
6072                                // Compose element CTM with pattern matrix so
6073                                // hairline_min_width sees the real device scale,
6074                                // not the tile's identity CTM.
6075                                let effective_ctm = pm.concat(&sp.ctm);
6076                                let mut sp_adj = sp.clone();
6077                                sp_adj.ctm = effective_ctm;
6078                                let stroke = build_stroke(&sp_adj, ctx.effective_dpi);
6079                                let paint = if params.paint_type == 1 {
6080                                    to_paint(&sp.color)
6081                                } else {
6082                                    to_paint(
6083                                        params
6084                                            .underlying_color
6085                                            .as_ref()
6086                                            .unwrap_or(&DeviceColor::black()),
6087                                    )
6088                                };
6089                                let t = to_transform(&sp.ctm);
6090                                let combined = t.post_concat(tile_transform);
6091                                tile_buf.stroke_path(&skp, &paint, &stroke, combined, clip_ref);
6092                            }
6093                        }
6094                        DisplayElement::Image { .. } => {
6095                            if let Some(ref pi) = preprocessed[img_idx] {
6096                                let combined = pi.img_transform.post_concat(tile_transform);
6097                                if let Some(img_ref) = stet_tiny_skia::PixmapRef::from_bytes(
6098                                    &pi.rgba, pi.width, pi.height,
6099                                ) {
6100                                    let paint = stet_tiny_skia::PixmapPaint {
6101                                        opacity: 1.0,
6102                                        blend_mode: BlendMode::SourceOver,
6103                                        quality: stet_tiny_skia::FilterQuality::Nearest,
6104                                    };
6105                                    tile_buf.draw_pixmap(0, 0, img_ref, &paint, combined, clip_ref);
6106                                }
6107                            }
6108                            img_idx += 1;
6109                        }
6110                        _ => {}
6111                    }
6112                }
6113            }
6114        }
6115    }
6116
6117    // Composite tile_buf onto main pixmap through the fill/stroke path
6118    let Some(fill_skia_path) = build_skia_path(&params.path) else {
6119        return;
6120    };
6121    let fill_rule = to_fill_rule(&params.fill_rule);
6122    let mut fill_mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6123    let path_transform = viewport_transform(
6124        Transform::identity(),
6125        ctx.vp_x,
6126        ctx.vp_y,
6127        ctx.scale_x,
6128        ctx.scale_y,
6129    );
6130    if let Some(ref sp) = params.stroke_params {
6131        // Stroke pattern: expand the centerline path to a fill outline
6132        // using the stroke parameters (width, cap, join, miter, dash).
6133        // Apply dash pattern first (Path::stroke doesn't handle dashing).
6134        let stroke = build_stroke(sp, ctx.effective_dpi);
6135        let ctm_transform = to_transform(&sp.ctm);
6136        let combined = ctm_transform.post_concat(path_transform);
6137        let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&combined);
6138        let dashed;
6139        let stroke_path = if let Some(ref dash) = stroke.dash {
6140            dashed = fill_skia_path.dash(dash, res_scale);
6141            match dashed.as_ref() {
6142                Some(p) => p,
6143                None => &fill_skia_path,
6144            }
6145        } else {
6146            &fill_skia_path
6147        };
6148        if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6149            fill_mask.fill_path(
6150                &outline,
6151                stet_tiny_skia::FillRule::Winding,
6152                !ctx.no_aa,
6153                combined,
6154            );
6155        }
6156    } else {
6157        fill_mask.fill_path(&fill_skia_path, fill_rule, !ctx.no_aa, path_transform);
6158    }
6159
6160    if let Some(clip_mask) = mask_ref {
6161        intersect_masks(&mut fill_mask, clip_mask);
6162    }
6163
6164    let img_paint = stet_tiny_skia::PixmapPaint::default();
6165    pixmap.draw_pixmap(
6166        0,
6167        0,
6168        tile_buf.as_ref(),
6169        &img_paint,
6170        Transform::identity(),
6171        Some(&fill_mask),
6172    );
6173}
6174
6175/// Unified clip path handling for both band and viewport rendering.
6176///
6177/// For band rendering (scale=1.0), includes rect fast-path and Y-bbox early exit.
6178/// For viewport rendering (scale!=1.0), uses the general mask path.
6179fn clip_path_unified(
6180    band_state: &mut BandState,
6181    path: &PsPath,
6182    params: &ClipParams,
6183    ctx: &RenderContext<'_>,
6184) {
6185    let is_unit_scale = ctx.scale_x == 1.0 && ctx.scale_y == 1.0;
6186
6187    // Band-mode optimizations (scale=1.0): Y-bbox early exit and rect fast-path
6188    if is_unit_scale {
6189        let y_start = ctx.vp_y as u32;
6190        let x_start = ctx.vp_x as u32;
6191
6192        // Y-bbox early exit: if clip path doesn't overlap this band, set empty clip
6193        // (only valid when CTM is identity — path coords must be in device space).
6194        // Skip when stroke_params is present: the path is in user space and
6195        // needs the stroke CTM transform, so raw Y bounds are meaningless here.
6196        if x_start == 0
6197            && params.stroke_params.is_none()
6198            && params.ctm.a == 1.0
6199            && params.ctm.d == 1.0
6200            && params.ctm.tx == 0.0
6201            && params.ctm.ty == 0.0
6202            && let Some(bbox) = path_y_bbox(path)
6203            && (bbox.y_max <= y_start as f64 || bbox.y_min >= (y_start + ctx.out_h) as f64)
6204        {
6205            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
6206                band_state.recycle_mask(mask);
6207            }
6208            band_state.clip_region = Some(ClipRegion::Rect(ClipRect {
6209                x0: 0,
6210                y0: 0,
6211                x1: 0,
6212                y1: 0,
6213            }));
6214            return;
6215        }
6216
6217        // Rect fast-path (only when x_start==0 and CTM is identity —
6218        // detect_rect uses raw path coords which are only in device space
6219        // when the CTM is identity)
6220        let ctm_is_identity = params.ctm.a == 1.0
6221            && params.ctm.b == 0.0
6222            && params.ctm.c == 0.0
6223            && params.ctm.d == 1.0
6224            && params.ctm.tx == 0.0
6225            && params.ctm.ty == 0.0;
6226        if x_start == 0
6227            && ctm_is_identity
6228            && params.stroke_params.is_none()
6229            && let Some(dev_rect) = detect_rect(path, ctx.out_w, u32::MAX)
6230        {
6231            let new_rect = translate_clip_rect(&dev_rect, y_start, ctx.out_h);
6232            match band_state.clip_region.take() {
6233                None => {
6234                    band_state.clip_region = Some(ClipRegion::Rect(new_rect));
6235                }
6236                Some(ClipRegion::Rect(existing)) => {
6237                    band_state.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6238                }
6239                Some(ClipRegion::Mask(mut mask)) => {
6240                    intersect_mask_with_rect(&mut mask, &new_rect, ctx.out_w, ctx.out_h);
6241                    band_state.clip_region = Some(ClipRegion::Mask(mask));
6242                }
6243            }
6244            return;
6245        }
6246    }
6247
6248    // General path: non-rectangular clip with cache + mask reuse
6249    let fill_rule = to_fill_rule(&params.fill_rule);
6250    let path_hash = hash_clip_path(path, &params.fill_rule);
6251    let prev_region = band_state.clip_region.take();
6252
6253    let mut mask = band_state.take_mask(ctx.out_w, ctx.out_h);
6254
6255    let path_mask = if let Some(cached) = band_state.clip_mask_cache.get(&path_hash) {
6256        mask.data_mut().copy_from_slice(cached.data());
6257        mask
6258    } else {
6259        let Some(skia_path) = build_skia_path(path) else {
6260            band_state.recycle_mask(mask);
6261            band_state.clip_region = prev_region;
6262            return;
6263        };
6264        mask.data_mut().fill(0);
6265        if let Some(ref sp) = params.stroke_params {
6266            // Stroke-based clip: expand centerline to stroke outline.
6267            // Apply dash pattern first (Path::stroke doesn't handle dashing).
6268            let stroke = build_stroke(sp, ctx.effective_dpi);
6269            let transform = ctx.transform(&sp.ctm);
6270            let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&transform);
6271            let dashed;
6272            let stroke_path = if let Some(ref dash) = stroke.dash {
6273                dashed = skia_path.dash(dash, res_scale);
6274                match dashed.as_ref() {
6275                    Some(p) => p,
6276                    None => &skia_path,
6277                }
6278            } else {
6279                &skia_path
6280            };
6281            if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6282                mask.fill_path(
6283                    &outline,
6284                    stet_tiny_skia::FillRule::Winding,
6285                    false,
6286                    transform,
6287                );
6288            }
6289        } else {
6290            let transform = ctx.transform(&params.ctm);
6291            mask.fill_path(&skia_path, fill_rule, false, transform);
6292        }
6293        if !band_state.clip_mask_seen.insert(path_hash) {
6294            band_state.clip_mask_cache.insert(path_hash, mask.clone());
6295        }
6296        mask
6297    };
6298
6299    match prev_region {
6300        None => {
6301            band_state.clip_region = Some(ClipRegion::Mask(path_mask));
6302        }
6303        Some(ClipRegion::Rect(rect)) => {
6304            if rect.is_empty() {
6305                band_state.recycle_mask(path_mask);
6306                // Intersection with empty clip is still empty — preserve empty state.
6307                // Without this, clip_region stays None (= no clip = paint everything).
6308                band_state.clip_region = Some(ClipRegion::Rect(rect));
6309            } else {
6310                let mut mask = path_mask;
6311                intersect_mask_with_rect(&mut mask, &rect, ctx.out_w, ctx.out_h);
6312                band_state.clip_region = Some(ClipRegion::Mask(mask));
6313            }
6314        }
6315        Some(ClipRegion::Mask(mut existing)) => {
6316            intersect_masks(&mut existing, &path_mask);
6317            band_state.recycle_mask(path_mask);
6318            band_state.clip_region = Some(ClipRegion::Mask(existing));
6319        }
6320    }
6321}
6322impl OutputDevice for SkiaDevice {
6323    fn fill_path(&mut self, path: &PsPath, params: &FillParams) {
6324        self.ensure_full_pixmap();
6325        let Some(skia_path) = build_skia_path(path) else {
6326            return;
6327        };
6328        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6329        let mut temp_mask = None;
6330        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6331            return; // empty clip
6332        };
6333
6334        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6335        let transform = to_transform(&params.ctm);
6336        let fill_rule = to_fill_rule(&params.fill_rule);
6337
6338        self.pixmap
6339            .fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
6340    }
6341
6342    fn stroke_path(&mut self, path: &PsPath, params: &StrokeParams) {
6343        self.ensure_full_pixmap();
6344        let stroke = build_stroke(params, self.dpi);
6345        let adjusted;
6346        let draw_path =
6347            if params.stroke_adjust && stroke.width <= 2.0 && ctm_is_device_space(&params.ctm) {
6348                adjusted =
6349                    stroke_adjust_path_viewport(path, stroke.width as f64, 1.0, 1.0, 0.0, 0.0);
6350                &adjusted
6351            } else {
6352                path
6353            };
6354        let Some(skia_path) = build_skia_path(draw_path) else {
6355            return;
6356        };
6357        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6358        let transform = to_transform(&params.ctm);
6359
6360        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6361        let mut temp_mask = None;
6362        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6363            return; // empty clip
6364        };
6365
6366        self.pixmap
6367            .stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
6368    }
6369
6370    fn clip_path(&mut self, path: &PsPath, params: &ClipParams) {
6371        self.ensure_full_pixmap();
6372        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6373
6374        // Fast path: detect axis-aligned rectangle
6375        if let Some(new_rect) = detect_rect(path, w, h) {
6376            match self.clip_region.take() {
6377                None => {
6378                    self.clip_region = Some(ClipRegion::Rect(new_rect));
6379                }
6380                Some(ClipRegion::Rect(existing)) => {
6381                    // O(1) rect-rect intersection
6382                    self.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6383                }
6384                Some(ClipRegion::Mask(mut mask)) => {
6385                    // Zero mask pixels outside rect
6386                    intersect_mask_with_rect(&mut mask, &new_rect, w, h);
6387                    self.clip_region = Some(ClipRegion::Mask(mask));
6388                }
6389            }
6390            return;
6391        }
6392
6393        // Slow path: non-rectangular clip with mask caching + allocation reuse.
6394        let fill_rule = to_fill_rule(&params.fill_rule);
6395        let path_hash = hash_clip_path(path, &params.fill_rule);
6396        let prev_region = self.clip_region.take();
6397
6398        // Reuse a spare mask buffer if available (avoids alloc/dealloc per tile).
6399        macro_rules! take_spare {
6400            ($self:expr, $w:expr, $h:expr) => {
6401                $self
6402                    .spare_mask
6403                    .take()
6404                    .unwrap_or_else(|| Mask::new($w, $h).expect("Failed to create mask"))
6405            };
6406        }
6407
6408        // Try cache first; rasterize only on miss
6409        let path_mask = if let Some(cached) = self.clip_mask_cache.get(&path_hash) {
6410            // Cache hit: copy cached data into reused buffer (memcpy, no alloc)
6411            let mut mask = take_spare!(self, w, h);
6412            mask.data_mut().copy_from_slice(cached.data());
6413            mask
6414        } else {
6415            let Some(skia_path) = build_skia_path(path) else {
6416                self.clip_region = prev_region;
6417                return;
6418            };
6419            let transform = to_transform(&params.ctm);
6420            let mut mask = take_spare!(self, w, h);
6421            mask.data_mut().fill(0); // zero before rasterizing (spare may have old data)
6422            mask.fill_path(&skia_path, fill_rule, false, transform);
6423            // Cache on second sight: first time just record, second time store
6424            if !self.clip_mask_seen.insert(path_hash) {
6425                // Seen before — cache it (this clone only happens once per unique path)
6426                self.clip_mask_cache.insert(path_hash, mask.clone());
6427            }
6428            mask
6429        };
6430
6431        match prev_region {
6432            None => {
6433                self.clip_region = Some(ClipRegion::Mask(path_mask));
6434            }
6435            Some(ClipRegion::Rect(rect)) => {
6436                if rect.is_empty() {
6437                    self.spare_mask = Some(path_mask); // recycle
6438                } else {
6439                    let mut mask = path_mask;
6440                    intersect_mask_with_rect(&mut mask, &rect, w, h);
6441                    self.clip_region = Some(ClipRegion::Mask(mask));
6442                }
6443            }
6444            Some(ClipRegion::Mask(mut existing)) => {
6445                intersect_masks(&mut existing, &path_mask);
6446                self.spare_mask = Some(path_mask); // recycle the copy
6447                self.clip_region = Some(ClipRegion::Mask(existing));
6448            }
6449        }
6450    }
6451
6452    fn init_clip(&mut self) {
6453        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6454            self.spare_mask = Some(mask);
6455        }
6456        self.clip_region = None;
6457    }
6458
6459    fn erase_page(&mut self) {
6460        // Only fill the full pixmap when it's actually allocated (non-banded path).
6461        // During banding, self.pixmap is a 1×1 placeholder — filling it is harmless.
6462        self.pixmap.fill(Color::WHITE);
6463        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6464            self.spare_mask = Some(mask);
6465        }
6466        self.clip_region = None;
6467    }
6468
6469    fn show_page(&mut self, output_path: &str) -> Result<(), String> {
6470        let w = self.pixmap.width();
6471        let h = self.pixmap.height();
6472        // Composite onto white background before output
6473        composite_onto_white(self.pixmap.data_mut());
6474        let mut sink = self.sink_factory.create_sink(output_path)?;
6475        sink.begin_page(w, h)?;
6476        sink.write_rows(self.pixmap.data(), h)?;
6477        sink.end_page()
6478    }
6479
6480    fn draw_image(&mut self, sample_data: &[u8], params: &ImageParams) {
6481        self.ensure_full_pixmap();
6482        let w = params.width;
6483        let h = params.height;
6484        if w == 0 || h == 0 {
6485            return;
6486        }
6487        let mut rgba_data =
6488            samples_to_rgba(sample_data, params, self.render_icc_cache.as_ref(), false);
6489        if params.mask_color.is_some() {
6490            apply_mask_color_rgba(&mut rgba_data, sample_data, params);
6491        }
6492        let expected = (w * h * 4) as usize;
6493        if rgba_data.len() < expected {
6494            return;
6495        }
6496
6497        let Some(image_inv) = params.image_matrix.invert() else {
6498            return;
6499        };
6500        let combined = params.ctm.concat(&image_inv);
6501        let raw_transform = enforce_min_image_size(to_transform(&combined), w, h);
6502
6503        let prescaled = prescale_image(&rgba_data, w, h, raw_transform, params.interpolate);
6504        let (img_data, img_w, img_h, transform) = match &prescaled {
6505            Some((data, pw, ph, t)) => (data.as_slice(), *pw, *ph, *t),
6506            None => (rgba_data.as_slice(), w, h, raw_transform),
6507        };
6508
6509        let Some(img_pixmap) = stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h) else {
6510            return;
6511        };
6512
6513        let (pw, ph) = (self.pixmap.width(), self.pixmap.height());
6514        let mut temp_mask = None;
6515        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, pw, ph) else {
6516            return;
6517        };
6518
6519        let paint = stet_tiny_skia::PixmapPaint {
6520            quality: image_filter_quality(transform, params.interpolate),
6521            opacity: params.alpha as f32,
6522            blend_mode: u8_to_blend_mode(params.blend_mode),
6523        };
6524        self.pixmap
6525            .draw_pixmap(0, 0, img_pixmap, &paint, transform, mask_ref);
6526    }
6527
6528    fn paint_axial_shading(&mut self, params: &AxialShadingParams) {
6529        self.ensure_full_pixmap();
6530        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6531        let mut temp_mask = None;
6532        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6533            return;
6534        };
6535        render_axial_shading(
6536            &mut self.pixmap,
6537            params,
6538            0.0,
6539            0.0,
6540            1.0,
6541            1.0,
6542            mask_ref,
6543            self.no_aa,
6544            None,
6545            None,
6546        );
6547    }
6548
6549    fn paint_radial_shading(&mut self, params: &RadialShadingParams) {
6550        self.ensure_full_pixmap();
6551        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6552        let mut temp_mask = None;
6553        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6554            return;
6555        };
6556        render_radial_shading(
6557            &mut self.pixmap,
6558            params,
6559            0.0,
6560            0.0,
6561            1.0,
6562            1.0,
6563            mask_ref,
6564            self.no_aa,
6565            None,
6566            None,
6567        );
6568    }
6569
6570    fn paint_mesh_shading(&mut self, params: &MeshShadingParams) {
6571        self.ensure_full_pixmap();
6572        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6573        let mut temp_mask = None;
6574        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6575            return;
6576        };
6577        render_mesh_shading(
6578            &mut self.pixmap,
6579            params,
6580            0.0,
6581            0.0,
6582            1.0,
6583            1.0,
6584            mask_ref,
6585            None,
6586            None,
6587        );
6588    }
6589
6590    fn paint_patch_shading(&mut self, params: &PatchShadingParams) {
6591        self.ensure_full_pixmap();
6592        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6593        let mut temp_mask = None;
6594        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6595            return;
6596        };
6597        render_patch_shading(
6598            &mut self.pixmap,
6599            params,
6600            0.0,
6601            0.0,
6602            1.0,
6603            1.0,
6604            mask_ref,
6605            None,
6606            None,
6607        );
6608    }
6609
6610    fn paint_pattern_fill(&mut self, params: &stet_graphics::device::PatternFillParams) {
6611        self.ensure_full_pixmap();
6612        let w = self.pixmap.width();
6613        let h = self.pixmap.height();
6614        let mut band_state = BandState {
6615            clip_region: self.clip_region.take(),
6616            spare_mask: self.spare_mask.take(),
6617            clip_mask_cache: HashMap::new(),
6618            clip_mask_seen: HashSet::new(),
6619            mask_pool: Vec::new(),
6620            cmyk_buffer: None,
6621            op_bg_snapshot: None,
6622            op_touched: None,
6623            spot_mask: None,
6624        };
6625        {
6626            let ctx = RenderContext {
6627                vp_x: 0.0,
6628                vp_y: 0.0,
6629                scale_x: 1.0,
6630                scale_y: 1.0,
6631                out_w: w,
6632                out_h: h,
6633                effective_dpi: self.dpi,
6634                icc: None,
6635                image_cache: None,
6636                preprocessed: None,
6637                elem_idx: 0,
6638                no_aa: self.no_aa,
6639                opm_zero_transparent: false,
6640                knockout_painter_pass: KnockoutPainterPass::None,
6641                parent_group_isolated: false,
6642                alpha_extraction_pass: false,
6643            };
6644            render_pattern_fill(&mut self.pixmap, &mut band_state, params, &ctx);
6645        }
6646        self.clip_region = band_state.clip_region.take();
6647        if let Some(mask) = band_state.spare_mask.take() {
6648            self.spare_mask = Some(mask);
6649        }
6650    }
6651
6652    fn page_size(&self) -> (u32, u32) {
6653        (self.page_w, self.page_h)
6654    }
6655
6656    fn replay_and_show(&mut self, list: DisplayList, output_path: &str) -> Result<(), String> {
6657        // Wait for any previous background render to complete
6658        self.join_pending()?;
6659
6660        let (page_w, page_h) = self.page_size();
6661
6662        // Audit mode: re-render through the viewport pipeline so visual tests
6663        // can catch viewport-only bugs against the same baselines. Same
6664        // `render_element`, same display list — differs only in how culling
6665        // and epochs are computed.
6666        if self.use_viewport_path {
6667            let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref());
6668            let rgba = render_to_rgba_viewport(
6669                &list,
6670                page_w,
6671                page_h,
6672                self.dpi,
6673                Some(&icc_cache),
6674                self.no_aa,
6675            );
6676            let mut sink = self.sink_factory.create_sink(output_path)?;
6677            sink.begin_page(page_w, page_h)?;
6678            sink.write_rows(&rgba, page_h)?;
6679            sink.end_page()?;
6680            return Ok(());
6681        }
6682
6683        let band_h = select_band_height(page_w, page_h);
6684
6685        // Build ICC cache for this page's display list
6686        let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref());
6687
6688        // If banding not worthwhile, render the full page as a single band.
6689        // This still uses render_element (same as banded path) so that Group
6690        // and SoftMasked elements get proper offscreen compositing.
6691        if band_h >= page_h {
6692            self.ensure_full_pixmap();
6693            let ctx = RenderContext {
6694                vp_x: 0.0,
6695                vp_y: 0.0,
6696                scale_x: 1.0,
6697                scale_y: 1.0,
6698                out_w: page_w,
6699                out_h: page_h,
6700                effective_dpi: self.dpi,
6701                icc: Some(&icc_cache),
6702                image_cache: None,
6703                preprocessed: None,
6704                elem_idx: 0,
6705                no_aa: self.no_aa,
6706                opm_zero_transparent: false,
6707                knockout_painter_pass: KnockoutPainterPass::None,
6708                parent_group_isolated: false,
6709                alpha_extraction_pass: false,
6710            };
6711            let mut band_state = BandState {
6712                clip_region: None,
6713                spare_mask: None,
6714                clip_mask_cache: HashMap::new(),
6715                clip_mask_seen: HashSet::new(),
6716                mask_pool: Vec::new(),
6717                cmyk_buffer: None,
6718                op_bg_snapshot: None,
6719                op_touched: None,
6720                spot_mask: None,
6721            };
6722            for (idx, elem) in list.elements().iter().enumerate() {
6723                let elem_ctx = RenderContext {
6724                    elem_idx: idx,
6725                    ..ctx
6726                };
6727                render_element(&mut self.pixmap, &mut band_state, elem, &elem_ctx);
6728            }
6729            return self.show_page(output_path);
6730        }
6731
6732        // Banded path: shrink self.pixmap to free memory — we use a
6733        // band-sized pixmap instead. This avoids holding a multi-GB
6734        // full-page buffer during rendering.
6735        if self.pixmap.width() > 1 {
6736            self.pixmap = Pixmap::new(1, 1).expect("Failed to create placeholder pixmap");
6737        }
6738
6739        // Create the sink for this page before spawning background work
6740        let mut sink = self.sink_factory.create_sink(output_path)?;
6741        let dpi = self.dpi;
6742
6743        #[cfg(feature = "parallel")]
6744        {
6745            // Spawn banded rendering on rayon's thread pool, overlapping with
6746            // interpretation of the next page. Using rayon::spawn avoids OS thread
6747            // creation overhead and keeps work on the warmed-up pool.
6748            let no_aa = self.no_aa;
6749            let (tx, rx) = std::sync::mpsc::sync_channel(1);
6750            rayon::spawn(move || {
6751                let result = render_banded_to_sink(
6752                    page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, no_aa,
6753                );
6754                let _ = tx.send(result);
6755            });
6756            self.pending_render = Some(rx);
6757        }
6758        #[cfg(not(feature = "parallel"))]
6759        {
6760            render_banded_to_sink(
6761                page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, self.no_aa,
6762            )?;
6763        }
6764
6765        Ok(())
6766    }
6767
6768    fn finish(&mut self) -> Result<(), String> {
6769        self.join_pending()
6770    }
6771}
6772
6773impl Drop for SkiaDevice {
6774    fn drop(&mut self) {
6775        // Safety net: ensure background render completes before device is destroyed.
6776        if let Some(rx) = self.pending_render.take() {
6777            let _ = rx.recv();
6778        }
6779    }
6780}
6781
6782impl SkiaDevice {
6783    /// Wait for the pending background render to complete, if any.
6784    fn join_pending(&mut self) -> Result<(), String> {
6785        if let Some(rx) = self.pending_render.take() {
6786            match rx.recv() {
6787                Ok(result) => result?,
6788                Err(_) => return Err("Background render task failed".to_string()),
6789            }
6790        }
6791        Ok(())
6792    }
6793}
6794
6795/// Returns true if any descendant transparency group declares an explicit
6796/// `/CS DeviceCMYK`. The renderer uses this to decide whether to allocate a
6797/// parallel CMYK buffer for the band/page so that compositing inside CMYK
6798/// groups can read the exact backdrop CMYK rather than rounding-trip via sRGB.
6799fn has_cmyk_group(list: &DisplayList) -> bool {
6800    use stet_graphics::display_list::GroupColorSpace;
6801    for elem in list.elements() {
6802        match elem {
6803            DisplayElement::Group { elements, params } => {
6804                if params.color_space == GroupColorSpace::DeviceCMYK {
6805                    return true;
6806                }
6807                if has_cmyk_group(elements) {
6808                    return true;
6809                }
6810            }
6811            DisplayElement::SoftMasked { content, mask, .. } => {
6812                if has_cmyk_group(content) || has_cmyk_group(mask) {
6813                    return true;
6814                }
6815            }
6816            DisplayElement::OcgGroup { elements, .. } => {
6817                if has_cmyk_group(elements) {
6818                    return true;
6819                }
6820            }
6821            _ => {}
6822        }
6823    }
6824    false
6825}
6826
6827/// Returns true if every visible element in `elements` is a `Fill` whose
6828/// color carries `native_cmyk`. Clip and `InitClip` ops are skipped (they
6829/// don't paint). Returns `false` for any other shape (shadings, images,
6830/// patterns, nested groups, etc.) where the inner CMYK buffer would be
6831/// derived from sRGB via the lossy `interpolate_cmyk_from_stops` /
6832/// `(1-r,1-g,1-b,0)` inverse rather than tracked from the source CMYK.
6833fn group_only_native_cmyk_fills(elements: &DisplayList) -> bool {
6834    let mut found_paint = false;
6835    for elem in elements.elements() {
6836        match elem {
6837            DisplayElement::InitClip => continue,
6838            DisplayElement::Clip { .. } => continue,
6839            DisplayElement::Fill { params, .. } => {
6840                if params.color.native_cmyk.is_none() {
6841                    return false;
6842                }
6843                found_paint = true;
6844            }
6845            DisplayElement::Stroke { params, .. } => {
6846                // Strokes write a single CMYK value per painted pixel just
6847                // like fills, so the parallel CMYK buffer stays in sync with
6848                // the pixmap. Including strokes here is required by GWG 16.1
6849                // painters whose X path is both filled and stroked with the
6850                // same registration color.
6851                if params.color.native_cmyk.is_none() {
6852                    return false;
6853                }
6854                found_paint = true;
6855            }
6856            _ => return false,
6857        }
6858    }
6859    found_paint
6860}
6861
6862/// Stronger predicate: returns `true` when every paint operation in `elements`
6863/// supplies its color directly as CMYK with one CMYK value per painted pixel
6864/// — i.e. the parallel CMYK buffer is *guaranteed* to match the rendered
6865/// pixmap on a per-pixel basis. When this holds, the per-pixel CMYK
6866/// composite-back can run safely.
6867///
6868/// Importantly, this excludes **shadings** even when their declared color
6869/// space is DeviceCMYK. The pixmap rasterizer interpolates the per-stop
6870/// `.color` (RGB) linearly across the gradient via [`build_gradient_lut`],
6871/// while [`interpolate_cmyk_from_stops`] interpolates the per-stop CMYK
6872/// `raw_components` linearly. Because the system CMYK ICC profile is
6873/// non-linear, the two interpolation strategies produce different intermediate
6874/// colors at each gradient pixel — the buffer no longer represents what the
6875/// pixmap shows, and feeding that into the composite-back yields visibly
6876/// shifted colors. Until the per-pixel rasterizer is taught to interpolate
6877/// CMYK directly (or the buffer is filled by ICC-reversing the pixmap), keep
6878/// shadings on the existing sRGB compositing path.
6879///
6880/// Recurses into nested groups and soft masks. Returns `false` if the group
6881/// contains no paint operations at all (so the composite-back has no work).
6882fn group_content_is_native_cmyk(elements: &DisplayList) -> bool {
6883    let mut found_paint = false;
6884    for elem in elements.elements() {
6885        match elem {
6886            DisplayElement::InitClip => continue,
6887            DisplayElement::Clip { .. } => continue,
6888            DisplayElement::Text { .. } => continue,
6889            DisplayElement::ErasePage => continue,
6890            DisplayElement::Fill { params, .. } => {
6891                if params.color.native_cmyk.is_none() {
6892                    return false;
6893                }
6894                found_paint = true;
6895            }
6896            DisplayElement::Stroke { params, .. } => {
6897                if params.color.native_cmyk.is_none() {
6898                    return false;
6899                }
6900                found_paint = true;
6901            }
6902            DisplayElement::Image { params, .. } => {
6903                if !is_cmyk_color_space(&params.color_space) {
6904                    return false;
6905                }
6906                found_paint = true;
6907            }
6908            DisplayElement::AxialShading { .. }
6909            | DisplayElement::RadialShading { .. }
6910            | DisplayElement::MeshShading { .. }
6911            | DisplayElement::PatchShading { .. } => {
6912                // See doc comment above: shading interpolation strategies
6913                // diverge between pixmap and buffer.
6914                return false;
6915            }
6916            DisplayElement::PatternFill { .. } => {
6917                // Pattern tiles render through their own BandState with
6918                // `cmyk_buffer: None`, so the parallel CMYK buffer can't track
6919                // per-tile source CMYK. Treat patterns as non-CMYK content.
6920                return false;
6921            }
6922            DisplayElement::Group { elements: sub, .. } => {
6923                if !group_content_is_native_cmyk(sub) {
6924                    return false;
6925                }
6926                found_paint = true;
6927            }
6928            DisplayElement::SoftMasked { .. } => {
6929                // Soft masks apply a per-pixel alpha modulation that the
6930                // parallel CMYK buffer cannot represent: the buffer holds raw
6931                // source CMYK while the pixmap holds the soft-masked blend
6932                // (`backdrop * (1 − mask) + source * mask`). Running
6933                // `composite_non_isolated_cmyk` over a soft-masked region
6934                // would feed the unmodulated source CMYK into the blend
6935                // formula and produce the wrong result for any non-Normal
6936                // parent blend mode (5310.pdf phone highlight regression).
6937                // Fall back to the sRGB contribution-extraction path, which
6938                // handles soft masks correctly.
6939                return false;
6940            }
6941            DisplayElement::OcgGroup { elements: sub, .. } => {
6942                if !group_content_is_native_cmyk(sub) {
6943                    return false;
6944                }
6945                found_paint = true;
6946            }
6947        }
6948    }
6949    found_paint
6950}
6951
6952/// True when `list` is a flat sequence of native-CMYK Fill/Stroke paints
6953/// with Normal blend and full opacity — i.e. the cmyk_buffer's content
6954/// faithfully represents what the pixmap shows. Used by `render_soft_masked`
6955/// to decide whether to interpolate the mask blend in CMYK (ICC→sRGB).
6956/// Rejects Group/SoftMasked/Image/Shading/Pattern and any blend-mode-modulated
6957/// paint because those would diverge from the parallel CMYK snapshot.
6958fn content_list_is_simple_native_cmyk(list: &DisplayList) -> bool {
6959    let mut found_paint = false;
6960    for elem in list.elements() {
6961        match elem {
6962            DisplayElement::InitClip
6963            | DisplayElement::Clip { .. }
6964            | DisplayElement::Text { .. }
6965            | DisplayElement::ErasePage => continue,
6966            DisplayElement::Fill { params, .. } => {
6967                if params.color.native_cmyk.is_none() {
6968                    return false;
6969                }
6970                if params.blend_mode != 0 || params.alpha != 1.0 {
6971                    return false;
6972                }
6973                found_paint = true;
6974            }
6975            DisplayElement::Stroke { params, .. } => {
6976                if params.color.native_cmyk.is_none() {
6977                    return false;
6978                }
6979                if params.blend_mode != 0 || params.alpha != 1.0 {
6980                    return false;
6981                }
6982                found_paint = true;
6983            }
6984            _ => return false,
6985        }
6986    }
6987    found_paint
6988}
6989
6990/// Scan a display list for any overprint fill/stroke elements that need CMYK simulation.
6991fn has_overprint_elements(list: &DisplayList) -> bool {
6992    for elem in list.elements() {
6993        match elem {
6994            DisplayElement::Fill { params, .. } => {
6995                if params.overprint {
6996                    return true;
6997                }
6998            }
6999            DisplayElement::Stroke { params, .. } => {
7000                if params.overprint {
7001                    return true;
7002                }
7003            }
7004            DisplayElement::Image { params, .. } => {
7005                if params.overprint {
7006                    return true;
7007                }
7008            }
7009            DisplayElement::AxialShading { params } => {
7010                if params.overprint {
7011                    return true;
7012                }
7013            }
7014            DisplayElement::RadialShading { params } => {
7015                if params.overprint {
7016                    return true;
7017                }
7018            }
7019            DisplayElement::MeshShading { params } => {
7020                if params.overprint {
7021                    return true;
7022                }
7023            }
7024            DisplayElement::PatchShading { params } => {
7025                if params.overprint {
7026                    return true;
7027                }
7028            }
7029            DisplayElement::Group { elements, .. } => {
7030                if has_overprint_elements(elements) {
7031                    return true;
7032                }
7033            }
7034            DisplayElement::SoftMasked { content, mask, .. } => {
7035                if has_overprint_elements(content) || has_overprint_elements(mask) {
7036                    return true;
7037                }
7038            }
7039            DisplayElement::OcgGroup { elements, .. } => {
7040                if has_overprint_elements(elements) {
7041                    return true;
7042                }
7043            }
7044            _ => {}
7045        }
7046    }
7047    false
7048}
7049
7050/// Render an overprint fill: rasterize path to coverage mask, then composite
7051/// at the CMYK level, converting the result to RGB for the pixmap.
7052#[allow(clippy::too_many_arguments)]
7053fn render_overprint_fill(
7054    pixmap: &mut Pixmap,
7055    cmyk_buf: &mut [f32],
7056    op_bg: &mut [u8],
7057    op_touched: &mut [u8],
7058    spot_mask: &[u8],
7059    band_state: &mut BandState,
7060    path: &PsPath,
7061    params: &FillParams,
7062    vp_x: f32,
7063    vp_y: f32,
7064    scale_x: f32,
7065    scale_y: f32,
7066    out_w: u32,
7067    out_h: u32,
7068    icc: Option<&IccCache>,
7069    no_aa: bool,
7070) {
7071    let Some(skia_path) = build_skia_path(path) else {
7072        return;
7073    };
7074    let fill_rule = to_fill_rule(&params.fill_rule);
7075
7076    let mut coverage_mask = match Mask::new(out_w, out_h) {
7077        Some(m) => m,
7078        None => return,
7079    };
7080    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7081    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7082
7083    // Compute path bbox for constrained iteration
7084    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7085        path_device_bbox(&skia_path, transform, out_w, out_h);
7086
7087    // Intersect with clip mask
7088    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7089        None => None,
7090        Some(ClipRegion::Rect(r)) => {
7091            // Only zero coverage within the path bbox (not the full page)
7092            let data = coverage_mask.data_mut();
7093            let stride = out_w as usize;
7094            for y in bbox_y0..bbox_y1 {
7095                let row_start = y * stride;
7096                for x in bbox_x0..bbox_x1 {
7097                    let yu = y as u32;
7098                    let xu = x as u32;
7099                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7100                        data[row_start + x] = 0;
7101                    }
7102                }
7103            }
7104            None
7105        }
7106        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7107    };
7108
7109    let (src_c, src_m, src_y, src_k) = params.color.native_cmyk.unwrap_or_else(|| {
7110        let r = params.color.r;
7111        let g = params.color.g;
7112        let b = params.color.b;
7113        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7114    });
7115
7116    // Custom spot paints (Separation/DeviceN whose named colorants don't include
7117    // any process channel) go to a separation plate, not CMYK. In the composite
7118    // preview we layer the spot's alt-CMYK onto the pixmap via multiplicative
7119    // ink stacking and leave the cmyk_buffer untouched — otherwise a later OPM 1
7120    // overprint would see the spot's alt-CMYK as "backdrop" and knock it out.
7121    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7122
7123    let mut channels = params.painted_channels;
7124    // Non-CMYK fills (painted_channels=0, e.g. Separation spot colors, RGB, Gray)
7125    // replace all color at each pixel — update all CMYK channels to keep buffer in sync.
7126    if channels == 0 {
7127        channels = stet_graphics::device::CMYK_ALL;
7128    }
7129    // OPM 1 per-pixel zero filtering only applies to DeviceCMYK, not DeviceN/Separation
7130    if params.overprint_mode == 1
7131        && channels == stet_graphics::device::CMYK_ALL
7132        && params.is_device_cmyk
7133    {
7134        channels = 0;
7135        if src_c != 0.0 {
7136            channels |= stet_graphics::device::CMYK_C;
7137        }
7138        if src_m != 0.0 {
7139            channels |= stet_graphics::device::CMYK_M;
7140        }
7141        if src_y != 0.0 {
7142            channels |= stet_graphics::device::CMYK_Y;
7143        }
7144        if src_k != 0.0 {
7145            channels |= stet_graphics::device::CMYK_K;
7146        }
7147        // PDF 1.7 §7.6.4.5: OPM 1 with /op true preserves zero-source
7148        // components — leave `channels = 0` for an all-zero CMYK source only
7149        // when /OPM and /op (or /OP) were set together in the same ExtGState
7150        // dict, signaling the author deliberately enabled strict-spec
7151        // semantics (as Adobe Illustrator emits). When the current /op was
7152        // set standalone and OPM was merely inherited, fall back to legacy
7153        // knockout so `0 0 0 0 k` still paints white. Matches Adobe Acrobat
7154        // behavior: GWG 4.0.1 swatches g/j (paired /OPM+/op in /GS0,/GS3)
7155        // preserve the backdrop; pdf_samples/2495.pdf page 5 icon (only /op
7156        // on /R20, OPM inherited from /R11) performs the expected knockout.
7157        if channels == 0 && !params.opm_paired {
7158            channels = stet_graphics::device::CMYK_ALL;
7159        }
7160    }
7161
7162    // Bulk tiny-skia fast path for the plain CMYK_ALL replace case. Skipped
7163    // only for K-only DeviceCMYK paints under OPM 0 (C=M=Y=0, any K) because
7164    // those match the Black plate of a DeviceN [Black, spot] backdrop and
7165    // need the per-pixel no-op-delta skip to preserve spot-derived colour —
7166    // the bulk fill_path here would otherwise wipe the spot. Other CMYK
7167    // overprints (teal, full-colour, etc.) stay on the fast path to avoid
7168    // AA drift vs the non-overprint rasteriser.
7169    let is_k_only_cmyk = params.is_device_cmyk
7170        && params.overprint_mode == 0
7171        && src_c == 0.0
7172        && src_m == 0.0
7173        && src_y == 0.0;
7174    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7175        let cov_data = coverage_mask.data();
7176        let stride = out_w as usize;
7177        for y in bbox_y0..bbox_y1 {
7178            for x in bbox_x0..bbox_x1 {
7179                let mi = y * stride + x;
7180                let mut cov = cov_data[mi] as f32 / 255.0;
7181                if let Some(clip) = clip_coverage {
7182                    cov *= clip[mi] as f32 / 255.0;
7183                }
7184                if cov > 0.0 {
7185                    let ci = mi * 4;
7186                    cmyk_buf[ci] = src_c as f32;
7187                    cmyk_buf[ci + 1] = src_m as f32;
7188                    cmyk_buf[ci + 2] = src_y as f32;
7189                    cmyk_buf[ci + 3] = src_k as f32;
7190                }
7191            }
7192        }
7193        let mut temp_mask = None;
7194        let Some(mask_ref) =
7195            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
7196        else {
7197            return;
7198        };
7199        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
7200        pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
7201        return;
7202    }
7203
7204    let cov_data = coverage_mask.data();
7205    let stride = out_w as usize;
7206    let px_data = pixmap.data_mut();
7207    let px_stride = out_w as usize * 4;
7208
7209    for y in bbox_y0..bbox_y1 {
7210        for x in bbox_x0..bbox_x1 {
7211            let mi = y * stride + x;
7212            let mut cov = cov_data[mi] as f32 / 255.0;
7213            if let Some(clip) = clip_coverage {
7214                cov *= clip[mi] as f32 / 255.0;
7215            }
7216            if cov <= 0.0 {
7217                continue;
7218            }
7219
7220            let ci = mi * 4;
7221            let pi = y * px_stride + x * 4;
7222            // Snapshot-based AA blending: on the first overprint touch of a
7223            // pixel that already has a backdrop (alpha > 0), capture the
7224            // pre-paint pixmap RGBA. Subsequent overprints at the same pixel
7225            // blend against the snapshot rather than the current pixmap, so
7226            // AA edges of stacked OPM-1 overprints do not leak colour from
7227            // earlier paints into later ones.
7228            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
7229                op_bg[pi] = px_data[pi];
7230                op_bg[pi + 1] = px_data[pi + 1];
7231                op_bg[pi + 2] = px_data[pi + 2];
7232                op_bg[pi + 3] = px_data[pi + 3];
7233                op_touched[mi] = 1;
7234            }
7235            let cur_c = cmyk_buf[ci] as f64;
7236            let cur_m = cmyk_buf[ci + 1] as f64;
7237            let cur_y = cmyk_buf[ci + 2] as f64;
7238            let cur_k = cmyk_buf[ci + 3] as f64;
7239            // Switch to multiplicative ink-stacking when the pixmap carries a
7240            // contribution not reflected in cmyk_buffer: either this paint is
7241            // itself a custom spot (painted_channels=0, non-CMYK) or the
7242            // process-ink state is empty while the pixmap shows colour *and*
7243            // is actually opaque — that signals a spot (or RGB) paint landed
7244            // here and the "replace" CMYK→RGB model would erase the
7245            // contribution for the channels being overwritten. Fully
7246            // transparent pixels are stored as premultiplied (0,0,0,0), so we
7247            // must require alpha>0 before trusting the RGB — otherwise fresh
7248            // paper (alpha=0) looks like "black backdrop" and multiplicative
7249            // darkening would paint the fill pure black.
7250            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
7251            let pixmap_has_colour = px_data[pi + 3] > 0
7252                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
7253            // Multiplicative ink-stacking only when the pixmap carries a real
7254            // backdrop: either this paint is a custom spot landing on an
7255            // already-coloured pixel, or the process-ink buffer is empty but
7256            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
7257            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
7258            // the fill to pure black, so those pixels fall through to the
7259            // replace path where the source RGB paints normally.
7260            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
7261
7262            // Promoted DeviceGray on a non-spot backdrop: fall back to a
7263            // plain knockout that replaces all four CMYK plates. The
7264            // `maybe_promote_gray_fill` path describes the paint as a
7265            // K-only subset so spot-backed swatches can preserve the spot
7266            // plate (GWG 3.0 "50% gray over spot"), but on a plain CMYK
7267            // backdrop that would preserve the old CMY values and turn the
7268            // cross into the bg colour (GWG 3.0 "50% gray over CMYK" e/k).
7269            // Expanding to CMYK_ALL here restores the regular-fill result
7270            // at those pixels.
7271            //
7272            // Gate on `params.painted_channels == CMYK_K` so this only fires
7273            // for genuinely-promoted DeviceGray. A `0 0 0 0.5 k` DeviceCMYK
7274            // paint filtered to CMYK_K by OPM 1 has `params.painted_channels
7275            // = CMYK_ALL`, and must stay K-subset so its CMY=0 values do
7276            // not wipe a CMYK backdrop (GWG 3.0 "50% K over CMYK" j/d).
7277            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
7278                && channels == stet_graphics::device::CMYK_K
7279                && params.is_device_cmyk
7280                && src_c == 0.0
7281                && src_m == 0.0
7282                && src_y == 0.0;
7283            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
7284                stet_graphics::device::CMYK_ALL
7285            } else {
7286                channels
7287            };
7288
7289            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
7290                src_c
7291            } else {
7292                cur_c
7293            };
7294            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
7295                src_m
7296            } else {
7297                cur_m
7298            };
7299            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
7300                src_y
7301            } else {
7302                cur_y
7303            };
7304            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
7305                src_k
7306            } else {
7307                cur_k
7308            };
7309
7310            // Custom spot paints live on a separation plate — skip the
7311            // cmyk_buffer write so a later OPM 1 overprint still sees the
7312            // original process-ink state as backdrop.
7313            if !is_custom_spot {
7314                cmyk_buf[ci] = new_c as f32;
7315                cmyk_buf[ci + 1] = new_m as f32;
7316                cmyk_buf[ci + 2] = new_y as f32;
7317                cmyk_buf[ci + 3] = new_k as f32;
7318            }
7319
7320            // No-op overprint: the paint's effective CMYK equals the existing
7321            // process state, so no plate actually changes. Skip the pixmap
7322            // write entirely — otherwise ICC(new_cmyk) paints a plain process
7323            // composite that erases any spot-derived colour already visible
7324            // at this pixel (GWG 3.0 "50% K over spot" swatches where the
7325            // backdrop's Black component and the cross's K value match).
7326            //
7327            // Only fire when a DeviceN/Separation paint with spot colorants
7328            // actually landed on this pixel (spot_mask[mi] != 0). On plain
7329            // CMYK backdrops, ICC(cmyk_buf) == pixmap_rgb already, and
7330            // skipping vs replacing produces the same result — but making
7331            // the skip unconditional subtly drifts AA edges because prior
7332            // stroke/fill precision accumulates (regressed GWG 1.0/1.1).
7333            let delta = (new_c - cur_c)
7334                .abs()
7335                .max((new_m - cur_m).abs())
7336                .max((new_y - cur_y).abs())
7337                .max((new_k - cur_k).abs());
7338            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
7339                continue;
7340            }
7341
7342            let (r, g, b) =
7343                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
7344                    // Promoted DeviceGray collapsing to a full replace — use the
7345                    // paint's RGB directly so the pixmap matches the colour a
7346                    // regular non-overprint gray fill would paint at the same
7347                    // pixel. Going through ICC(CMYK) here would produce a
7348                    // slightly different gray (e.g. 151 vs 127) and leave a
7349                    // darker outline where a subsequent non-promoted gray
7350                    // stroke overpaints on top of it.
7351                    //
7352                    // Checked before `use_multiplicative` because a white gray
7353                    // paint (`1 g`, native CMYK (0,0,0,0)) on a coloured RGB
7354                    // backdrop (e.g. the red `Reset Form` button in 682.pdf
7355                    // page 2) would otherwise hit the multiplicative branch
7356                    // with all-zero source CMYK, which leaves the backdrop
7357                    // unchanged — hiding the white label.
7358                    (params.color.r, params.color.g, params.color.b)
7359                } else if use_multiplicative {
7360                    // Multiplicative ink stacking: each painted channel attenuates
7361                    // the corresponding RGB component; preserved channels leave
7362                    // the pixmap's existing colour untouched. This keeps any spot
7363                    // contribution already in the pixmap visible under overprints
7364                    // whose zero-valued CMYK components should not erase it.
7365                    let bg_r = px_data[pi] as f64 / 255.0;
7366                    let bg_g = px_data[pi + 1] as f64 / 255.0;
7367                    let bg_b = px_data[pi + 2] as f64 / 255.0;
7368                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
7369                        1.0 - src_c
7370                    } else {
7371                        1.0
7372                    };
7373                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
7374                        1.0 - src_m
7375                    } else {
7376                        1.0
7377                    };
7378                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
7379                        1.0 - src_y
7380                    } else {
7381                        1.0
7382                    };
7383                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
7384                        1.0 - src_k
7385                    } else {
7386                        1.0
7387                    };
7388                    (
7389                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
7390                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
7391                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
7392                    )
7393                } else if let Some(icc_cache) = icc {
7394                    icc_cache
7395                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
7396                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
7397                } else {
7398                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
7399                };
7400
7401            let a = (cov * params.alpha as f32).min(1.0);
7402            // Blend backdrop: prefer the pre-overprint snapshot only when
7403            // this paint's colour is close to the snapshot — that signals
7404            // the paint effectively returns the pixel to its original
7405            // backdrop (e.g. the almost-white cross in GWG 4.1 cancelling
7406            // the red cross's M/Y contributions). In that case blending
7407            // against the snapshot keeps AA edges clean.
7408            //
7409            // When the paint introduces colour (e.g. a magenta stroke
7410            // following a magenta fill — both lay down ink that should
7411            // stack), fall through to the current pixmap so repeated
7412            // same-colour paints keep compounding at edges instead of
7413            // snapping back to bg.
7414            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
7415                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
7416                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
7417                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
7418                let dr = (op_bg[pi] as f32 - new_r).abs();
7419                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
7420                let db = (op_bg[pi + 2] as f32 - new_b).abs();
7421                if dr.max(dg).max(db) <= 4.0 {
7422                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
7423                } else {
7424                    (
7425                        px_data[pi],
7426                        px_data[pi + 1],
7427                        px_data[pi + 2],
7428                        px_data[pi + 3],
7429                    )
7430                }
7431            } else {
7432                (
7433                    px_data[pi],
7434                    px_data[pi + 1],
7435                    px_data[pi + 2],
7436                    px_data[pi + 3],
7437                )
7438            };
7439            let dst_a = bk_a as f32 / 255.0;
7440            let one_minus_a = 1.0 - a;
7441            let out_a = a + dst_a * one_minus_a;
7442            if out_a > 0.0 {
7443                // tiny-skia stores premultiplied RGBA. Use the standard
7444                // src-over formula in premul space: result_pre = src*a + dst_pre*(1-a).
7445                // The backdrop values are already premultiplied, so no
7446                // additional divide-by-out_a step is needed.
7447                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
7448                    .clamp(0.0, 255.0)
7449                    .round() as u8;
7450                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
7451                    .clamp(0.0, 255.0)
7452                    .round() as u8;
7453                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
7454                    .clamp(0.0, 255.0)
7455                    .round() as u8;
7456                px_data[pi + 3] = (out_a * 255.0).round() as u8;
7457            }
7458        }
7459    }
7460}
7461/// PLRM CMYK-to-RGB formula fallback.
7462fn cmyk_to_rgb_plrm(c: f64, m: f64, y: f64, k: f64) -> (f64, f64, f64) {
7463    (
7464        1.0 - (c + k).min(1.0),
7465        1.0 - (m + k).min(1.0),
7466        1.0 - (y + k).min(1.0),
7467    )
7468}
7469
7470/// Update the CMYK buffer for a non-overprint fill (to track backdrop for future overprints).
7471#[allow(clippy::too_many_arguments)]
7472/// Compute the device-space bounding box of a tiny-skia path after transform,
7473/// clamped to `(0, 0, w, h)`. Returns `(x0, y0, x1, y1)` as pixel indices.
7474fn path_device_bbox(
7475    skia_path: &stet_tiny_skia::Path,
7476    transform: Transform,
7477    w: u32,
7478    h: u32,
7479) -> (usize, usize, usize, usize) {
7480    let b = skia_path.bounds();
7481    let mut corners = [
7482        stet_tiny_skia::Point {
7483            x: b.left(),
7484            y: b.top(),
7485        },
7486        stet_tiny_skia::Point {
7487            x: b.right(),
7488            y: b.top(),
7489        },
7490        stet_tiny_skia::Point {
7491            x: b.right(),
7492            y: b.bottom(),
7493        },
7494        stet_tiny_skia::Point {
7495            x: b.left(),
7496            y: b.bottom(),
7497        },
7498    ];
7499    transform.map_points(&mut corners);
7500    let min_x = corners.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
7501    let min_y = corners.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
7502    let max_x = corners
7503        .iter()
7504        .map(|p| p.x)
7505        .fold(f32::NEG_INFINITY, f32::max);
7506    let max_y = corners
7507        .iter()
7508        .map(|p| p.y)
7509        .fold(f32::NEG_INFINITY, f32::max);
7510    // Floor/ceil + clamp to output dimensions (with 1px margin for AA)
7511    let x0 = (min_x.floor() as i32 - 1).max(0) as usize;
7512    let y0 = (min_y.floor() as i32 - 1).max(0) as usize;
7513    let x1 = (max_x.ceil() as i32 + 1).clamp(0, w as i32) as usize;
7514    let y1 = (max_y.ceil() as i32 + 1).clamp(0, h as i32) as usize;
7515    (x0, y0, x1, y1)
7516}
7517
7518fn update_cmyk_buffer_for_fill(
7519    cmyk_buf: &mut [f32],
7520    spot_mask: &mut [u8],
7521    path: &PsPath,
7522    params: &FillParams,
7523    vp_x: f32,
7524    vp_y: f32,
7525    scale_x: f32,
7526    scale_y: f32,
7527    out_w: u32,
7528    out_h: u32,
7529    clip_region: &Option<ClipRegion>,
7530    no_aa: bool,
7531    icc: Option<&IccCache>,
7532) {
7533    // Custom spot paints (Separation/DeviceN naming no process channel) go to
7534    // their own separation plate — the process CMYK buffer must be zeroed
7535    // under the paint (knockout) so a later overprint sees "no process ink"
7536    // and falls into the multiplicative-blend branch that preserves the
7537    // spot's visible contribution in the pixmap.
7538    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7539
7540    // A DeviceN/Separation paint leaves "spot contribution" on the pixmap
7541    // when its full alt-CMYK (`native_cmyk`) differs from the process-only
7542    // tint (`process_cmyk`) — the extra RGB in the pixmap comes from a spot
7543    // plate that `cmyk_buf` cannot reflect. Pure DeviceCMYK paints have
7544    // `process_cmyk == None` (fall back to native), so no spot contribution.
7545    //
7546    // A "real" custom spot paint (`is_custom_spot && native_cmyk.is_some()`)
7547    // also deposits spot RGB that `cmyk_buf` loses (it's zeroed by the
7548    // custom-spot branch). Exclude DeviceRGB / DeviceGray / ICCBased-RGB
7549    // paints — those also satisfy `is_custom_spot = painted==0 &&
7550    // !is_device_cmyk` but carry no spot-plate contribution, and flagging
7551    // them would gate later OPM-1 cancel skips on a signal that doesn't
7552    // actually mean anything.
7553    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
7554        || matches!(
7555            (params.color.native_cmyk, params.color.process_cmyk),
7556            (Some(nat), Some(proc_))
7557                if (nat.0 - proc_.0).abs() > 1e-6
7558                    || (nat.1 - proc_.1).abs() > 1e-6
7559                    || (nat.2 - proc_.2).abs() > 1e-6
7560                    || (nat.3 - proc_.3).abs() > 1e-6
7561        );
7562
7563    // Source CMYK preference: process-only CMYK (from Separation/DeviceN paints
7564    // so spot-colorant tint contributions stay out of the process buffer) >
7565    // native CMYK (full alt-CMYK tint, fine for pure DeviceCMYK paints) > ICC
7566    // reverse (sRGB→CMYK via the system CMYK profile) > PLRM (1−r, 1−g, 1−b, 0)
7567    // fallback. The ICC reverse keeps non-CMYK fills (RGB/Gray/Lab/etc.)
7568    // representable as accurate CMYK in the parallel buffer so the
7569    // non-isolated CMYK composite-back can blend them correctly.
7570    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
7571        (0.0, 0.0, 0.0, 0.0)
7572    } else if let Some(c) = params.color.process_cmyk {
7573        c
7574    } else if let Some(c) = params.color.native_cmyk {
7575        c
7576    } else if let Some(cmyk) = icc.and_then(|i| {
7577        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
7578    }) {
7579        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
7580    } else {
7581        (
7582            (1.0 - params.color.r).clamp(0.0, 1.0),
7583            (1.0 - params.color.g).clamp(0.0, 1.0),
7584            (1.0 - params.color.b).clamp(0.0, 1.0),
7585            0.0,
7586        )
7587    };
7588    let Some(skia_path) = build_skia_path(path) else {
7589        return;
7590    };
7591
7592    let mut coverage_mask = match Mask::new(out_w, out_h) {
7593        Some(m) => m,
7594        None => return,
7595    };
7596    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7597    let fill_rule = to_fill_rule(&params.fill_rule);
7598    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7599
7600    let cov_data = coverage_mask.data();
7601    let clip_data: Option<&[u8]> = match clip_region {
7602        Some(ClipRegion::Mask(m)) => Some(m.data()),
7603        _ => None,
7604    };
7605
7606    // Constrain iteration to the path's device-space bounding box
7607    let (mut bx0, mut by0, mut bx1, mut by1) =
7608        path_device_bbox(&skia_path, transform, out_w, out_h);
7609    if let Some(ClipRegion::Rect(r)) = clip_region {
7610        bx0 = bx0.max(r.x0 as usize);
7611        by0 = by0.max(r.y0 as usize);
7612        bx1 = bx1.min(r.x1 as usize);
7613        by1 = by1.min(r.y1 as usize);
7614    }
7615
7616    let stride = out_w as usize;
7617    for y in by0..by1 {
7618        for x in bx0..bx1 {
7619            let mi = y * stride + x;
7620            let mut cov = cov_data[mi] as f32 / 255.0;
7621            if let Some(clip) = clip_data {
7622                cov *= clip[mi] as f32 / 255.0;
7623            }
7624            if cov > 0.0 {
7625                let ci = mi * 4;
7626                cmyk_buf[ci] = src_c as f32;
7627                cmyk_buf[ci + 1] = src_m as f32;
7628                cmyk_buf[ci + 2] = src_y as f32;
7629                cmyk_buf[ci + 3] = src_k as f32;
7630                if has_spot_contrib {
7631                    spot_mask[mi] = 1;
7632                }
7633            }
7634        }
7635    }
7636}
7637
7638/// Render an overprint stroke: convert the stroke outline to a fill path,
7639/// rasterize a coverage mask, then composite per-pixel in CMYK so the painted
7640/// channels of the stroke colour replace the matching backdrop channels and
7641/// the result lands in the pixmap as RGB. Mirrors `render_overprint_fill`.
7642#[allow(clippy::too_many_arguments)]
7643fn render_overprint_stroke(
7644    pixmap: &mut Pixmap,
7645    cmyk_buf: &mut [f32],
7646    op_bg: &mut [u8],
7647    op_touched: &mut [u8],
7648    spot_mask: &[u8],
7649    band_state: &mut BandState,
7650    skia_path: &stet_tiny_skia::Path,
7651    stroke: &Stroke,
7652    transform: Transform,
7653    params: &StrokeParams,
7654    out_w: u32,
7655    out_h: u32,
7656    icc: Option<&IccCache>,
7657    no_aa: bool,
7658) {
7659    // Convert stroke outline to fill path. Mirrors update_cmyk_buffer_for_stroke_overprint.
7660    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
7661        .sqrt()
7662        .max(1.0);
7663    let dashed_op;
7664    let stroke_src = if let Some(ref dash) = stroke.dash {
7665        dashed_op = skia_path.dash(dash, resolution_scale);
7666        match dashed_op.as_ref() {
7667            Some(p) => p,
7668            None => skia_path,
7669        }
7670    } else {
7671        skia_path
7672    };
7673    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
7674        return;
7675    };
7676    let Some(stroked) = stroked_user.transform(transform) else {
7677        return;
7678    };
7679
7680    let mut coverage_mask = match Mask::new(out_w, out_h) {
7681        Some(m) => m,
7682        None => return,
7683    };
7684    coverage_mask.fill_path(
7685        &stroked,
7686        SkiaFillRule::Winding,
7687        !no_aa,
7688        Transform::identity(),
7689    );
7690
7691    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7692        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
7693
7694    // Intersect with clip mask (same logic as render_overprint_fill).
7695    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7696        None => None,
7697        Some(ClipRegion::Rect(r)) => {
7698            let data = coverage_mask.data_mut();
7699            let stride = out_w as usize;
7700            for y in bbox_y0..bbox_y1 {
7701                let row_start = y * stride;
7702                for x in bbox_x0..bbox_x1 {
7703                    let yu = y as u32;
7704                    let xu = x as u32;
7705                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7706                        data[row_start + x] = 0;
7707                    }
7708                }
7709            }
7710            None
7711        }
7712        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7713    };
7714
7715    let (src_c, src_m, src_y, src_k) = params.color.native_cmyk.unwrap_or_else(|| {
7716        let r = params.color.r;
7717        let g = params.color.g;
7718        let b = params.color.b;
7719        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7720    });
7721
7722    // See render_overprint_fill for the rationale: a custom spot stroke must
7723    // preserve the process CMYK buffer and blend multiplicatively in RGB so
7724    // later OPM 1 overprints don't knock out the spot's visible colour.
7725    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7726
7727    let mut channels = params.painted_channels;
7728    if channels == 0 {
7729        channels = stet_graphics::device::CMYK_ALL;
7730    }
7731    if params.overprint_mode == 1
7732        && channels == stet_graphics::device::CMYK_ALL
7733        && params.is_device_cmyk
7734    {
7735        channels = 0;
7736        if src_c != 0.0 {
7737            channels |= stet_graphics::device::CMYK_C;
7738        }
7739        if src_m != 0.0 {
7740            channels |= stet_graphics::device::CMYK_M;
7741        }
7742        if src_y != 0.0 {
7743            channels |= stet_graphics::device::CMYK_Y;
7744        }
7745        if src_k != 0.0 {
7746            channels |= stet_graphics::device::CMYK_K;
7747        }
7748        // See render_overprint_fill: an all-zero CMYK source preserves the
7749        // backdrop only when /OPM and /op|/OP were set together in the same
7750        // ExtGState. Inherited-OPM cases fall back to legacy knockout.
7751        if channels == 0 && !params.opm_paired {
7752            channels = stet_graphics::device::CMYK_ALL;
7753        }
7754    }
7755
7756    let is_k_only_cmyk = params.is_device_cmyk
7757        && params.overprint_mode == 0
7758        && src_c == 0.0
7759        && src_m == 0.0
7760        && src_y == 0.0;
7761    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7762        // Full-channel replacement: write source CMYK to buffer for covered
7763        // pixels and let tiny-skia stroke the pixmap with the source colour.
7764        // Only K-only DeviceCMYK OPM 0 paints are routed to the per-pixel
7765        // path (see render_overprint_fill).
7766        let cov_data = coverage_mask.data();
7767        let stride = out_w as usize;
7768        for y in bbox_y0..bbox_y1 {
7769            for x in bbox_x0..bbox_x1 {
7770                let mi = y * stride + x;
7771                let mut cov = cov_data[mi] as f32 / 255.0;
7772                if let Some(clip) = clip_coverage {
7773                    cov *= clip[mi] as f32 / 255.0;
7774                }
7775                if cov > 0.0 {
7776                    let ci = mi * 4;
7777                    cmyk_buf[ci] = src_c as f32;
7778                    cmyk_buf[ci + 1] = src_m as f32;
7779                    cmyk_buf[ci + 2] = src_y as f32;
7780                    cmyk_buf[ci + 3] = src_k as f32;
7781                }
7782            }
7783        }
7784        let mut temp_mask = None;
7785        let Some(mask_ref) =
7786            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
7787        else {
7788            return;
7789        };
7790        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
7791        pixmap.stroke_path(skia_path, &paint, stroke, transform, mask_ref);
7792        return;
7793    }
7794
7795    let cov_data = coverage_mask.data();
7796    let stride = out_w as usize;
7797    let px_data = pixmap.data_mut();
7798    let px_stride = out_w as usize * 4;
7799
7800    for y in bbox_y0..bbox_y1 {
7801        for x in bbox_x0..bbox_x1 {
7802            let mi = y * stride + x;
7803            let mut cov = cov_data[mi] as f32 / 255.0;
7804            if let Some(clip) = clip_coverage {
7805                cov *= clip[mi] as f32 / 255.0;
7806            }
7807            if cov <= 0.0 {
7808                continue;
7809            }
7810
7811            let ci = mi * 4;
7812            let pi = y * px_stride + x * 4;
7813            // Snapshot-based AA blending — see render_overprint_fill for the
7814            // rationale. Capture the pre-paint pixmap on first overprint touch
7815            // so stacked overprints at the same pixel blend against the
7816            // original backdrop rather than each other.
7817            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
7818                op_bg[pi] = px_data[pi];
7819                op_bg[pi + 1] = px_data[pi + 1];
7820                op_bg[pi + 2] = px_data[pi + 2];
7821                op_bg[pi + 3] = px_data[pi + 3];
7822                op_touched[mi] = 1;
7823            }
7824            let cur_c = cmyk_buf[ci] as f64;
7825            let cur_m = cmyk_buf[ci + 1] as f64;
7826            let cur_y = cmyk_buf[ci + 2] as f64;
7827            let cur_k = cmyk_buf[ci + 3] as f64;
7828            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
7829            let pixmap_has_colour = px_data[pi + 3] > 0
7830                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
7831            // Multiplicative ink-stacking only when the pixmap carries a real
7832            // backdrop: either this paint is a custom spot landing on an
7833            // already-coloured pixel, or the process-ink buffer is empty but
7834            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
7835            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
7836            // the fill to pure black, so those pixels fall through to the
7837            // replace path where the source RGB paints normally.
7838            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
7839
7840            // Promoted DeviceGray on non-spot backdrop: replace all channels
7841            // (see render_overprint_fill).
7842            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
7843                && channels == stet_graphics::device::CMYK_K
7844                && params.is_device_cmyk
7845                && src_c == 0.0
7846                && src_m == 0.0
7847                && src_y == 0.0;
7848            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
7849                stet_graphics::device::CMYK_ALL
7850            } else {
7851                channels
7852            };
7853
7854            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
7855                src_c
7856            } else {
7857                cur_c
7858            };
7859            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
7860                src_m
7861            } else {
7862                cur_m
7863            };
7864            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
7865                src_y
7866            } else {
7867                cur_y
7868            };
7869            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
7870                src_k
7871            } else {
7872                cur_k
7873            };
7874
7875            if !is_custom_spot {
7876                cmyk_buf[ci] = new_c as f32;
7877                cmyk_buf[ci + 1] = new_m as f32;
7878                cmyk_buf[ci + 2] = new_y as f32;
7879                cmyk_buf[ci + 3] = new_k as f32;
7880            }
7881
7882            // No-op overprint skip — see render_overprint_fill for rationale.
7883            let delta = (new_c - cur_c)
7884                .abs()
7885                .max((new_m - cur_m).abs())
7886                .max((new_y - cur_y).abs())
7887                .max((new_k - cur_k).abs());
7888            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
7889                continue;
7890            }
7891
7892            let (r, g, b) =
7893                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
7894                    // Promoted DeviceGray collapsing to a full replace — see
7895                    // render_overprint_fill for the rationale (must run before
7896                    // the multiplicative branch so a `1 g` / `1 G` white paint
7897                    // doesn't get folded into the backdrop via zero-source
7898                    // multiplication).
7899                    (params.color.r, params.color.g, params.color.b)
7900                } else if use_multiplicative {
7901                    let bg_r = px_data[pi] as f64 / 255.0;
7902                    let bg_g = px_data[pi + 1] as f64 / 255.0;
7903                    let bg_b = px_data[pi + 2] as f64 / 255.0;
7904                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
7905                        1.0 - src_c
7906                    } else {
7907                        1.0
7908                    };
7909                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
7910                        1.0 - src_m
7911                    } else {
7912                        1.0
7913                    };
7914                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
7915                        1.0 - src_y
7916                    } else {
7917                        1.0
7918                    };
7919                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
7920                        1.0 - src_k
7921                    } else {
7922                        1.0
7923                    };
7924                    (
7925                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
7926                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
7927                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
7928                    )
7929                } else if let Some(icc_cache) = icc {
7930                    icc_cache
7931                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
7932                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
7933                } else {
7934                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
7935                };
7936
7937            let a = (cov * params.alpha as f32).min(1.0);
7938            // Blend backdrop: prefer snapshot only when this paint's colour
7939            // closely matches the snapshot — see render_overprint_fill for
7940            // the rationale (keeps aw-on-red-style cancel paints clean at
7941            // edges while preserving additive same-colour stacking).
7942            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
7943                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
7944                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
7945                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
7946                let dr = (op_bg[pi] as f32 - new_r).abs();
7947                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
7948                let db = (op_bg[pi + 2] as f32 - new_b).abs();
7949                if dr.max(dg).max(db) <= 4.0 {
7950                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
7951                } else {
7952                    (
7953                        px_data[pi],
7954                        px_data[pi + 1],
7955                        px_data[pi + 2],
7956                        px_data[pi + 3],
7957                    )
7958                }
7959            } else {
7960                (
7961                    px_data[pi],
7962                    px_data[pi + 1],
7963                    px_data[pi + 2],
7964                    px_data[pi + 3],
7965                )
7966            };
7967            let dst_a = bk_a as f32 / 255.0;
7968            let one_minus_a = 1.0 - a;
7969            let out_a = a + dst_a * one_minus_a;
7970            if out_a > 0.0 {
7971                // tiny-skia stores premultiplied RGBA (see render_overprint_fill).
7972                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
7973                    .clamp(0.0, 255.0)
7974                    .round() as u8;
7975                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
7976                    .clamp(0.0, 255.0)
7977                    .round() as u8;
7978                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
7979                    .clamp(0.0, 255.0)
7980                    .round() as u8;
7981                px_data[pi + 3] = (out_a * 255.0).round() as u8;
7982            }
7983        }
7984    }
7985}
7986
7987/// Update the CMYK buffer for a non-overprint stroke. Mirrors
7988/// [`update_cmyk_buffer_for_fill`] but rasterizes a stroked outline path
7989/// instead of a filled one. Source-CMYK selection follows the same
7990/// native_cmyk → ICC reverse → PLRM cascade.
7991#[allow(clippy::too_many_arguments)]
7992fn update_cmyk_buffer_for_stroke(
7993    cmyk_buf: &mut [f32],
7994    spot_mask: &mut [u8],
7995    path: &PsPath,
7996    params: &StrokeParams,
7997    stroke: &Stroke,
7998    transform: Transform,
7999    out_w: u32,
8000    out_h: u32,
8001    clip_region: &Option<ClipRegion>,
8002    no_aa: bool,
8003    icc: Option<&IccCache>,
8004) {
8005    // Custom spot strokes knockout the process CMYK plates — zero the buffer
8006    // under the stroke so later overprints fall into the multiplicative-blend
8007    // branch (see update_cmyk_buffer_for_fill).
8008    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
8009    // See update_cmyk_buffer_for_fill for rationale.
8010    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
8011        || matches!(
8012            (params.color.native_cmyk, params.color.process_cmyk),
8013            (Some(nat), Some(proc_))
8014                if (nat.0 - proc_.0).abs() > 1e-6
8015                    || (nat.1 - proc_.1).abs() > 1e-6
8016                    || (nat.2 - proc_.2).abs() > 1e-6
8017                    || (nat.3 - proc_.3).abs() > 1e-6
8018        );
8019
8020    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
8021        (0.0, 0.0, 0.0, 0.0)
8022    } else if let Some(c) = params.color.process_cmyk {
8023        c
8024    } else if let Some(c) = params.color.native_cmyk {
8025        c
8026    } else if let Some(cmyk) = icc.and_then(|i| {
8027        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
8028    }) {
8029        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
8030    } else {
8031        (
8032            (1.0 - params.color.r).clamp(0.0, 1.0),
8033            (1.0 - params.color.g).clamp(0.0, 1.0),
8034            (1.0 - params.color.b).clamp(0.0, 1.0),
8035            0.0,
8036        )
8037    };
8038
8039    let Some(skia_path) = build_skia_path(path) else {
8040        return;
8041    };
8042
8043    // Convert the stroke outline into a fill path so we can rasterize it via
8044    // Mask::fill_path. Mirrors the dance in the overprint stroke branch:
8045    // dash → stroke-to-outline (in user space) → device transform.
8046    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
8047        .sqrt()
8048        .max(1.0);
8049    let dashed_op;
8050    let stroke_src = if let Some(ref dash) = stroke.dash {
8051        dashed_op = skia_path.dash(dash, resolution_scale);
8052        match dashed_op.as_ref() {
8053            Some(p) => p,
8054            None => &skia_path,
8055        }
8056    } else {
8057        &skia_path
8058    };
8059    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
8060        return;
8061    };
8062    let Some(stroked) = stroked_user.transform(transform) else {
8063        return;
8064    };
8065
8066    let mut coverage_mask = match Mask::new(out_w, out_h) {
8067        Some(m) => m,
8068        None => return,
8069    };
8070    coverage_mask.fill_path(
8071        &stroked,
8072        SkiaFillRule::Winding,
8073        !no_aa,
8074        Transform::identity(),
8075    );
8076
8077    let cov_data = coverage_mask.data();
8078    let clip_data: Option<&[u8]> = match clip_region {
8079        Some(ClipRegion::Mask(m)) => Some(m.data()),
8080        _ => None,
8081    };
8082
8083    let (mut bx0, mut by0, mut bx1, mut by1) =
8084        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
8085    if let Some(ClipRegion::Rect(r)) = clip_region {
8086        bx0 = bx0.max(r.x0 as usize);
8087        by0 = by0.max(r.y0 as usize);
8088        bx1 = bx1.min(r.x1 as usize);
8089        by1 = by1.min(r.y1 as usize);
8090    }
8091
8092    let stride = out_w as usize;
8093    for y in by0..by1 {
8094        for x in bx0..bx1 {
8095            let mi = y * stride + x;
8096            let mut cov = cov_data[mi] as f32 / 255.0;
8097            if let Some(clip) = clip_data {
8098                cov *= clip[mi] as f32 / 255.0;
8099            }
8100            if cov > 0.0 {
8101                let ci = mi * 4;
8102                cmyk_buf[ci] = src_c as f32;
8103                cmyk_buf[ci + 1] = src_m as f32;
8104                cmyk_buf[ci + 2] = src_y as f32;
8105                cmyk_buf[ci + 3] = src_k as f32;
8106                if has_spot_contrib {
8107                    spot_mask[mi] = 1;
8108                }
8109            }
8110        }
8111    }
8112}
8113
8114/// Render an overprint image with viewport params.
8115#[allow(clippy::too_many_arguments)]
8116fn render_overprint_image(
8117    pixmap: &mut Pixmap,
8118    cmyk_buf: &mut [f32],
8119    op_bg: &mut [u8],
8120    op_touched: &mut [u8],
8121    band_state: &mut BandState,
8122    sample_data: &[u8],
8123    params: &ImageParams,
8124    vp_x: f32,
8125    vp_y: f32,
8126    scale_x: f32,
8127    scale_y: f32,
8128    out_w: u32,
8129    out_h: u32,
8130    icc: Option<&IccCache>,
8131) {
8132    let iw = params.width as usize;
8133    let ih = params.height as usize;
8134    let Some(image_inv) = params.image_matrix.invert() else {
8135        return;
8136    };
8137    let combined = params.ctm.concat(&image_inv);
8138    let Some(inv_combined) = combined.invert() else {
8139        return;
8140    };
8141
8142    let px_data = pixmap.data_mut();
8143    let stride = out_w as usize;
8144    let inv_sx = 1.0 / scale_x as f64;
8145    let inv_sy = 1.0 / scale_y as f64;
8146
8147    let clip_data: Option<&[u8]> = match &band_state.clip_region {
8148        Some(ClipRegion::Mask(m)) => Some(m.data()),
8149        _ => None,
8150    };
8151    let clip_rect = match &band_state.clip_region {
8152        Some(ClipRegion::Rect(r)) => Some(*r),
8153        _ => None,
8154    };
8155
8156    let mask_info = if let ImageColorSpace::Mask { color, polarity } = &params.color_space {
8157        let (src_c, src_m, src_y, src_k) = color.native_cmyk.unwrap_or_else(|| {
8158            let r = color.r;
8159            let g = color.g;
8160            let b = color.b;
8161            (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
8162        });
8163        Some((src_c, src_m, src_y, src_k, *polarity, iw.div_ceil(8)))
8164    } else {
8165        None
8166    };
8167
8168    for by in 0..out_h as usize {
8169        for bx in 0..out_w as usize {
8170            if let Some(ref r) = clip_rect
8171                && ((by as u32) < r.y0
8172                    || (by as u32) >= r.y1
8173                    || (bx as u32) < r.x0
8174                    || (bx as u32) >= r.x1)
8175            {
8176                continue;
8177            }
8178            if let Some(clip) = clip_data {
8179                let ci_clip = by * stride + bx;
8180                if clip[ci_clip] == 0 {
8181                    let bh = out_h as usize;
8182                    let has_neighbor = (bx > 0 && clip[ci_clip - 1] != 0)
8183                        || (bx + 1 < stride && clip[ci_clip + 1] != 0)
8184                        || (by > 0 && clip[ci_clip - stride] != 0)
8185                        || (by + 1 < bh && clip[ci_clip + stride] != 0)
8186                        || (bx > 0 && by > 0 && clip[ci_clip - stride - 1] != 0)
8187                        || (bx + 1 < stride && by > 0 && clip[ci_clip - stride + 1] != 0)
8188                        || (bx > 0 && by + 1 < bh && clip[ci_clip + stride - 1] != 0)
8189                        || (bx + 1 < stride && by + 1 < bh && clip[ci_clip + stride + 1] != 0);
8190                    if !has_neighbor {
8191                        continue;
8192                    }
8193                }
8194            }
8195
8196            // Map output pixel to device space, then to image space
8197            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8198            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8199            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8200            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8201
8202            let col = ix.floor() as i64;
8203            let row = iy.floor() as i64;
8204            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8205                continue;
8206            }
8207            let col = col as usize;
8208            let row = row as usize;
8209
8210            let (src_c, src_m, src_y, src_k) =
8211                if let Some((mc, mm, my, mk, polarity, bytes_per_row)) = mask_info {
8212                    let byte_idx = row * bytes_per_row + col / 8;
8213                    let bit_offset = 7 - (col % 8);
8214                    let bit = if byte_idx < sample_data.len() {
8215                        (sample_data[byte_idx] >> bit_offset) & 1
8216                    } else {
8217                        0
8218                    };
8219                    let paint = if polarity { bit == 1 } else { bit == 0 };
8220                    if !paint {
8221                        continue;
8222                    }
8223                    (mc, mm, my, mk)
8224                } else if let Some(cmyk) =
8225                    sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8226                {
8227                    cmyk
8228                } else {
8229                    continue;
8230                };
8231
8232            let mi = by * stride + bx;
8233            let ci = mi * 4;
8234            let pi = mi * 4;
8235
8236            // Spot-tint images (Separation / DeviceN with CMYK alt and at
8237            // least one non-process colorant): per PDF spec 11.7.4.5 the
8238            // image affects only the device colorants identified by its color
8239            // space.  In composite preview that means:
8240            //   * Where the CMYK buffer is empty (fresh paper or a custom
8241            //     spot painted earlier whose alt-CMYK we never tracked),
8242            //     paint the pixel directly from the image's tint output —
8243            //     the spot's full alt-CMYK contribution shows up, and a
8244            //     same-spot underlying paint (e.g. a /GWG-Green X under an
8245            //     image whose GWG-Green is zero) is knocked out because
8246            //     ICC(0,0,0,0) is white.
8247            //   * Where the CMYK buffer carries prior CMYK (a `1 0 1 0.5 k`
8248            //     ✓ underneath), REPLACE only the NAMED PROCESS plates with
8249            //     the image's tint output and PRESERVE the rest, then
8250            //     recompose the pixmap.  A duotone DeviceN [Black, Green]
8251            //     image's "no ink" pixel knocks the ✓'s K=0.5 down to 0 —
8252            //     lightening it to (C=1, M=0, Y=1, K=0) — while leaving its
8253            //     C=1, Y=1 untouched.
8254            if image_cs_has_spot_tint_transform(&params.color_space) {
8255                let cur_c = cmyk_buf[ci] as f64;
8256                let cur_m = cmyk_buf[ci + 1] as f64;
8257                let cur_y = cmyk_buf[ci + 2] as f64;
8258                let cur_k = cmyk_buf[ci + 3] as f64;
8259                let cur_is_zero = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8260                let named = params.painted_channels;
8261                // OPM=1 zero-source preservation: when the image's tint
8262                // output for a named plate is zero, the underlying value is
8263                // preserved instead of replaced.  Without this, a duotone
8264                // DeviceN [Black, GWG-Green] image's "no ink" pixel
8265                // overwrote the K=0.5 of an underlying CMYK ✓ with 0,
8266                // rendering the checkmark too light versus Adobe Acrobat.
8267                let opm1 = params.overprint_mode == 1;
8268                let (new_c, new_m, new_y, new_k) = if cur_is_zero {
8269                    (src_c, src_m, src_y, src_k)
8270                } else {
8271                    let nc =
8272                        if named & stet_graphics::device::CMYK_C != 0 && !(opm1 && src_c == 0.0) {
8273                            src_c
8274                        } else {
8275                            cur_c
8276                        };
8277                    let nm =
8278                        if named & stet_graphics::device::CMYK_M != 0 && !(opm1 && src_m == 0.0) {
8279                            src_m
8280                        } else {
8281                            cur_m
8282                        };
8283                    let ny =
8284                        if named & stet_graphics::device::CMYK_Y != 0 && !(opm1 && src_y == 0.0) {
8285                            src_y
8286                        } else {
8287                            cur_y
8288                        };
8289                    let nk =
8290                        if named & stet_graphics::device::CMYK_K != 0 && !(opm1 && src_k == 0.0) {
8291                            src_k
8292                        } else {
8293                            cur_k
8294                        };
8295                    (nc, nm, ny, nk)
8296                };
8297                cmyk_buf[ci] = new_c as f32;
8298                cmyk_buf[ci + 1] = new_m as f32;
8299                cmyk_buf[ci + 2] = new_y as f32;
8300                cmyk_buf[ci + 3] = new_k as f32;
8301                let (r, g, b) = if let Some(icc_cache) = icc {
8302                    icc_cache
8303                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8304                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8305                } else {
8306                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8307                };
8308                if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8309                    op_bg[pi] = px_data[pi];
8310                    op_bg[pi + 1] = px_data[pi + 1];
8311                    op_bg[pi + 2] = px_data[pi + 2];
8312                    op_bg[pi + 3] = px_data[pi + 3];
8313                    op_touched[mi] = 1;
8314                }
8315                px_data[pi] = (r * 255.0).round() as u8;
8316                px_data[pi + 1] = (g * 255.0).round() as u8;
8317                px_data[pi + 2] = (b * 255.0).round() as u8;
8318                px_data[pi + 3] = 255;
8319                continue;
8320            }
8321
8322            let mut channels = params.painted_channels;
8323            // Non-CMYK images (painted_channels=0, e.g. Separation/DeviceN spot colors)
8324            // replace all CMYK channels with the tinted equivalent.
8325            if channels == 0 {
8326                channels = stet_graphics::device::CMYK_ALL;
8327            }
8328            let is_direct_cmyk = matches!(
8329                &params.color_space,
8330                ImageColorSpace::DeviceCMYK
8331                    | ImageColorSpace::ICCBased { n: 4, .. }
8332                    | ImageColorSpace::Mask { .. }
8333            );
8334            // Custom spot image: process plates stay untouched and the per-pixel
8335            // sampled CMYK is the spot's alt-CMYK, which we layer multiplicatively
8336            // onto the pixmap. For image masks, the spot identity lives on the
8337            // fill color (recognise them via painted_channels=0 paired with a
8338            // native-CMYK fill color from the alt-space conversion). Indexed
8339            // images inherit the base space, so an Indexed /DeviceCMYK palette
8340            // is NOT a custom spot even when painted_channels=0. Plain DeviceCMYK
8341            // / ICCBased(4) images keep is_custom_spot=false so standard OPM 1
8342            // behaviour still applies.
8343            let is_custom_spot = params.painted_channels == 0
8344                && !is_cmyk_color_space(&params.color_space)
8345                && match &params.color_space {
8346                    ImageColorSpace::Mask { color, .. } => color.native_cmyk.is_some(),
8347                    _ => true,
8348                };
8349            if params.overprint_mode == 1
8350                && channels == stet_graphics::device::CMYK_ALL
8351                && is_direct_cmyk
8352            {
8353                channels = 0;
8354                if src_c != 0.0 {
8355                    channels |= stet_graphics::device::CMYK_C;
8356                }
8357                if src_m != 0.0 {
8358                    channels |= stet_graphics::device::CMYK_M;
8359                }
8360                if src_y != 0.0 {
8361                    channels |= stet_graphics::device::CMYK_Y;
8362                }
8363                if src_k != 0.0 {
8364                    channels |= stet_graphics::device::CMYK_K;
8365                }
8366            }
8367
8368            let cur_c = cmyk_buf[ci] as f64;
8369            let cur_m = cmyk_buf[ci + 1] as f64;
8370            let cur_y = cmyk_buf[ci + 2] as f64;
8371            let cur_k = cmyk_buf[ci + 3] as f64;
8372            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8373            let pixmap_has_colour = px_data[pi + 3] > 0
8374                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
8375            // Multiplicative ink-stacking only when the pixmap carries a real
8376            // backdrop: either this paint is a custom spot landing on an
8377            // already-coloured pixel, or the process-ink buffer is empty but
8378            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
8379            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
8380            // the fill to pure black, so those pixels fall through to the
8381            // replace path where the source RGB paints normally.
8382            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
8383
8384            let new_c = if channels & stet_graphics::device::CMYK_C != 0 {
8385                src_c
8386            } else {
8387                cur_c
8388            };
8389            let new_m = if channels & stet_graphics::device::CMYK_M != 0 {
8390                src_m
8391            } else {
8392                cur_m
8393            };
8394            let new_y = if channels & stet_graphics::device::CMYK_Y != 0 {
8395                src_y
8396            } else {
8397                cur_y
8398            };
8399            let new_k = if channels & stet_graphics::device::CMYK_K != 0 {
8400                src_k
8401            } else {
8402                cur_k
8403            };
8404
8405            if !is_custom_spot {
8406                cmyk_buf[ci] = new_c as f32;
8407                cmyk_buf[ci + 1] = new_m as f32;
8408                cmyk_buf[ci + 2] = new_y as f32;
8409                cmyk_buf[ci + 3] = new_k as f32;
8410            }
8411
8412            let (r, g, b) = if use_multiplicative {
8413                let bg_r = px_data[pi] as f64 / 255.0;
8414                let bg_g = px_data[pi + 1] as f64 / 255.0;
8415                let bg_b = px_data[pi + 2] as f64 / 255.0;
8416                let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
8417                    1.0 - src_c
8418                } else {
8419                    1.0
8420                };
8421                let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
8422                    1.0 - src_m
8423                } else {
8424                    1.0
8425                };
8426                let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
8427                    1.0 - src_y
8428                } else {
8429                    1.0
8430                };
8431                let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
8432                    1.0 - src_k
8433                } else {
8434                    1.0
8435                };
8436                (
8437                    (bg_r * over_r * k_fac).clamp(0.0, 1.0),
8438                    (bg_g * over_g * k_fac).clamp(0.0, 1.0),
8439                    (bg_b * over_b * k_fac).clamp(0.0, 1.0),
8440                )
8441            } else if let Some(icc_cache) = icc {
8442                icc_cache
8443                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8444                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8445            } else {
8446                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8447            };
8448
8449            // Snapshot the pre-paint pixmap so a later overprint fill/stroke
8450            // at this pixel can blend against it (see render_overprint_fill).
8451            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8452                op_bg[pi] = px_data[pi];
8453                op_bg[pi + 1] = px_data[pi + 1];
8454                op_bg[pi + 2] = px_data[pi + 2];
8455                op_bg[pi + 3] = px_data[pi + 3];
8456                op_touched[mi] = 1;
8457            }
8458
8459            px_data[pi] = (r * 255.0).round() as u8;
8460            px_data[pi + 1] = (g * 255.0).round() as u8;
8461            px_data[pi + 2] = (b * 255.0).round() as u8;
8462            px_data[pi + 3] = 255;
8463        }
8464    }
8465}
8466
8467/// Update CMYK buffer for a non-overprint image.
8468///
8469/// For native-CMYK image color spaces (DeviceCMYK / ICCBased(4) / Separation
8470/// or DeviceN with CMYK alt), the source CMYK is sampled directly via
8471/// `sample_pixel_cmyk`. For non-CMYK source spaces (RGB/Gray/Lab/etc.), the
8472/// already-composited pixmap pixel is read and reverse-converted to CMYK via
8473/// the system CMYK ICC profile, falling back to the PLRM formula. This keeps
8474/// the parallel CMYK buffer faithful for any image painter inside a
8475/// CMYK-tracked context.
8476#[allow(clippy::too_many_arguments)]
8477fn update_cmyk_buffer_for_image(
8478    cmyk_buf: &mut [f32],
8479    sample_data: &[u8],
8480    pixmap_rgba: &[u8],
8481    params: &ImageParams,
8482    vp_x: f32,
8483    vp_y: f32,
8484    scale_x: f32,
8485    scale_y: f32,
8486    out_w: u32,
8487    out_h: u32,
8488    clip_region: &Option<ClipRegion>,
8489    icc: Option<&IccCache>,
8490) {
8491    let iw = params.width as usize;
8492    let ih = params.height as usize;
8493    let Some(image_inv) = params.image_matrix.invert() else {
8494        return;
8495    };
8496    let combined = params.ctm.concat(&image_inv);
8497    let Some(inv_combined) = combined.invert() else {
8498        return;
8499    };
8500    let stride = out_w as usize;
8501    let inv_sx = 1.0 / scale_x as f64;
8502    let inv_sy = 1.0 / scale_y as f64;
8503
8504    let mask_info = if let ImageColorSpace::Mask { color, polarity } = &params.color_space {
8505        let Some((c, m, y, k)) = color.native_cmyk else {
8506            return;
8507        };
8508        Some((
8509            c as f32,
8510            m as f32,
8511            y as f32,
8512            k as f32,
8513            *polarity,
8514            iw.div_ceil(8),
8515        ))
8516    } else {
8517        None
8518    };
8519
8520    let clip_data: Option<&[u8]> = match clip_region {
8521        Some(ClipRegion::Mask(m)) => Some(m.data()),
8522        _ => None,
8523    };
8524    let clip_rect = match clip_region {
8525        Some(ClipRegion::Rect(r)) => Some(*r),
8526        _ => None,
8527    };
8528
8529    for by in 0..out_h as usize {
8530        for bx in 0..out_w as usize {
8531            if let Some(ref r) = clip_rect
8532                && ((by as u32) < r.y0
8533                    || (by as u32) >= r.y1
8534                    || (bx as u32) < r.x0
8535                    || (bx as u32) >= r.x1)
8536            {
8537                continue;
8538            }
8539            if let Some(clip) = clip_data
8540                && clip[by * stride + bx] == 0
8541            {
8542                continue;
8543            }
8544
8545            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8546            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8547            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8548            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8549
8550            let col = ix.floor() as i64;
8551            let row = iy.floor() as i64;
8552            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8553                continue;
8554            }
8555            let col = col as usize;
8556            let row = row as usize;
8557
8558            let ci = (by * stride + bx) * 4;
8559            if let Some((sc, sm, sy, sk, polarity, bytes_per_row)) = mask_info {
8560                let byte_idx = row * bytes_per_row + col / 8;
8561                let bit_offset = 7 - (col % 8);
8562                let bit = if byte_idx < sample_data.len() {
8563                    (sample_data[byte_idx] >> bit_offset) & 1
8564                } else {
8565                    0
8566                };
8567                let paint = if polarity { bit == 1 } else { bit == 0 };
8568                if paint {
8569                    cmyk_buf[ci] = sc;
8570                    cmyk_buf[ci + 1] = sm;
8571                    cmyk_buf[ci + 2] = sy;
8572                    cmyk_buf[ci + 3] = sk;
8573                }
8574            } else if let Some((sc, sm, sy, sk)) =
8575                sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8576            {
8577                cmyk_buf[ci] = sc as f32;
8578                cmyk_buf[ci + 1] = sm as f32;
8579                cmyk_buf[ci + 2] = sy as f32;
8580                cmyk_buf[ci + 3] = sk as f32;
8581            } else if ci + 3 < pixmap_rgba.len() && pixmap_rgba[ci + 3] > 0 {
8582                // Non-CMYK source space: reverse-convert the composited pixmap
8583                // pixel to CMYK via the system profile. Falls back to PLRM
8584                // (1 − r, 1 − g, 1 − b, 0) when no ICC reverse is available.
8585                let r = pixmap_rgba[ci] as f64 / 255.0;
8586                let g = pixmap_rgba[ci + 1] as f64 / 255.0;
8587                let b = pixmap_rgba[ci + 2] as f64 / 255.0;
8588                let cmyk =
8589                    if let Some(c) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
8590                        c
8591                    } else {
8592                        [
8593                            (1.0 - r).clamp(0.0, 1.0),
8594                            (1.0 - g).clamp(0.0, 1.0),
8595                            (1.0 - b).clamp(0.0, 1.0),
8596                            0.0,
8597                        ]
8598                    };
8599                cmyk_buf[ci] = cmyk[0] as f32;
8600                cmyk_buf[ci + 1] = cmyk[1] as f32;
8601                cmyk_buf[ci + 2] = cmyk[2] as f32;
8602                cmyk_buf[ci + 3] = cmyk[3] as f32;
8603            }
8604        }
8605    }
8606}
8607/// Check if an image color space can be rendered through the overprint path.
8608/// Image masks always work (they use the fill color's native CMYK).
8609/// Other color spaces must be CMYK-resolvable via `sample_pixel_cmyk`.
8610fn image_supports_overprint(cs: &ImageColorSpace) -> bool {
8611    match cs {
8612        ImageColorSpace::Mask { .. } => true,
8613        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => true,
8614        ImageColorSpace::Separation { alt_space, .. }
8615        | ImageColorSpace::DeviceN { alt_space, .. } => {
8616            matches!(
8617                alt_space.as_ref(),
8618                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8619            )
8620        }
8621        ImageColorSpace::Indexed { base, .. } => image_supports_overprint(base),
8622        _ => false,
8623    }
8624}
8625
8626/// Check if an image color space is CMYK-based (DeviceCMYK, ICCBased 4-component, or Indexed over CMYK).
8627fn is_cmyk_color_space(cs: &ImageColorSpace) -> bool {
8628    match cs {
8629        ImageColorSpace::DeviceCMYK => true,
8630        ImageColorSpace::ICCBased { n: 4, .. } => true,
8631        ImageColorSpace::Indexed { base, .. } => is_cmyk_color_space(base),
8632        _ => false,
8633    }
8634}
8635
8636/// True when an image's color space is a Separation/DeviceN with a CMYK
8637/// alternate AND at least one non-process spot colorant.  These images
8638/// represent paint that affects a virtual spot plate; the per-pixel CMYK
8639/// produced by the tint transform must blend multiplicatively with the
8640/// tracked CMYK buffer (rather than per-channel REPLACE) so that
8641/// underlying CMYK paints survive while same-spot underlying paints are
8642/// replaced by the image's "no ink" pixels.
8643fn image_cs_has_spot_tint_transform(cs: &ImageColorSpace) -> bool {
8644    use stet_graphics::device::cmyk_channel_for_name;
8645    match cs {
8646        ImageColorSpace::Separation {
8647            name, alt_space, ..
8648        } => {
8649            cmyk_channel_for_name(name) == 0
8650                && matches!(
8651                    alt_space.as_ref(),
8652                    ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8653                )
8654        }
8655        ImageColorSpace::DeviceN {
8656            names, alt_space, ..
8657        } => {
8658            matches!(
8659                alt_space.as_ref(),
8660                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8661            ) && names.iter().any(|n| cmyk_channel_for_name(n) == 0)
8662        }
8663        ImageColorSpace::Indexed { base, .. } => image_cs_has_spot_tint_transform(base),
8664        _ => false,
8665    }
8666}
8667
8668/// Sample a single pixel's CMYK values from image data, handling DeviceCMYK,
8669/// ICCBased(4), Separation/DeviceN with CMYK alt, and Indexed color spaces.
8670/// Returns None for non-CMYK images.
8671fn sample_pixel_cmyk(
8672    sample_data: &[u8],
8673    cs: &ImageColorSpace,
8674    iw: usize,
8675    row: usize,
8676    col: usize,
8677) -> Option<(f64, f64, f64, f64)> {
8678    match cs {
8679        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => {
8680            let si = (row * iw + col) * 4;
8681            if si + 3 < sample_data.len() {
8682                Some((
8683                    sample_data[si] as f64 / 255.0,
8684                    sample_data[si + 1] as f64 / 255.0,
8685                    sample_data[si + 2] as f64 / 255.0,
8686                    sample_data[si + 3] as f64 / 255.0,
8687                ))
8688            } else {
8689                None
8690            }
8691        }
8692        ImageColorSpace::Separation {
8693            alt_space,
8694            tint_table,
8695            ..
8696        } => {
8697            if !matches!(
8698                alt_space.as_ref(),
8699                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8700            ) {
8701                return None;
8702            }
8703            let si = row * iw + col;
8704            if si >= sample_data.len() {
8705                return None;
8706            }
8707            let tint = sample_data[si] as f32 / 255.0;
8708            let mut alt = [0.0f32; 4];
8709            tint_table.lookup_1d(tint, &mut alt);
8710            Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64))
8711        }
8712        ImageColorSpace::DeviceN {
8713            alt_space,
8714            tint_table,
8715            ..
8716        } => {
8717            if !matches!(
8718                alt_space.as_ref(),
8719                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8720            ) {
8721                return None;
8722            }
8723            let ni = tint_table.num_inputs as usize;
8724            let si = (row * iw + col) * ni;
8725            if si + ni > sample_data.len() {
8726                return None;
8727            }
8728            let mut inputs = vec![0.0f32; ni];
8729            for (c, inp) in inputs.iter_mut().enumerate() {
8730                *inp = sample_data[si + c] as f32 / 255.0;
8731            }
8732            let mut alt = [0.0f32; 4];
8733            tint_table.lookup_nd(&inputs, &mut alt);
8734            Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64))
8735        }
8736        ImageColorSpace::Indexed {
8737            base,
8738            hival,
8739            lookup,
8740        } => {
8741            let pi = row * iw + col;
8742            if pi >= sample_data.len() {
8743                return None;
8744            }
8745            let idx = sample_data[pi] as usize;
8746            let idx = idx.min(*hival as usize);
8747            let base_ncomp = base.num_components() as usize;
8748            let li = idx * base_ncomp;
8749            // For direct CMYK base (4 components): read CMYK from lookup table
8750            if is_cmyk_color_space(base) && base_ncomp == 4 && li + 3 < lookup.len() {
8751                return Some((
8752                    lookup[li] as f64 / 255.0,
8753                    lookup[li + 1] as f64 / 255.0,
8754                    lookup[li + 2] as f64 / 255.0,
8755                    lookup[li + 3] as f64 / 255.0,
8756                ));
8757            }
8758            // For Separation/DeviceN base: extract base components from lookup, then tint
8759            if li + base_ncomp <= lookup.len() {
8760                match base.as_ref() {
8761                    ImageColorSpace::Separation {
8762                        alt_space,
8763                        tint_table,
8764                        ..
8765                    } if matches!(
8766                        alt_space.as_ref(),
8767                        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8768                    ) =>
8769                    {
8770                        let tint = lookup[li] as f32 / 255.0;
8771                        let mut alt = [0.0f32; 4];
8772                        tint_table.lookup_1d(tint, &mut alt);
8773                        return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
8774                    }
8775                    ImageColorSpace::DeviceN {
8776                        alt_space,
8777                        tint_table,
8778                        ..
8779                    } if matches!(
8780                        alt_space.as_ref(),
8781                        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8782                    ) =>
8783                    {
8784                        let ni = tint_table.num_inputs as usize;
8785                        let mut inputs = vec![0.0f32; ni];
8786                        for (c, inp) in inputs.iter_mut().enumerate() {
8787                            if c < base_ncomp {
8788                                *inp = lookup[li + c] as f32 / 255.0;
8789                            }
8790                        }
8791                        let mut alt = [0.0f32; 4];
8792                        tint_table.lookup_nd(&inputs, &mut alt);
8793                        return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
8794                    }
8795                    _ => {}
8796                }
8797            }
8798            None
8799        }
8800        _ => None,
8801    }
8802}
8803/// Banded rendering as a free function — runs on a background thread.
8804///
8805/// Renders the display list in horizontal bands and streams the output
8806/// to a `PageSink`. This function is self-contained: it creates its own
8807/// band pixmaps, clip state, and streams rows to the sink.
8808#[allow(clippy::too_many_arguments)]
8809fn render_banded_to_sink(
8810    page_w: u32,
8811    page_h: u32,
8812    band_h: u32,
8813    dpi: f64,
8814    list: &DisplayList,
8815    sink: &mut dyn stet_graphics::device::PageSink,
8816    icc_cache: &IccCache,
8817    no_aa: bool,
8818) -> Result<(), String> {
8819    // Precompute Y bounding boxes for culling
8820    let bboxes = precompute_bboxes(list, dpi);
8821
8822    // Build clip epochs — groups of elements between InitClip boundaries.
8823    // Epochs whose paint elements don't overlap a band can be skipped entirely,
8824    // avoiding both the per-element iteration AND clip mask rasterization.
8825    let epochs = build_clip_epochs(list, &bboxes);
8826
8827    // Pre-populate clip_mask_seen so repeated clip paths get cached from first band
8828    let clip_seen = precompute_clip_seen(list);
8829
8830    // Allocate a CMYK buffer at the page level when CMYK math is needed:
8831    // overprint simulation, an explicit DeviceCMYK page-level transparency
8832    // group (PDF spec §11.6.7), or any descendant group that declares its own
8833    // DeviceCMYK transparency CS.
8834    use stet_graphics::display_list::GroupColorSpace;
8835    let needs_cmyk_buffer = has_overprint_elements(list)
8836        || list.page_group_color_space() == GroupColorSpace::DeviceCMYK
8837        || has_cmyk_group(list);
8838
8839    // Pre-convert and prescale images once (instead of per-band)
8840    let preprocessed_images = preprocess_images_for_bands(list, Some(icc_cache));
8841
8842    // Extra rows rendered above and below each band to provide anti-aliasing
8843    // context at band seams. Without this, tiny-skia clips geometry at the
8844    // pixmap edge, producing visible discontinuities in thin diagonal strokes.
8845    const BAND_OVERLAP: u32 = 6;
8846
8847    let render_h = band_h + 2 * BAND_OVERLAP;
8848
8849    // Initialize the sink for this page
8850    sink.begin_page(page_w, page_h)?;
8851
8852    let num_bands = page_h.div_ceil(band_h);
8853    let elements = list.elements();
8854    let row_bytes = page_w as usize * 4;
8855    let icc_ref = Some(icc_cache);
8856
8857    // Closure that renders a single band and returns its RGBA pixels.
8858    let render_band = |band_idx: u32| -> Vec<u8> {
8859        let y_start = band_idx * band_h;
8860        let actual_h = (page_h - y_start).min(band_h);
8861
8862        let render_y_start = y_start.saturating_sub(BAND_OVERLAP);
8863        let render_y_end_f = ((y_start + actual_h + BAND_OVERLAP).min(page_h)) as f64;
8864        let band_offset = y_start - render_y_start;
8865
8866        let mut band_pixmap = Pixmap::new(page_w, render_h).expect("Failed to create band pixmap");
8867        // Start transparent — white background composited after content rendering
8868        band_pixmap.as_mut().data_mut().fill(0x00);
8869
8870        let cmyk_buf = if needs_cmyk_buffer {
8871            // CMYK buffer for the render region (including overlap)
8872            Some(vec![0.0f32; page_w as usize * render_h as usize * 4])
8873        } else {
8874            None
8875        };
8876
8877        let mut band_state = BandState {
8878            clip_region: None,
8879            spare_mask: None,
8880            clip_mask_cache: HashMap::new(),
8881            clip_mask_seen: clip_seen.clone(),
8882            mask_pool: Vec::new(),
8883            cmyk_buffer: cmyk_buf,
8884            op_bg_snapshot: None,
8885            op_touched: None,
8886            spot_mask: None,
8887        };
8888
8889        // Epoch-based replay
8890        for epoch in &epochs {
8891            if !epoch.has_erase_page {
8892                match epoch.paint_bbox {
8893                    Some(ref pb)
8894                        if pb.y_max <= render_y_start as f64 || pb.y_min >= render_y_end_f =>
8895                    {
8896                        continue;
8897                    }
8898                    None => continue,
8899                    _ => {}
8900                }
8901            }
8902
8903            for i in epoch.start_idx..epoch.end_idx {
8904                // OcgGroups containing Clip/InitClip must always be
8905                // processed so their clip-state changes apply for every
8906                // band — per-element Y culling would strand clip mutations
8907                // inside a group whose paint content doesn't touch the
8908                // current band.
8909                let force_process = matches!(
8910                    &elements[i],
8911                    DisplayElement::OcgGroup { elements: inner, .. }
8912                        if contains_clip_op(inner)
8913                );
8914                if !force_process
8915                    && let Some(ref bbox) = bboxes[i]
8916                    && (bbox.y_max <= render_y_start as f64 || bbox.y_min >= render_y_end_f)
8917                {
8918                    continue;
8919                }
8920                let ctx = RenderContext {
8921                    vp_x: 0.0,
8922                    vp_y: render_y_start as f32,
8923                    scale_x: 1.0,
8924                    scale_y: 1.0,
8925                    out_w: page_w,
8926                    out_h: render_h,
8927                    effective_dpi: dpi,
8928                    icc: icc_ref,
8929                    image_cache: None,
8930                    preprocessed: Some(&preprocessed_images),
8931                    elem_idx: i,
8932                    no_aa,
8933                    opm_zero_transparent: false,
8934                    knockout_painter_pass: KnockoutPainterPass::None,
8935                    parent_group_isolated: false,
8936                    alpha_extraction_pass: false,
8937                };
8938                render_element(&mut band_pixmap, &mut band_state, &elements[i], &ctx);
8939            }
8940        }
8941
8942        // Composite content onto white background (premultiplied alpha)
8943        composite_onto_white(band_pixmap.data_mut());
8944
8945        // Extract only the actual band rows (skip overlap)
8946        let start_byte = band_offset as usize * row_bytes;
8947        let total_bytes = actual_h as usize * row_bytes;
8948        band_pixmap.data()[start_byte..start_byte + total_bytes].to_vec()
8949    };
8950
8951    // Render bands in parallel (when available), write to sink in order.
8952    #[cfg(feature = "parallel")]
8953    {
8954        // Process in chunks of `chunk_size` bands to limit peak memory
8955        // (each rendered band is ~band_h * page_w * 4 bytes).
8956        // Cap at 8 threads — sequential sink writing bottleneck means
8957        // additional cores yield no speedup (benchmarked: 8→7.8s plateau).
8958        let chunk_size = rayon::current_num_threads().max(1);
8959
8960        for chunk_start in (0..num_bands).step_by(chunk_size) {
8961            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
8962
8963            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
8964                .into_par_iter()
8965                .map(&render_band)
8966                .collect();
8967
8968            for (i, band_data) in rendered.iter().enumerate() {
8969                let band_idx = chunk_start + i as u32;
8970                let y_start = band_idx * band_h;
8971                let actual_h = (page_h - y_start).min(band_h);
8972                sink.write_rows(band_data, actual_h)?;
8973            }
8974        }
8975    }
8976    #[cfg(not(feature = "parallel"))]
8977    {
8978        // Sequential single-threaded rendering
8979        for band_idx in 0..num_bands {
8980            let band_data = render_band(band_idx);
8981            let y_start = band_idx * band_h;
8982            let actual_h = (page_h - y_start).min(band_h);
8983            sink.write_rows(&band_data, actual_h)?;
8984        }
8985    }
8986
8987    sink.end_page()
8988}
8989
8990/// 2D bounding box in device pixels.
8991#[derive(Clone, Copy)]
8992struct BBox2D {
8993    x_min: f64,
8994    y_min: f64,
8995    x_max: f64,
8996    y_max: f64,
8997}
8998
8999/// Compute full 2D bounding boxes for display list elements (for viewport culling).
9000fn precompute_full_bboxes(list: &DisplayList, dpi: f64) -> Vec<Option<BBox2D>> {
9001    list.elements()
9002        .iter()
9003        .map(|elem| match elem {
9004            DisplayElement::Fill { path, params } => fill_device_full_bbox(path, &params.ctm),
9005            DisplayElement::Stroke { path, params } => {
9006                path_full_bbox(path).map(|mut bbox| {
9007                    // Use effective line width: actual width or hairline minimum
9008                    let effective_lw = params.line_width.max(hairline_min_width(&params.ctm, dpi));
9009                    let expand = effective_lw * params.miter_limit * 0.5;
9010                    let m = &params.ctm;
9011                    let is_identity = m.a == 1.0
9012                        && m.b == 0.0
9013                        && m.c == 0.0
9014                        && m.d == 1.0
9015                        && m.tx == 0.0
9016                        && m.ty == 0.0;
9017                    if is_identity {
9018                        bbox.x_min -= expand;
9019                        bbox.x_max += expand;
9020                        bbox.y_min -= expand;
9021                        bbox.y_max += expand;
9022                    } else {
9023                        // Path is in user space — expand for stroke, then
9024                        // transform bbox corners through CTM to device space.
9025                        let col_x_len = (m.a * m.a + m.b * m.b).sqrt().max(1.0);
9026                        let col_y_len = (m.c * m.c + m.d * m.d).sqrt().max(1.0);
9027                        let expand_x = effective_lw * col_x_len * params.miter_limit * 0.5;
9028                        let expand_y = effective_lw * col_y_len * params.miter_limit * 0.5;
9029                        bbox.x_min -= expand_x;
9030                        bbox.x_max += expand_x;
9031                        bbox.y_min -= expand_y;
9032                        bbox.y_max += expand_y;
9033                        // Transform all 4 corners to device space
9034                        let corners = [
9035                            (
9036                                m.a * bbox.x_min + m.c * bbox.y_min + m.tx,
9037                                m.b * bbox.x_min + m.d * bbox.y_min + m.ty,
9038                            ),
9039                            (
9040                                m.a * bbox.x_max + m.c * bbox.y_min + m.tx,
9041                                m.b * bbox.x_max + m.d * bbox.y_min + m.ty,
9042                            ),
9043                            (
9044                                m.a * bbox.x_min + m.c * bbox.y_max + m.tx,
9045                                m.b * bbox.x_min + m.d * bbox.y_max + m.ty,
9046                            ),
9047                            (
9048                                m.a * bbox.x_max + m.c * bbox.y_max + m.tx,
9049                                m.b * bbox.x_max + m.d * bbox.y_max + m.ty,
9050                            ),
9051                        ];
9052                        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9053                        bbox.x_max = corners
9054                            .iter()
9055                            .map(|c| c.0)
9056                            .fold(f64::NEG_INFINITY, f64::max);
9057                        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9058                        bbox.y_max = corners
9059                            .iter()
9060                            .map(|c| c.1)
9061                            .fold(f64::NEG_INFINITY, f64::max);
9062                    }
9063                    bbox
9064                })
9065            }
9066            DisplayElement::Image { params, .. } => image_full_bbox(params),
9067            DisplayElement::AxialShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9068            DisplayElement::RadialShading { params } => {
9069                shading_full_bbox(&params.bbox, &params.ctm)
9070            }
9071            DisplayElement::MeshShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9072            DisplayElement::PatchShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9073            DisplayElement::PatternFill { params } => pattern_fill_full_bbox(params),
9074            DisplayElement::Group { params, .. } => Some(BBox2D {
9075                x_min: params.bbox[0],
9076                y_min: params.bbox[1],
9077                x_max: params.bbox[2],
9078                y_max: params.bbox[3],
9079            }),
9080            DisplayElement::SoftMasked { params, .. } => Some(BBox2D {
9081                x_min: params.bbox[0],
9082                y_min: params.bbox[1],
9083                x_max: params.bbox[2],
9084                y_max: params.bbox[3],
9085            }),
9086            DisplayElement::OcgGroup {
9087                elements,
9088                default_visible,
9089                ..
9090            } => {
9091                // Hidden groups without clip ops contribute nothing. Hidden
9092                // + has clip ops is force-processed at the render-loop layer
9093                // (see the viewport render_region_prepared loop) so we still
9094                // return the paint bounds here for correct epoch bbox.
9095                if !*default_visible && !contains_clip_op(elements) {
9096                    return None;
9097                }
9098                let child_bboxes = precompute_full_bboxes(elements, dpi);
9099                let mut x_min = f64::INFINITY;
9100                let mut y_min = f64::INFINITY;
9101                let mut x_max = f64::NEG_INFINITY;
9102                let mut y_max = f64::NEG_INFINITY;
9103                for cb in child_bboxes.into_iter().flatten() {
9104                    x_min = x_min.min(cb.x_min);
9105                    y_min = y_min.min(cb.y_min);
9106                    x_max = x_max.max(cb.x_max);
9107                    y_max = y_max.max(cb.y_max);
9108                }
9109                if x_min <= x_max && y_min <= y_max {
9110                    Some(BBox2D {
9111                        x_min,
9112                        y_min,
9113                        x_max,
9114                        y_max,
9115                    })
9116                } else {
9117                    None
9118                }
9119            }
9120            _ => None, // Clip, InitClip, ErasePage: always process
9121        })
9122        .collect()
9123}
9124
9125/// Compute the device-space bounding box of a Clip element's path.
9126///
9127/// Clip paths emitted by the PDF reader use `ctm = identity`, so the path
9128/// segments are already in device space. For Clips that come from other
9129/// sources (PostScript, the pattern transform path), the `ctm` field may
9130/// be non-identity and the path is in user space — transform the path's
9131/// bbox corners through the CTM in that case. Stroke-clips are expanded
9132/// by half the line width.
9133fn clip_path_bbox(path: &PsPath, params: &ClipParams) -> Option<BBox2D> {
9134    let mut bbox = path_full_bbox(path)?;
9135    let ctm = &params.ctm;
9136    let is_identity = ctm.a == 1.0
9137        && ctm.b == 0.0
9138        && ctm.c == 0.0
9139        && ctm.d == 1.0
9140        && ctm.tx == 0.0
9141        && ctm.ty == 0.0;
9142    if !is_identity {
9143        let corners = [
9144            ctm.transform_point(bbox.x_min, bbox.y_min),
9145            ctm.transform_point(bbox.x_max, bbox.y_min),
9146            ctm.transform_point(bbox.x_min, bbox.y_max),
9147            ctm.transform_point(bbox.x_max, bbox.y_max),
9148        ];
9149        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9150        bbox.x_max = corners
9151            .iter()
9152            .map(|c| c.0)
9153            .fold(f64::NEG_INFINITY, f64::max);
9154        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9155        bbox.y_max = corners
9156            .iter()
9157            .map(|c| c.1)
9158            .fold(f64::NEG_INFINITY, f64::max);
9159    }
9160    if let Some(sp) = &params.stroke_params {
9161        let scale = (ctm.a * ctm.a + ctm.b * ctm.b)
9162            .sqrt()
9163            .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt())
9164            .max(1.0);
9165        let expand = sp.line_width * 0.5 * scale;
9166        bbox.x_min -= expand;
9167        bbox.x_max += expand;
9168        bbox.y_min -= expand;
9169        bbox.y_max += expand;
9170    }
9171    Some(bbox)
9172}
9173
9174/// Intersect two bboxes; returns `None` if they don't overlap.
9175fn intersect_bbox(a: &BBox2D, b: &BBox2D) -> Option<BBox2D> {
9176    let x_min = a.x_min.max(b.x_min);
9177    let y_min = a.y_min.max(b.y_min);
9178    let x_max = a.x_max.min(b.x_max);
9179    let y_max = a.y_max.min(b.y_max);
9180    if x_min < x_max && y_min < y_max {
9181        Some(BBox2D {
9182            x_min,
9183            y_min,
9184            x_max,
9185            y_max,
9186        })
9187    } else {
9188        None
9189    }
9190}
9191
9192/// Compute the union of all paint elements' device-space bounds in
9193/// `list`, with awareness of the active clip stack.
9194///
9195/// Used by the soft-mask rasterization path: a SoftMasked element's
9196/// `params.bbox` is derived from the form's `/BBox` transformed by the
9197/// gs-time CTM, but the form's internal `cm` operators may translate
9198/// individual paint elements outside that bbox. The mask raster needs to
9199/// be sized against the actual paint bounds, not the form bbox.
9200///
9201/// **Why clip-awareness matters**: a mask form may contain a shading
9202/// without an explicit `/BBox`, in which case `precompute_full_bboxes`
9203/// returns a sentinel "infinite" bbox (`shading_full_bbox` falls back to
9204/// `0..1e9`) so band rendering doesn't cull it. If `compute_paint_bounds`
9205/// just unioned that, the result would exceed the mask raster size cap
9206/// and `rasterize_mask` would return `None`, making the entire SoftMasked
9207/// element invisible. Tracking the active clip stack lets us bound those
9208/// shadings to their effective paint area.
9209///
9210/// Returns `None` when the list contains no paintable elements or when
9211/// no element survives clip culling.
9212fn compute_paint_bounds(list: &DisplayList, _dpi: f64) -> Option<BBox2D> {
9213    // Active clip stack: each entry is the intersection so far. The
9214    // current clip is `clip_stack.last()`; an empty stack means
9215    // "unbounded" (no clip established yet, or just after InitClip).
9216    let mut clip_stack: Vec<BBox2D> = Vec::new();
9217    let mut union: Option<BBox2D> = None;
9218
9219    let push_paint = |union: &mut Option<BBox2D>, clip_stack: &[BBox2D], bbox: BBox2D| {
9220        // Intersect against the active clip if any. If the clip is
9221        // tighter than the bbox, the visible region is the intersection;
9222        // if the bbox is fully clipped away, skip it.
9223        let visible = match clip_stack.last() {
9224            Some(clip) => match intersect_bbox(clip, &bbox) {
9225                Some(b) => b,
9226                None => return,
9227            },
9228            None => bbox,
9229        };
9230        *union = Some(match union.take() {
9231            None => visible,
9232            Some(u) => BBox2D {
9233                x_min: u.x_min.min(visible.x_min),
9234                y_min: u.y_min.min(visible.y_min),
9235                x_max: u.x_max.max(visible.x_max),
9236                y_max: u.y_max.max(visible.y_max),
9237            },
9238        });
9239    };
9240
9241    for elem in list.elements() {
9242        match elem {
9243            DisplayElement::Clip { path, params } => {
9244                if let Some(cb) = clip_path_bbox(path, params) {
9245                    let new_top = match clip_stack.last() {
9246                        Some(prev) => match intersect_bbox(prev, &cb) {
9247                            Some(b) => b,
9248                            // Clip cleared the visible region; push an
9249                            // empty bbox so subsequent paints are
9250                            // clipped away.
9251                            None => BBox2D {
9252                                x_min: 0.0,
9253                                y_min: 0.0,
9254                                x_max: 0.0,
9255                                y_max: 0.0,
9256                            },
9257                        },
9258                        None => cb,
9259                    };
9260                    clip_stack.push(new_top);
9261                }
9262            }
9263            DisplayElement::InitClip | DisplayElement::ErasePage => {
9264                clip_stack.clear();
9265            }
9266            DisplayElement::Fill { path, .. } => {
9267                if let Some(b) = path_full_bbox(path) {
9268                    push_paint(&mut union, &clip_stack, b);
9269                }
9270            }
9271            DisplayElement::Stroke { path, params } => {
9272                if let Some(mut b) = path_full_bbox(path) {
9273                    let expand = params.line_width * params.miter_limit * 0.5;
9274                    b.x_min -= expand;
9275                    b.x_max += expand;
9276                    b.y_min -= expand;
9277                    b.y_max += expand;
9278                    push_paint(&mut union, &clip_stack, b);
9279                }
9280            }
9281            DisplayElement::Image { params, .. } => {
9282                if let Some(b) = image_full_bbox(params) {
9283                    push_paint(&mut union, &clip_stack, b);
9284                }
9285            }
9286            DisplayElement::AxialShading { params } => {
9287                let b = match &params.bbox {
9288                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9289                    None => clip_stack.last().copied(),
9290                };
9291                if let Some(b) = b {
9292                    push_paint(&mut union, &clip_stack, b);
9293                }
9294            }
9295            DisplayElement::RadialShading { params } => {
9296                let b = match &params.bbox {
9297                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9298                    None => clip_stack.last().copied(),
9299                };
9300                if let Some(b) = b {
9301                    push_paint(&mut union, &clip_stack, b);
9302                }
9303            }
9304            DisplayElement::MeshShading { params } => {
9305                let b = match &params.bbox {
9306                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9307                    None => clip_stack.last().copied(),
9308                };
9309                if let Some(b) = b {
9310                    push_paint(&mut union, &clip_stack, b);
9311                }
9312            }
9313            DisplayElement::PatchShading { params } => {
9314                let b = match &params.bbox {
9315                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9316                    None => clip_stack.last().copied(),
9317                };
9318                if let Some(b) = b {
9319                    push_paint(&mut union, &clip_stack, b);
9320                }
9321            }
9322            DisplayElement::PatternFill { params } => {
9323                if let Some(b) = pattern_fill_full_bbox(params) {
9324                    push_paint(&mut union, &clip_stack, b);
9325                }
9326            }
9327            DisplayElement::Group { params, .. } => {
9328                push_paint(
9329                    &mut union,
9330                    &clip_stack,
9331                    BBox2D {
9332                        x_min: params.bbox[0],
9333                        y_min: params.bbox[1],
9334                        x_max: params.bbox[2],
9335                        y_max: params.bbox[3],
9336                    },
9337                );
9338            }
9339            DisplayElement::SoftMasked { params, .. } => {
9340                push_paint(
9341                    &mut union,
9342                    &clip_stack,
9343                    BBox2D {
9344                        x_min: params.bbox[0],
9345                        y_min: params.bbox[1],
9346                        x_max: params.bbox[2],
9347                        y_max: params.bbox[3],
9348                    },
9349                );
9350            }
9351            DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
9352            DisplayElement::OcgGroup { .. } => {
9353                // OCG groups have no inherent bbox; their children's bounds
9354                // are unknown without recursion. Conservative: skip here —
9355                // if the mask form contains OCG layers, the parent bbox cap
9356                // provides a sufficient upper bound.
9357            }
9358        }
9359    }
9360    union
9361}
9362
9363/// Compute full 2D bounds from path segments.
9364/// Compute device-space 2D bounds for a Fill element, accounting for CTM.
9365/// Paths may be stored in device space (identity CTM) or user space
9366/// (non-identity CTM, e.g. synthesized annotation appearances).
9367fn fill_device_full_bbox(path: &PsPath, ctm: &Matrix) -> Option<BBox2D> {
9368    let bbox = path_full_bbox(path)?;
9369    let is_identity = ctm.a == 1.0
9370        && ctm.b == 0.0
9371        && ctm.c == 0.0
9372        && ctm.d == 1.0
9373        && ctm.tx == 0.0
9374        && ctm.ty == 0.0;
9375    if is_identity {
9376        return Some(bbox);
9377    }
9378    let corners = [
9379        (bbox.x_min, bbox.y_min),
9380        (bbox.x_max, bbox.y_min),
9381        (bbox.x_min, bbox.y_max),
9382        (bbox.x_max, bbox.y_max),
9383    ];
9384    let mut x_min = f64::INFINITY;
9385    let mut x_max = f64::NEG_INFINITY;
9386    let mut y_min = f64::INFINITY;
9387    let mut y_max = f64::NEG_INFINITY;
9388    for (x, y) in &corners {
9389        let dx = ctm.a * x + ctm.c * y + ctm.tx;
9390        let dy = ctm.b * x + ctm.d * y + ctm.ty;
9391        x_min = x_min.min(dx);
9392        x_max = x_max.max(dx);
9393        y_min = y_min.min(dy);
9394        y_max = y_max.max(dy);
9395    }
9396    Some(BBox2D {
9397        x_min,
9398        y_min,
9399        x_max,
9400        y_max,
9401    })
9402}
9403
9404fn path_full_bbox(path: &PsPath) -> Option<BBox2D> {
9405    let mut x_min = f64::INFINITY;
9406    let mut x_max = f64::NEG_INFINITY;
9407    let mut y_min = f64::INFINITY;
9408    let mut y_max = f64::NEG_INFINITY;
9409    for seg in &path.segments {
9410        match seg {
9411            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => {
9412                x_min = x_min.min(*x);
9413                x_max = x_max.max(*x);
9414                y_min = y_min.min(*y);
9415                y_max = y_max.max(*y);
9416            }
9417            PathSegment::CurveTo {
9418                x1,
9419                y1,
9420                x2,
9421                y2,
9422                x3,
9423                y3,
9424            } => {
9425                x_min = x_min.min(*x1).min(*x2).min(*x3);
9426                x_max = x_max.max(*x1).max(*x2).max(*x3);
9427                y_min = y_min.min(*y1).min(*y2).min(*y3);
9428                y_max = y_max.max(*y1).max(*y2).max(*y3);
9429            }
9430            PathSegment::ClosePath => {}
9431        }
9432    }
9433    if x_min <= x_max {
9434        Some(BBox2D {
9435            x_min,
9436            y_min,
9437            x_max,
9438            y_max,
9439        })
9440    } else {
9441        None
9442    }
9443}
9444
9445/// Compute full 2D bounds for a PatternFill element.
9446/// For stroke patterns, the path is in user space and must be transformed
9447/// through the CTM to get device-space bounds, then expanded by half
9448/// the stroke width.
9449fn pattern_fill_full_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<BBox2D> {
9450    if let Some(ref sp) = params.stroke_params {
9451        let bbox = path_full_bbox(&params.path)?;
9452        let ctm = &sp.ctm;
9453        let corners = [
9454            ctm.transform_point(bbox.x_min, bbox.y_min),
9455            ctm.transform_point(bbox.x_max, bbox.y_min),
9456            ctm.transform_point(bbox.x_min, bbox.y_max),
9457            ctm.transform_point(bbox.x_max, bbox.y_max),
9458        ];
9459        let mut dev_bbox = BBox2D {
9460            x_min: f64::INFINITY,
9461            y_min: f64::INFINITY,
9462            x_max: f64::NEG_INFINITY,
9463            y_max: f64::NEG_INFINITY,
9464        };
9465        for (x, y) in &corners {
9466            dev_bbox.x_min = dev_bbox.x_min.min(*x);
9467            dev_bbox.y_min = dev_bbox.y_min.min(*y);
9468            dev_bbox.x_max = dev_bbox.x_max.max(*x);
9469            dev_bbox.y_max = dev_bbox.y_max.max(*y);
9470        }
9471        let half_w = sp.line_width
9472            * 0.5
9473            * (ctm.a * ctm.a + ctm.b * ctm.b)
9474                .sqrt()
9475                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
9476        dev_bbox.x_min -= half_w;
9477        dev_bbox.y_min -= half_w;
9478        dev_bbox.x_max += half_w;
9479        dev_bbox.y_max += half_w;
9480        Some(dev_bbox)
9481    } else {
9482        path_full_bbox(&params.path)
9483    }
9484}
9485
9486/// Compute Y-axis bounds for a PatternFill element (banded rendering).
9487fn pattern_fill_y_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<YBBox> {
9488    let bbox = pattern_fill_full_bbox(params)?;
9489    Some(YBBox {
9490        y_min: bbox.y_min,
9491        y_max: bbox.y_max,
9492    })
9493}
9494
9495/// Compute full 2D bounds for an image from its transform.
9496fn image_full_bbox(params: &ImageParams) -> Option<BBox2D> {
9497    let m = &params.ctm;
9498    let im = &params.image_matrix;
9499    let im_inv = im.invert()?;
9500    let combined = m.concat(&im_inv);
9501    // Image occupies [0, width] × [0, height] in image space
9502    let w = params.width as f64;
9503    let h = params.height as f64;
9504    let corners = [
9505        combined.transform_point(0.0, 0.0),
9506        combined.transform_point(w, 0.0),
9507        combined.transform_point(0.0, h),
9508        combined.transform_point(w, h),
9509    ];
9510    let mut x_min = f64::INFINITY;
9511    let mut x_max = f64::NEG_INFINITY;
9512    let mut y_min = f64::INFINITY;
9513    let mut y_max = f64::NEG_INFINITY;
9514    for (x, y) in &corners {
9515        x_min = x_min.min(*x);
9516        x_max = x_max.max(*x);
9517        y_min = y_min.min(*y);
9518        y_max = y_max.max(*y);
9519    }
9520    Some(BBox2D {
9521        x_min,
9522        y_min,
9523        x_max,
9524        y_max,
9525    })
9526}
9527
9528/// Compute full 2D bounds for a shading element from its BBox.
9529fn shading_full_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<BBox2D> {
9530    if let Some(bbox) = bbox {
9531        let corners = [
9532            ctm.transform_point(bbox[0], bbox[1]),
9533            ctm.transform_point(bbox[2], bbox[1]),
9534            ctm.transform_point(bbox[0], bbox[3]),
9535            ctm.transform_point(bbox[2], bbox[3]),
9536        ];
9537        let mut x_min = f64::INFINITY;
9538        let mut x_max = f64::NEG_INFINITY;
9539        let mut y_min = f64::INFINITY;
9540        let mut y_max = f64::NEG_INFINITY;
9541        for (x, y) in &corners {
9542            x_min = x_min.min(*x);
9543            x_max = x_max.max(*x);
9544            y_min = y_min.min(*y);
9545            y_max = y_max.max(*y);
9546        }
9547        Some(BBox2D {
9548            x_min,
9549            y_min,
9550            x_max,
9551            y_max,
9552        })
9553    } else {
9554        Some(BBox2D {
9555            x_min: 0.0,
9556            y_min: 0.0,
9557            x_max: 1e9,
9558            y_max: 1e9,
9559        })
9560    }
9561}
9562
9563/// Build 2D clip epochs for viewport culling.
9564fn build_viewport_epochs(list: &DisplayList, bboxes: &[Option<BBox2D>]) -> Vec<ViewportEpoch> {
9565    let elements = list.elements();
9566    let mut epochs = Vec::new();
9567    let mut epoch_start = 0;
9568    let mut x_min = f64::INFINITY;
9569    let mut x_max = f64::NEG_INFINITY;
9570    let mut y_min = f64::INFINITY;
9571    let mut y_max = f64::NEG_INFINITY;
9572    let mut has_erase = false;
9573
9574    for (i, element) in elements.iter().enumerate() {
9575        if matches!(element, DisplayElement::InitClip) && i > epoch_start {
9576            epochs.push(ViewportEpoch {
9577                start_idx: epoch_start,
9578                end_idx: i,
9579                paint_bbox: if x_min <= x_max {
9580                    Some(BBox2D {
9581                        x_min,
9582                        y_min,
9583                        x_max,
9584                        y_max,
9585                    })
9586                } else {
9587                    None
9588                },
9589                has_erase_page: has_erase,
9590            });
9591            epoch_start = i;
9592            x_min = f64::INFINITY;
9593            x_max = f64::NEG_INFINITY;
9594            y_min = f64::INFINITY;
9595            y_max = f64::NEG_INFINITY;
9596            has_erase = false;
9597        }
9598        if matches!(element, DisplayElement::ErasePage) {
9599            has_erase = true;
9600        }
9601        if let Some(ref bbox) = bboxes[i] {
9602            x_min = x_min.min(bbox.x_min);
9603            x_max = x_max.max(bbox.x_max);
9604            y_min = y_min.min(bbox.y_min);
9605            y_max = y_max.max(bbox.y_max);
9606        }
9607    }
9608    if epoch_start < elements.len() {
9609        epochs.push(ViewportEpoch {
9610            start_idx: epoch_start,
9611            end_idx: elements.len(),
9612            paint_bbox: if x_min <= x_max {
9613                Some(BBox2D {
9614                    x_min,
9615                    y_min,
9616                    x_max,
9617                    y_max,
9618                })
9619            } else {
9620                None
9621            },
9622            has_erase_page: has_erase,
9623        });
9624    }
9625    epochs
9626}
9627
9628/// Clip epoch with full 2D bounding box for viewport culling.
9629struct ViewportEpoch {
9630    start_idx: usize,
9631    end_idx: usize,
9632    paint_bbox: Option<BBox2D>,
9633    has_erase_page: bool,
9634}
9635
9636/// Pre-computed metadata for fast viewport rendering.
9637///
9638/// Compute once per display list via [`prepare_display_list()`],
9639/// reuse across all [`render_region_prepared()`] calls. This avoids
9640/// three expensive traversals (bboxes, epochs, clip_seen) on every pan.
9641pub struct PreparedDisplayList {
9642    bboxes: Vec<Option<BBox2D>>,
9643    epochs: Vec<ViewportEpoch>,
9644    clip_seen: HashSet<u64>,
9645}
9646
9647/// Precompute display list metadata for fast viewport rendering.
9648///
9649/// Uses a conservative DPI (72.0) for hairline expansion in bounding boxes,
9650/// producing safe overestimates that work at any zoom level without recomputation.
9651pub fn prepare_display_list(list: &DisplayList) -> PreparedDisplayList {
9652    let bboxes = precompute_full_bboxes(list, 72.0);
9653    let epochs = build_viewport_epochs(list, &bboxes);
9654    let clip_seen = precompute_clip_seen(list);
9655    PreparedDisplayList {
9656        bboxes,
9657        epochs,
9658        clip_seen,
9659    }
9660}
9661
9662/// Pre-converted and prescaled image for banded rendering.
9663///
9664/// Built once per page before the band loop so that expensive RGBA conversion
9665/// and box-filter prescaling run once instead of once-per-band.
9666struct PreprocessedImage {
9667    /// RGBA pixel data (prescaled if applicable).
9668    data: Vec<u8>,
9669    /// Dimensions after prescaling.
9670    width: u32,
9671    height: u32,
9672    /// Scale/rotation part of the adjusted transform.
9673    /// Per-band rendering reconstructs the full transform by combining these
9674    /// with the band-specific translation (tx, ty).
9675    adj_sx: f32,
9676    adj_ky: f32,
9677    adj_kx: f32,
9678    adj_sy: f32,
9679    /// Filter quality for draw_pixmap.
9680    quality: stet_tiny_skia::FilterQuality,
9681}
9682
9683/// Pre-converted RGBA image data cache, indexed by display list element index.
9684///
9685/// Built once per page after display list capture. Reused across all viewport
9686/// renders so that ICC color conversion (especially CMYK→sRGB) is not repeated
9687/// on every pan/zoom.
9688pub struct ImageCache {
9689    /// RGBA data per element index. `None` for non-image elements.
9690    entries: Vec<Option<Vec<u8>>>,
9691}
9692
9693impl ImageCache {
9694    /// Build cache by pre-converting all images in the display list.
9695    pub fn build(list: &DisplayList, icc: Option<&IccCache>) -> Self {
9696        let entries = list
9697            .elements()
9698            .iter()
9699            .map(|elem| {
9700                if let DisplayElement::Image {
9701                    sample_data,
9702                    params,
9703                } = elem
9704                {
9705                    if params.width == 0 || params.height == 0 {
9706                        return None;
9707                    }
9708                    let mut rgba = samples_to_rgba(sample_data, params, icc, false);
9709                    if params.mask_color.is_some() {
9710                        apply_mask_color_rgba(&mut rgba, sample_data, params);
9711                    }
9712                    Some(rgba)
9713                } else {
9714                    None
9715                }
9716            })
9717            .collect();
9718        Self { entries }
9719    }
9720
9721    /// Get pre-converted RGBA for the element at the given index.
9722    pub fn get(&self, index: usize) -> Option<&[u8]> {
9723        self.entries.get(index).and_then(|e| e.as_deref())
9724    }
9725}
9726
9727/// Build preprocessed image cache for banded rendering.
9728///
9729/// For each Image element, converts to RGBA and prescales once.
9730/// Banded rendering then only needs `draw_pixmap` per band.
9731fn preprocess_images_for_bands(
9732    list: &DisplayList,
9733    icc: Option<&IccCache>,
9734) -> Vec<Option<PreprocessedImage>> {
9735    list.elements()
9736        .iter()
9737        .map(|elem| {
9738            let DisplayElement::Image {
9739                sample_data,
9740                params,
9741            } = elem
9742            else {
9743                return None;
9744            };
9745            let iw = params.width;
9746            let ih = params.height;
9747            if iw == 0 || ih == 0 {
9748                return None;
9749            }
9750            // Skip overprint images — they use a separate rendering path
9751            if params.overprint {
9752                return None;
9753            }
9754
9755            // Convert to RGBA
9756            let mut rgba = samples_to_rgba(sample_data, params, icc, false);
9757            if params.mask_color.is_some() {
9758                apply_mask_color_rgba(&mut rgba, sample_data, params);
9759            }
9760
9761            // Compute the device-space transform (vp_y=0, scale=1.0)
9762            let image_inv = params.image_matrix.invert()?;
9763            let combined = params.ctm.concat(&image_inv);
9764            let base_transform = enforce_min_image_size(to_transform(&combined), iw, ih);
9765
9766            // Prescale
9767            let (data, width, height, adj_t) =
9768                match prescale_image(&rgba, iw, ih, base_transform, params.interpolate) {
9769                    Some((d, w, h, t)) => {
9770                        drop(rgba); // free the full-size RGBA
9771                        (d, w, h, t)
9772                    }
9773                    None => (rgba, iw, ih, base_transform),
9774                };
9775
9776            let quality = image_filter_quality(adj_t, params.interpolate);
9777
9778            Some(PreprocessedImage {
9779                data,
9780                width,
9781                height,
9782                adj_sx: adj_t.sx,
9783                adj_ky: adj_t.ky,
9784                adj_kx: adj_t.kx,
9785                adj_sy: adj_t.sy,
9786                quality,
9787            })
9788        })
9789        .collect()
9790}
9791
9792/// Render a rectangular viewport region using precomputed metadata.
9793///
9794/// Like [`render_region()`] but skips the three precomputation passes,
9795/// using the [`PreparedDisplayList`] instead. Significantly faster for
9796/// repeated renders of the same display list (e.g., panning at a fixed zoom).
9797#[allow(clippy::too_many_arguments)]
9798pub fn render_region_prepared(
9799    list: &DisplayList,
9800    prepared: &PreparedDisplayList,
9801    vp_x: f64,
9802    vp_y: f64,
9803    vp_w: f64,
9804    vp_h: f64,
9805    pixel_w: u32,
9806    pixel_h: u32,
9807    dpi: f64,
9808    icc: Option<&IccCache>,
9809    image_cache: Option<&ImageCache>,
9810    no_aa: bool,
9811) -> Vec<u8> {
9812    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
9813        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
9814    }
9815
9816    let scale_x = pixel_w as f64 / vp_w;
9817    let scale_y = pixel_h as f64 / vp_h;
9818    let effective_dpi = dpi * scale_x;
9819
9820    // Allocate a pixmap with the same OVERLAP padding as the banded page
9821    // renderer. This is essential for matching the banded baseline: the page
9822    // pipeline always allocates `band_h + 2*BAND_OVERLAP` rows, even for a
9823    // single-band render. tiny-skia's `Mask::fill_path` chooses between
9824    // edge-clipped and unclipped rasterization based on whether the path
9825    // bounds fit within the mask, and the two paths produce subtly different
9826    // winding counts at some pixels. Without the OVERLAP padding here, the
9827    // viewport pipeline rasterizes clip paths into a tighter mask than the
9828    // banded pipeline does, producing 39 (and other counts) of edge-pixel
9829    // divergences on samples like 1915_1.pdf.
9830    const OVERLAP: u32 = 6;
9831    let render_h = pixel_h + 2 * OVERLAP;
9832    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
9833    // Start transparent — white background composited after content rendering
9834    pixmap.fill(Color::TRANSPARENT);
9835
9836    let cmyk_buf = if has_overprint_elements(list)
9837        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
9838        || has_cmyk_group(list)
9839    {
9840        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
9841    } else {
9842        None
9843    };
9844
9845    let mut state = BandState {
9846        clip_region: None,
9847        spare_mask: None,
9848        clip_mask_cache: HashMap::new(),
9849        clip_mask_seen: prepared.clip_seen.clone(),
9850        mask_pool: Vec::new(),
9851        cmyk_buffer: cmyk_buf,
9852        op_bg_snapshot: None,
9853        op_touched: None,
9854        spot_mask: None,
9855    };
9856
9857    let elements = list.elements();
9858    let vp_x_f = vp_x as f32;
9859    let vp_y_f = vp_y as f32;
9860    let sx = scale_x as f32;
9861    let sy = scale_y as f32;
9862    let vp_x_max = vp_x + vp_w;
9863    let vp_y_max = vp_y + vp_h;
9864
9865    for epoch in &prepared.epochs {
9866        if !epoch.has_erase_page {
9867            match epoch.paint_bbox {
9868                Some(ref pb)
9869                    if pb.x_max <= vp_x
9870                        || pb.x_min >= vp_x_max
9871                        || pb.y_max <= vp_y
9872                        || pb.y_min >= vp_y_max =>
9873                {
9874                    continue;
9875                }
9876                None => continue,
9877                _ => {}
9878            }
9879        }
9880
9881        #[allow(clippy::needless_range_loop)]
9882        for i in epoch.start_idx..epoch.end_idx {
9883            // OcgGroups with Clip/InitClip must always be processed — see
9884            // the banded renderer for the rationale.
9885            let force_process = matches!(
9886                &elements[i],
9887                DisplayElement::OcgGroup { elements: inner, .. }
9888                    if contains_clip_op(inner)
9889            );
9890            if !force_process
9891                && let Some(ref bbox) = prepared.bboxes[i]
9892                && (bbox.x_max <= vp_x
9893                    || bbox.x_min >= vp_x_max
9894                    || bbox.y_max <= vp_y
9895                    || bbox.y_min >= vp_y_max)
9896            {
9897                continue;
9898            }
9899            let ctx = RenderContext {
9900                vp_x: vp_x_f,
9901                vp_y: vp_y_f,
9902                scale_x: sx,
9903                scale_y: sy,
9904                out_w: pixel_w,
9905                out_h: render_h,
9906                effective_dpi,
9907                icc,
9908                image_cache,
9909                preprocessed: None,
9910                elem_idx: i,
9911                no_aa,
9912                opm_zero_transparent: false,
9913                knockout_painter_pass: KnockoutPainterPass::None,
9914                parent_group_isolated: false,
9915                alpha_extraction_pass: false,
9916            };
9917            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
9918        }
9919    }
9920
9921    // Composite onto white background
9922    composite_onto_white(pixmap.data_mut());
9923    // Extract only the requested pixel_h rows (skip the OVERLAP padding at the bottom).
9924    let row_bytes = pixel_w as usize * 4;
9925    let end = pixel_h as usize * row_bytes;
9926    pixmap.data()[..end].to_vec()
9927}
9928
9929/// Compute the number of bands and band height for viewport banding.
9930///
9931/// Returns `(num_bands, band_height)` using the same L2-cache-budget logic
9932/// as the full-page banded renderer.
9933pub fn viewport_band_count(pixel_w: u32, pixel_h: u32) -> (u32, u32) {
9934    let band_h = select_band_height(pixel_w, pixel_h);
9935    let num_bands = if band_h >= pixel_h {
9936        1
9937    } else {
9938        pixel_h.div_ceil(band_h)
9939    };
9940    (num_bands, band_h)
9941}
9942
9943/// Render a single horizontal band of a viewport region.
9944///
9945/// This is the per-band counterpart to [`render_region_prepared()`]. The caller
9946/// loops over `band_idx` in `0..num_bands`, collecting RGBA strips that tile
9947/// vertically to form the full viewport image.
9948///
9949/// Returns RGBA pixel data for `actual_h` rows (may be less than `band_h` for
9950/// the last band).
9951#[allow(clippy::too_many_arguments)]
9952pub fn render_region_single_band(
9953    list: &DisplayList,
9954    prepared: &PreparedDisplayList,
9955    vp_x: f64,
9956    vp_y: f64,
9957    vp_w: f64,
9958    vp_h: f64,
9959    pixel_w: u32,
9960    pixel_h: u32,
9961    band_idx: u32,
9962    band_h: u32,
9963    num_bands: u32,
9964    dpi: f64,
9965    icc: Option<&IccCache>,
9966    image_cache: Option<&ImageCache>,
9967    no_aa: bool,
9968) -> Vec<u8> {
9969    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
9970        let actual_h = if band_idx < num_bands - 1 {
9971            band_h
9972        } else {
9973            pixel_h - band_idx * band_h
9974        };
9975        return vec![0xFF; pixel_w as usize * actual_h as usize * 4];
9976    }
9977
9978    let scale_x = pixel_w as f64 / vp_w;
9979    let scale_y = pixel_h as f64 / vp_h;
9980    let effective_dpi = dpi * scale_x;
9981
9982    // Output Y range for this band
9983    let out_y_start = band_idx * band_h;
9984    let actual_h = if band_idx < num_bands - 1 {
9985        band_h
9986    } else {
9987        pixel_h - out_y_start
9988    };
9989
9990    // Add overlap above/below for anti-aliasing at seams.
9991    //
9992    // The pixmap is always `band_h + 2*OVERLAP` rows — matching the page
9993    // renderer (`render_banded_to_sink`) — even at the bottom band, where
9994    // content rendering stops at `pixel_h`. Without this, the bottom band's
9995    // pixmap is shorter than the page renderer's, and tiny-skia's
9996    // `Mask::fill_path` rasterizes clip paths into a tighter mask, producing
9997    // edge-pixel divergences from the banded baseline (39 pixels on
9998    // 1915_1.pdf, etc.). The extra rows below `pixel_h` are unused for output
9999    // but ensure mask-size-independent rasterization.
10000    const OVERLAP: u32 = 6;
10001    let render_y_start = out_y_start.saturating_sub(OVERLAP);
10002    let render_y_end = (out_y_start + actual_h + OVERLAP).min(pixel_h);
10003    let render_h = band_h + 2 * OVERLAP;
10004    let overlap_top = out_y_start - render_y_start;
10005
10006    // Source-space Y range for culling
10007    let src_y_min = vp_y + render_y_start as f64 / scale_y;
10008    let src_y_max = vp_y + render_y_end as f64 / scale_y;
10009
10010    // Adjusted viewport offset for this band's pixmap
10011    let band_vp_y = vp_y + render_y_start as f64 / scale_y;
10012
10013    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create band pixmap");
10014    pixmap.fill(Color::TRANSPARENT);
10015
10016    let cmyk_buf = if has_overprint_elements(list)
10017        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10018        || has_cmyk_group(list)
10019    {
10020        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10021    } else {
10022        None
10023    };
10024
10025    let mut state = BandState {
10026        clip_region: None,
10027        spare_mask: None,
10028        clip_mask_cache: HashMap::new(),
10029        clip_mask_seen: prepared.clip_seen.clone(),
10030        mask_pool: Vec::new(),
10031        cmyk_buffer: cmyk_buf,
10032        op_bg_snapshot: None,
10033        op_touched: None,
10034        spot_mask: None,
10035    };
10036
10037    let elements = list.elements();
10038    let vp_x_f = vp_x as f32;
10039    let band_vp_y_f = band_vp_y as f32;
10040    let sx = scale_x as f32;
10041    let sy = scale_y as f32;
10042    let vp_x_max = vp_x + vp_w;
10043
10044    for epoch in &prepared.epochs {
10045        if !epoch.has_erase_page {
10046            match epoch.paint_bbox {
10047                Some(ref pb)
10048                    if pb.x_max <= vp_x
10049                        || pb.x_min >= vp_x_max
10050                        || pb.y_max <= src_y_min
10051                        || pb.y_min >= src_y_max =>
10052                {
10053                    continue;
10054                }
10055                None => continue,
10056                _ => {}
10057            }
10058        }
10059
10060        #[allow(clippy::needless_range_loop)]
10061        for i in epoch.start_idx..epoch.end_idx {
10062            // OcgGroups containing Clip/InitClip must always be processed
10063            // regardless of this band's bbox — see the full-page banded
10064            // renderer for the rationale.
10065            let force_process = matches!(
10066                &elements[i],
10067                DisplayElement::OcgGroup { elements: inner, .. }
10068                    if contains_clip_op(inner)
10069            );
10070            if !force_process
10071                && let Some(ref bbox) = prepared.bboxes[i]
10072                && (bbox.x_max <= vp_x
10073                    || bbox.x_min >= vp_x_max
10074                    || bbox.y_max <= src_y_min
10075                    || bbox.y_min >= src_y_max)
10076            {
10077                continue;
10078            }
10079            let ctx = RenderContext {
10080                vp_x: vp_x_f,
10081                vp_y: band_vp_y_f,
10082                scale_x: sx,
10083                scale_y: sy,
10084                out_w: pixel_w,
10085                out_h: render_h,
10086                effective_dpi,
10087                icc,
10088                image_cache,
10089                preprocessed: None,
10090                elem_idx: i,
10091                no_aa,
10092                opm_zero_transparent: false,
10093                knockout_painter_pass: KnockoutPainterPass::None,
10094                parent_group_isolated: false,
10095                alpha_extraction_pass: false,
10096            };
10097            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10098        }
10099    }
10100
10101    // Composite onto white background
10102    composite_onto_white(pixmap.data_mut());
10103
10104    // Extract only the non-overlap rows
10105    let row_bytes = pixel_w as usize * 4;
10106    let start = overlap_top as usize * row_bytes;
10107    let end = start + actual_h as usize * row_bytes;
10108    pixmap.data()[start..end].to_vec()
10109}
10110
10111/// Render a viewport region using parallel banded rendering via rayon.
10112///
10113/// This is the WASM counterpart to the parallel path in `render_banded_to_sink`.
10114/// All bands are rendered in parallel using `par_iter`, then assembled into the
10115/// final RGBA buffer in order.
10116///
10117/// Requires the `parallel` feature (rayon). Falls back to sequential rendering
10118/// if `parallel` is not enabled.
10119#[allow(clippy::too_many_arguments)]
10120pub fn render_region_prepared_parallel(
10121    list: &DisplayList,
10122    prepared: &PreparedDisplayList,
10123    vp_x: f64,
10124    vp_y: f64,
10125    vp_w: f64,
10126    vp_h: f64,
10127    pixel_w: u32,
10128    pixel_h: u32,
10129    dpi: f64,
10130    icc: Option<&IccCache>,
10131    image_cache: Option<&ImageCache>,
10132    no_aa: bool,
10133) -> Vec<u8> {
10134    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10135
10136    if num_bands <= 1 {
10137        // Single band — no parallelism needed
10138        return render_region_prepared(
10139            list,
10140            prepared,
10141            vp_x,
10142            vp_y,
10143            vp_w,
10144            vp_h,
10145            pixel_w,
10146            pixel_h,
10147            dpi,
10148            icc,
10149            image_cache,
10150            no_aa,
10151        );
10152    }
10153
10154    let render_band = |band_idx: u32| -> Vec<u8> {
10155        render_region_single_band(
10156            list,
10157            prepared,
10158            vp_x,
10159            vp_y,
10160            vp_w,
10161            vp_h,
10162            pixel_w,
10163            pixel_h,
10164            band_idx,
10165            band_h,
10166            num_bands,
10167            dpi,
10168            icc,
10169            image_cache,
10170            no_aa,
10171        )
10172    };
10173
10174    let row_bytes = pixel_w as usize * 4;
10175    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10176
10177    #[cfg(feature = "parallel")]
10178    {
10179        let chunk_size = rayon::current_num_threads().max(1);
10180
10181        for chunk_start in (0..num_bands).step_by(chunk_size) {
10182            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10183
10184            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10185                .into_par_iter()
10186                .map(&render_band)
10187                .collect();
10188
10189            for (i, band_data) in rendered.iter().enumerate() {
10190                let band_idx = chunk_start + i as u32;
10191                let y_start = (band_idx * band_h) as usize;
10192                let dest_start = y_start * row_bytes;
10193                let len = band_data.len();
10194                result[dest_start..dest_start + len].copy_from_slice(band_data);
10195            }
10196        }
10197    }
10198    #[cfg(not(feature = "parallel"))]
10199    {
10200        for band_idx in 0..num_bands {
10201            let band_data = render_band(band_idx);
10202            let y_start = (band_idx * band_h) as usize;
10203            let dest_start = y_start * row_bytes;
10204            let len = band_data.len();
10205            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10206        }
10207    }
10208
10209    result
10210}
10211
10212/// Like [`render_region_prepared_parallel()`] but with an atomic progress counter.
10213///
10214/// The counter is incremented after each chunk of bands completes. The total
10215/// number of bands is returned alongside the counter via [`viewport_band_count()`].
10216#[allow(clippy::too_many_arguments)]
10217pub fn render_region_prepared_parallel_with_progress(
10218    list: &DisplayList,
10219    prepared: &PreparedDisplayList,
10220    vp_x: f64,
10221    vp_y: f64,
10222    vp_w: f64,
10223    vp_h: f64,
10224    pixel_w: u32,
10225    pixel_h: u32,
10226    dpi: f64,
10227    icc: Option<&IccCache>,
10228    image_cache: Option<&ImageCache>,
10229    no_aa: bool,
10230    progress: &std::sync::atomic::AtomicU32,
10231) -> Vec<u8> {
10232    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10233
10234    if num_bands <= 1 {
10235        let result = render_region_prepared(
10236            list,
10237            prepared,
10238            vp_x,
10239            vp_y,
10240            vp_w,
10241            vp_h,
10242            pixel_w,
10243            pixel_h,
10244            dpi,
10245            icc,
10246            image_cache,
10247            no_aa,
10248        );
10249        progress.store(1, std::sync::atomic::Ordering::Relaxed);
10250        return result;
10251    }
10252
10253    let render_band = |band_idx: u32| -> Vec<u8> {
10254        render_region_single_band(
10255            list,
10256            prepared,
10257            vp_x,
10258            vp_y,
10259            vp_w,
10260            vp_h,
10261            pixel_w,
10262            pixel_h,
10263            band_idx,
10264            band_h,
10265            num_bands,
10266            dpi,
10267            icc,
10268            image_cache,
10269            no_aa,
10270        )
10271    };
10272
10273    let row_bytes = pixel_w as usize * 4;
10274    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10275
10276    #[cfg(feature = "parallel")]
10277    {
10278        let chunk_size = rayon::current_num_threads().max(1);
10279
10280        for chunk_start in (0..num_bands).step_by(chunk_size) {
10281            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10282
10283            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10284                .into_par_iter()
10285                .map(&render_band)
10286                .collect();
10287
10288            for (i, band_data) in rendered.iter().enumerate() {
10289                let band_idx = chunk_start + i as u32;
10290                let y_start = (band_idx * band_h) as usize;
10291                let dest_start = y_start * row_bytes;
10292                let len = band_data.len();
10293                result[dest_start..dest_start + len].copy_from_slice(band_data);
10294            }
10295            progress.store(chunk_end, std::sync::atomic::Ordering::Relaxed);
10296        }
10297    }
10298    #[cfg(not(feature = "parallel"))]
10299    {
10300        for band_idx in 0..num_bands {
10301            let band_data = render_band(band_idx);
10302            let y_start = (band_idx * band_h) as usize;
10303            let dest_start = y_start * row_bytes;
10304            let len = band_data.len();
10305            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10306            progress.store(band_idx + 1, std::sync::atomic::Ordering::Relaxed);
10307        }
10308    }
10309
10310    result
10311}
10312
10313/// Like [`render_region_prepared_parallel()`] but checks a cancellation flag
10314/// between band chunks. Returns `None` if cancelled.
10315#[allow(clippy::too_many_arguments)]
10316pub fn render_region_prepared_parallel_cancellable(
10317    list: &DisplayList,
10318    prepared: &PreparedDisplayList,
10319    vp_x: f64,
10320    vp_y: f64,
10321    vp_w: f64,
10322    vp_h: f64,
10323    pixel_w: u32,
10324    pixel_h: u32,
10325    dpi: f64,
10326    icc: Option<&IccCache>,
10327    image_cache: Option<&ImageCache>,
10328    no_aa: bool,
10329    cancelled: &std::sync::atomic::AtomicBool,
10330) -> Option<Vec<u8>> {
10331    if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10332        return None;
10333    }
10334
10335    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10336
10337    if num_bands <= 1 {
10338        return Some(render_region_prepared(
10339            list,
10340            prepared,
10341            vp_x,
10342            vp_y,
10343            vp_w,
10344            vp_h,
10345            pixel_w,
10346            pixel_h,
10347            dpi,
10348            icc,
10349            image_cache,
10350            no_aa,
10351        ));
10352    }
10353
10354    let render_band = |band_idx: u32| -> Vec<u8> {
10355        render_region_single_band(
10356            list,
10357            prepared,
10358            vp_x,
10359            vp_y,
10360            vp_w,
10361            vp_h,
10362            pixel_w,
10363            pixel_h,
10364            band_idx,
10365            band_h,
10366            num_bands,
10367            dpi,
10368            icc,
10369            image_cache,
10370            no_aa,
10371        )
10372    };
10373
10374    let row_bytes = pixel_w as usize * 4;
10375    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10376
10377    #[cfg(feature = "parallel")]
10378    {
10379        let chunk_size = rayon::current_num_threads().max(1);
10380
10381        for chunk_start in (0..num_bands).step_by(chunk_size) {
10382            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10383                return None;
10384            }
10385            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10386
10387            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10388                .into_par_iter()
10389                .map(&render_band)
10390                .collect();
10391
10392            for (i, band_data) in rendered.iter().enumerate() {
10393                let band_idx = chunk_start + i as u32;
10394                let y_start = (band_idx * band_h) as usize;
10395                let dest_start = y_start * row_bytes;
10396                let len = band_data.len();
10397                result[dest_start..dest_start + len].copy_from_slice(band_data);
10398            }
10399        }
10400    }
10401    #[cfg(not(feature = "parallel"))]
10402    {
10403        for band_idx in 0..num_bands {
10404            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10405                return None;
10406            }
10407            let band_data = render_band(band_idx);
10408            let y_start = (band_idx * band_h) as usize;
10409            let dest_start = y_start * row_bytes;
10410            let len = band_data.len();
10411            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10412        }
10413    }
10414
10415    Some(result)
10416}
10417
10418/// Render a full-page display list to RGBA pixels using the banded parallel renderer.
10419///
10420/// This is the preferred way to render a complete page — it uses rayon parallelism
10421/// (when the `parallel` feature is enabled) and L2-cache-friendly band sizing.
10422/// For sub-region / zoomed viewport rendering, use `render_region` instead.
10423///
10424/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`, composited onto white.
10425pub fn render_to_rgba(
10426    list: &DisplayList,
10427    pixel_w: u32,
10428    pixel_h: u32,
10429    dpi: f64,
10430    icc: Option<&IccCache>,
10431    no_aa: bool,
10432) -> Vec<u8> {
10433    if pixel_w == 0 || pixel_h == 0 {
10434        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10435    }
10436
10437    let mut icc_cache = match icc {
10438        Some(c) => c.clone(),
10439        None => IccCache::new(),
10440    };
10441    // Register any ICC profiles from shadings in the display list
10442    // (the caller's cache only has image profiles)
10443    register_shading_icc_profiles(list, &mut icc_cache);
10444
10445    let mut sink = MemorySink {
10446        data: Vec::new(),
10447        width: 0,
10448    };
10449
10450    let band_h = select_band_height(pixel_w, pixel_h);
10451    if let Err(e) = render_banded_to_sink(
10452        pixel_w, pixel_h, band_h, dpi, list, &mut sink, &icc_cache, no_aa,
10453    ) {
10454        eprintln!("render_to_rgba: banded render failed: {e}");
10455        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10456    }
10457
10458    sink.data
10459}
10460
10461/// Render a display list to RGBA using the **viewport** code path, with
10462/// the viewport set to the full page at 1:1 scale.
10463///
10464/// This exists to audit the viewport pipeline (`render_region_prepared_*`)
10465/// against the same baselines the banded PNG path uses. The two paths share
10466/// `render_element` and the same display list, so their output should be
10467/// pixel-identical on a correctly implemented display list. Differences
10468/// indicate a bug in one of the two culling / epoch / bbox pipelines.
10469///
10470/// The CLI exposes this as `--device viewport-png`; the visual test runner
10471/// uses it to double-cover each sample without maintaining a second
10472/// baseline.
10473pub fn render_to_rgba_viewport(
10474    list: &DisplayList,
10475    pixel_w: u32,
10476    pixel_h: u32,
10477    dpi: f64,
10478    icc: Option<&IccCache>,
10479    no_aa: bool,
10480) -> Vec<u8> {
10481    if pixel_w == 0 || pixel_h == 0 {
10482        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10483    }
10484
10485    let mut icc_cache = match icc {
10486        Some(c) => c.clone(),
10487        None => IccCache::new(),
10488    };
10489    register_shading_icc_profiles(list, &mut icc_cache);
10490
10491    let prepared = prepare_display_list(list);
10492    render_region_prepared_parallel(
10493        list,
10494        &prepared,
10495        0.0,
10496        0.0,
10497        pixel_w as f64,
10498        pixel_h as f64,
10499        pixel_w,
10500        pixel_h,
10501        dpi,
10502        Some(&icc_cache),
10503        None,
10504        no_aa,
10505    )
10506}
10507
10508/// Debug helper: format both bbox precomputations side-by-side.
10509///
10510/// Returns one line per element describing its Y-only bbox (used by the
10511/// banded page pipeline) and its 2D bbox (used by the viewport pipeline).
10512/// Elements that disagree on presence, or whose 2D bbox's Y extent differs
10513/// from the Y-only bbox, are marked with `DIFF`.
10514fn debug_bbox_lines(list: &DisplayList, dpi: f64, depth: usize, out: &mut Vec<String>) {
10515    let y_bboxes = precompute_bboxes(list, dpi);
10516    let full_bboxes = precompute_full_bboxes(list, dpi);
10517    let elements = list.elements();
10518    let indent = "  ".repeat(depth);
10519    for (i, elem) in elements.iter().enumerate() {
10520        let kind = match elem {
10521            DisplayElement::Fill { .. } => "Fill",
10522            DisplayElement::Stroke { .. } => "Stroke",
10523            DisplayElement::Image { .. } => "Image",
10524            DisplayElement::AxialShading { .. } => "AxialShading",
10525            DisplayElement::RadialShading { .. } => "RadialShading",
10526            DisplayElement::MeshShading { .. } => "MeshShading",
10527            DisplayElement::PatchShading { .. } => "PatchShading",
10528            DisplayElement::PatternFill { .. } => "PatternFill",
10529            DisplayElement::Group { .. } => "Group",
10530            DisplayElement::SoftMasked { .. } => "SoftMasked",
10531            DisplayElement::OcgGroup { .. } => "OcgGroup",
10532            DisplayElement::Clip { .. } => "Clip",
10533            DisplayElement::InitClip => "InitClip",
10534            DisplayElement::ErasePage => "ErasePage",
10535            DisplayElement::Text { .. } => "Text",
10536        };
10537        let yb = &y_bboxes[i];
10538        let fb = &full_bboxes[i];
10539        let mut diff = false;
10540        if yb.is_some() != fb.is_some() {
10541            diff = true;
10542        }
10543        if let (Some(yb), Some(fb)) = (yb, fb)
10544            && ((yb.y_min - fb.y_min).abs() > 1e-9 || (yb.y_max - fb.y_max).abs() > 1e-9)
10545        {
10546            diff = true;
10547        }
10548        let yb_s = match yb {
10549            Some(b) => format!("Y[{:8.3}..{:8.3}]", b.y_min, b.y_max),
10550            None => "Y[None]".to_string(),
10551        };
10552        let fb_s = match fb {
10553            Some(b) => format!(
10554                "2D[x {:8.3}..{:8.3} y {:8.3}..{:8.3}]",
10555                b.x_min, b.x_max, b.y_min, b.y_max
10556            ),
10557            None => "2D[None]".to_string(),
10558        };
10559        out.push(format!(
10560            "{}{:4} {:15} {:30} {:55} {}",
10561            indent,
10562            i,
10563            kind,
10564            yb_s,
10565            fb_s,
10566            if diff { "DIFF" } else { "" }
10567        ));
10568        if let DisplayElement::Stroke { path, params } = elem {
10569            let rp = path_full_bbox(path);
10570            let m = &params.ctm;
10571            out.push(format!(
10572                "{}        ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] lw={:.4} miter={:.4} raw={}",
10573                indent,
10574                m.a,
10575                m.b,
10576                m.c,
10577                m.d,
10578                m.tx,
10579                m.ty,
10580                params.line_width,
10581                params.miter_limit,
10582                match rp {
10583                    Some(b) => format!(
10584                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
10585                        b.x_min, b.x_max, b.y_min, b.y_max
10586                    ),
10587                    None => "None".to_string(),
10588                }
10589            ));
10590        }
10591        if let DisplayElement::Clip { path, params } = elem {
10592            let rp = path_full_bbox(path);
10593            let m = &params.ctm;
10594            out.push(format!(
10595                "{}        clip ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] rule={:?} raw={}",
10596                indent,
10597                m.a,
10598                m.b,
10599                m.c,
10600                m.d,
10601                m.tx,
10602                m.ty,
10603                params.fill_rule,
10604                match rp {
10605                    Some(b) => format!(
10606                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
10607                        b.x_min, b.x_max, b.y_min, b.y_max
10608                    ),
10609                    None => "None".to_string(),
10610                }
10611            ));
10612        }
10613        if let DisplayElement::PatchShading { params } = elem {
10614            out.push(format!(
10615                "{}        patch ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] bbox={:?} patches={}",
10616                indent,
10617                params.ctm.a,
10618                params.ctm.b,
10619                params.ctm.c,
10620                params.ctm.d,
10621                params.ctm.tx,
10622                params.ctm.ty,
10623                params.bbox,
10624                params.patches.len()
10625            ));
10626            if !params.patches.is_empty() {
10627                let patch = &params.patches[0];
10628                // Compute device-space bbox of patch points
10629                let mut x_min = f64::INFINITY;
10630                let mut y_min = f64::INFINITY;
10631                let mut x_max = f64::NEG_INFINITY;
10632                let mut y_max = f64::NEG_INFINITY;
10633                for &(px, py) in &patch.points {
10634                    let (dx, dy) = params.ctm.transform_point(px, py);
10635                    x_min = x_min.min(dx);
10636                    y_min = y_min.min(dy);
10637                    x_max = x_max.max(dx);
10638                    y_max = y_max.max(dy);
10639                }
10640                out.push(format!(
10641                    "{}        patch[0] pts={} dev x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
10642                    indent,
10643                    patch.points.len(),
10644                    x_min,
10645                    x_max,
10646                    y_min,
10647                    y_max
10648                ));
10649            }
10650        }
10651        if let DisplayElement::Group {
10652            elements: inner,
10653            params,
10654        } = elem
10655        {
10656            out.push(format!(
10657                "{}        group bbox={:?} iso={} ko={} alpha={} bm={} cs={:?}",
10658                indent,
10659                params.bbox,
10660                params.isolated,
10661                params.knockout,
10662                params.alpha,
10663                params.blend_mode,
10664                params.color_space
10665            ));
10666            debug_bbox_lines(inner, dpi, depth + 1, out);
10667        }
10668        if let DisplayElement::SoftMasked {
10669            content, params, ..
10670        } = elem
10671        {
10672            out.push(format!(
10673                "{}        softmasked bbox={:?}",
10674                indent, params.bbox
10675            ));
10676            debug_bbox_lines(content, dpi, depth + 1, out);
10677        }
10678        if let DisplayElement::OcgGroup {
10679            elements: inner,
10680            default_visible,
10681            ..
10682        } = elem
10683        {
10684            out.push(format!(
10685                "{}        ocg default_visible={}",
10686                indent, default_visible
10687            ));
10688            debug_bbox_lines(inner, dpi, depth + 1, out);
10689        }
10690    }
10691}
10692
10693pub fn debug_bbox_comparison(list: &DisplayList, dpi: f64) -> Vec<String> {
10694    let mut out = Vec::new();
10695    debug_bbox_lines(list, dpi, 0, &mut out);
10696    out
10697}
10698
10699/// In-memory page sink that collects RGBA rows into a Vec.
10700struct MemorySink {
10701    data: Vec<u8>,
10702    width: u32,
10703}
10704
10705impl stet_graphics::device::PageSink for MemorySink {
10706    fn begin_page(&mut self, width: u32, height: u32) -> Result<(), String> {
10707        self.width = width;
10708        self.data.reserve(width as usize * height as usize * 4);
10709        Ok(())
10710    }
10711
10712    fn write_rows(&mut self, rgba_rows: &[u8], _num_rows: u32) -> Result<(), String> {
10713        self.data.extend_from_slice(rgba_rows);
10714        Ok(())
10715    }
10716
10717    fn end_page(&mut self) -> Result<(), String> {
10718        Ok(())
10719    }
10720}
10721
10722/// Render a rectangular viewport region of a display list to RGBA pixels.
10723///
10724/// - `list`: The display list to render (in device-space coordinates at the reference DPI)
10725/// - `vp_x, vp_y, vp_w, vp_h`: Viewport rectangle in device-space pixels
10726/// - `pixel_w, pixel_h`: Output pixel dimensions
10727/// - `dpi`: Reference DPI (for hairline width decisions)
10728///
10729/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`.
10730#[allow(clippy::too_many_arguments)]
10731pub fn render_region(
10732    list: &DisplayList,
10733    vp_x: f64,
10734    vp_y: f64,
10735    vp_w: f64,
10736    vp_h: f64,
10737    pixel_w: u32,
10738    pixel_h: u32,
10739    dpi: f64,
10740    icc: Option<&IccCache>,
10741    image_cache: Option<&ImageCache>,
10742    no_aa: bool,
10743) -> Vec<u8> {
10744    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
10745        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10746    }
10747
10748    let scale_x = pixel_w as f64 / vp_w;
10749    let scale_y = pixel_h as f64 / vp_h;
10750    // Effective DPI for hairline decisions — reference DPI scaled by zoom
10751    let effective_dpi = dpi * scale_x;
10752
10753    let bboxes = precompute_full_bboxes(list, effective_dpi);
10754    let epochs = build_viewport_epochs(list, &bboxes);
10755    let clip_seen = precompute_clip_seen(list);
10756
10757    // OVERLAP padding to match `render_banded_to_sink`. See the comment in
10758    // `render_region_prepared` for why this is required for tiny-skia
10759    // mask-rasterization parity with the page renderer.
10760    const OVERLAP: u32 = 6;
10761    let render_h = pixel_h + 2 * OVERLAP;
10762
10763    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
10764    pixmap.fill(Color::TRANSPARENT);
10765
10766    let cmyk_buf = if has_overprint_elements(list)
10767        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10768        || has_cmyk_group(list)
10769    {
10770        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10771    } else {
10772        None
10773    };
10774
10775    let mut state = BandState {
10776        clip_region: None,
10777        spare_mask: None,
10778        clip_mask_cache: HashMap::new(),
10779        clip_mask_seen: clip_seen,
10780        mask_pool: Vec::new(),
10781        cmyk_buffer: cmyk_buf,
10782        op_bg_snapshot: None,
10783        op_touched: None,
10784        spot_mask: None,
10785    };
10786
10787    let elements = list.elements();
10788    let vp_x_f = vp_x as f32;
10789    let vp_y_f = vp_y as f32;
10790    let sx = scale_x as f32;
10791    let sy = scale_y as f32;
10792    let vp_x_max = vp_x + vp_w;
10793    let vp_y_max = vp_y + vp_h;
10794
10795    for epoch in &epochs {
10796        // Epoch-level culling
10797        if !epoch.has_erase_page {
10798            match epoch.paint_bbox {
10799                Some(ref pb)
10800                    if pb.x_max <= vp_x
10801                        || pb.x_min >= vp_x_max
10802                        || pb.y_max <= vp_y
10803                        || pb.y_min >= vp_y_max =>
10804                {
10805                    continue;
10806                }
10807                None => continue,
10808                _ => {}
10809            }
10810        }
10811
10812        for i in epoch.start_idx..epoch.end_idx {
10813            // OcgGroups with Clip/InitClip must always be processed — see
10814            // render_region_prepared for the rationale.
10815            let force_process = matches!(
10816                &elements[i],
10817                DisplayElement::OcgGroup { elements: inner, .. }
10818                    if contains_clip_op(inner)
10819            );
10820            // Element-level culling
10821            if !force_process
10822                && let Some(ref bbox) = bboxes[i]
10823                && (bbox.x_max <= vp_x
10824                    || bbox.x_min >= vp_x_max
10825                    || bbox.y_max <= vp_y
10826                    || bbox.y_min >= vp_y_max)
10827            {
10828                continue;
10829            }
10830            let ctx = RenderContext {
10831                vp_x: vp_x_f,
10832                vp_y: vp_y_f,
10833                scale_x: sx,
10834                scale_y: sy,
10835                out_w: pixel_w,
10836                out_h: render_h,
10837                effective_dpi,
10838                icc,
10839                image_cache,
10840                preprocessed: None,
10841                elem_idx: i,
10842                no_aa,
10843                opm_zero_transparent: false,
10844                knockout_painter_pass: KnockoutPainterPass::None,
10845                parent_group_isolated: false,
10846                alpha_extraction_pass: false,
10847            };
10848            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10849        }
10850    }
10851
10852    composite_onto_white(pixmap.data_mut());
10853    // Extract only the requested pixel_h rows (skip OVERLAP padding).
10854    let row_bytes = pixel_w as usize * 4;
10855    let end = pixel_h as usize * row_bytes;
10856    pixmap.data()[..end].to_vec()
10857}
10858/// Copy a rectangular region from parent pixmap into a smaller crop pixmap.
10859fn copy_backdrop_crop(
10860    parent: &Pixmap,
10861    crop_x: i32,
10862    crop_y: i32,
10863    crop_w: u32,
10864    crop_h: u32,
10865) -> Vec<u8> {
10866    let pw = parent.width() as usize;
10867    let src = parent.data();
10868    let cw = crop_w as usize;
10869    let ch = crop_h as usize;
10870    let cx = crop_x as usize;
10871    let cy = crop_y as usize;
10872    let mut backdrop = vec![0u8; cw * ch * 4];
10873    for row in 0..ch {
10874        let src_off = ((cy + row) * pw + cx) * 4;
10875        let dst_off = row * cw * 4;
10876        backdrop[dst_off..dst_off + cw * 4].copy_from_slice(&src[src_off..src_off + cw * 4]);
10877    }
10878    backdrop
10879}
10880// ---- Shading rendering ----
10881
10882/// Sutherland-Hodgman polygon clipping against a half-plane.
10883/// Keeps the side where `nx*(x-px) + ny*(y-py) >= 0`.
10884fn clip_polygon_halfplane(
10885    poly: &[(f32, f32)],
10886    nx: f32,
10887    ny: f32,
10888    px: f32,
10889    py: f32,
10890) -> Vec<(f32, f32)> {
10891    if poly.is_empty() {
10892        return vec![];
10893    }
10894    let dot = |x: f32, y: f32| nx * (x - px) + ny * (y - py);
10895    let mut out = Vec::with_capacity(poly.len() + 1);
10896    let n = poly.len();
10897    for i in 0..n {
10898        let (ax, ay) = poly[i];
10899        let (bx, by) = poly[(i + 1) % n];
10900        let da = dot(ax, ay);
10901        let db = dot(bx, by);
10902        if da >= 0.0 {
10903            out.push((ax, ay));
10904        }
10905        if (da >= 0.0) != (db >= 0.0) {
10906            // Edge crosses the clipping line — compute intersection
10907            let t = da / (da - db);
10908            out.push((ax + t * (bx - ax), ay + t * (by - ay)));
10909        }
10910    }
10911    out
10912}
10913
10914/// Render an axial (linear) gradient shading.
10915#[allow(clippy::too_many_arguments)]
10916fn render_axial_shading(
10917    pixmap: &mut Pixmap,
10918    params: &AxialShadingParams,
10919    vp_x: f32,
10920    vp_y: f32,
10921    scale_x: f32,
10922    scale_y: f32,
10923    clip_mask: Option<&Mask>,
10924    no_aa: bool,
10925    cmyk_buf: Option<&mut [f32]>,
10926    icc: Option<&IccCache>,
10927) {
10928    let pw = pixmap.width();
10929    let ph = pixmap.height();
10930    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
10931        return;
10932    }
10933
10934    let (mut rx_min, mut ry_min, mut rx_max, mut ry_max) = if let Some(bbox) = &params.bbox {
10935        let corners = [
10936            params.ctm.transform_point(bbox[0], bbox[1]),
10937            params.ctm.transform_point(bbox[2], bbox[1]),
10938            params.ctm.transform_point(bbox[0], bbox[3]),
10939            params.ctm.transform_point(bbox[2], bbox[3]),
10940        ];
10941        let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
10942        let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
10943        let x_max = corners
10944            .iter()
10945            .map(|c| c.0)
10946            .fold(f64::NEG_INFINITY, f64::max);
10947        let y_max = corners
10948            .iter()
10949            .map(|c| c.1)
10950            .fold(f64::NEG_INFINITY, f64::max);
10951        (
10952            ((x_min as f32 - vp_x) * scale_x).max(0.0),
10953            ((y_min as f32 - vp_y) * scale_y).max(0.0),
10954            ((x_max as f32 - vp_x) * scale_x).min(pw as f32),
10955            ((y_max as f32 - vp_y) * scale_y).min(ph as f32),
10956        )
10957    } else {
10958        (0.0, 0.0, pw as f32, ph as f32)
10959    };
10960
10961    if rx_max <= rx_min || ry_max <= ry_min {
10962        return;
10963    }
10964
10965    // Transform endpoints to device space for perpendicular clipping
10966    let (dx0, dy0) = params.ctm.transform_point(params.x0, params.y0);
10967    let (dx1, dy1) = params.ctm.transform_point(params.x1, params.y1);
10968
10969    // When extend is false on a side, clip the fill area along a line
10970    // perpendicular to the gradient axis through that endpoint. For diagonal
10971    // gradients this produces a diagonal cutoff (not axis-aligned).
10972    let needs_perpendicular_clip = (!params.extend_start || !params.extend_end) && {
10973        let axis_x = dx1 - dx0;
10974        let axis_y = dy1 - dy0;
10975        axis_x.abs() > 1e-6 && axis_y.abs() > 1e-6
10976    };
10977
10978    // Detect rotated BBox: if CTM has rotation components (b or c non-zero),
10979    // the BBox is not axis-aligned in device space and needs proper polygon clipping.
10980    let bbox_is_rotated =
10981        params.bbox.is_some() && (params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10);
10982
10983    if needs_perpendicular_clip {
10984        // Diagonal gradient with non-extended side — fall back to tiny-skia
10985        // for Sutherland-Hodgman polygon clipping.
10986        let stops = build_gradient_stops(&params.color_stops);
10987        if stops.is_empty() {
10988            return;
10989        }
10990        let start = stet_tiny_skia::Point::from_xy(params.x0 as f32, params.y0 as f32);
10991        let end = stet_tiny_skia::Point::from_xy(params.x1 as f32, params.y1 as f32);
10992        let gradient_transform =
10993            viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
10994        let Some(gradient) = stet_tiny_skia::LinearGradient::new(
10995            start,
10996            end,
10997            stops,
10998            stet_tiny_skia::SpreadMode::Pad,
10999            gradient_transform,
11000        ) else {
11001            return;
11002        };
11003        let paint = Paint {
11004            shader: gradient,
11005            anti_alias: !no_aa,
11006            ..Paint::default()
11007        };
11008
11009        // Use rotated BBox polygon when CTM has rotation, otherwise axis-aligned rect
11010        let mut poly: Vec<(f32, f32)> = if bbox_is_rotated {
11011            let bbox = params.bbox.as_ref().unwrap();
11012            let corners = [
11013                params.ctm.transform_point(bbox[0], bbox[1]),
11014                params.ctm.transform_point(bbox[2], bbox[1]),
11015                params.ctm.transform_point(bbox[2], bbox[3]),
11016                params.ctm.transform_point(bbox[0], bbox[3]),
11017            ];
11018            corners
11019                .iter()
11020                .map(|(x, y)| ((*x as f32 - vp_x) * scale_x, (*y as f32 - vp_y) * scale_y))
11021                .collect()
11022        } else {
11023            vec![
11024                (rx_min, ry_min),
11025                (rx_max, ry_min),
11026                (rx_max, ry_max),
11027                (rx_min, ry_max),
11028            ]
11029        };
11030        let ax = (dx1 - dx0) as f32 * scale_x;
11031        let ay = (dy1 - dy0) as f32 * scale_y;
11032        if !params.extend_start {
11033            let px = (dx0 as f32 - vp_x) * scale_x;
11034            let py = (dy0 as f32 - vp_y) * scale_y;
11035            poly = clip_polygon_halfplane(&poly, ax, ay, px, py);
11036        }
11037        if !params.extend_end {
11038            let px = (dx1 as f32 - vp_x) * scale_x;
11039            let py = (dy1 as f32 - vp_y) * scale_y;
11040            poly = clip_polygon_halfplane(&poly, -ax, -ay, px, py);
11041        }
11042        if poly.len() >= 3 {
11043            let mut pb = PathBuilder::new();
11044            pb.move_to(poly[0].0, poly[0].1);
11045            for &(x, y) in &poly[1..] {
11046                pb.line_to(x, y);
11047            }
11048            pb.close();
11049            if let Some(path) = pb.finish() {
11050                pixmap.fill_path(
11051                    &path,
11052                    &paint,
11053                    SkiaFillRule::Winding,
11054                    Transform::identity(),
11055                    clip_mask,
11056                );
11057            }
11058        }
11059    } else {
11060        // Common case: axis-aligned or both sides extended — direct rasterization.
11061        // Clip fill rect to gradient extent when sides aren't extended.
11062        if !params.extend_start || !params.extend_end {
11063            let axis_x = dx1 - dx0;
11064            let axis_y = dy1 - dy0;
11065            let gx0 = (dx0 as f32 - vp_x) * scale_x;
11066            let gy0 = (dy0 as f32 - vp_y) * scale_y;
11067            let gx1 = (dx1 as f32 - vp_x) * scale_x;
11068            let gy1 = (dy1 as f32 - vp_y) * scale_y;
11069
11070            if axis_x.abs() >= axis_y.abs() {
11071                if !params.extend_start {
11072                    if axis_x >= 0.0 {
11073                        rx_min = rx_min.max(gx0);
11074                    } else {
11075                        rx_max = rx_max.min(gx0);
11076                    }
11077                }
11078                if !params.extend_end {
11079                    if axis_x >= 0.0 {
11080                        rx_max = rx_max.min(gx1);
11081                    } else {
11082                        rx_min = rx_min.max(gx1);
11083                    }
11084                }
11085            } else {
11086                if !params.extend_start {
11087                    if axis_y >= 0.0 {
11088                        ry_min = ry_min.max(gy0);
11089                    } else {
11090                        ry_max = ry_max.min(gy0);
11091                    }
11092                }
11093                if !params.extend_end {
11094                    if axis_y >= 0.0 {
11095                        ry_max = ry_max.min(gy1);
11096                    } else {
11097                        ry_min = ry_min.max(gy1);
11098                    }
11099                }
11100            }
11101            if rx_max <= rx_min || ry_max <= ry_min {
11102                return;
11103            }
11104        }
11105
11106        // Compute gradient axis in shading space.
11107        let ax = params.x1 - params.x0;
11108        let ay = params.y1 - params.y0;
11109        let axis_sq = ax * ax + ay * ay;
11110        if axis_sq < 1e-20 {
11111            return;
11112        }
11113
11114        // Size the LUT to the gradient's pixel span so each entry covers ≤1 pixel.
11115        // This ensures nearest-neighbor lookup produces pixel-perfect sharp edges
11116        // at stitching function discontinuities without banding in smooth gradients.
11117        let pixel_dx = (dx1 - dx0) * scale_x as f64;
11118        let pixel_dy = (dy1 - dy0) * scale_y as f64;
11119        let pixel_axis_len = (pixel_dx * pixel_dx + pixel_dy * pixel_dy).sqrt();
11120        let lut_size = (pixel_axis_len as usize)
11121            .max(params.color_stops.len())
11122            .max(256)
11123            .min(16384);
11124        let lut = build_gradient_lut(&params.color_stops, lut_size);
11125
11126        let Some(inv) = params.ctm.invert() else {
11127            return;
11128        };
11129        let inv_sx = 1.0 / scale_x as f64;
11130        let inv_sy = 1.0 / scale_y as f64;
11131        let dev_origin_x = vp_x as f64;
11132        let dev_origin_y = vp_y as f64;
11133
11134        // Shading-space coords as linear function of pixel coords:
11135        //   sx = sx_base + dsx_dx * px + dsx_dy * py
11136        //   sy = sy_base + dsy_dx * px + dsy_dy * py
11137        let sx_base = inv.a * dev_origin_x + inv.c * dev_origin_y + inv.tx;
11138        let sy_base = inv.b * dev_origin_x + inv.d * dev_origin_y + inv.ty;
11139        let dsx_dx = inv.a * inv_sx;
11140        let dsx_dy = inv.c * inv_sy;
11141        let dsy_dx = inv.b * inv_sx;
11142        let dsy_dy = inv.d * inv_sy;
11143
11144        // t = dot(P_shading - P0, axis) / dot(axis, axis)
11145        let inv_axis_sq = 1.0 / axis_sq;
11146        let t_origin = ((sx_base - params.x0) * ax + (sy_base - params.y0) * ay) * inv_axis_sq;
11147        let dt_dx = (dsx_dx * ax + dsy_dx * ay) * inv_axis_sq;
11148        let dt_dy = (dsx_dy * ax + dsy_dy * ay) * inv_axis_sq;
11149
11150        // Per-pixel rotated BBox clipping: reuse inverse CTM to map each pixel
11151        // back to shading space and check against the original BBox.
11152        let bbox_pixel_clip = if bbox_is_rotated {
11153            let bbox = params.bbox.as_ref().unwrap();
11154            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11155            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11156            Some((
11157                dsx_dx, dsx_dy, sx_base, dsy_dx, dsy_dy, sy_base, bx0, by0, bx1, by1,
11158            ))
11159        } else {
11160            None
11161        };
11162
11163        let ix_min = rx_min.floor() as u32;
11164        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11165        let iy_min = ry_min.floor() as u32;
11166        let iy_max = ry_max.ceil().min(ph as f32) as u32;
11167
11168        let stride = pw as usize * 4;
11169        let data = pixmap.data_mut();
11170        let mask_data = clip_mask.map(|m| m.data());
11171        let alpha = (params.alpha.clamp(0.0, 1.0) * 255.0 + 0.5) as u16;
11172
11173        for py in iy_min..iy_max {
11174            let t_row = t_origin + dt_dy * py as f64;
11175            let row_offset = py as usize * stride;
11176
11177            // Precompute row-base values for rotated BBox check
11178            let (ux_row, uy_row) =
11179                if let Some((_, dux_dy, ux_base, _, duy_dy, uy_base, ..)) = &bbox_pixel_clip {
11180                    (ux_base + dux_dy * py as f64, uy_base + duy_dy * py as f64)
11181                } else {
11182                    (0.0, 0.0)
11183                };
11184
11185            for px in ix_min..ix_max {
11186                // Check clip mask
11187                if let Some(md) = mask_data {
11188                    if md[py as usize * pw as usize + px as usize] == 0 {
11189                        continue;
11190                    }
11191                }
11192
11193                // Per-pixel rotated BBox clip
11194                if let Some((dux_dx, _, _, duy_dx, _, _, bx0, by0, bx1, by1)) = &bbox_pixel_clip {
11195                    let ux = ux_row + dux_dx * px as f64;
11196                    let uy = uy_row + duy_dx * px as f64;
11197                    if ux < *bx0 || ux > *bx1 || uy < *by0 || uy > *by1 {
11198                        continue;
11199                    }
11200                }
11201
11202                let t = t_row + dt_dx * px as f64;
11203                let t_clamped = t.clamp(0.0, 1.0);
11204                let idx = (t_clamped * (lut_size - 1) as f64 + 0.5) as usize;
11205                let [r, g, b, _] = lut[idx.min(lut_size - 1)];
11206
11207                let offset = row_offset + px as usize * 4;
11208                if alpha >= 255 {
11209                    data[offset] = r;
11210                    data[offset + 1] = g;
11211                    data[offset + 2] = b;
11212                    data[offset + 3] = 255;
11213                } else {
11214                    // Alpha blend: premultiply and composite over existing pixel
11215                    let a = alpha as u16;
11216                    let inv_a = 255 - a;
11217                    data[offset] = ((r as u16 * a + data[offset] as u16 * inv_a + 127) / 255) as u8;
11218                    data[offset + 1] =
11219                        ((g as u16 * a + data[offset + 1] as u16 * inv_a + 127) / 255) as u8;
11220                    data[offset + 2] =
11221                        ((b as u16 * a + data[offset + 2] as u16 * inv_a + 127) / 255) as u8;
11222                    data[offset + 3] = ((a + data[offset + 3] as u16 * inv_a / 255).min(255)) as u8;
11223                }
11224            }
11225        }
11226    }
11227
11228    // Update CMYK tracking buffer for axial shading
11229    if let Some(buf) = cmyk_buf {
11230        let pw = pixmap.width();
11231        let inv_sx = 1.0 / scale_x as f64;
11232        let inv_sy = 1.0 / scale_y as f64;
11233        let axis_x = params.x1 - params.x0;
11234        let axis_y = params.y1 - params.y0;
11235        let axis_len_sq = axis_x * axis_x + axis_y * axis_y;
11236        let Some(inv_ctm) = params.ctm.invert() else {
11237            return;
11238        };
11239
11240        let iy_min = ry_min.floor() as u32;
11241        let iy_max = ry_max.ceil().min(pixmap.height() as f32) as u32;
11242        let ix_min = rx_min.floor() as u32;
11243        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11244
11245        for py in iy_min..iy_max {
11246            let dev_y = py as f64 * inv_sy + vp_y as f64;
11247            for px in ix_min..ix_max {
11248                let dev_x = px as f64 * inv_sx + vp_x as f64;
11249                let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11250                let t = if axis_len_sq > 1e-10 {
11251                    ((ux - params.x0) * axis_x + (uy - params.y0) * axis_y) / axis_len_sq
11252                } else {
11253                    0.0
11254                };
11255                if t < 0.0 && !params.extend_start {
11256                    continue;
11257                }
11258                if t > 1.0 && !params.extend_end {
11259                    continue;
11260                }
11261                let clamped = t.clamp(0.0, 1.0);
11262
11263                if let Some(mask) = clip_mask {
11264                    let mi = py as usize * pw as usize + px as usize;
11265                    if mask.data()[mi] == 0 {
11266                        continue;
11267                    }
11268                }
11269
11270                let color = interpolate_color_stops(&params.color_stops, clamped);
11271                let cmyk = interpolate_cmyk_from_stops(
11272                    &params.color_stops,
11273                    &params.color_space,
11274                    clamped,
11275                    &color,
11276                    icc,
11277                );
11278                let ci = (py as usize * pw as usize + px as usize) * 4;
11279                if ci + 3 < buf.len() {
11280                    if params.spot_tint_blend && params.overprint {
11281                        // Per PDF spec 11.7.4.5 a Separation/DeviceN gradient
11282                        // only affects the device colorants identified by its
11283                        // color space: plates for NAMED PROCESS colorants are
11284                        // REPLACED with the gradient's CMYK value at this
11285                        // pixel, plates not tied to a named process colorant
11286                        // are PRESERVED.  The LUT-painted pixmap already
11287                        // carries the spot's full ICC-converted color, so:
11288                        //
11289                        // Gated on `overprint` because the LUT pass for
11290                        // non-overprint shadings carries the author-intended
11291                        // blend mode (e.g. 2265.pdf draws each circle wedge
11292                        // twice — Normal then Multiply — and the multiplied
11293                        // pixmap is the wedge's final color).  Recomposing
11294                        // here would overwrite the multiply-darkened result
11295                        // with a single ICC sample of the source CMYK.
11296                        //   * Where the CMYK buffer is empty (fresh paper),
11297                        //     leave the pixmap alone — re-running CMYK→RGB
11298                        //     here would round-trip through the system
11299                        //     profile and produce a perceptibly different
11300                        //     gradient curve (the snowman shading regression
11301                        //     guarded against in the original recompose
11302                        //     branch).  Just record the named-process
11303                        //     contribution to the buffer for later overprint
11304                        //     tracking.
11305                        //   * Where the CMYK buffer has prior values (a
11306                        //     CMYK fill underneath, e.g. a `1 0 1 0.5 k`
11307                        //     checkmark under the strip), the LUT-paint had
11308                        //     wiped that underlying paint from the pixmap.
11309                        //     Recompose the pixmap from the merged CMYK
11310                        //     (REPLACE named, preserve non-named) to restore
11311                        //     the checkmark with the gradient's named-plate
11312                        //     contribution layered on top.
11313                        let cur_c = buf[ci] as f64;
11314                        let cur_m = buf[ci + 1] as f64;
11315                        let cur_y = buf[ci + 2] as f64;
11316                        let cur_k = buf[ci + 3] as f64;
11317                        let cur_is_zero =
11318                            cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
11319                        let named = params.painted_channels;
11320                        if cur_is_zero {
11321                            if named & stet_graphics::device::CMYK_C != 0 {
11322                                buf[ci] = cmyk.0 as f32;
11323                            }
11324                            if named & stet_graphics::device::CMYK_M != 0 {
11325                                buf[ci + 1] = cmyk.1 as f32;
11326                            }
11327                            if named & stet_graphics::device::CMYK_Y != 0 {
11328                                buf[ci + 2] = cmyk.2 as f32;
11329                            }
11330                            if named & stet_graphics::device::CMYK_K != 0 {
11331                                buf[ci + 3] = cmyk.3 as f32;
11332                            }
11333                        } else {
11334                            let new_c = if named & stet_graphics::device::CMYK_C != 0 {
11335                                cmyk.0
11336                            } else {
11337                                cur_c
11338                            };
11339                            let new_m = if named & stet_graphics::device::CMYK_M != 0 {
11340                                cmyk.1
11341                            } else {
11342                                cur_m
11343                            };
11344                            let new_y = if named & stet_graphics::device::CMYK_Y != 0 {
11345                                cmyk.2
11346                            } else {
11347                                cur_y
11348                            };
11349                            let new_k = if named & stet_graphics::device::CMYK_K != 0 {
11350                                cmyk.3
11351                            } else {
11352                                cur_k
11353                            };
11354                            buf[ci] = new_c as f32;
11355                            buf[ci + 1] = new_m as f32;
11356                            buf[ci + 2] = new_y as f32;
11357                            buf[ci + 3] = new_k as f32;
11358                            let (rv, gv, bv) = if let Some(icc_cache) = icc {
11359                                icc_cache
11360                                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
11361                                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
11362                            } else {
11363                                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
11364                            };
11365                            let stride = pixmap.data().len() / pixmap.height() as usize;
11366                            let offset = py as usize * stride + px as usize * 4;
11367                            let data = pixmap.data_mut();
11368                            data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11369                            data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11370                            data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11371                        }
11372                    } else if params.overprint
11373                        && params.painted_channels != stet_graphics::device::CMYK_ALL
11374                    {
11375                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11376                            buf[ci] = cmyk.0 as f32;
11377                        }
11378                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11379                            buf[ci + 1] = cmyk.1 as f32;
11380                        }
11381                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11382                            buf[ci + 2] = cmyk.2 as f32;
11383                        }
11384                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
11385                            buf[ci + 3] = cmyk.3 as f32;
11386                        }
11387                        // Recomposite RGB from merged CMYK via ICC
11388                        let c = buf[ci] as f64;
11389                        let m = buf[ci + 1] as f64;
11390                        let y = buf[ci + 2] as f64;
11391                        let k = buf[ci + 3] as f64;
11392                        let (rv, gv, bv) = if let Some(icc_cache) = icc {
11393                            icc_cache
11394                                .convert_cmyk_readonly(c, m, y, k)
11395                                .unwrap_or_else(|| cmyk_to_rgb_plrm(c, m, y, k))
11396                        } else {
11397                            cmyk_to_rgb_plrm(c, m, y, k)
11398                        };
11399                        let stride = pixmap.data().len() / pixmap.height() as usize;
11400                        let offset = py as usize * stride + px as usize * 4;
11401                        let data = pixmap.data_mut();
11402                        data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11403                        data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11404                        data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11405                    } else {
11406                        // Non-overprint axial shading: write the source CMYK
11407                        // to the buffer for any consumer that needs it (e.g.
11408                        // overprint sibling tracking) but leave the pixmap
11409                        // alone — `build_gradient_lut` already painted the
11410                        // pixel with linearly-interpolated source RGB, and
11411                        // round-tripping CMYK→RGB through the ICC profile
11412                        // produces a different gradient curve (linear in
11413                        // CMYK rather than linear in RGB) that diverges
11414                        // visibly from the LUT result. The CMYK buffer is
11415                        // only consumed by `composite_non_isolated_cmyk`,
11416                        // which excludes shading-containing groups via
11417                        // `group_content_is_native_cmyk`, so the
11418                        // buffer/pixmap mismatch never reaches a consumer
11419                        // that would notice. Reintroducing the round-trip
11420                        // here was the 3000_9 / 3000_10 snowman shading
11421                        // regression in the silly-weaving-bird plan.
11422                        buf[ci] = cmyk.0 as f32;
11423                        buf[ci + 1] = cmyk.1 as f32;
11424                        buf[ci + 2] = cmyk.2 as f32;
11425                        buf[ci + 3] = cmyk.3 as f32;
11426                    }
11427                }
11428            }
11429        }
11430    }
11431}
11432
11433/// Render a radial gradient shading.
11434#[allow(clippy::too_many_arguments)]
11435fn render_radial_shading(
11436    pixmap: &mut Pixmap,
11437    params: &RadialShadingParams,
11438    vp_x: f32,
11439    vp_y: f32,
11440    scale_x: f32,
11441    scale_y: f32,
11442    clip_mask: Option<&Mask>,
11443    _no_aa: bool,
11444    mut cmyk_buf: Option<&mut [f32]>,
11445    icc: Option<&IccCache>,
11446) {
11447    let pw = pixmap.width();
11448    let ph = pixmap.height();
11449    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
11450        return;
11451    }
11452
11453    let Some(inv_ctm) = params.ctm.invert() else {
11454        return;
11455    };
11456
11457    let (px_min, py_min, px_max, py_max) = if let Some(bbox) = &params.bbox {
11458        let corners = [
11459            params.ctm.transform_point(bbox[0], bbox[1]),
11460            params.ctm.transform_point(bbox[2], bbox[1]),
11461            params.ctm.transform_point(bbox[0], bbox[3]),
11462            params.ctm.transform_point(bbox[2], bbox[3]),
11463        ];
11464        let x_min = corners
11465            .iter()
11466            .map(|c| c.0 as f32)
11467            .fold(f32::INFINITY, f32::min);
11468        let y_min = corners
11469            .iter()
11470            .map(|c| c.1 as f32)
11471            .fold(f32::INFINITY, f32::min);
11472        let x_max = corners
11473            .iter()
11474            .map(|c| c.0 as f32)
11475            .fold(f32::NEG_INFINITY, f32::max);
11476        let y_max = corners
11477            .iter()
11478            .map(|c| c.1 as f32)
11479            .fold(f32::NEG_INFINITY, f32::max);
11480        (
11481            ((x_min - vp_x) * scale_x).max(0.0) as u32,
11482            ((y_min - vp_y) * scale_y).max(0.0) as u32,
11483            (((x_max - vp_x) * scale_x).ceil() as u32).min(pw),
11484            (((y_max - vp_y) * scale_y).ceil() as u32).min(ph),
11485        )
11486    } else {
11487        (0, 0, pw, ph)
11488    };
11489
11490    let inv_sx = 1.0 / scale_x as f64;
11491    let inv_sy = 1.0 / scale_y as f64;
11492
11493    // Rotated BBox: check per-pixel user-space containment
11494    let rotated_bbox = if let Some(bbox) = &params.bbox {
11495        if params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10 {
11496            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11497            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11498            Some((bx0, by0, bx1, by1))
11499        } else {
11500            None
11501        }
11502    } else {
11503        None
11504    };
11505
11506    let data = pixmap.data_mut();
11507    let stride = pw as usize * 4;
11508
11509    for py in py_min..py_max {
11510        let dev_y = py as f64 * inv_sy + vp_y as f64;
11511        for px in px_min..px_max {
11512            let dev_x = px as f64 * inv_sx + vp_x as f64;
11513            let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11514
11515            // Per-pixel rotated BBox clip
11516            if let Some((bx0, by0, bx1, by1)) = rotated_bbox {
11517                if ux < bx0 || ux > bx1 || uy < by0 || uy > by1 {
11518                    continue;
11519                }
11520            }
11521
11522            let t = solve_radial_t(
11523                ux,
11524                uy,
11525                params.x0,
11526                params.y0,
11527                params.r0,
11528                params.x1,
11529                params.y1,
11530                params.r1,
11531                params.extend_start,
11532                params.extend_end,
11533            );
11534            if let Some(t) = t {
11535                let clamped = t.clamp(0.0, 1.0);
11536                let color = interpolate_color_stops(&params.color_stops, clamped);
11537
11538                let clipped = clip_mask
11539                    .is_some_and(|mask| mask.data()[py as usize * pw as usize + px as usize] == 0);
11540
11541                if clipped {
11542                    continue;
11543                }
11544
11545                // Decide whether this pixel should use the multiplicative
11546                // ink-stacking blend to preserve a spot backdrop. We mirror
11547                // the rule in `render_overprint_fill`: overprint + subset
11548                // painted channels + buffer effectively empty at this pixel
11549                // means the pixmap carries a non-CMYK contribution (or the
11550                // pixel is fresh), so per-channel ink-stacking gives the
11551                // correct result whether the backdrop was spot-painted or
11552                // plain.
11553                let cmyk = interpolate_cmyk_from_stops(
11554                    &params.color_stops,
11555                    &params.color_space,
11556                    clamped,
11557                    &color,
11558                    icc,
11559                );
11560                let ci = (py as usize * pw as usize + px as usize) * 4;
11561                let buffer_clean = if let Some(ref buf) = cmyk_buf {
11562                    if ci + 3 < buf.len() {
11563                        buf[ci] == 0.0
11564                            && buf[ci + 1] == 0.0
11565                            && buf[ci + 2] == 0.0
11566                            && buf[ci + 3] == 0.0
11567                    } else {
11568                        false
11569                    }
11570                } else {
11571                    false
11572                };
11573                let offset_for_check = py as usize * stride + px as usize * 4;
11574                let pixmap_has_colour = data[offset_for_check + 3] > 0
11575                    && (data[offset_for_check] < 250
11576                        || data[offset_for_check + 1] < 250
11577                        || data[offset_for_check + 2] < 250);
11578                let use_multiplicative = params.overprint
11579                    && params.painted_channels != stet_graphics::device::CMYK_ALL
11580                    && buffer_clean
11581                    && pixmap_has_colour;
11582
11583                // Write CMYK buffer at non-clipped pixels
11584                if let Some(ref mut buf) = cmyk_buf
11585                    && ci + 3 < buf.len()
11586                {
11587                    if params.overprint
11588                        && params.painted_channels != stet_graphics::device::CMYK_ALL
11589                    {
11590                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11591                            buf[ci] = cmyk.0 as f32;
11592                        }
11593                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11594                            buf[ci + 1] = cmyk.1 as f32;
11595                        }
11596                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11597                            buf[ci + 2] = cmyk.2 as f32;
11598                        }
11599                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
11600                            buf[ci + 3] = cmyk.3 as f32;
11601                        }
11602                    } else {
11603                        buf[ci] = cmyk.0 as f32;
11604                        buf[ci + 1] = cmyk.1 as f32;
11605                        buf[ci + 2] = cmyk.2 as f32;
11606                        buf[ci + 3] = cmyk.3 as f32;
11607                    }
11608                }
11609
11610                let offset = py as usize * stride + px as usize * 4;
11611                if use_multiplicative {
11612                    // Ink-stack the per-stop CMYK onto the pixmap RGB. Only
11613                    // channels named by painted_channels contribute; others
11614                    // leave the pixmap untouched, so a spot-painted backdrop
11615                    // survives with just the named inks darkening it.
11616                    let bg_r = data[offset] as f64 / 255.0;
11617                    let bg_g = data[offset + 1] as f64 / 255.0;
11618                    let bg_b = data[offset + 2] as f64 / 255.0;
11619                    let over_r = if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11620                        1.0 - cmyk.0
11621                    } else {
11622                        1.0
11623                    };
11624                    let over_g = if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11625                        1.0 - cmyk.1
11626                    } else {
11627                        1.0
11628                    };
11629                    let over_b = if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11630                        1.0 - cmyk.2
11631                    } else {
11632                        1.0
11633                    };
11634                    let k_fac = if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
11635                        1.0 - cmyk.3
11636                    } else {
11637                        1.0
11638                    };
11639                    data[offset] = ((bg_r * over_r * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
11640                    data[offset + 1] =
11641                        ((bg_g * over_g * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
11642                    data[offset + 2] =
11643                        ((bg_b * over_b * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
11644                    data[offset + 3] = 255;
11645                } else {
11646                    data[offset] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
11647                    data[offset + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
11648                    data[offset + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
11649                    data[offset + 3] = 255;
11650
11651                    // Recomposite RGB from the CMYK buffer via ICC only for
11652                    // overprint DeviceCMYK shadings on a CMYK-only backdrop,
11653                    // where the per-channel merge in the buffer means the
11654                    // displayed pixel must reflect the merged CMYK rather
11655                    // than the source's RGB. For non-overprint shadings the
11656                    // LUT-rendered pixmap (above) is already correct, and
11657                    // round-tripping CMYK→RGB through the ICC profile
11658                    // produces a different gradient curve (linear in CMYK
11659                    // rather than linear in RGB) — that drift was the
11660                    // 3000_9 / 3000_10 snowman shading regression. The CMYK
11661                    // buffer is only consumed by `composite_non_isolated_cmyk`,
11662                    // which excludes shading-containing groups via
11663                    // `group_content_is_native_cmyk`, so the buffer/pixmap
11664                    // mismatch never reaches a consumer that would notice.
11665                    if params.overprint
11666                        && params.painted_channels != stet_graphics::device::CMYK_ALL
11667                        && matches!(params.color_space, ShadingColorSpace::DeviceCMYK)
11668                        && let Some(ref mut buf) = cmyk_buf
11669                        && ci + 3 < buf.len()
11670                        && let Some(icc_cache) = icc
11671                    {
11672                        let c = buf[ci] as f64;
11673                        let m = buf[ci + 1] as f64;
11674                        let y = buf[ci + 2] as f64;
11675                        let k = buf[ci + 3] as f64;
11676                        if let Some((r, g, b)) = icc_cache.convert_cmyk_readonly(c, m, y, k) {
11677                            data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
11678                            data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
11679                            data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
11680                        }
11681                    }
11682                }
11683            }
11684        }
11685    }
11686}
11687/// Solve for the parameter t of a two-circle radial gradient at point (px, py).
11688///
11689/// Returns the largest root of the circle equation that falls within the valid
11690/// domain and has R(t) >= 0. The valid domain is [0,1], extended by extend flags.
11691#[allow(clippy::too_many_arguments)]
11692fn solve_radial_t(
11693    px: f64,
11694    py: f64,
11695    x0: f64,
11696    y0: f64,
11697    r0: f64,
11698    x1: f64,
11699    y1: f64,
11700    r1: f64,
11701    extend_start: bool,
11702    extend_end: bool,
11703) -> Option<f64> {
11704    // Parametric: C(t) = (1-t)*C0 + t*C1, R(t) = (1-t)*r0 + t*r1
11705    // Solve: (px - Cx(t))^2 + (py - Cy(t))^2 = R(t)^2
11706    let cdx = x1 - x0;
11707    let cdy = y1 - y0;
11708    let dr = r1 - r0;
11709
11710    let a = cdx * cdx + cdy * cdy - dr * dr;
11711    let dpx = px - x0;
11712    let dpy = py - y0;
11713    let b = -2.0 * (dpx * cdx + dpy * cdy + r0 * dr);
11714    let c = dpx * dpx + dpy * dpy - r0 * r0;
11715
11716    // Helper: check if a root is in the valid domain
11717    let in_domain = |t: f64| -> bool {
11718        (0.0..=1.0).contains(&t) || (t < 0.0 && extend_start) || (t > 1.0 && extend_end)
11719    };
11720
11721    if a.abs() < 1e-10 {
11722        // Linear case
11723        if b.abs() < 1e-10 {
11724            return None;
11725        }
11726        let t = -c / b;
11727        let radius = r0 + t * dr;
11728        if radius >= 0.0 && in_domain(t) {
11729            return Some(t);
11730        }
11731        return None;
11732    }
11733
11734    let discriminant = b * b - 4.0 * a * c;
11735    if discriminant < 0.0 {
11736        return None;
11737    }
11738    let sqrt_d = discriminant.sqrt();
11739    let t1 = (-b + sqrt_d) / (2.0 * a);
11740    let t2 = (-b - sqrt_d) / (2.0 * a);
11741
11742    // Pick the largest root that is in the valid domain and has R(t) >= 0
11743    let mut best: Option<f64> = None;
11744    for t in [t1, t2] {
11745        let radius = r0 + t * dr;
11746        if radius >= 0.0 && in_domain(t) {
11747            best = Some(match best {
11748                Some(prev) => prev.max(t),
11749                None => t,
11750            });
11751        }
11752    }
11753    best
11754}
11755
11756/// Render a Gouraud-shaded triangle mesh.
11757#[allow(clippy::too_many_arguments)]
11758fn render_mesh_shading(
11759    pixmap: &mut Pixmap,
11760    params: &MeshShadingParams,
11761    vp_x: f32,
11762    vp_y: f32,
11763    scale_x: f32,
11764    scale_y: f32,
11765    clip_mask: Option<&Mask>,
11766    mut cmyk_buf: Option<&mut [f32]>,
11767    icc: Option<&IccCache>,
11768) {
11769    let pw = pixmap.width() as usize;
11770    let ph = pixmap.height() as usize;
11771    if pw == 0 || ph == 0 {
11772        return;
11773    }
11774    let data = pixmap.data_mut();
11775    let stride = pw * 4;
11776
11777    let lut = params.color_lut.as_deref();
11778
11779    for tri in &params.triangles {
11780        let (dx0, dy0) = params.ctm.transform_point(tri.v0.x, tri.v0.y);
11781        let (dx1, dy1) = params.ctm.transform_point(tri.v1.x, tri.v1.y);
11782        let (dx2, dy2) = params.ctm.transform_point(tri.v2.x, tri.v2.y);
11783
11784        let x0 = (dx0 as f32 - vp_x) * scale_x;
11785        let y0 = (dy0 as f32 - vp_y) * scale_y;
11786        let x1 = (dx1 as f32 - vp_x) * scale_x;
11787        let y1 = (dy1 as f32 - vp_y) * scale_y;
11788        let x2 = (dx2 as f32 - vp_x) * scale_x;
11789        let y2 = (dy2 as f32 - vp_y) * scale_y;
11790
11791        let min_x = (x0.min(x1).min(x2).floor().max(0.0)) as usize;
11792        let max_x = (x0.max(x1).max(x2).ceil() as usize).min(pw);
11793        let min_y = (y0.min(y1).min(y2).floor().max(0.0)) as usize;
11794        let max_y = (y0.max(y1).max(y2).ceil() as usize).min(ph);
11795
11796        if min_x >= max_x || min_y >= max_y {
11797            continue;
11798        }
11799
11800        let x0 = x0 as f64;
11801        let y0 = y0 as f64;
11802        let x1 = x1 as f64;
11803        let y1 = y1 as f64;
11804        let x2 = x2 as f64;
11805        let y2 = y2 as f64;
11806        // Swap vertices 1 and 2 when the triangle has reversed winding
11807        // (from a CTM with negative determinant, e.g. X- or Y-flip).
11808        // This ensures barycentric coordinates stay positive for interior
11809        // points regardless of the CTM orientation.
11810        let denom = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2);
11811        if denom.abs() < 1e-10 {
11812            continue;
11813        }
11814        let (x1, y1, x2, y2) = if denom < 0.0 {
11815            (x2, y2, x1, y1)
11816        } else {
11817            (x1, y1, x2, y2)
11818        };
11819        let (v1_ref, v2_ref) = if denom < 0.0 {
11820            (&tri.v2, &tri.v1)
11821        } else {
11822            (&tri.v1, &tri.v2)
11823        };
11824        let denom = denom.abs();
11825        let inv_denom = 1.0 / denom;
11826
11827        for py in min_y..max_y {
11828            for px in min_x..max_x {
11829                let pxf = px as f64 + 0.5;
11830                let pyf = py as f64 + 0.5;
11831
11832                let w0 = ((y1 - y2) * (pxf - x2) + (x2 - x1) * (pyf - y2)) * inv_denom;
11833                let w1 = ((y2 - y0) * (pxf - x2) + (x0 - x2) * (pyf - y2)) * inv_denom;
11834                let w2 = 1.0 - w0 - w1;
11835
11836                if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
11837                    continue;
11838                }
11839
11840                let clipped = clip_mask.is_some_and(|mask| mask.data()[py * pw + px] == 0);
11841
11842                let w0c = w0.max(0.0);
11843                let w1c = w1.max(0.0);
11844                let w2c = w2.max(0.0);
11845                let wsum = w0c + w1c + w2c;
11846                let w0n = w0c / wsum;
11847                let w1n = w1c / wsum;
11848                let w2n = w2c / wsum;
11849
11850                // Per-pixel color: either LUT lookup (for function-based meshes)
11851                // or direct Gouraud interpolation of vertex DeviceColors.
11852                let (r, g, b) = if let Some(lut) = lut {
11853                    // Interpolate raw function input values per-pixel
11854                    let raw = w0n * tri.v0.raw_components[0]
11855                        + w1n * v1_ref.raw_components[0]
11856                        + w2n * v2_ref.raw_components[0];
11857                    let raw = raw.clamp(0.0, 1.0);
11858                    // Linear interpolation in the LUT
11859                    let fi = raw * (lut.len() - 1) as f64;
11860                    let i0 = (fi as usize).min(lut.len().saturating_sub(2));
11861                    let frac = fi - i0 as f64;
11862                    let c0 = &lut[i0];
11863                    let c1 = &lut[i0 + 1];
11864                    (
11865                        c0.r + frac * (c1.r - c0.r),
11866                        c0.g + frac * (c1.g - c0.g),
11867                        c0.b + frac * (c1.b - c0.b),
11868                    )
11869                } else {
11870                    (
11871                        w0n * tri.v0.color.r + w1n * v1_ref.color.r + w2n * v2_ref.color.r,
11872                        w0n * tri.v0.color.g + w1n * v1_ref.color.g + w2n * v2_ref.color.g,
11873                        w0n * tri.v0.color.b + w1n * v1_ref.color.b + w2n * v2_ref.color.b,
11874                    )
11875                };
11876
11877                // Write CMYK buffer
11878                if let Some(ref mut buf) = cmyk_buf {
11879                    let ci = (py * pw + px) * 4;
11880                    if ci + 3 < buf.len() {
11881                        let cmyk = interpolate_cmyk_from_vertices(
11882                            &tri.v0,
11883                            v1_ref,
11884                            v2_ref,
11885                            w0n,
11886                            w1n,
11887                            w2n,
11888                            &params.color_space,
11889                            r,
11890                            g,
11891                            b,
11892                            icc,
11893                        );
11894                        if params.overprint
11895                            && params.painted_channels != stet_graphics::device::CMYK_ALL
11896                        {
11897                            if !clipped {
11898                                if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11899                                    buf[ci] = cmyk.0 as f32;
11900                                }
11901                                if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11902                                    buf[ci + 1] = cmyk.1 as f32;
11903                                }
11904                                if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11905                                    buf[ci + 2] = cmyk.2 as f32;
11906                                }
11907                                if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
11908                                    buf[ci + 3] = cmyk.3 as f32;
11909                                }
11910                            }
11911                        } else {
11912                            buf[ci] = cmyk.0 as f32;
11913                            buf[ci + 1] = cmyk.1 as f32;
11914                            buf[ci + 2] = cmyk.2 as f32;
11915                            buf[ci + 3] = cmyk.3 as f32;
11916                        }
11917                    }
11918                }
11919
11920                if clipped {
11921                    continue;
11922                }
11923
11924                let offset = py * stride + px * 4;
11925                data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
11926                data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
11927                data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
11928                data[offset + 3] = 255;
11929            }
11930        }
11931    }
11932}
11933
11934/// Render a Coons/tensor-product patch mesh by subdividing into triangles.
11935#[allow(clippy::too_many_arguments)]
11936fn render_patch_shading(
11937    pixmap: &mut Pixmap,
11938    params: &PatchShadingParams,
11939    vp_x: f32,
11940    vp_y: f32,
11941    scale_x: f32,
11942    scale_y: f32,
11943    clip_mask: Option<&Mask>,
11944    cmyk_buf: Option<&mut [f32]>,
11945    icc: Option<&IccCache>,
11946) {
11947    let mut triangles = Vec::new();
11948    let scale = scale_x.max(scale_y) as f64;
11949    for patch in &params.patches {
11950        if patch.points.len() >= 12 {
11951            // Compute device-space extent to choose subdivision level
11952            let mut x_min = f64::INFINITY;
11953            let mut y_min = f64::INFINITY;
11954            let mut x_max = f64::NEG_INFINITY;
11955            let mut y_max = f64::NEG_INFINITY;
11956            for &(px, py) in &patch.points {
11957                let (dx, dy) = params.ctm.transform_point(px, py);
11958                x_min = x_min.min(dx);
11959                y_min = y_min.min(dy);
11960                x_max = x_max.max(dx);
11961                y_max = y_max.max(dy);
11962            }
11963            let extent = (x_max - x_min).max(y_max - y_min).abs() * scale;
11964            // Target ~2 device pixels per boundary segment
11965            let n = (extent / 2.0).ceil().clamp(8.0, 64.0) as usize;
11966            // Extract ICC profile hash for per-grid-point color conversion
11967            let icc_profile_hash = match &params.color_space {
11968                stet_graphics::device::ShadingColorSpace::ICCBased { profile_hash, .. } => {
11969                    Some(profile_hash)
11970                }
11971                _ => None,
11972            };
11973            subdivide_patch_to_triangles(patch, &mut triangles, n, icc_profile_hash, icc);
11974        }
11975    }
11976    if !triangles.is_empty() {
11977        let mesh_params = MeshShadingParams {
11978            triangles,
11979            ctm: params.ctm,
11980            bbox: params.bbox,
11981            color_space: params.color_space.clone(),
11982            overprint: params.overprint,
11983            painted_channels: params.painted_channels,
11984            color_lut: params.color_lut.clone(),
11985        };
11986        render_mesh_shading(
11987            pixmap,
11988            &mesh_params,
11989            vp_x,
11990            vp_y,
11991            scale_x,
11992            scale_y,
11993            clip_mask,
11994            cmyk_buf,
11995            icc,
11996        );
11997    }
11998}
11999/// Subdivide a Coons/tensor patch into triangles via grid subdivision.
12000/// Evaluates the patch at NxN points and triangulates the resulting grid.
12001/// When an ICC profile hash and cache are provided, interpolates colors in the
12002/// source ICC color space and converts per-grid-point for accurate rendering.
12003fn subdivide_patch_to_triangles(
12004    patch: &stet_graphics::device::ShadingPatch,
12005    triangles: &mut Vec<stet_graphics::device::ShadingTriangle>,
12006    n: usize,
12007    icc_profile_hash: Option<&stet_graphics::icc::ProfileHash>,
12008    icc_cache: Option<&IccCache>,
12009) {
12010    // Evaluate patch at grid points.
12011    // Use tensor-product evaluation when 16 control points are available (Type 7),
12012    // otherwise fall back to Coons blending (Type 6, 12 points).
12013    let mut grid: Vec<(f64, f64, DeviceColor, Vec<f64>)> = Vec::with_capacity((n + 1) * (n + 1));
12014    let use_tensor = patch.points.len() >= 16;
12015    let has_raw = !patch.raw_colors[0].is_empty();
12016    // Use per-grid-point ICC conversion when profile info is available
12017    let use_icc_interp = has_raw && icc_profile_hash.is_some() && icc_cache.is_some();
12018
12019    for row in 0..=n {
12020        let v = row as f64 / n as f64;
12021        for col in 0..=n {
12022            let u = col as f64 / n as f64;
12023            let (x, y) = if use_tensor {
12024                eval_tensor_patch(patch, u, v)
12025            } else {
12026                eval_coons_patch(patch, u, v)
12027            };
12028            let raw = if has_raw {
12029                bilinear_raw(&patch.raw_colors, u, v)
12030            } else {
12031                vec![]
12032            };
12033            // When ICC profile is available, convert the interpolated raw
12034            // components at each grid point for accurate color rendering.
12035            // This interpolates in the source color space (e.g. ProPhoto RGB)
12036            // and converts per-grid-point, rather than interpolating pre-converted
12037            // sRGB values from only the 4 corners.
12038            let color = if use_icc_interp {
12039                if let Some((r, g, b)) = icc_cache
12040                    .unwrap()
12041                    .convert_color_readonly(icc_profile_hash.unwrap(), &raw)
12042                {
12043                    DeviceColor::from_rgb(r, g, b)
12044                } else {
12045                    bilinear_color(&patch.colors, u, v)
12046                }
12047            } else {
12048                bilinear_color(&patch.colors, u, v)
12049            };
12050            grid.push((x, y, color, raw));
12051        }
12052    }
12053
12054    // Triangulate grid
12055    let cols = n + 1;
12056    for row in 0..n {
12057        for col in 0..n {
12058            let i00 = row * cols + col;
12059            let i10 = i00 + 1;
12060            let i01 = i00 + cols;
12061            let i11 = i01 + 1;
12062
12063            let (x00, y00, c00, r00) = &grid[i00];
12064            let (x10, y10, c10, r10) = &grid[i10];
12065            let (x01, y01, c01, r01) = &grid[i01];
12066            let (x11, y11, c11, r11) = &grid[i11];
12067
12068            use stet_graphics::device::ShadingVertex;
12069            triangles.push(stet_graphics::device::ShadingTriangle {
12070                v0: ShadingVertex {
12071                    x: *x00,
12072                    y: *y00,
12073                    color: c00.clone(),
12074                    raw_components: r00.clone(),
12075                },
12076                v1: ShadingVertex {
12077                    x: *x10,
12078                    y: *y10,
12079                    color: c10.clone(),
12080                    raw_components: r10.clone(),
12081                },
12082                v2: ShadingVertex {
12083                    x: *x01,
12084                    y: *y01,
12085                    color: c01.clone(),
12086                    raw_components: r01.clone(),
12087                },
12088            });
12089            triangles.push(stet_graphics::device::ShadingTriangle {
12090                v0: ShadingVertex {
12091                    x: *x10,
12092                    y: *y10,
12093                    color: c10.clone(),
12094                    raw_components: r10.clone(),
12095                },
12096                v1: ShadingVertex {
12097                    x: *x11,
12098                    y: *y11,
12099                    color: c11.clone(),
12100                    raw_components: r11.clone(),
12101                },
12102                v2: ShadingVertex {
12103                    x: *x01,
12104                    y: *y01,
12105                    color: c01.clone(),
12106                    raw_components: r01.clone(),
12107                },
12108            });
12109        }
12110    }
12111}
12112
12113/// Evaluate a Coons patch at parameter (u, v).
12114/// The 12 control points define 4 cubic Bezier boundary curves.
12115fn eval_coons_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12116    let pts = &patch.points;
12117    if pts.len() < 12 {
12118        return (0.0, 0.0);
12119    }
12120
12121    // Side 0 (bottom): pts[0..4], u goes 0→1
12122    // Side 1 (right): pts[3..7], v goes 0→1
12123    // Side 2 (top): pts[6..10], u goes 1→0 (reversed)
12124    // Side 3 (left): pts[9..12] + pts[0], v goes 1→0 (reversed)
12125    let c0 = eval_cubic_bezier(pts[0], pts[1], pts[2], pts[3], u);
12126    let c2 = eval_cubic_bezier(pts[6], pts[7], pts[8], pts[9], 1.0 - u);
12127    let d0 = eval_cubic_bezier(pts[0], pts[11], pts[10], pts[9], v);
12128    let d1 = eval_cubic_bezier(pts[3], pts[4], pts[5], pts[6], v);
12129
12130    // Bilinear blending of corners
12131    let p00 = pts[0];
12132    let p10 = pts[3];
12133    let p01 = pts[9];
12134    let p11 = pts[6];
12135    let bx = (1.0 - u) * (1.0 - v) * p00.0
12136        + u * (1.0 - v) * p10.0
12137        + (1.0 - u) * v * p01.0
12138        + u * v * p11.0;
12139    let by = (1.0 - u) * (1.0 - v) * p00.1
12140        + u * (1.0 - v) * p10.1
12141        + (1.0 - u) * v * p01.1
12142        + u * v * p11.1;
12143
12144    // Coons blending: S(u,v) = c(u,v) + d(u,v) - B(u,v)
12145    let x = (1.0 - v) * c0.0 + v * c2.0 + (1.0 - u) * d0.0 + u * d1.0 - bx;
12146    let y = (1.0 - v) * c0.1 + v * c2.1 + (1.0 - u) * d0.1 + u * d1.1 - by;
12147
12148    (x, y)
12149}
12150
12151/// Evaluate a Type 7 tensor-product patch at parameter (u, v).
12152///
12153/// Uses 16 control points arranged in a 4×4 grid, evaluated as a bicubic
12154/// Bernstein surface: S(u,v) = ΣΣ B_i(u) * B_j(v) * P_ij
12155///
12156/// PDF spec (ISO 32000, Table 85) data ordering for flag=0:
12157///   p₁₁ p₁₂ p₁₃ p₁₄  p₂₁ p₂₂ p₂₃ p₂₄  p₃₁ p₃₂ p₃₃ p₃₄  p₄₁ p₄₂ p₄₃ p₄₄
12158///
12159/// In the grid (Figure 86), column index = u direction, row index = v direction:
12160///   grid[v=0][u] = p₁₁, p₂₁, p₃₁, p₄₁  = pts[0], pts[4], pts[8],  pts[12]
12161///   grid[v=⅓][u] = p₁₂, p₂₂, p₃₂, p₄₂  = pts[1], pts[5], pts[9],  pts[13]
12162///   grid[v=⅔][u] = p₁₃, p₂₃, p₃₃, p₄₃  = pts[2], pts[6], pts[10], pts[14]
12163///   grid[v=1][u] = p₁₄, p₂₄, p₃₄, p₄₄  = pts[3], pts[7], pts[11], pts[15]
12164fn eval_tensor_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12165    let pts = &patch.points;
12166
12167    // Map data indices to 4×4 grid [row][col].
12168    // pts[0..12] are boundary points around the perimeter (same as Type 6).
12169    // pts[12..16] are the 4 interior control points.
12170    let grid: [[usize; 4]; 4] = [[0, 1, 2, 3], [11, 12, 13, 4], [10, 15, 14, 5], [9, 8, 7, 6]];
12171
12172    // Cubic Bernstein basis values
12173    let su = 1.0 - u;
12174    let bu = [su * su * su, 3.0 * su * su * u, 3.0 * su * u * u, u * u * u];
12175    let sv = 1.0 - v;
12176    let bv = [sv * sv * sv, 3.0 * sv * sv * v, 3.0 * sv * v * v, v * v * v];
12177
12178    let mut x = 0.0;
12179    let mut y = 0.0;
12180    for j in 0..4 {
12181        for i in 0..4 {
12182            let w = bu[i] * bv[j];
12183            let p = pts[grid[j][i]];
12184            x += w * p.0;
12185            y += w * p.1;
12186        }
12187    }
12188    (x, y)
12189}
12190
12191/// Evaluate a cubic Bezier curve at parameter t.
12192fn eval_cubic_bezier(
12193    p0: (f64, f64),
12194    p1: (f64, f64),
12195    p2: (f64, f64),
12196    p3: (f64, f64),
12197    t: f64,
12198) -> (f64, f64) {
12199    let s = 1.0 - t;
12200    let s2 = s * s;
12201    let t2 = t * t;
12202    let b0 = s2 * s;
12203    let b1 = 3.0 * s2 * t;
12204    let b2 = 3.0 * s * t2;
12205    let b3 = t2 * t;
12206    (
12207        b0 * p0.0 + b1 * p1.0 + b2 * p2.0 + b3 * p3.0,
12208        b0 * p0.1 + b1 * p1.1 + b2 * p2.1 + b3 * p3.1,
12209    )
12210}
12211
12212/// Bilinear color interpolation across patch corners.
12213fn bilinear_color(colors: &[DeviceColor; 4], u: f64, v: f64) -> DeviceColor {
12214    let r = (1.0 - u) * (1.0 - v) * colors[0].r
12215        + u * (1.0 - v) * colors[1].r
12216        + (1.0 - u) * v * colors[3].r
12217        + u * v * colors[2].r;
12218    let g = (1.0 - u) * (1.0 - v) * colors[0].g
12219        + u * (1.0 - v) * colors[1].g
12220        + (1.0 - u) * v * colors[3].g
12221        + u * v * colors[2].g;
12222    let b = (1.0 - u) * (1.0 - v) * colors[0].b
12223        + u * (1.0 - v) * colors[1].b
12224        + (1.0 - u) * v * colors[3].b
12225        + u * v * colors[2].b;
12226    DeviceColor::from_rgb(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
12227}
12228
12229/// Bilinear interpolation of raw color components across patch corners.
12230fn bilinear_raw(raw_colors: &[Vec<f64>; 4], u: f64, v: f64) -> Vec<f64> {
12231    let n = raw_colors[0].len();
12232    let mut result = vec![0.0; n];
12233    for i in 0..n {
12234        result[i] = (1.0 - u) * (1.0 - v) * raw_colors[0][i]
12235            + u * (1.0 - v) * raw_colors[1][i]
12236            + (1.0 - u) * v * raw_colors[3][i]
12237            + u * v * raw_colors[2][i];
12238    }
12239    result
12240}
12241
12242/// Pre-rasterize color stops into a 256-entry RGBA lookup table.
12243///
12244/// Each entry is linearly interpolated from the color stops. Used by the
12245/// direct-rasterization axial shading path to replace per-pixel stop search
12246/// with a single array lookup.
12247fn build_gradient_lut(stops: &[stet_graphics::device::ColorStop], size: usize) -> Vec<[u8; 4]> {
12248    let size = size.max(2);
12249    let mut lut = vec![[0u8; 4]; size];
12250    if stops.is_empty() {
12251        return lut;
12252    }
12253    let mut si = 0usize; // current stop index
12254    let last = (size - 1) as f64;
12255    for i in 0..size {
12256        let t = i as f64 / last;
12257        // Advance stop index
12258        while si + 1 < stops.len() && stops[si + 1].position < t {
12259            si += 1;
12260        }
12261        let (r, g, b) = if si + 1 >= stops.len() {
12262            let c = &stops[stops.len() - 1].color;
12263            (c.r, c.g, c.b)
12264        } else if t <= stops[si].position {
12265            let c = &stops[si].color;
12266            (c.r, c.g, c.b)
12267        } else {
12268            let t0 = stops[si].position;
12269            let t1 = stops[si + 1].position;
12270            let frac = if (t1 - t0).abs() < 1e-10 {
12271                0.0
12272            } else {
12273                (t - t0) / (t1 - t0)
12274            };
12275            let c0 = &stops[si].color;
12276            let c1 = &stops[si + 1].color;
12277            (
12278                c0.r + frac * (c1.r - c0.r),
12279                c0.g + frac * (c1.g - c0.g),
12280                c0.b + frac * (c1.b - c0.b),
12281            )
12282        };
12283        lut[i] = [
12284            (r * 255.0).round().clamp(0.0, 255.0) as u8,
12285            (g * 255.0).round().clamp(0.0, 255.0) as u8,
12286            (b * 255.0).round().clamp(0.0, 255.0) as u8,
12287            255,
12288        ];
12289    }
12290    lut
12291}
12292
12293/// Build tiny-skia gradient stops from color stops.
12294fn build_gradient_stops(
12295    stops: &[stet_graphics::device::ColorStop],
12296) -> Vec<stet_tiny_skia::GradientStop> {
12297    let mut result = Vec::with_capacity(stops.len());
12298    for stop in stops {
12299        let r = (stop.color.r * 255.0).round().clamp(0.0, 255.0) as u8;
12300        let g = (stop.color.g * 255.0).round().clamp(0.0, 255.0) as u8;
12301        let b = (stop.color.b * 255.0).round().clamp(0.0, 255.0) as u8;
12302        result.push(stet_tiny_skia::GradientStop::new(
12303            stop.position as f32,
12304            Color::from_rgba8(r, g, b, 255),
12305        ));
12306    }
12307    result
12308}
12309
12310/// Interpolate between color stops at a given position (0.0..=1.0).
12311fn interpolate_color_stops(
12312    stops: &[stet_graphics::device::ColorStop],
12313    position: f64,
12314) -> DeviceColor {
12315    if stops.is_empty() {
12316        return DeviceColor::from_gray(0.0);
12317    }
12318    if stops.len() == 1 || position <= stops[0].position {
12319        return stops[0].color.clone();
12320    }
12321    if position >= stops.last().unwrap().position {
12322        return stops.last().unwrap().color.clone();
12323    }
12324
12325    // Find the two stops bracketing this position
12326    for i in 1..stops.len() {
12327        if position <= stops[i].position {
12328            let t0 = stops[i - 1].position;
12329            let t1 = stops[i].position;
12330            let frac = if (t1 - t0).abs() < 1e-10 {
12331                0.0
12332            } else {
12333                (position - t0) / (t1 - t0)
12334            };
12335            let c0 = &stops[i - 1].color;
12336            let c1 = &stops[i].color;
12337            return DeviceColor::from_rgb(
12338                (c0.r + frac * (c1.r - c0.r)).clamp(0.0, 1.0),
12339                (c0.g + frac * (c1.g - c0.g)).clamp(0.0, 1.0),
12340                (c0.b + frac * (c1.b - c0.b)).clamp(0.0, 1.0),
12341            );
12342        }
12343    }
12344
12345    stops.last().unwrap().color.clone()
12346}
12347
12348/// Derive CMYK values from color stops at parameter t.
12349///
12350/// For DeviceCMYK shading color spaces the per-stop `raw_components` carry the
12351/// authoritative 4-channel CMYK values (already tint-transformed for
12352/// Separation/DeviceN with a CMYK alt) — those are interpolated directly.
12353///
12354/// For non-CMYK source color spaces (DeviceRGB, DeviceGray, CalRGB, CalGray,
12355/// ICCBased non-4) the interpolated sRGB color is round-tripped to CMYK via
12356/// the system CMYK ICC profile so the parallel CMYK buffer holds an accurate
12357/// representation. Falls back to PLRM `(1−r, 1−g, 1−b, 0)` when no system
12358/// profile is registered (e.g. `--no-icc`).
12359fn interpolate_cmyk_from_stops(
12360    stops: &[stet_graphics::device::ColorStop],
12361    cs: &ShadingColorSpace,
12362    t: f64,
12363    color: &DeviceColor,
12364    icc: Option<&IccCache>,
12365) -> (f64, f64, f64, f64) {
12366    let rgb_to_cmyk = |c: &DeviceColor| -> (f64, f64, f64, f64) {
12367        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(c.r, c.g, c.b)) {
12368            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
12369        } else {
12370            (
12371                (1.0 - c.r).clamp(0.0, 1.0),
12372                (1.0 - c.g).clamp(0.0, 1.0),
12373                (1.0 - c.b).clamp(0.0, 1.0),
12374                0.0,
12375            )
12376        }
12377    };
12378
12379    match cs {
12380        ShadingColorSpace::DeviceCMYK => {
12381            // Interpolate raw CMYK components from stops
12382            if stops.len() == 1 {
12383                let rc = &stops[0].raw_components;
12384                if rc.len() >= 4 {
12385                    return (rc[0], rc[1], rc[2], rc[3]);
12386                }
12387            }
12388            // Find surrounding stops and interpolate
12389            let mut lo = &stops[0];
12390            let mut hi = stops.last().unwrap();
12391            for i in 0..stops.len() - 1 {
12392                if stops[i + 1].position >= t {
12393                    lo = &stops[i];
12394                    hi = &stops[i + 1];
12395                    break;
12396                }
12397            }
12398            let span = hi.position - lo.position;
12399            let frac = if span > 1e-10 {
12400                (t - lo.position) / span
12401            } else {
12402                0.0
12403            };
12404            let frac = frac.clamp(0.0, 1.0);
12405            if lo.raw_components.len() >= 4 && hi.raw_components.len() >= 4 {
12406                (
12407                    lo.raw_components[0] + frac * (hi.raw_components[0] - lo.raw_components[0]),
12408                    lo.raw_components[1] + frac * (hi.raw_components[1] - lo.raw_components[1]),
12409                    lo.raw_components[2] + frac * (hi.raw_components[2] - lo.raw_components[2]),
12410                    lo.raw_components[3] + frac * (hi.raw_components[3] - lo.raw_components[3]),
12411                )
12412            } else {
12413                rgb_to_cmyk(color)
12414            }
12415        }
12416        _ => rgb_to_cmyk(color),
12417    }
12418}
12419
12420/// Derive CMYK values from triangle mesh vertices using barycentric weights.
12421///
12422/// Mirrors [`interpolate_cmyk_from_stops`]: DeviceCMYK source spaces use the
12423/// per-vertex `raw_components`, non-CMYK spaces ICC-reverse the interpolated
12424/// sRGB color, and PLRM is the last-resort fallback.
12425#[allow(clippy::too_many_arguments)]
12426fn interpolate_cmyk_from_vertices(
12427    v0: &ShadingVertex,
12428    v1: &ShadingVertex,
12429    v2: &ShadingVertex,
12430    w0: f64,
12431    w1: f64,
12432    w2: f64,
12433    cs: &ShadingColorSpace,
12434    r: f64,
12435    g: f64,
12436    b: f64,
12437    icc: Option<&IccCache>,
12438) -> (f64, f64, f64, f64) {
12439    let rgb_to_cmyk = |r: f64, g: f64, b: f64| -> (f64, f64, f64, f64) {
12440        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
12441            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
12442        } else {
12443            (
12444                (1.0 - r).clamp(0.0, 1.0),
12445                (1.0 - g).clamp(0.0, 1.0),
12446                (1.0 - b).clamp(0.0, 1.0),
12447                0.0,
12448            )
12449        }
12450    };
12451
12452    match cs {
12453        ShadingColorSpace::DeviceCMYK => {
12454            if v0.raw_components.len() >= 4
12455                && v1.raw_components.len() >= 4
12456                && v2.raw_components.len() >= 4
12457            {
12458                (
12459                    w0 * v0.raw_components[0]
12460                        + w1 * v1.raw_components[0]
12461                        + w2 * v2.raw_components[0],
12462                    w0 * v0.raw_components[1]
12463                        + w1 * v1.raw_components[1]
12464                        + w2 * v2.raw_components[1],
12465                    w0 * v0.raw_components[2]
12466                        + w1 * v1.raw_components[2]
12467                        + w2 * v2.raw_components[2],
12468                    w0 * v0.raw_components[3]
12469                        + w1 * v1.raw_components[3]
12470                        + w2 * v2.raw_components[3],
12471                )
12472            } else {
12473                rgb_to_cmyk(r, g, b)
12474            }
12475        }
12476        _ => rgb_to_cmyk(r, g, b),
12477    }
12478}
12479
12480#[cfg(test)]
12481mod tests {
12482    use super::*;
12483    use stet_graphics::color::DashPattern;
12484    use stet_graphics::device::{BgUcrState, HalftoneState, TransferState};
12485
12486    #[test]
12487    fn test_create_device() {
12488        let dev = SkiaDevice::new(100, 100);
12489        assert_eq!(dev.page_size(), (100, 100));
12490    }
12491
12492    #[test]
12493    fn test_fill_rect() {
12494        let mut dev = SkiaDevice::new(100, 100);
12495        let mut path = PsPath::new();
12496        path.segments.push(PathSegment::MoveTo(10.0, 10.0));
12497        path.segments.push(PathSegment::LineTo(90.0, 10.0));
12498        path.segments.push(PathSegment::LineTo(90.0, 90.0));
12499        path.segments.push(PathSegment::LineTo(10.0, 90.0));
12500        path.segments.push(PathSegment::ClosePath);
12501
12502        let params = FillParams {
12503            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
12504            fill_rule: FillRule::NonZeroWinding,
12505            ctm: Matrix::identity(),
12506            is_text_glyph: false,
12507            overprint: false,
12508            overprint_mode: 0,
12509            opm_paired: false,
12510            painted_channels: 0,
12511            is_device_cmyk: false,
12512            spot_color: None,
12513            rendering_intent: 0,
12514            transfer: TransferState::default(),
12515            halftone: HalftoneState::default(),
12516            bg_ucr: BgUcrState::default(),
12517            alpha: 1.0,
12518            blend_mode: 0,
12519        };
12520        dev.fill_path(&path, &params);
12521
12522        // Check that pixel at center is red
12523        let pixel = dev.pixmap().pixel(50, 50).unwrap();
12524        assert_eq!(pixel.red(), 255);
12525        assert_eq!(pixel.green(), 0);
12526        assert_eq!(pixel.blue(), 0);
12527    }
12528
12529    #[test]
12530    fn test_stroke_line() {
12531        let mut dev = SkiaDevice::new(100, 100);
12532        let mut path = PsPath::new();
12533        path.segments.push(PathSegment::MoveTo(10.0, 50.0));
12534        path.segments.push(PathSegment::LineTo(90.0, 50.0));
12535
12536        let params = StrokeParams {
12537            color: DeviceColor::from_rgb(0.0, 0.0, 1.0),
12538            line_width: 4.0,
12539            line_cap: LineCap::Butt,
12540            line_join: LineJoin::Miter,
12541            miter_limit: 10.0,
12542            dash_pattern: DashPattern::solid(),
12543            ctm: Matrix::identity(),
12544            stroke_adjust: false,
12545            is_text_glyph: false,
12546            overprint: false,
12547            overprint_mode: 0,
12548            opm_paired: false,
12549            painted_channels: 0,
12550            is_device_cmyk: false,
12551            spot_color: None,
12552            rendering_intent: 0,
12553            transfer: TransferState::default(),
12554            halftone: HalftoneState::default(),
12555            bg_ucr: BgUcrState::default(),
12556            alpha: 1.0,
12557            blend_mode: 0,
12558        };
12559        dev.stroke_path(&path, &params);
12560
12561        // Check that pixel on the line is blue
12562        let pixel = dev.pixmap().pixel(50, 50).unwrap();
12563        assert_eq!(pixel.blue(), 255);
12564    }
12565
12566    #[test]
12567    fn test_clip() {
12568        let mut dev = SkiaDevice::new(100, 100);
12569
12570        // Set clip to left half
12571        let mut clip_path = PsPath::new();
12572        clip_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
12573        clip_path.segments.push(PathSegment::LineTo(50.0, 0.0));
12574        clip_path.segments.push(PathSegment::LineTo(50.0, 100.0));
12575        clip_path.segments.push(PathSegment::LineTo(0.0, 100.0));
12576        clip_path.segments.push(PathSegment::ClosePath);
12577
12578        let clip_params = ClipParams {
12579            fill_rule: FillRule::NonZeroWinding,
12580            ctm: Matrix::identity(),
12581            stroke_params: None,
12582        };
12583        dev.clip_path(&clip_path, &clip_params);
12584
12585        // Fill entire page with red
12586        let mut fill_path = PsPath::new();
12587        fill_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
12588        fill_path.segments.push(PathSegment::LineTo(100.0, 0.0));
12589        fill_path.segments.push(PathSegment::LineTo(100.0, 100.0));
12590        fill_path.segments.push(PathSegment::LineTo(0.0, 100.0));
12591        fill_path.segments.push(PathSegment::ClosePath);
12592
12593        let fill_params = FillParams {
12594            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
12595            fill_rule: FillRule::NonZeroWinding,
12596            ctm: Matrix::identity(),
12597            is_text_glyph: false,
12598            overprint: false,
12599            overprint_mode: 0,
12600            opm_paired: false,
12601            painted_channels: 0,
12602            is_device_cmyk: false,
12603            spot_color: None,
12604            rendering_intent: 0,
12605            transfer: TransferState::default(),
12606            halftone: HalftoneState::default(),
12607            bg_ucr: BgUcrState::default(),
12608            alpha: 1.0,
12609            blend_mode: 0,
12610        };
12611        dev.fill_path(&fill_path, &fill_params);
12612
12613        // Left half should be red
12614        let left_pixel = dev.pixmap().pixel(25, 50).unwrap();
12615        assert_eq!(left_pixel.red(), 255);
12616
12617        // Right half should still be white
12618        let right_pixel = dev.pixmap().pixel(75, 50).unwrap();
12619        assert_eq!(right_pixel.red(), 255);
12620        assert_eq!(right_pixel.green(), 255); // white
12621    }
12622
12623    #[test]
12624    fn test_erase_page() {
12625        let mut dev = SkiaDevice::new(100, 100);
12626        // Fill with red
12627        let mut path = PsPath::new();
12628        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
12629        path.segments.push(PathSegment::LineTo(100.0, 0.0));
12630        path.segments.push(PathSegment::LineTo(100.0, 100.0));
12631        path.segments.push(PathSegment::LineTo(0.0, 100.0));
12632        path.segments.push(PathSegment::ClosePath);
12633        let params = FillParams {
12634            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
12635            fill_rule: FillRule::NonZeroWinding,
12636            ctm: Matrix::identity(),
12637            is_text_glyph: false,
12638            overprint: false,
12639            overprint_mode: 0,
12640            opm_paired: false,
12641            painted_channels: 0,
12642            is_device_cmyk: false,
12643            spot_color: None,
12644            rendering_intent: 0,
12645            transfer: TransferState::default(),
12646            halftone: HalftoneState::default(),
12647            bg_ucr: BgUcrState::default(),
12648            alpha: 1.0,
12649            blend_mode: 0,
12650        };
12651        dev.fill_path(&path, &params);
12652
12653        dev.erase_page();
12654
12655        // Should be white again
12656        let pixel = dev.pixmap().pixel(50, 50).unwrap();
12657        assert_eq!(pixel.red(), 255);
12658        assert_eq!(pixel.green(), 255);
12659        assert_eq!(pixel.blue(), 255);
12660    }
12661
12662    #[test]
12663    fn test_show_page() {
12664        let mut dev = SkiaDevice::new(10, 10);
12665        let path = std::env::temp_dir().join("stet_test_output.png");
12666        let path_str = path.to_string_lossy();
12667        let result = dev.show_page(&path_str);
12668        assert!(result.is_ok());
12669        assert!(path.exists());
12670        std::fs::remove_file(&path).ok();
12671    }
12672
12673    #[test]
12674    fn test_transform() {
12675        let mut dev = SkiaDevice::new(200, 200);
12676        // Draw at origin with a translate transform
12677        let mut path = PsPath::new();
12678        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
12679        path.segments.push(PathSegment::LineTo(10.0, 0.0));
12680        path.segments.push(PathSegment::LineTo(10.0, 10.0));
12681        path.segments.push(PathSegment::LineTo(0.0, 10.0));
12682        path.segments.push(PathSegment::ClosePath);
12683
12684        let params = FillParams {
12685            color: DeviceColor::from_rgb(0.0, 1.0, 0.0),
12686            fill_rule: FillRule::NonZeroWinding,
12687            ctm: Matrix::translate(100.0, 100.0),
12688            is_text_glyph: false,
12689            overprint: false,
12690            overprint_mode: 0,
12691            opm_paired: false,
12692            painted_channels: 0,
12693            is_device_cmyk: false,
12694            spot_color: None,
12695            rendering_intent: 0,
12696            transfer: TransferState::default(),
12697            halftone: HalftoneState::default(),
12698            bg_ucr: BgUcrState::default(),
12699            alpha: 1.0,
12700            blend_mode: 0,
12701        };
12702        dev.fill_path(&path, &params);
12703
12704        // Pixel at translated location should be green
12705        let pixel = dev.pixmap().pixel(105, 105).unwrap();
12706        assert_eq!(pixel.green(), 255);
12707        assert_eq!(pixel.red(), 0);
12708    }
12709
12710    fn make_test_fill_at(x: f64, y: f64, w: f64, h: f64) -> DisplayElement {
12711        let mut path = PsPath::new();
12712        path.segments.push(PathSegment::MoveTo(x, y));
12713        path.segments.push(PathSegment::LineTo(x + w, y));
12714        path.segments.push(PathSegment::LineTo(x + w, y + h));
12715        path.segments.push(PathSegment::LineTo(x, y + h));
12716        path.segments.push(PathSegment::ClosePath);
12717        DisplayElement::Fill {
12718            path,
12719            params: FillParams {
12720                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
12721                fill_rule: FillRule::NonZeroWinding,
12722                ctm: Matrix::identity(),
12723                is_text_glyph: false,
12724                overprint: false,
12725                overprint_mode: 0,
12726                opm_paired: false,
12727                painted_channels: 0,
12728                is_device_cmyk: false,
12729                spot_color: None,
12730                rendering_intent: 0,
12731                transfer: TransferState::default(),
12732                halftone: HalftoneState::default(),
12733                bg_ucr: BgUcrState::default(),
12734                alpha: 1.0,
12735                blend_mode: 0,
12736            },
12737        }
12738    }
12739
12740    #[test]
12741    fn test_compute_paint_bounds_two_fills() {
12742        let mut list = DisplayList::new();
12743        list.push(make_test_fill_at(10.0, 20.0, 30.0, 40.0)); // [10..40, 20..60]
12744        list.push(make_test_fill_at(100.0, 50.0, 50.0, 25.0)); // [100..150, 50..75]
12745
12746        let bounds = compute_paint_bounds(&list, 72.0).expect("expected union bounds");
12747        assert!(
12748            (bounds.x_min - 10.0).abs() < 1e-9,
12749            "x_min was {}",
12750            bounds.x_min
12751        );
12752        assert!(
12753            (bounds.y_min - 20.0).abs() < 1e-9,
12754            "y_min was {}",
12755            bounds.y_min
12756        );
12757        assert!(
12758            (bounds.x_max - 150.0).abs() < 1e-9,
12759            "x_max was {}",
12760            bounds.x_max
12761        );
12762        assert!(
12763            (bounds.y_max - 75.0).abs() < 1e-9,
12764            "y_max was {}",
12765            bounds.y_max
12766        );
12767    }
12768
12769    #[test]
12770    fn test_compute_paint_bounds_empty_list() {
12771        let list = DisplayList::new();
12772        assert!(compute_paint_bounds(&list, 72.0).is_none());
12773    }
12774
12775    #[test]
12776    fn test_compute_paint_bounds_only_clip_returns_none() {
12777        let mut list = DisplayList::new();
12778        list.push(DisplayElement::InitClip);
12779        // Clip / InitClip / ErasePage are skipped (return None from
12780        // precompute_full_bboxes), so a list of only clip ops yields no bounds.
12781        assert!(compute_paint_bounds(&list, 72.0).is_none());
12782    }
12783
12784    #[test]
12785    fn test_rasterize_mask_anchors_to_paint_bounds() {
12786        use stet_graphics::display_list::{SoftMaskParams, SoftMaskSubtype};
12787
12788        // A 50×40 white fill at page coords (200, 300)..(250, 340).
12789        // Mask paint bounds in device units: x [200..250], y [300..340].
12790        let mut mask = DisplayList::new();
12791        let mut path = PsPath::new();
12792        path.segments.push(PathSegment::MoveTo(200.0, 300.0));
12793        path.segments.push(PathSegment::LineTo(250.0, 300.0));
12794        path.segments.push(PathSegment::LineTo(250.0, 340.0));
12795        path.segments.push(PathSegment::LineTo(200.0, 340.0));
12796        path.segments.push(PathSegment::ClosePath);
12797        mask.push(DisplayElement::Fill {
12798            path,
12799            params: FillParams {
12800                color: DeviceColor::from_rgb(1.0, 1.0, 1.0),
12801                fill_rule: FillRule::NonZeroWinding,
12802                ctm: Matrix::identity(),
12803                is_text_glyph: false,
12804                overprint: false,
12805                overprint_mode: 0,
12806                opm_paired: false,
12807                painted_channels: 0,
12808                is_device_cmyk: false,
12809                spot_color: None,
12810                rendering_intent: 0,
12811                transfer: TransferState::default(),
12812                halftone: HalftoneState::default(),
12813                bg_ucr: BgUcrState::default(),
12814                alpha: 1.0,
12815                blend_mode: 0,
12816            },
12817        });
12818
12819        let params = SoftMaskParams {
12820            subtype: SoftMaskSubtype::Luminosity,
12821            // Form bbox; intentionally tighter than paint bounds — the
12822            // raster should follow paint bounds, not this.
12823            bbox: [0.0, 0.0, 100.0, 100.0],
12824            backdrop_color: None, // black backdrop → out-of-bounds value = 0
12825            transfer_invert: false,
12826            has_nested_mask_scope: false,
12827            parent_clip_bbox: None,
12828        };
12829
12830        let raster =
12831            rasterize_mask(&mask, &params, None, false, 72.0, 1.0, 1.0).expect("expected raster");
12832
12833        // Origin must be at (or just before) the paint bounds, with the
12834        // 1-pixel AA pad.
12835        assert_eq!(raster.origin_x, 199);
12836        assert_eq!(raster.origin_y, 299);
12837        // Width / height = paint bounds + 2 pixels of pad (1 each side).
12838        assert_eq!(raster.width, 52);
12839        assert_eq!(raster.height, 42);
12840        assert_eq!(raster.scale_x, 1.0);
12841        assert_eq!(raster.scale_y, 1.0);
12842
12843        // The raster should be non-zero somewhere inside the painted region.
12844        // Sample the center of the painted area: page (225, 320) → mask
12845        // index (225 - 199, 320 - 299) = (26, 21).
12846        let mx = 225 - raster.origin_x;
12847        let my = 320 - raster.origin_y;
12848        assert!(mx >= 0 && (mx as u32) < raster.width);
12849        assert!(my >= 0 && (my as u32) < raster.height);
12850        let center_value = raster.data[(my as usize) * raster.width as usize + mx as usize];
12851        assert_eq!(
12852            center_value, 255,
12853            "center of painted mask should be opaque white (lum=255)"
12854        );
12855
12856        // A point outside the paint bounds (page (300, 320)) maps to mask
12857        // index (101, 21) which is outside the raster width — sampling
12858        // there should fall back to out_of_bounds_mask_value(params) = 0.
12859        let mx_out = 300 - raster.origin_x;
12860        let in_bounds = mx_out >= 0 && (mx_out as u32) < raster.width;
12861        assert!(!in_bounds, "page x=300 should be outside the mask raster");
12862        assert_eq!(
12863            out_of_bounds_mask_value(&params),
12864            0,
12865            "black backdrop → out-of-bounds = 0"
12866        );
12867    }
12868
12869    #[test]
12870    fn test_band_local_to_mask_formula() {
12871        // Verify the band-local → page-pixel → mask-index arithmetic for
12872        // several band offsets. This is the highest-risk part of Step 4
12873        // because it bridges three coordinate systems:
12874        //
12875        //   band-local pixel (x, y)
12876        //     + (crop_x, crop_y)            → soft-mask offset within band
12877        //     + (vp_x_pixels, vp_y_pixels)  → page-pixel position
12878        //     - (origin_x, origin_y)        → mask raster index
12879
12880        // Mask raster anchored at page-pixel (200, 300).
12881        let raster_origin_x = 200i32;
12882        let raster_origin_y = 300i32;
12883
12884        // Helper that runs the formula from render_soft_masked.
12885        let sample = |vp_x_dev: f32,
12886                      vp_y_dev: f32,
12887                      scale: f32,
12888                      crop_x: i32,
12889                      crop_y: i32,
12890                      x: i32,
12891                      y: i32|
12892         -> (i32, i32) {
12893            let vp_x_pixels = (vp_x_dev * scale).round() as i32;
12894            let vp_y_pixels = (vp_y_dev * scale).round() as i32;
12895            let page_x = vp_x_pixels + crop_x + x;
12896            let page_y = vp_y_pixels + crop_y + y;
12897            let mx = page_x - raster_origin_x;
12898            let my = page_y - raster_origin_y;
12899            (mx, my)
12900        };
12901
12902        // Case 1: band starts at page Y=0 (top band of page).
12903        // vp_y=0, scale=1. The soft-mask top-left page (220, 310) must
12904        // map to mask index (20, 10).
12905        // crop_x = floor((220 - 0) * 1) = 220, crop_y = floor((310 - 0) * 1) = 310
12906        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 0, 0);
12907        assert_eq!((mx, my), (20, 10), "top band: smask top-left");
12908
12909        // 5 pixels into the smask region (band-local): page (225, 315)
12910        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 5, 5);
12911        assert_eq!((mx, my), (25, 15), "top band: 5px into smask");
12912
12913        // Case 2: band starts at page Y=400. The smask region [310..340]
12914        // doesn't intersect this band — covered by the early-return path.
12915        // But test a band that DOES intersect the smask, e.g. starting at
12916        // Y=305. Then page-Y 310 is band-local Y=5.
12917        // vp_y_pixels = round(305 * 1) = 305
12918        // crop_y = floor((310 - 305) * 1) = 5  (band-local)
12919        // For content y=0 (band-local), page_y = 305 + 5 + 0 = 310 ✓
12920        let (mx, my) = sample(0.0, 305.0, 1.0, 220, 5, 0, 0);
12921        assert_eq!((mx, my), (20, 10), "mid band: smask top-left");
12922
12923        // Case 3: viewport rendering at scale 2. vp_x=100.0, vp_y=150.0,
12924        // scale=2. Page pixel offset = (200, 300). The smask region
12925        // [220..270] in device units = [440..540] in page-pixels at scale 2.
12926        // But the mask raster was built at scale 1, so this is a
12927        // SCALE-MISMATCH case — the cache would invalidate and rebuild.
12928        // We're not testing the rebuild, just that the formula computes
12929        // the right page-pixel coords:
12930        //   vp_x_pixels = round(100 * 2) = 200
12931        //   smask in band: page (440..540), band-local (240..340)
12932        //   crop_x = max(0, floor((220 - 100) * 2)) = 240
12933        //   For x=0 (band-local), page_x = 200 + 240 + 0 = 440 ✓
12934        let vp_x_pixels = (100.0_f32 * 2.0).round() as i32;
12935        let crop_x = ((220.0_f32 - 100.0) * 2.0).floor() as i32;
12936        let page_x_for_x_zero = vp_x_pixels + crop_x;
12937        assert_eq!(page_x_for_x_zero, 440, "viewport scale-2: page-x at x=0");
12938    }
12939
12940    // --- obscured-fill skip (§ GWG reference-under-test pattern) ---
12941
12942    fn x_path() -> PsPath {
12943        let mut p = PsPath::new();
12944        p.segments.push(PathSegment::MoveTo(10.0, 10.0));
12945        p.segments.push(PathSegment::LineTo(20.0, 20.0));
12946        p.segments.push(PathSegment::LineTo(30.0, 10.0));
12947        p.segments.push(PathSegment::LineTo(20.0, 0.0));
12948        p.segments.push(PathSegment::ClosePath);
12949        p
12950    }
12951
12952    fn x_path_perturbed() -> PsPath {
12953        // Same shape, sub-unit rounding — stand-in for GWG's 0.001-unit
12954        // coordinate drift between duplicated path emissions.
12955        let mut p = PsPath::new();
12956        p.segments.push(PathSegment::MoveTo(10.001, 10.0));
12957        p.segments.push(PathSegment::LineTo(20.0, 19.999));
12958        p.segments.push(PathSegment::LineTo(30.002, 10.001));
12959        p.segments.push(PathSegment::LineTo(19.999, 0.0));
12960        p.segments.push(PathSegment::ClosePath);
12961        p
12962    }
12963
12964    fn fill(path: PsPath, alpha: f64, blend: u8) -> DisplayElement {
12965        DisplayElement::Fill {
12966            path,
12967            params: FillParams {
12968                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
12969                fill_rule: FillRule::NonZeroWinding,
12970                ctm: Matrix::identity(),
12971                is_text_glyph: false,
12972                overprint: false,
12973                overprint_mode: 0,
12974                opm_paired: false,
12975                painted_channels: 0,
12976                is_device_cmyk: false,
12977                spot_color: None,
12978                rendering_intent: 0,
12979                transfer: TransferState::default(),
12980                halftone: HalftoneState::default(),
12981                bg_ucr: BgUcrState::default(),
12982                alpha,
12983                blend_mode: blend,
12984            },
12985        }
12986    }
12987
12988    fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> PsPath {
12989        let mut p = PsPath::new();
12990        p.segments.push(PathSegment::MoveTo(x0, y0));
12991        p.segments.push(PathSegment::LineTo(x1, y0));
12992        p.segments.push(PathSegment::LineTo(x1, y1));
12993        p.segments.push(PathSegment::LineTo(x0, y1));
12994        p.segments.push(PathSegment::ClosePath);
12995        p
12996    }
12997
12998    fn clip_elem(path: PsPath) -> DisplayElement {
12999        DisplayElement::Clip {
13000            path,
13001            params: ClipParams {
13002                fill_rule: FillRule::NonZeroWinding,
13003                ctm: Matrix::identity(),
13004                stroke_params: None,
13005            },
13006        }
13007    }
13008
13009    fn group_elem(
13010        inner: Vec<DisplayElement>,
13011        bbox: [f64; 4],
13012        isolated: bool,
13013        alpha: f64,
13014        blend: u8,
13015    ) -> DisplayElement {
13016        let mut dl = DisplayList::new();
13017        for e in inner {
13018            dl.push(e);
13019        }
13020        DisplayElement::Group {
13021            elements: dl,
13022            params: stet_graphics::display_list::GroupParams {
13023                bbox,
13024                isolated,
13025                knockout: false,
13026                blend_mode: blend,
13027                alpha,
13028                color_space: stet_graphics::display_list::GroupColorSpace::Inherited,
13029            },
13030        }
13031    }
13032
13033    fn dl(elements: Vec<DisplayElement>) -> DisplayList {
13034        let mut d = DisplayList::new();
13035        for e in elements {
13036            d.push(e);
13037        }
13038        d
13039    }
13040
13041    #[test]
13042    fn obscured_skip_fires_on_matching_fill_plus_iso_group() {
13043        // Classic GWG pattern: parent Fill, then a clip, then an isolated
13044        // alpha-1 Group whose first paint is a matching Fill.
13045        let parent = fill(x_path(), 1.0, 0);
13046        let inner = vec![fill(x_path_perturbed(), 1.0, 0)];
13047        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13048        let d = dl(vec![
13049            parent,
13050            clip_elem(rect_path(0.0, -5.0, 40.0, 30.0)),
13051            grp,
13052        ]);
13053        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
13054    }
13055
13056    #[test]
13057    fn obscured_skip_does_not_fire_on_non_isolated_group() {
13058        let parent = fill(x_path(), 1.0, 0);
13059        let inner = vec![fill(x_path(), 1.0, 0)];
13060        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], false, 1.0, 0);
13061        let d = dl(vec![parent, grp]);
13062        assert!(compute_obscured_fill_skips(&d).is_empty());
13063    }
13064
13065    #[test]
13066    fn obscured_skip_does_not_fire_on_partial_alpha_group() {
13067        let parent = fill(x_path(), 1.0, 0);
13068        let inner = vec![fill(x_path(), 1.0, 0)];
13069        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 0.5, 0);
13070        let d = dl(vec![parent, grp]);
13071        assert!(compute_obscured_fill_skips(&d).is_empty());
13072    }
13073
13074    #[test]
13075    fn obscured_skip_does_not_fire_on_non_normal_blend() {
13076        let parent = fill(x_path(), 1.0, 0);
13077        let inner = vec![fill(x_path(), 1.0, 0)];
13078        // blend_mode = 10 (Difference) on the group — composite-back
13079        // semantics differ from Normal, so skipping parent is unsafe.
13080        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 10);
13081        let d = dl(vec![parent, grp]);
13082        assert!(compute_obscured_fill_skips(&d).is_empty());
13083    }
13084
13085    #[test]
13086    fn obscured_skip_does_not_fire_when_paths_differ() {
13087        let parent = fill(rect_path(0.0, 0.0, 5.0, 5.0), 1.0, 0);
13088        let inner = vec![fill(x_path(), 1.0, 0)];
13089        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13090        let d = dl(vec![parent, grp]);
13091        assert!(compute_obscured_fill_skips(&d).is_empty());
13092    }
13093
13094    #[test]
13095    fn obscured_skip_does_not_fire_when_group_bbox_too_small() {
13096        // Parent fills a rectangle larger than the group's declared
13097        // bbox — the form's BBox would clip the inner fill to a subset
13098        // of the parent's extent, so the parent cannot be dropped.
13099        let big = rect_path(0.0, 0.0, 100.0, 100.0);
13100        let parent = fill(big.clone(), 1.0, 0);
13101        let inner = vec![fill(big, 1.0, 0)];
13102        // Group bbox only covers [0..10, 0..10], much smaller than parent.
13103        let grp = group_elem(inner, [0.0, 0.0, 10.0, 10.0], true, 1.0, 0);
13104        let d = dl(vec![parent, grp]);
13105        assert!(compute_obscured_fill_skips(&d).is_empty());
13106    }
13107
13108    #[test]
13109    fn obscured_skip_does_not_fire_when_intervening_clip_too_small() {
13110        // A clip between the parent fill and the group is narrower than
13111        // the parent's extent — dropping the parent's fill would reveal
13112        // backdrop where the group couldn't paint.
13113        let parent = fill(x_path(), 1.0, 0);
13114        let narrow_clip = clip_elem(rect_path(12.0, 5.0, 18.0, 15.0));
13115        let inner = vec![fill(x_path(), 1.0, 0)];
13116        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13117        let d = dl(vec![parent, narrow_clip, grp]);
13118        assert!(compute_obscured_fill_skips(&d).is_empty());
13119    }
13120
13121    #[test]
13122    fn obscured_skip_does_not_fire_when_inner_clip_too_small() {
13123        // Clip *inside* the group is narrower than the parent's extent.
13124        let parent = fill(x_path(), 1.0, 0);
13125        let inner = vec![
13126            clip_elem(rect_path(12.0, 5.0, 18.0, 15.0)),
13127            fill(x_path(), 1.0, 0),
13128        ];
13129        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13130        let d = dl(vec![parent, grp]);
13131        assert!(compute_obscured_fill_skips(&d).is_empty());
13132    }
13133
13134    #[test]
13135    fn obscured_skip_fires_when_inner_clip_is_wider_than_parent_path() {
13136        // A clip inside the group that's larger than the parent's fill
13137        // doesn't threaten coverage; still safe to skip the parent.
13138        let parent = fill(x_path(), 1.0, 0);
13139        let inner = vec![
13140            clip_elem(rect_path(-10.0, -10.0, 40.0, 30.0)),
13141            fill(x_path_perturbed(), 1.0, 0),
13142        ];
13143        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13144        let d = dl(vec![parent, grp]);
13145        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
13146    }
13147
13148    #[test]
13149    fn obscured_skip_does_not_fire_on_partial_alpha_parent() {
13150        // A parent fill at alpha < 1 might blend with backdrop; dropping
13151        // it changes the visual even when the group overpaints.
13152        let parent = fill(x_path(), 0.5, 0);
13153        let inner = vec![fill(x_path(), 1.0, 0)];
13154        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13155        let d = dl(vec![parent, grp]);
13156        assert!(compute_obscured_fill_skips(&d).is_empty());
13157    }
13158}