Skip to main content

stet_render/
skia_device.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! tiny-skia implementation of the `OutputDevice` trait.
6
7use std::collections::{HashMap, HashSet};
8use std::hash::{Hash, Hasher};
9use std::sync::{Arc, Mutex};
10
11#[cfg(feature = "parallel")]
12use rayon::prelude::*;
13use stet_tiny_skia::{
14    BlendMode, Color, FillRule as SkiaFillRule, LineCap as SkiaLineCap, LineJoin as SkiaLineJoin,
15    Mask, Paint, PathBuilder, Pixmap, Stroke, StrokeDash, Transform,
16};
17
18use stet_core::device::OutputDevice;
19use stet_fonts::geometry::{Matrix, PathSegment, PsPath};
20use stet_graphics::color::{DeviceColor, FillRule, LineCap, LineJoin};
21use stet_graphics::device::{
22    AxialShadingParams, ClipParams, FillParams, ImageColorSpace, ImageParams, MeshShadingParams,
23    PageSinkFactory, PatchShadingParams, RadialShadingParams, ShadingColorSpace, ShadingVertex,
24    StrokeParams, TintLookupTable,
25};
26use stet_graphics::icc::IccCache;
27use stet_graphics::layer_set::LayerSet;
28
29/// Axis-aligned rectangle in device pixel coordinates.
30#[derive(Clone, Copy)]
31struct ClipRect {
32    x0: u32,
33    y0: u32, // top-left (inclusive)
34    x1: u32,
35    y1: u32, // bottom-right (exclusive)
36}
37
38impl ClipRect {
39    /// Intersect two rectangles. Result may be empty.
40    fn intersect(&self, other: &ClipRect) -> ClipRect {
41        ClipRect {
42            x0: self.x0.max(other.x0),
43            y0: self.y0.max(other.y0),
44            x1: self.x1.min(other.x1),
45            y1: self.y1.min(other.y1),
46        }
47    }
48
49    fn is_empty(&self) -> bool {
50        self.x0 >= self.x1 || self.y0 >= self.y1
51    }
52
53    /// True if this rect covers the entire page.
54    fn is_full_page(&self, w: u32, h: u32) -> bool {
55        self.x0 == 0 && self.y0 == 0 && self.x1 == w && self.y1 == h
56    }
57
58    /// Create a mask with 255 inside the rect, 0 outside.
59    fn make_mask(self, w: u32, h: u32) -> Option<Mask> {
60        if self.is_empty() {
61            return None;
62        }
63        let mut mask = Mask::new(w, h)?;
64        let data = mask.data_mut();
65        let stride = w as usize;
66        for y in self.y0..self.y1 {
67            let row_start = y as usize * stride + self.x0 as usize;
68            let row_end = y as usize * stride + self.x1 as usize;
69            data[row_start..row_end].fill(255);
70        }
71        Some(mask)
72    }
73}
74
75/// Clip region: either a simple rectangle (fast) or a full rasterized mask.
76enum ClipRegion {
77    Rect(ClipRect),
78    Mask(Mask),
79}
80
81/// tiny-skia based raster device.
82pub struct SkiaDevice {
83    pixmap: Pixmap,
84    /// Page dimensions in device pixels. Stored separately so we can shrink
85    /// the pixmap during banded rendering without losing page size info.
86    page_w: u32,
87    page_h: u32,
88    /// Device resolution in DPI (for hairline width decisions).
89    dpi: f64,
90    clip_region: Option<ClipRegion>,
91    /// Cache of rasterized clip masks keyed by path hash.
92    /// Only paths seen more than once are cached (cache-on-second-sight).
93    clip_mask_cache: HashMap<u64, Mask>,
94    clip_mask_seen: HashSet<u64>,
95    /// Recycled mask buffer to avoid repeated alloc/dealloc of large masks.
96    spare_mask: Option<Mask>,
97    /// Receiver for background render result (pipelined multi-page rendering).
98    /// Uses rayon::spawn + oneshot channel to avoid OS thread spawn overhead.
99    pending_render: Option<std::sync::mpsc::Receiver<Result<(), String>>>,
100    /// Factory for creating page sinks (PNG, viewer, etc.).
101    sink_factory: Box<dyn PageSinkFactory>,
102    /// Raw bytes of the system CMYK ICC profile (for building render-thread IccCaches).
103    system_cmyk_bytes: Option<std::sync::Arc<Vec<u8>>>,
104    /// Transient IccCache used during non-banded replay_to_device rendering.
105    render_icc_cache: Option<IccCache>,
106    /// Disable anti-aliasing for all fill/stroke operations (matches GhostScript).
107    no_aa: bool,
108    /// Route `replay_and_show` through the viewport code path instead of the
109    /// banded full-page path. Used by `--device viewport-png` to audit the
110    /// viewport pipeline against the banded PNG baselines — same display list,
111    /// different culling/epoch logic, same expected output.
112    use_viewport_path: bool,
113    /// OCG visibility overrides applied to every render that consults
114    /// the layer system. Defaults to empty (every layer falls back to
115    /// its `default_visible`); a consumer building a layer panel can
116    /// install an explicit set via `set_layer_set`.
117    layer_set: LayerSet,
118}
119
120impl SkiaDevice {
121    /// Create a new device with the given page dimensions and default PNG output.
122    ///
123    /// Defers the full-page pixmap allocation — only a 1×1 placeholder is
124    /// created here. The full pixmap is allocated lazily in `replay_and_show`
125    /// only when the non-banded rendering path is needed.
126    pub fn new(width: u32, height: u32) -> Self {
127        Self::with_sink_factory(width, height, Box::new(crate::PngSinkFactory))
128    }
129
130    /// Create a new device with a custom page sink factory.
131    pub fn with_sink_factory(
132        width: u32,
133        height: u32,
134        sink_factory: Box<dyn PageSinkFactory>,
135    ) -> Self {
136        // Estimate DPI from page height (assumes ~792pt US Letter as reference).
137        // Close enough for hairline width threshold decisions.
138        let dpi = height as f64 * 72.0 / 792.0;
139
140        // Start with a tiny placeholder. The full-page pixmap is allocated
141        // lazily only when the non-banded path is used (small pages / low DPI).
142        // For banded rendering, band-sized pixmaps are created in replay_and_show.
143        let pixmap = Pixmap::new(1, 1).expect("Failed to create placeholder pixmap");
144        Self {
145            pixmap,
146            page_w: width,
147            page_h: height,
148            dpi,
149            clip_region: None,
150            clip_mask_cache: HashMap::new(),
151            clip_mask_seen: HashSet::new(),
152            spare_mask: None,
153            pending_render: None,
154            sink_factory,
155            system_cmyk_bytes: None,
156            render_icc_cache: None,
157            no_aa: false,
158            use_viewport_path: false,
159            layer_set: LayerSet::new(),
160        }
161    }
162
163    /// Route rendering through the viewport pipeline. Used by the visual
164    /// test runner's `--device viewport-png` mode.
165    pub fn set_use_viewport_path(&mut self, on: bool) {
166        self.use_viewport_path = on;
167    }
168
169    /// Replace the device's OCG visibility overrides.
170    ///
171    /// The empty default has every layer fall back to its
172    /// `default_visible` baked into the display list. Callers building
173    /// a layer panel hand in a populated [`LayerSet`] each render
174    /// pass.
175    pub fn set_layer_set(&mut self, layer_set: LayerSet) {
176        self.layer_set = layer_set;
177    }
178
179    /// Read-only view of the device's current OCG visibility overrides.
180    pub fn layer_set(&self) -> &LayerSet {
181        &self.layer_set
182    }
183
184    /// Ensure `self.pixmap` is allocated at full page dimensions.
185    /// Called before non-banded rendering which operates on the full pixmap.
186    fn ensure_full_pixmap(&mut self) {
187        if self.pixmap.width() != self.page_w || self.pixmap.height() != self.page_h {
188            self.pixmap =
189                Pixmap::new(self.page_w, self.page_h).expect("Failed to create page pixmap");
190            self.pixmap.fill(Color::WHITE);
191        }
192    }
193
194    /// Get the underlying pixmap (for testing).
195    pub fn pixmap(&self) -> &Pixmap {
196        &self.pixmap
197    }
198
199    /// Set the system CMYK ICC profile bytes for ICC-aware rendering.
200    pub fn set_system_cmyk_bytes(&mut self, bytes: std::sync::Arc<Vec<u8>>) {
201        self.system_cmyk_bytes = Some(bytes);
202    }
203
204    /// Disable anti-aliasing for all fill/stroke operations.
205    pub fn set_no_aa(&mut self, no_aa: bool) {
206        self.no_aa = no_aa;
207    }
208}
209
210/// Convert a PostScript `Matrix` to tiny-skia `Transform` (f32).
211fn to_transform(m: &Matrix) -> Transform {
212    Transform::from_row(
213        m.a as f32,
214        m.b as f32,
215        m.c as f32,
216        m.d as f32,
217        m.tx as f32,
218        m.ty as f32,
219    )
220}
221
222/// Convert a `DeviceColor` to tiny-skia `Paint`.
223fn to_paint(color: &DeviceColor) -> Paint<'static> {
224    to_paint_alpha(color, 1.0, 0, false)
225}
226
227/// Convert a `DeviceColor` to tiny-skia `Paint` with the given opacity and blend mode.
228fn to_paint_alpha(color: &DeviceColor, alpha: f64, blend_mode: u8, no_aa: bool) -> Paint<'static> {
229    let mut paint = Paint::default();
230    let a = (alpha * 255.0).round().clamp(0.0, 255.0) as u8;
231    paint.set_color_rgba8(
232        (color.r * 255.0).round().clamp(0.0, 255.0) as u8,
233        (color.g * 255.0).round().clamp(0.0, 255.0) as u8,
234        (color.b * 255.0).round().clamp(0.0, 255.0) as u8,
235        a,
236    );
237    paint.anti_alias = !no_aa;
238    paint.blend_mode = u8_to_blend_mode(blend_mode);
239    paint
240}
241
242/// Map a blend mode byte (0–15) to the corresponding tiny-skia `BlendMode`.
243fn u8_to_blend_mode(mode: u8) -> BlendMode {
244    match mode {
245        1 => BlendMode::Multiply,
246        2 => BlendMode::Screen,
247        3 => BlendMode::Overlay,
248        4 => BlendMode::Darken,
249        5 => BlendMode::Lighten,
250        6 => BlendMode::ColorDodge,
251        7 => BlendMode::ColorBurn,
252        8 => BlendMode::HardLight,
253        9 => BlendMode::SoftLight,
254        10 => BlendMode::Difference,
255        11 => BlendMode::Exclusion,
256        12 => BlendMode::Hue,
257        13 => BlendMode::Saturation,
258        14 => BlendMode::Color,
259        15 => BlendMode::Luminosity,
260        _ => BlendMode::SourceOver,
261    }
262}
263
264/// Convert a `PsPath` to tiny-skia `Path`.
265/// Maximum coordinate magnitude for path rasterization.
266/// Coordinates beyond this cause integer overflow in the scanline rasterizer.
267/// 1e6 is well beyond any real page (e.g. 612×792 pt at 600 DPI = ~5100×6600 px)
268/// but safely within f32 precision and fixed-point limits.
269const MAX_PATH_COORD: f32 = 1e6;
270
271fn build_skia_path(path: &PsPath) -> Option<stet_tiny_skia::Path> {
272    let mut pb = PathBuilder::new();
273
274    for seg in &path.segments {
275        match seg {
276            PathSegment::MoveTo(x, y) => {
277                pb.move_to(*x as f32, *y as f32);
278            }
279            PathSegment::LineTo(x, y) => {
280                pb.line_to(*x as f32, *y as f32);
281            }
282            PathSegment::CurveTo {
283                x1,
284                y1,
285                x2,
286                y2,
287                x3,
288                y3,
289            } => {
290                pb.cubic_to(
291                    *x1 as f32, *y1 as f32, *x2 as f32, *y2 as f32, *x3 as f32, *y3 as f32,
292                );
293            }
294            PathSegment::ClosePath => {
295                pb.close();
296            }
297        }
298    }
299
300    let result = pb.finish()?;
301
302    // Reject paths with extreme coordinates that would overflow the scanline
303    // rasterizer's integer math. This handles corrupted PDF content streams
304    // with garbled coordinates.
305    let b = result.bounds();
306    if b.left().abs() > MAX_PATH_COORD
307        || b.top().abs() > MAX_PATH_COORD
308        || b.right().abs() > MAX_PATH_COORD
309        || b.bottom().abs() > MAX_PATH_COORD
310    {
311        return None;
312    }
313
314    Some(result)
315}
316
317/// Detect degenerate fill paths that have zero extent in one dimension.
318///
319/// PDFs commonly draw table grid lines as zero-width or zero-height filled
320/// rectangles (e.g., `8 0 1031 0 re f`). Since these have no area, the
321/// fill rasterizer produces zero pixels. This function detects such paths
322/// so they can be rendered as hairline strokes instead.
323///
324/// The check is performed in the path's own coordinate space (pre-transform)
325/// using a very tight epsilon, so only paths with *exactly* zero extent in
326/// one dimension are detected. Paths containing curves are never degenerate
327/// — only MoveTo/LineTo/ClosePath segments qualify.
328fn is_degenerate_fill(path: &PsPath) -> bool {
329    let mut x_min = f64::INFINITY;
330    let mut x_max = f64::NEG_INFINITY;
331    let mut y_min = f64::INFINITY;
332    let mut y_max = f64::NEG_INFINITY;
333
334    for seg in &path.segments {
335        let (x, y) = match seg {
336            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => (*x, *y),
337            // Paths with curves are real shapes, not degenerate lines
338            PathSegment::CurveTo { .. } => return false,
339            PathSegment::ClosePath => continue,
340        };
341        x_min = x_min.min(x);
342        x_max = x_max.max(x);
343        y_min = y_min.min(y);
344        y_max = y_max.max(y);
345    }
346
347    if x_min > x_max {
348        return false; // empty path
349    }
350
351    let w = x_max - x_min;
352    let h = y_max - y_min;
353
354    // Degenerate if one dimension is exactly zero (within f64 epsilon)
355    // while the other has real extent. This catches `re` rects with
356    // zero width or height but not legitimate small shapes.
357    let eps = 1e-6;
358    (w < eps && h > eps) || (h < eps && w > eps)
359}
360
361/// Convert a tiny-skia Path back to a PsPath.
362/// Used for overprint stroke handling where we convert a stroked outline to a fill.
363
364/// Convert PostScript FillRule to tiny-skia FillRule.
365fn to_fill_rule(rule: &FillRule) -> SkiaFillRule {
366    match rule {
367        FillRule::NonZeroWinding => SkiaFillRule::Winding,
368        FillRule::EvenOdd => SkiaFillRule::EvenOdd,
369        _ => SkiaFillRule::Winding,
370    }
371}
372
373/// Convert PostScript LineCap to tiny-skia LineCap.
374fn to_line_cap(cap: LineCap) -> SkiaLineCap {
375    match cap {
376        LineCap::Butt => SkiaLineCap::Butt,
377        LineCap::Round => SkiaLineCap::Round,
378        LineCap::Square => SkiaLineCap::Square,
379        _ => SkiaLineCap::Butt,
380    }
381}
382
383/// Convert PostScript LineJoin to tiny-skia LineJoin.
384fn to_line_join(join: LineJoin) -> SkiaLineJoin {
385    match join {
386        LineJoin::Miter => SkiaLineJoin::Miter,
387        LineJoin::Round => SkiaLineJoin::Round,
388        LineJoin::Bevel => SkiaLineJoin::Bevel,
389        _ => SkiaLineJoin::Miter,
390    }
391}
392
393/// Detect if a path is an axis-aligned rectangle. Returns pixel-coordinate ClipRect if so.
394/// Handles both CW and CCW winding, with optional trailing ClosePath.
395fn detect_rect(path: &PsPath, page_w: u32, page_h: u32) -> Option<ClipRect> {
396    let segs = &path.segments;
397    // Expect: MoveTo + 3 LineTo + ClosePath (5 segments)
398    // or MoveTo + 3 LineTo + LineTo(back to start) + ClosePath (6 segments)
399    // or MoveTo + 3 LineTo (4 segments, implicitly closed)
400    let (move_to, lines, _has_close) = match segs.len() {
401        5 => {
402            // MoveTo + 3 LineTo + ClosePath
403            if !matches!(segs[4], PathSegment::ClosePath) {
404                return None;
405            }
406            (&segs[0], &segs[1..4], true)
407        }
408        6 => {
409            // MoveTo + 4 LineTo + ClosePath (4th LineTo returns to start)
410            if !matches!(segs[5], PathSegment::ClosePath) {
411                return None;
412            }
413            (&segs[0], &segs[1..5], true)
414        }
415        4 => {
416            // MoveTo + 3 LineTo (no explicit close)
417            (&segs[0], &segs[1..4], false)
418        }
419        _ => return None,
420    };
421
422    let PathSegment::MoveTo(mx, my) = move_to else {
423        return None;
424    };
425
426    // Collect all corner points
427    let mut pts = vec![(*mx, *my)];
428    for seg in lines {
429        match seg {
430            PathSegment::LineTo(x, y) => pts.push((*x, *y)),
431            _ => return None,
432        }
433    }
434
435    // If 5 points (4 LineTos), last must return to start
436    if pts.len() == 5 {
437        let (fx, fy) = pts[0];
438        let (lx, ly) = pts[4];
439        if (fx - lx).abs() > 0.01 || (fy - ly).abs() > 0.01 {
440            return None;
441        }
442        pts.truncate(4);
443    }
444
445    // Check axis-aligned: each edge must be horizontal or vertical
446    for i in 0..4 {
447        let (x1, y1) = pts[i];
448        let (x2, y2) = pts[(i + 1) % 4];
449        let dx = (x2 - x1).abs();
450        let dy = (y2 - y1).abs();
451        if dx > 0.01 && dy > 0.01 {
452            return None; // diagonal edge
453        }
454    }
455
456    // Compute bounding box
457    let min_x = pts.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
458    let min_y = pts.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
459    let max_x = pts.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max);
460    let max_y = pts.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
461
462    // Convert to pixel coords: floor for top-left, ceil for bottom-right, clamp to page
463    let x0 = (min_x.floor().max(0.0) as u32).min(page_w);
464    let y0 = (min_y.floor().max(0.0) as u32).min(page_h);
465    let x1 = (max_x.ceil().max(0.0) as u32).min(page_w);
466    let y1 = (max_y.ceil().max(0.0) as u32).min(page_h);
467
468    Some(ClipRect { x0, y0, x1, y1 })
469}
470
471/// Zero out mask pixels outside the given rectangle bounds.
472fn intersect_mask_with_rect(mask: &mut Mask, rect: &ClipRect, w: u32, h: u32) {
473    let data = mask.data_mut();
474    let stride = w as usize;
475
476    // Zero rows above rect
477    if rect.y0 > 0 {
478        let end = (rect.y0 as usize * stride).min(data.len());
479        data[..end].fill(0);
480    }
481
482    // Zero rows below rect
483    if rect.y1 < h {
484        let start = (rect.y1 as usize * stride).min(data.len());
485        data[start..].fill(0);
486    }
487
488    // Zero left and right margins within rect rows
489    for y in rect.y0..rect.y1.min(h) {
490        let row_start = y as usize * stride;
491        // Left margin
492        if rect.x0 > 0 {
493            let end = row_start + rect.x0 as usize;
494            data[row_start..end].fill(0);
495        }
496        // Right margin
497        if rect.x1 < w {
498            let start = row_start + rect.x1 as usize;
499            let end = row_start + stride;
500            data[start..end].fill(0);
501        }
502    }
503}
504
505/// Resolve a ClipRegion to an Option<&Mask> for paint operations.
506/// Returns `None` if the clip is empty (caller should skip painting).
507/// Returns `Some(None)` if no mask is needed (full page or no clip).
508/// Returns `Some(Some(&Mask))` if a mask should be applied.
509fn resolve_clip_mask<'a>(
510    clip_region: &'a Option<ClipRegion>,
511    temp_mask: &'a mut Option<Mask>,
512    w: u32,
513    h: u32,
514) -> Option<Option<&'a Mask>> {
515    match clip_region {
516        None => Some(None),
517        Some(ClipRegion::Mask(m)) => Some(Some(m)),
518        Some(ClipRegion::Rect(rect)) => {
519            if rect.is_empty() {
520                return None; // empty clip → skip painting
521            }
522            if rect.is_full_page(w, h) {
523                return Some(None); // full page → no mask needed
524            }
525            *temp_mask = rect.make_mask(w, h);
526            Some(temp_mask.as_ref())
527        }
528    }
529}
530
531/// Hash a PsPath's segments for clip mask caching. Uses bit-exact f64 comparison
532/// since paths are already in device space.
533fn hash_clip_path(path: &PsPath, fill_rule: &FillRule) -> u64 {
534    let mut hasher = std::collections::hash_map::DefaultHasher::new();
535    std::mem::discriminant(fill_rule).hash(&mut hasher);
536    for seg in &path.segments {
537        match seg {
538            PathSegment::MoveTo(x, y) => {
539                0u8.hash(&mut hasher);
540                x.to_bits().hash(&mut hasher);
541                y.to_bits().hash(&mut hasher);
542            }
543            PathSegment::LineTo(x, y) => {
544                1u8.hash(&mut hasher);
545                x.to_bits().hash(&mut hasher);
546                y.to_bits().hash(&mut hasher);
547            }
548            PathSegment::CurveTo {
549                x1,
550                y1,
551                x2,
552                y2,
553                x3,
554                y3,
555            } => {
556                2u8.hash(&mut hasher);
557                x1.to_bits().hash(&mut hasher);
558                y1.to_bits().hash(&mut hasher);
559                x2.to_bits().hash(&mut hasher);
560                y2.to_bits().hash(&mut hasher);
561                x3.to_bits().hash(&mut hasher);
562                y3.to_bits().hash(&mut hasher);
563            }
564            PathSegment::ClosePath => {
565                3u8.hash(&mut hasher);
566            }
567        }
568    }
569    hasher.finish()
570}
571
572/// Pixel-multiply two masks: dst[i] = dst[i] * src[i] / 255.
573fn intersect_masks(dst: &mut Mask, src: &Mask) {
574    let dst_data = dst.data_mut();
575    let src_data = src.data();
576    for (d, s) in dst_data.iter_mut().zip(src_data.iter()) {
577        *d = ((*d as u16 * *s as u16 + 127) / 255) as u8;
578    }
579}
580
581// ---- Banded rendering support ----
582
583use stet_graphics::display_list::{DisplayElement, DisplayList};
584
585/// Band-local clip state, rebuilt for each band.
586struct BandState {
587    clip_region: Option<ClipRegion>,
588    spare_mask: Option<Mask>,
589    /// Per-band cache (cleared each band since masks are band-sized).
590    clip_mask_cache: HashMap<u64, Mask>,
591    /// Persists across bands for cache-on-second-sight.
592    clip_mask_seen: HashSet<u64>,
593    /// Pool of recycled masks to avoid alloc/dealloc (mmap/munmap) per band.
594    mask_pool: Vec<Mask>,
595    /// Per-pixel CMYK tracking buffer for overprint simulation.
596    /// Only allocated when the display list contains overprint elements.
597    /// Layout: [C, M, Y, K] as f32 per pixel, band_w * band_h * 4 entries.
598    cmyk_buffer: Option<Vec<f32>>,
599    /// Per-pixel snapshot of pixmap RGBA *before* the first overprint paint
600    /// touched that pixel in this band. Subsequent overprint paints at the
601    /// same pixel blend their result against this snapshot instead of the
602    /// current (already-overprinted) pixmap, so AA edges of stacked overprints
603    /// do not leak earlier colour through later paints.
604    /// Lazily allocated on first overprint paint. 4 bytes per pixel.
605    op_bg_snapshot: Option<Vec<u8>>,
606    /// Parallel to `op_bg_snapshot`: 1 byte per pixel, non-zero iff the
607    /// snapshot for that pixel has been captured. Reset to zero over the
608    /// paint bbox on non-overprint writes so a later non-overprint fill
609    /// establishes a fresh backdrop for subsequent overprints.
610    op_touched: Option<Vec<u8>>,
611    /// Per-pixel marker for "this pixel's pixmap colour includes spot-
612    /// colorant contribution not reflected in `cmyk_buffer`". Set by
613    /// DeviceN/Separation paints that include at least one spot colorant
614    /// (i.e. `process_cmyk != native_cmyk`). Consulted by CMYK overprint
615    /// rendering so the no-op-delta skip only fires on pixels where
616    /// preserving the pixmap actually preserves spot colour — other pixels
617    /// still go through the ICC(new_cmyk) replace path.
618    spot_mask: Option<Vec<u8>>,
619}
620
621/// Maximum masks to keep in the recycling pool. Enough to avoid alloc churn
622/// without accumulating unbounded memory across bands.
623const MAX_POOL_MASKS: usize = 8;
624
625impl BandState {
626    /// Recycle all cached masks into the pool, clearing the cache for the next band.
627    #[allow(dead_code)]
628    fn recycle_cache(&mut self) {
629        for (_, mask) in self.clip_mask_cache.drain() {
630            if self.mask_pool.len() < MAX_POOL_MASKS {
631                self.mask_pool.push(mask);
632            }
633            // else: drop mask, returning memory to OS
634        }
635    }
636
637    /// Return a mask to the pool if under capacity, otherwise drop it.
638    fn recycle_mask(&mut self, mask: Mask) {
639        if self.mask_pool.len() < MAX_POOL_MASKS {
640            self.mask_pool.push(mask);
641        }
642    }
643
644    /// Get a recycled mask or allocate a new one.
645    fn take_mask(&mut self, w: u32, h: u32) -> Mask {
646        self.spare_mask
647            .take()
648            .or_else(|| self.mask_pool.pop())
649            .unwrap_or_else(|| Mask::new(w, h).expect("Failed to create mask"))
650    }
651
652    /// Take (or lazily allocate) the overprint background snapshot and
653    /// touched-flag buffers. Caller must pass them back via
654    /// `restore_op_buffers`. Layout: snapshot is 4 bytes/pixel (RGBA),
655    /// touched is 1 byte/pixel.
656    fn take_op_buffers(&mut self, w: u32, h: u32) -> (Vec<u8>, Vec<u8>) {
657        let n = w as usize * h as usize;
658        let bg = self
659            .op_bg_snapshot
660            .take()
661            .unwrap_or_else(|| vec![0u8; n * 4]);
662        let touched = self.op_touched.take().unwrap_or_else(|| vec![0u8; n]);
663        (bg, touched)
664    }
665
666    /// Put the overprint buffers back after an overprint render pass.
667    fn restore_op_buffers(&mut self, bg: Vec<u8>, touched: Vec<u8>) {
668        self.op_bg_snapshot = Some(bg);
669        self.op_touched = Some(touched);
670    }
671
672    /// Take (or lazily allocate) the spot-contribution mask (1 byte/pixel).
673    fn take_spot_mask(&mut self, w: u32, h: u32) -> Vec<u8> {
674        let n = w as usize * h as usize;
675        self.spot_mask.take().unwrap_or_else(|| vec![0u8; n])
676    }
677
678    /// Put the spot-contribution mask back after a paint.
679    fn restore_spot_mask(&mut self, mask: Vec<u8>) {
680        self.spot_mask = Some(mask);
681    }
682
683    /// Clear the overprint touched flag for pixels in the given bbox. Called
684    /// by non-overprint paints so a subsequent overprint at those pixels
685    /// captures a fresh backdrop snapshot instead of reusing a stale one.
686    #[allow(dead_code)]
687    fn invalidate_op_snapshot(
688        &mut self,
689        bbox_x0: usize,
690        bbox_y0: usize,
691        bbox_x1: usize,
692        bbox_y1: usize,
693        stride: usize,
694    ) {
695        if let Some(touched) = self.op_touched.as_mut() {
696            for y in bbox_y0..bbox_y1 {
697                let row = y * stride;
698                for x in bbox_x0..bbox_x1 {
699                    touched[row + x] = 0;
700                }
701            }
702        }
703    }
704}
705
706/// Unified rendering context that parameterizes both band and viewport rendering.
707///
708/// Band rendering is viewport rendering with `scale_x = scale_y = 1.0`.
709/// `viewport_transform(t, vp_x, vp_y, 1.0, 1.0)` == `offset_transform_xy(t, vp_x, vp_y)`.
710struct RenderContext<'a> {
711    /// Viewport/band origin X in device space.
712    vp_x: f32,
713    /// Viewport/band origin Y in device space.
714    vp_y: f32,
715    /// Horizontal scale (1.0 for band rendering, zoom for viewport).
716    scale_x: f32,
717    /// Vertical scale (1.0 for band rendering, zoom for viewport).
718    scale_y: f32,
719    /// Output pixmap width in pixels.
720    out_w: u32,
721    /// Output pixmap height in pixels.
722    out_h: u32,
723    /// Effective DPI at output scale.
724    effective_dpi: f64,
725    /// ICC color profile cache (for CMYK conversions).
726    icc: Option<&'a IccCache>,
727    /// Pre-converted image data cache (for viewport rendering).
728    image_cache: Option<&'a ImageCache>,
729    /// Pre-converted and prescaled images (for banded rendering).
730    preprocessed: Option<&'a [Option<PreprocessedImage>]>,
731    /// Element index in parent display list (for image cache lookup).
732    elem_idx: usize,
733    /// Disable anti-aliasing for all fill/stroke operations.
734    no_aa: bool,
735    /// When true, CMYK(0,0,0,0) pixels in images produce alpha=0 (OPM=1).
736    opm_zero_transparent: bool,
737    /// Knockout group painter rendering pass override. The knockout group
738    /// renders each Group painter twice — once for the blended-color result
739    /// (`ColorPass`), once for the painter's coverage mask (`CoveragePass`).
740    /// Both passes need to override `render_group`'s usual decisions:
741    ///   * `ColorPass` expands the per-pixel CMYK composite-back gate to all
742    ///     non-Normal blend modes so painters with separable blends like
743    ///     Screen / ColorDodge / Overlay / SoftLight blend in DeviceCMYK
744    ///     (matching the spec for `/CS DeviceCMYK` knockout groups) instead
745    ///     of in tiny-skia's sRGB blend.
746    ///   * `CoveragePass` disables the CMYK composite-back (its
747    ///     "source==backdrop" guard would discard white-CMYK painters
748    ///     against the transparent coverage backdrop) and forces the
749    ///     painter's alpha to 1.0 with Normal blend so the coverage offscreen
750    ///     captures the painter's *shape* even when the original alpha was 0
751    ///     (Opacity 0% test) or its blend mode would erase the source.
752    knockout_painter_pass: KnockoutPainterPass,
753    /// True when the immediately enclosing transparency group was isolated.
754    /// GWG 16.2's nested CMYK painter pattern (Painter B → Sub A/B) only
755    /// requires CMYK math at the inner non-isolated layer when Painter B
756    /// itself is isolated; for non-isolated parents (the 907 p28 financial
757    /// chart pattern) the existing sRGB compositing path produces the right
758    /// result and the new CMYK math would over-darken anti-aliased gray
759    /// strokes.
760    parent_group_isolated: bool,
761    /// True when rendering an alpha-extraction pass for a non-isolated group
762    /// with non-Normal blend mode.  Nested groups must render as isolated
763    /// (no backdrop preload, no two-pass) so the alpha channel reflects
764    /// pure element coverage rather than backdrop-blended results.
765    alpha_extraction_pass: bool,
766    /// OCG visibility overrides. Empty (every layer at its
767    /// `default_visible`) when the caller didn't supply one.
768    layer_set: &'a LayerSet,
769}
770
771/// Override mode applied to `render_group` while the knockout group renders
772/// one of its painters; see [`RenderContext::knockout_painter_pass`].
773#[derive(Clone, Copy, PartialEq, Eq)]
774enum KnockoutPainterPass {
775    /// Default rendering — no knockout overrides.
776    None,
777    /// Pass 1 (color): widen `plan_cmyk_compose` to any non-Normal blend mode.
778    ColorPass,
779    /// Pass 2 (coverage): disable CMYK composite-back, force full alpha and
780    /// Normal blend so the coverage offscreen captures the painter's shape.
781    CoveragePass,
782}
783
784impl RenderContext<'_> {
785    /// Apply viewport transform to a PostScript matrix.
786    fn transform(&self, m: &Matrix) -> Transform {
787        viewport_transform(
788            to_transform(m),
789            self.vp_x,
790            self.vp_y,
791            self.scale_x,
792            self.scale_y,
793        )
794    }
795}
796
797/// Y-axis bounding box in device pixels.
798struct YBBox {
799    y_min: f64,
800    y_max: f64,
801}
802
803/// A group of display list elements between consecutive InitClip boundaries.
804/// Each epoch starts with an InitClip (except possibly the first) and contains
805/// all elements up to the next InitClip. Epochs whose paint elements don't
806/// overlap a band can be skipped entirely.
807struct ClipEpoch {
808    /// Index of the first element in this epoch (the InitClip, or 0).
809    start_idx: usize,
810    /// One past the last element in this epoch.
811    end_idx: usize,
812    /// Y bounding box of all paint elements (Fill/Stroke/Image) in this epoch.
813    /// None if the epoch has no paint elements (pure clip setup).
814    paint_bbox: Option<YBBox>,
815    /// True if this epoch contains an ErasePage element (must process for all bands).
816    has_erase_page: bool,
817}
818
819/// Choose band height so that band pixmap + 2 clip masks fit in ~2 MB (L2 cache).
820/// Returns `page_h` when banding is not worthwhile (≤2 bands).
821fn select_band_height(w: u32, h: u32) -> u32 {
822    if w == 0 || h == 0 {
823        return h;
824    }
825    // Per-row cost: w*4 (RGBA) + w*1 (clip mask) + w*1 (spare mask) = w*6
826    let per_row = w as u64 * 6;
827    let budget = 2 * 1024 * 1024u64; // 2 MB (L2)
828    let max_rows = budget / per_row;
829
830    // Floor to power of 2, clamp to [16, h]
831    let band = if max_rows >= h as u64 {
832        h
833    } else {
834        let mut p = 1u32;
835        while (p as u64) * 2 <= max_rows {
836            p *= 2;
837        }
838        // Minimum 128 rows per band. At very high DPI the L2 budget yields
839        // tiny bands (16 rows at 2400 DPI = 1650 bands) where display list
840        // replay overhead dominates. 128-row minimum balances L3 cache fit
841        // (~15 MB working set at 2400 DPI) against per-band overhead (207 bands).
842        // Benchmarked: 16→31.3s, 64→22.5s, 128→21.8s, 256→22.1s.
843        p.clamp(128, h)
844    };
845
846    // Skip banding if ≤2 bands
847    if h.div_ceil(band) <= 2 {
848        return h;
849    }
850    band
851}
852
853/// True if this display list contains any `Clip`/`InitClip` op, recursively
854/// descending into `OcgGroup` / `Group` / `SoftMasked` children. When an
855/// `OcgGroup` wraps clip ops, Y-bbox culling would skip the whole group for
856/// bands its paint content doesn't overlap, but the clip state changes inside
857/// must still be applied — otherwise subsequent top-level elements inherit a
858/// stale clip. Use this to force such `OcgGroup`s to always be processed.
859fn contains_clip_op(list: &DisplayList) -> bool {
860    list.elements().iter().any(|e| match e {
861        DisplayElement::Clip { .. } | DisplayElement::InitClip => true,
862        DisplayElement::OcgGroup { elements, .. } => contains_clip_op(elements),
863        DisplayElement::Group { elements, .. } => contains_clip_op(elements),
864        DisplayElement::SoftMasked { content, .. } => contains_clip_op(content),
865        _ => false,
866    })
867}
868
869/// Compute conservative Y bounding boxes for display list elements.
870/// Returns `None` for elements that must always be processed (Clip, InitClip, ErasePage).
871///
872/// All returned Y values are in **device space** (pixel coordinates) so they can be
873/// compared directly against band boundaries.
874fn precompute_bboxes(list: &DisplayList, dpi: f64) -> Vec<Option<YBBox>> {
875    list.elements()
876        .iter()
877        .map(|elem| match elem {
878            DisplayElement::Fill { path, params } => fill_device_y_bbox(path, &params.ctm),
879            DisplayElement::Stroke { path, params } => stroke_device_y_bbox(path, params, dpi),
880            DisplayElement::Image { params, .. } => image_y_bbox(params),
881            DisplayElement::AxialShading { params } => {
882                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
883            }
884            DisplayElement::RadialShading { params } => {
885                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
886            }
887            DisplayElement::MeshShading { params } => {
888                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
889            }
890            DisplayElement::PatchShading { params } => {
891                shading_y_bbox_from_bbox(&params.bbox, &params.ctm)
892            }
893            DisplayElement::PatternFill { params } => pattern_fill_y_bbox(params),
894            DisplayElement::Group { params, .. } => Some(YBBox {
895                y_min: params.bbox[1],
896                y_max: params.bbox[3],
897            }),
898            DisplayElement::SoftMasked { params, .. } => Some(YBBox {
899                y_min: params.bbox[1],
900                y_max: params.bbox[3],
901            }),
902            DisplayElement::OcgGroup {
903                elements,
904                visibility,
905            } => {
906                // Hidden groups without clip ops contribute nothing — cull.
907                // (Hidden + has clip ops is handled below: we return paint
908                // bounds so the epoch has correct extent, and the band loop
909                // skips per-element culling for OcgGroups so the clip ops
910                // always execute.)
911                if !visibility.default_visible() && !contains_clip_op(elements) {
912                    return None;
913                }
914                let child_bboxes = precompute_bboxes(elements, dpi);
915                let mut y_min = f64::INFINITY;
916                let mut y_max = f64::NEG_INFINITY;
917                for cb in child_bboxes.into_iter().flatten() {
918                    y_min = y_min.min(cb.y_min);
919                    y_max = y_max.max(cb.y_max);
920                }
921                if y_min <= y_max {
922                    Some(YBBox { y_min, y_max })
923                } else {
924                    None
925                }
926            }
927            _ => None, // Clip, InitClip, ErasePage: always process
928        })
929        .collect()
930}
931
932/// Compute device-space Y bounding box for a shading element.
933/// Uses the BBox if present, otherwise returns a full-page sentinel
934/// (y_min=0, y_max=very large) so the element is never culled.
935fn shading_y_bbox_from_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<YBBox> {
936    if let Some(bbox) = bbox {
937        let corners = [
938            (bbox[0], bbox[1]),
939            (bbox[2], bbox[1]),
940            (bbox[0], bbox[3]),
941            (bbox[2], bbox[3]),
942        ];
943        let mut y_min = f64::INFINITY;
944        let mut y_max = f64::NEG_INFINITY;
945        for (x, y) in &corners {
946            let (_, dy) = ctm.transform_point(*x, *y);
947            y_min = y_min.min(dy);
948            y_max = y_max.max(dy);
949        }
950        Some(YBBox { y_min, y_max })
951    } else {
952        // No BBox — shading covers unbounded area; return sentinel so it's
953        // never culled by band processing.
954        Some(YBBox {
955            y_min: 0.0,
956            y_max: 1e9,
957        })
958    }
959}
960
961/// Compute device-space Y bounding box for a stroke element.
962///
963/// Isotropic strokes have paths already in device space (Identity CTM), so
964/// `path_y_bbox` gives device-space bounds directly. Anisotropic strokes have
965/// paths in user space with the full CTM — we must transform the bounding box
966/// through the CTM to get device-space bounds.
967fn stroke_device_y_bbox(path: &PsPath, params: &StrokeParams, dpi: f64) -> Option<YBBox> {
968    let m = &params.ctm;
969    let is_identity =
970        m.a == 1.0 && m.b == 0.0 && m.c == 0.0 && m.d == 1.0 && m.tx == 0.0 && m.ty == 0.0;
971
972    // Use effective line width: actual width or hairline minimum, whichever is larger
973    let effective_lw = params.line_width.max(hairline_min_width(&params.ctm, dpi));
974
975    if is_identity {
976        // Path in device space — just read Y coords and expand for stroke width.
977        return path_y_bbox(path).map(|mut bbox| {
978            let expand = effective_lw * params.miter_limit * 0.5;
979            bbox.y_min -= expand;
980            bbox.y_max += expand;
981            bbox
982        });
983    }
984
985    // Anisotropic: path in user space. Compute full XY bbox, transform corners
986    // through CTM to get device-space Y range.
987    let (mut x_min, mut x_max) = (f64::INFINITY, f64::NEG_INFINITY);
988    let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY);
989    for seg in &path.segments {
990        match seg {
991            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => {
992                x_min = x_min.min(*x);
993                x_max = x_max.max(*x);
994                y_min = y_min.min(*y);
995                y_max = y_max.max(*y);
996            }
997            PathSegment::CurveTo {
998                x1,
999                y1,
1000                x2,
1001                y2,
1002                x3,
1003                y3,
1004            } => {
1005                x_min = x_min.min(*x1).min(*x2).min(*x3);
1006                x_max = x_max.max(*x1).max(*x2).max(*x3);
1007                y_min = y_min.min(*y1).min(*y2).min(*y3);
1008                y_max = y_max.max(*y1).max(*y2).max(*y3);
1009            }
1010            PathSegment::ClosePath => {}
1011        }
1012    }
1013    if x_min > x_max {
1014        return None;
1015    }
1016
1017    // Transform all 4 corners of user-space bbox to device space
1018    let corners = [
1019        (x_min, y_min),
1020        (x_max, y_min),
1021        (x_min, y_max),
1022        (x_max, y_max),
1023    ];
1024    let mut dev_y_min = f64::INFINITY;
1025    let mut dev_y_max = f64::NEG_INFINITY;
1026    for (x, y) in &corners {
1027        let dy = m.b * x + m.d * y + m.ty;
1028        dev_y_min = dev_y_min.min(dy);
1029        dev_y_max = dev_y_max.max(dy);
1030    }
1031
1032    // Expand for stroke width + miter in device-space units.
1033    // ||[c,d]|| converts user-space line_width to device-space Y expansion.
1034    let col_y_len = (m.c * m.c + m.d * m.d).sqrt().max(1.0);
1035    let expand = effective_lw * col_y_len * params.miter_limit * 0.5;
1036    dev_y_min -= expand;
1037    dev_y_max += expand;
1038
1039    Some(YBBox {
1040        y_min: dev_y_min,
1041        y_max: dev_y_max,
1042    })
1043}
1044
1045/// Compute device-space Y bounds for a Fill element, accounting for CTM.
1046/// Mirrors `stroke_device_y_bbox` but without stroke-width expansion.
1047/// Paths may be stored either in device space (identity CTM, content streams)
1048/// or user space (non-identity CTM, synthesized annotation appearances).
1049fn fill_device_y_bbox(path: &PsPath, ctm: &Matrix) -> Option<YBBox> {
1050    let is_identity = ctm.a == 1.0
1051        && ctm.b == 0.0
1052        && ctm.c == 0.0
1053        && ctm.d == 1.0
1054        && ctm.tx == 0.0
1055        && ctm.ty == 0.0;
1056    if is_identity {
1057        return path_y_bbox(path);
1058    }
1059    let bbox = path_full_bbox(path)?;
1060    let corners = [
1061        (bbox.x_min, bbox.y_min),
1062        (bbox.x_max, bbox.y_min),
1063        (bbox.x_min, bbox.y_max),
1064        (bbox.x_max, bbox.y_max),
1065    ];
1066    let mut dev_y_min = f64::INFINITY;
1067    let mut dev_y_max = f64::NEG_INFINITY;
1068    for (x, y) in &corners {
1069        let dy = ctm.b * x + ctm.d * y + ctm.ty;
1070        dev_y_min = dev_y_min.min(dy);
1071        dev_y_max = dev_y_max.max(dy);
1072    }
1073    Some(YBBox {
1074        y_min: dev_y_min,
1075        y_max: dev_y_max,
1076    })
1077}
1078
1079/// Compute Y bounds from path segments (conservative: uses control points for curves).
1080fn path_y_bbox(path: &PsPath) -> Option<YBBox> {
1081    let mut y_min = f64::INFINITY;
1082    let mut y_max = f64::NEG_INFINITY;
1083    for seg in &path.segments {
1084        match seg {
1085            PathSegment::MoveTo(_, y) | PathSegment::LineTo(_, y) => {
1086                y_min = y_min.min(*y);
1087                y_max = y_max.max(*y);
1088            }
1089            PathSegment::CurveTo { y1, y2, y3, .. } => {
1090                y_min = y_min.min(*y1).min(*y2).min(*y3);
1091                y_max = y_max.max(*y1).max(*y2).max(*y3);
1092            }
1093            PathSegment::ClosePath => {}
1094        }
1095    }
1096    if y_min <= y_max {
1097        Some(YBBox { y_min, y_max })
1098    } else {
1099        None
1100    }
1101}
1102
1103/// Compute Y bounds for an image element from its transform.
1104fn image_y_bbox(params: &ImageParams) -> Option<YBBox> {
1105    let image_inv = params.image_matrix.invert()?;
1106    let combined = params.ctm.concat(&image_inv);
1107    let corners = [
1108        (0.0, 0.0),
1109        (params.width as f64, 0.0),
1110        (params.width as f64, params.height as f64),
1111        (0.0, params.height as f64),
1112    ];
1113    let mut y_min = f64::INFINITY;
1114    let mut y_max = f64::NEG_INFINITY;
1115    for (x, y) in &corners {
1116        let (_, dy) = combined.transform_point(*x, *y);
1117        y_min = y_min.min(dy);
1118        y_max = y_max.max(dy);
1119    }
1120    Some(YBBox { y_min, y_max })
1121}
1122
1123/// Pre-populate clip_mask_seen with hashes of clip paths that appear ≥2 times.
1124/// This lets the first band immediately cache repeated clip paths.
1125fn precompute_clip_seen(list: &DisplayList) -> HashSet<u64> {
1126    let mut counts: HashMap<u64, u32> = HashMap::new();
1127    for elem in list.elements() {
1128        if let DisplayElement::Clip { path, params } = elem {
1129            let hash = hash_clip_path(path, &params.fill_rule);
1130            *counts.entry(hash).or_insert(0) += 1;
1131        }
1132    }
1133    counts
1134        .into_iter()
1135        .filter(|(_, c)| *c > 1)
1136        .map(|(h, _)| h)
1137        .collect()
1138}
1139
1140/// Build clip epochs — groups of elements between InitClip boundaries.
1141/// Each epoch's paint_bbox is the union of Y ranges for all paint elements in it.
1142fn build_clip_epochs(list: &DisplayList, bboxes: &[Option<YBBox>]) -> Vec<ClipEpoch> {
1143    let elements = list.elements();
1144    let mut epochs = Vec::new();
1145    let mut epoch_start = 0;
1146    let mut y_min = f64::INFINITY;
1147    let mut y_max = f64::NEG_INFINITY;
1148    let mut has_erase = false;
1149
1150    for (i, element) in elements.iter().enumerate() {
1151        // InitClip starts a new epoch (close the previous one first)
1152        if matches!(element, DisplayElement::InitClip) && i > epoch_start {
1153            epochs.push(ClipEpoch {
1154                start_idx: epoch_start,
1155                end_idx: i,
1156                paint_bbox: if y_min <= y_max {
1157                    Some(YBBox { y_min, y_max })
1158                } else {
1159                    None
1160                },
1161                has_erase_page: has_erase,
1162            });
1163            epoch_start = i;
1164            y_min = f64::INFINITY;
1165            y_max = f64::NEG_INFINITY;
1166            has_erase = false;
1167        }
1168        if matches!(element, DisplayElement::ErasePage) {
1169            has_erase = true;
1170        }
1171        if let Some(ref bbox) = bboxes[i] {
1172            y_min = y_min.min(bbox.y_min);
1173            y_max = y_max.max(bbox.y_max);
1174        }
1175    }
1176    // Final epoch
1177    if epoch_start < elements.len() {
1178        epochs.push(ClipEpoch {
1179            start_idx: epoch_start,
1180            end_idx: elements.len(),
1181            paint_bbox: if y_min <= y_max {
1182                Some(YBBox { y_min, y_max })
1183            } else {
1184                None
1185            },
1186            has_erase_page: has_erase,
1187        });
1188    }
1189    epochs
1190}
1191
1192/// Apply a device-space Y offset to a tiny-skia Transform.
1193/// The original transform maps from path space to full-page device space;
1194/// we subtract `y_offset` from `ty` so band rows [y_start, y_start+band_h)
1195/// map to pixmap rows [0, band_h).
1196/// Composite premultiplied-alpha RGBA pixels onto a white background.
1197/// After this, all pixels are fully opaque (alpha=255).
1198fn composite_onto_white(data: &mut [u8]) {
1199    for pixel in data.chunks_exact_mut(4) {
1200        let a = pixel[3] as u16;
1201        if a == 255 {
1202            continue; // fully opaque — no compositing needed
1203        }
1204        let inv_a = 255 - a;
1205        pixel[0] = (pixel[0] as u16 + inv_a).min(255) as u8;
1206        pixel[1] = (pixel[1] as u16 + inv_a).min(255) as u8;
1207        pixel[2] = (pixel[2] as u16 + inv_a).min(255) as u8;
1208        pixel[3] = 255;
1209    }
1210}
1211
1212/// Extract the contribution of a non-isolated transparency group and composite
1213/// it onto the parent using the group's blend mode and alpha.
1214///
1215/// Composite a (possibly cropped) non-isolated group offscreen onto the parent pixmap.
1216///
1217/// Like `extract_and_composite_contribution`, but the offscreen and backdrop
1218/// are crop-sized (only covering the group's bounding box region), positioned
1219/// at `(crop_x, crop_y)` in the parent's coordinate system.
1220fn composite_non_isolated_group_cropped(
1221    target: &mut Pixmap,
1222    source: &Pixmap,
1223    backdrop: &[u8],
1224    params: &stet_graphics::display_list::GroupParams,
1225    clip_mask: Option<&stet_tiny_skia::Mask>,
1226    crop_x: i32,
1227    crop_y: i32,
1228) {
1229    let cw = source.width();
1230    let ch = source.height();
1231
1232    // Build a contribution pixmap: pixels that changed vs backdrop
1233    let Some(mut contribution) = Pixmap::new(cw, ch) else {
1234        return;
1235    };
1236    let src_data = source.data();
1237    let contrib_data = contribution.data_mut();
1238
1239    for (i, chunk) in contrib_data.chunks_exact_mut(4).enumerate() {
1240        let off = i * 4;
1241        if src_data[off] != backdrop[off]
1242            || src_data[off + 1] != backdrop[off + 1]
1243            || src_data[off + 2] != backdrop[off + 2]
1244            || src_data[off + 3] != backdrop[off + 3]
1245        {
1246            chunk.copy_from_slice(&src_data[off..off + 4]);
1247        }
1248    }
1249
1250    let paint = stet_tiny_skia::PixmapPaint {
1251        opacity: params.alpha as f32,
1252        blend_mode: u8_to_blend_mode(params.blend_mode),
1253        quality: stet_tiny_skia::FilterQuality::Nearest,
1254    };
1255    target.draw_pixmap(
1256        crop_x,
1257        crop_y,
1258        contribution.as_ref(),
1259        &paint,
1260        Transform::identity(),
1261        clip_mask,
1262    );
1263}
1264
1265/// Non-isolated group composite-back using the proper source-extraction
1266/// formula (ISO 32000-1 §11.4.8).
1267///
1268/// `source` was rendered against the `backdrop`; `isolated` was rendered
1269/// against transparent.  The isolated render's alpha channel gives the
1270/// group's shape, which lets us extract the source color:
1271///
1272///   C_g_premul = R - B · (1 - α_g)      (premultiplied source color)
1273///   α_g        = isolated alpha channel
1274///
1275/// The extracted contribution is then composited onto `target` with the
1276/// group's blend mode and opacity.
1277fn composite_non_isolated_extracted(
1278    target: &mut Pixmap,
1279    source: &Pixmap,
1280    isolated: &Pixmap,
1281    backdrop: &[u8],
1282    params: &stet_graphics::display_list::GroupParams,
1283    clip_mask: Option<&stet_tiny_skia::Mask>,
1284    crop_x: i32,
1285    crop_y: i32,
1286) {
1287    let cw = source.width();
1288    let ch = source.height();
1289
1290    let Some(mut contribution) = Pixmap::new(cw, ch) else {
1291        return;
1292    };
1293    let src_data = source.data();
1294    let iso_data = isolated.data();
1295    let contrib_data = contribution.data_mut();
1296
1297    for i in 0..(cw as usize * ch as usize) {
1298        let off = i * 4;
1299        let alpha_g = iso_data[off + 3];
1300        if alpha_g == 0 {
1301            continue; // no group contribution at this pixel
1302        }
1303
1304        // Extract premultiplied source: C_g_premul = R - B · (1 - α_g/255)
1305        let inv_alpha = 255 - alpha_g as i32;
1306        for c in 0..3 {
1307            let r = src_data[off + c] as i32;
1308            let b = backdrop[off + c] as i32;
1309            let raw = r - (b * inv_alpha + 127) / 255;
1310            contrib_data[off + c] = raw.clamp(0, 255) as u8;
1311        }
1312        contrib_data[off + 3] = alpha_g;
1313    }
1314
1315    let paint = stet_tiny_skia::PixmapPaint {
1316        opacity: params.alpha as f32,
1317        blend_mode: u8_to_blend_mode(params.blend_mode),
1318        quality: stet_tiny_skia::FilterQuality::Nearest,
1319    };
1320    target.draw_pixmap(
1321        crop_x,
1322        crop_y,
1323        contribution.as_ref(),
1324        &paint,
1325        Transform::identity(),
1326        clip_mask,
1327    );
1328}
1329
1330/// Apply a combined offset + scale to a tiny-skia Transform for viewport rendering.
1331/// Maps device-space coordinates into viewport-local pixel coordinates:
1332///   output_x = (device_x - vp_x) * scale_x
1333///   output_y = (device_y - vp_y) * scale_y
1334fn viewport_transform(t: Transform, vp_x: f32, vp_y: f32, scale_x: f32, scale_y: f32) -> Transform {
1335    // Post-compose: first apply `t` (path→device), then translate(-vp_x,-vp_y), then scale
1336    Transform::from_row(
1337        t.sx * scale_x,
1338        t.ky * scale_y,
1339        t.kx * scale_x,
1340        t.sy * scale_y,
1341        (t.tx - vp_x) * scale_x,
1342        (t.ty - vp_y) * scale_y,
1343    )
1344}
1345
1346/// Fast area-average box filter resample for downscaling.
1347///
1348/// Each output pixel averages all source pixels that fall within its footprint.
1349/// Two-pass separable (horizontal then vertical) for O(src) total work regardless
1350/// of scale ratio. Produces quality equivalent to Lanczos3 for downscaling at a
1351/// fraction of the cost.
1352fn box_resample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
1353    if dw == 0 || dh == 0 {
1354        return Vec::new();
1355    }
1356    let (sw, sh, dw, dh) = (sw as usize, sh as usize, dw as usize, dh as usize);
1357
1358    // Pass 1: horizontal (sw → dw) with fractional edge weights.
1359    // Each output pixel covers [left_f, right_f] in source space. Edge source
1360    // pixels get proportional weight; interior pixels get weight 1.0.
1361    let ratio_x = sw as f32 / dw as f32;
1362    let mut tmp = vec![0.0f32; dw * sh * 4];
1363    let tmp_stride = dw * 4;
1364
1365    for y in 0..sh {
1366        let row_off = y * sw * 4;
1367        let dst_row = y * tmp_stride;
1368        for dx in 0..dw {
1369            let left_f = dx as f32 * ratio_x;
1370            let right_f = (dx + 1) as f32 * ratio_x;
1371            let left = (left_f as usize).min(sw - 1);
1372            let right = (right_f.ceil() as usize).min(sw);
1373            let inv_area = 1.0 / (right_f - left_f);
1374            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1375            for sx in left..right {
1376                // Weight: fraction of this source pixel covered by the output pixel
1377                let pixel_left = sx as f32;
1378                let pixel_right = (sx + 1) as f32;
1379                let w = pixel_right.min(right_f) - pixel_left.max(left_f);
1380                let i = row_off + sx * 4;
1381                r += src[i] as f32 * w;
1382                g += src[i + 1] as f32 * w;
1383                b += src[i + 2] as f32 * w;
1384                a += src[i + 3] as f32 * w;
1385            }
1386            let di = dst_row + dx * 4;
1387            tmp[di] = r * inv_area;
1388            tmp[di + 1] = g * inv_area;
1389            tmp[di + 2] = b * inv_area;
1390            tmp[di + 3] = a * inv_area;
1391        }
1392    }
1393
1394    // Pass 2: vertical (sh → dh) with fractional edge weights, row-major order.
1395    let ratio_y = sh as f32 / dh as f32;
1396    let mut out = vec![0u8; dw * dh * 4];
1397    let out_stride = dw * 4;
1398
1399    for dy in 0..dh {
1400        let top_f = dy as f32 * ratio_y;
1401        let bottom_f = (dy + 1) as f32 * ratio_y;
1402        let top = (top_f as usize).min(sh - 1);
1403        let bottom = (bottom_f.ceil() as usize).min(sh);
1404        let inv_area = 1.0 / (bottom_f - top_f);
1405
1406        // Pre-compute row weights
1407        let n_rows = bottom - top;
1408        let mut row_weights_buf: [(usize, f32); 8] = [(0, 0.0); 8];
1409        let row_weights_vec: Vec<(usize, f32)>;
1410        let row_weights: &[(usize, f32)] = if n_rows <= 8 {
1411            for (i, sy) in (top..bottom).enumerate() {
1412                let pixel_top = sy as f32;
1413                let pixel_bottom = (sy + 1) as f32;
1414                let w = pixel_bottom.min(bottom_f) - pixel_top.max(top_f);
1415                row_weights_buf[i] = (sy, w);
1416            }
1417            &row_weights_buf[..n_rows]
1418        } else {
1419            row_weights_vec = (top..bottom)
1420                .map(|sy| {
1421                    let pixel_top = sy as f32;
1422                    let pixel_bottom = (sy + 1) as f32;
1423                    let w = pixel_bottom.min(bottom_f) - pixel_top.max(top_f);
1424                    (sy, w)
1425                })
1426                .collect();
1427            &row_weights_vec
1428        };
1429
1430        let dst_row = dy * out_stride;
1431        for dx in 0..dw {
1432            let col = dx * 4;
1433            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1434            for &(sy, w) in row_weights {
1435                let i = sy * tmp_stride + col;
1436                r += tmp[i] * w;
1437                g += tmp[i + 1] * w;
1438                b += tmp[i + 2] * w;
1439                a += tmp[i + 3] * w;
1440            }
1441            let di = dst_row + col;
1442            out[di] = (r * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1443            out[di + 1] = (g * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1444            out[di + 2] = (b * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1445            out[di + 3] = (a * inv_area + 0.5).clamp(0.0, 255.0) as u8;
1446        }
1447    }
1448
1449    out
1450}
1451
1452/// Bicubic (Catmull-Rom) resample for upscaling — two-pass separable.
1453///
1454/// Pass 1: horizontal resample (sw → dw) at f32 precision.
1455/// Pass 2: vertical resample (sh → dh) and quantize to u8.
1456///
1457/// Separable approach: O(dw×sh + dw×dh) × 4 taps instead of O(dw×dh) × 16 taps.
1458fn bicubic_resample(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
1459    if dw == 0 || dh == 0 {
1460        return Vec::new();
1461    }
1462
1463    let (sw, sh, dw, dh) = (sw as usize, sh as usize, dw as usize, dh as usize);
1464    let ratio_x = sw as f32 / dw as f32;
1465    let ratio_y = sh as f32 / dh as f32;
1466
1467    // Pass 1: horizontal (sw → dw), keep sh rows, store as f32.
1468    let mut tmp = vec![0.0f32; dw * sh * 4];
1469    for y in 0..sh {
1470        let src_row = y * sw * 4;
1471        let dst_row = y * dw * 4;
1472        for dx in 0..dw {
1473            let sx = (dx as f32 + 0.5) * ratio_x - 0.5;
1474            let sx_floor = sx.floor() as i32;
1475            let fx = sx - sx_floor as f32;
1476            let w0 = catmull_rom(fx + 1.0);
1477            let w1 = catmull_rom(fx);
1478            let w2 = catmull_rom(1.0 - fx);
1479            let w3 = catmull_rom(2.0 - fx);
1480            let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0, 0.0, 0.0);
1481            for (k, w) in [
1482                (sx_floor - 1, w0),
1483                (sx_floor, w1),
1484                (sx_floor + 1, w2),
1485                (sx_floor + 2, w3),
1486            ] {
1487                let px = k.clamp(0, sw as i32 - 1) as usize;
1488                let i = src_row + px * 4;
1489                r += src[i] as f32 * w;
1490                g += src[i + 1] as f32 * w;
1491                b += src[i + 2] as f32 * w;
1492                a += src[i + 3] as f32 * w;
1493            }
1494            let di = dst_row + dx * 4;
1495            tmp[di] = r;
1496            tmp[di + 1] = g;
1497            tmp[di + 2] = b;
1498            tmp[di + 3] = a;
1499        }
1500    }
1501
1502    // Pass 2: vertical (sh → dh) on the dw-wide tmp, quantize to u8.
1503    // Row-major order for cache-friendly access.
1504    let mut out = vec![0u8; dw * dh * 4];
1505    let tmp_stride = dw * 4;
1506    let out_stride = dw * 4;
1507    for dy in 0..dh {
1508        let sy = (dy as f32 + 0.5) * ratio_y - 0.5;
1509        let sy_floor = sy.floor() as i32;
1510        let fy = sy - sy_floor as f32;
1511        let w0 = catmull_rom(fy + 1.0);
1512        let w1 = catmull_rom(fy);
1513        let w2 = catmull_rom(1.0 - fy);
1514        let w3 = catmull_rom(2.0 - fy);
1515        let py0 = (sy_floor - 1).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1516        let py1 = sy_floor.clamp(0, sh as i32 - 1) as usize * tmp_stride;
1517        let py2 = (sy_floor + 1).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1518        let py3 = (sy_floor + 2).clamp(0, sh as i32 - 1) as usize * tmp_stride;
1519        let dst_row = dy * out_stride;
1520        for dx in 0..dw {
1521            let col = dx * 4;
1522            let r = tmp[py0 + col] * w0
1523                + tmp[py1 + col] * w1
1524                + tmp[py2 + col] * w2
1525                + tmp[py3 + col] * w3;
1526            let g = tmp[py0 + col + 1] * w0
1527                + tmp[py1 + col + 1] * w1
1528                + tmp[py2 + col + 1] * w2
1529                + tmp[py3 + col + 1] * w3;
1530            let b = tmp[py0 + col + 2] * w0
1531                + tmp[py1 + col + 2] * w1
1532                + tmp[py2 + col + 2] * w2
1533                + tmp[py3 + col + 2] * w3;
1534            let a = tmp[py0 + col + 3] * w0
1535                + tmp[py1 + col + 3] * w1
1536                + tmp[py2 + col + 3] * w2
1537                + tmp[py3 + col + 3] * w3;
1538            let di = dst_row + col;
1539            out[di] = r.round().clamp(0.0, 255.0) as u8;
1540            out[di + 1] = g.round().clamp(0.0, 255.0) as u8;
1541            out[di + 2] = b.round().clamp(0.0, 255.0) as u8;
1542            out[di + 3] = a.round().clamp(0.0, 255.0) as u8;
1543        }
1544    }
1545
1546    out
1547}
1548
1549/// Catmull-Rom spline weight (a = -0.5).
1550#[inline]
1551fn catmull_rom(t: f32) -> f32 {
1552    let t = t.abs();
1553    if t < 1.0 {
1554        (1.5 * t - 2.5) * t * t + 1.0
1555    } else if t < 2.0 {
1556        ((-0.5 * t + 2.5) * t - 4.0) * t + 2.0
1557    } else {
1558        0.0
1559    }
1560}
1561
1562/// Pre-downsample an image when the transform indicates significant downscaling.
1563///
1564/// tiny-skia's bilinear filter only samples a 2×2 neighborhood — it has no mipmap
1565/// support, so large downscale ratios cause severe aliasing (e.g., 300 DPI bitmap
1566/// fonts rendered at screen resolution).
1567///
1568/// For axis-aligned transforms: box-filter resample to the exact target dimensions.
1569///
1570/// Build an `IccCache` from ICC profiles found in a display list.
1571///
1572/// Registers all unique ICCBased profiles and optionally the system CMYK
1573/// profile. When `proofing_enabled` is true, ICCBased profiles registered
1574/// while scanning the display list are color-managed *through* the system
1575/// CMYK (the PDF's OutputIntent), so a render-thread cache built from the
1576/// effective OutputIntent matches the bake-time cache that produced the
1577/// display list. PostScript callers should pass `false` (no
1578/// PDF/X OutputIntent semantics).
1579pub fn build_icc_cache_for_list(
1580    list: &DisplayList,
1581    system_cmyk_bytes: Option<&std::sync::Arc<Vec<u8>>>,
1582    proofing_enabled: bool,
1583) -> IccCache {
1584    let mut cache = IccCache::new();
1585    let mut seen = HashSet::new();
1586
1587    // Register system CMYK profile first. Proofing must stay off here: the
1588    // OutputIntent itself converts directly to sRGB, not through itself.
1589    if let Some(cmyk_bytes) = system_cmyk_bytes
1590        && let Some(hash) = cache.register_profile(cmyk_bytes)
1591    {
1592        seen.insert(hash);
1593        // Set the default CMYK hash so convert_image_8bit works for DeviceCMYK
1594        cache.set_default_cmyk_hash(hash);
1595        // Pre-warm the sRGB→CMYK reverse transform so band renderers, which
1596        // only hold an `&IccCache`, can use `convert_rgb_to_cmyk_readonly`
1597        // when populating the parallel CMYK buffer for non-CMYK painters.
1598        cache.prepare_reverse_cmyk();
1599        // Pre-build the per-intent Lab → OI CMYK samplers so Lab fills can
1600        // populate `native_cmyk` from `&IccCache` (mirrors the PNG path's
1601        // `apply_output_intent_as_default_cmyk`). Required for GWG 22.1.
1602        cache.prepare_lab_to_oi_cmyk();
1603    }
1604
1605    // Enable proofing AFTER the OutputIntent itself is registered so the
1606    // chain logic in `register_profile` sees `default_cmyk_hash` set when
1607    // subsequent ICCBased profiles arrive — those get chained through the
1608    // OutputIntent.
1609    cache.set_proofing_enabled(proofing_enabled);
1610
1611    // Scan display list for ICCBased images and shadings (recursing into Groups)
1612    fn scan_elements(
1613        elements: &[DisplayElement],
1614        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1615        cache: &mut IccCache,
1616    ) {
1617        for element in elements {
1618            // Recurse into groups
1619            if let DisplayElement::Group { elements: sub, .. } = element {
1620                scan_elements(sub.elements(), seen, cache);
1621            }
1622            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1623                scan_elements(content.elements(), seen, cache);
1624                scan_elements(mask.elements(), seen, cache);
1625            }
1626            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1627                scan_elements(sub.elements(), seen, cache);
1628            }
1629            // Shading color spaces
1630            let shading_cs = match element {
1631                DisplayElement::AxialShading { params } => Some(&params.color_space),
1632                DisplayElement::RadialShading { params } => Some(&params.color_space),
1633                DisplayElement::MeshShading { params } => Some(&params.color_space),
1634                DisplayElement::PatchShading { params } => Some(&params.color_space),
1635                _ => None,
1636            };
1637            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1638                n,
1639                profile_hash,
1640                profile_data,
1641            }) = shading_cs
1642            {
1643                if seen.insert(*profile_hash) {
1644                    cache.register_profile_with_n(profile_data, Some(*n));
1645                }
1646            }
1647            // Image color spaces
1648            if let DisplayElement::Image { params, .. } = element {
1649                match &params.color_space {
1650                    ImageColorSpace::ICCBased {
1651                        n,
1652                        profile_hash,
1653                        profile_data,
1654                    } if seen.insert(*profile_hash) => {
1655                        cache.register_profile_with_n(profile_data, Some(*n));
1656                    }
1657                    ImageColorSpace::Indexed { base, .. }
1658                        if matches!(base.as_ref(), ImageColorSpace::ICCBased { .. }) =>
1659                    {
1660                        if let ImageColorSpace::ICCBased {
1661                            n,
1662                            profile_hash,
1663                            profile_data,
1664                        } = base.as_ref()
1665                        {
1666                            if seen.insert(*profile_hash) {
1667                                cache.register_profile_with_n(profile_data, Some(*n));
1668                            }
1669                        }
1670                    }
1671                    _ => {}
1672                }
1673            }
1674        }
1675    }
1676    scan_elements(list.elements(), &mut seen, &mut cache);
1677
1678    cache
1679}
1680
1681/// Register ICC profiles from shading elements in a display list.
1682///
1683/// Recursively scans Groups and SoftMasks for ICCBased shading color spaces
1684/// and registers their profiles in the cache.
1685fn register_shading_icc_profiles(list: &DisplayList, cache: &mut IccCache) {
1686    fn register_image_iccs(
1687        cs: &ImageColorSpace,
1688        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1689        cache: &mut IccCache,
1690    ) {
1691        match cs {
1692            ImageColorSpace::ICCBased {
1693                n,
1694                profile_hash,
1695                profile_data,
1696            } => {
1697                if seen.insert(*profile_hash) {
1698                    cache.register_profile_with_n(profile_data, Some(*n));
1699                }
1700            }
1701            ImageColorSpace::Indexed { base, .. } => register_image_iccs(base, seen, cache),
1702            ImageColorSpace::Separation { alt_space, .. }
1703            | ImageColorSpace::DeviceN { alt_space, .. } => {
1704                register_image_iccs(alt_space, seen, cache)
1705            }
1706            _ => {}
1707        }
1708    }
1709    fn scan(
1710        elements: &[DisplayElement],
1711        seen: &mut HashSet<stet_graphics::icc::ProfileHash>,
1712        cache: &mut IccCache,
1713    ) {
1714        for element in elements {
1715            if let DisplayElement::Group { elements: sub, .. } = element {
1716                scan(sub.elements(), seen, cache);
1717            }
1718            if let DisplayElement::SoftMasked { content, mask, .. } = element {
1719                scan(content.elements(), seen, cache);
1720                scan(mask.elements(), seen, cache);
1721            }
1722            if let DisplayElement::OcgGroup { elements: sub, .. } = element {
1723                scan(sub.elements(), seen, cache);
1724            }
1725            let shading_cs = match element {
1726                DisplayElement::AxialShading { params } => Some(&params.color_space),
1727                DisplayElement::RadialShading { params } => Some(&params.color_space),
1728                DisplayElement::MeshShading { params } => Some(&params.color_space),
1729                DisplayElement::PatchShading { params } => Some(&params.color_space),
1730                _ => None,
1731            };
1732            if let Some(stet_graphics::device::ShadingColorSpace::ICCBased {
1733                n,
1734                profile_hash,
1735                profile_data,
1736            }) = shading_cs
1737                && seen.insert(*profile_hash)
1738            {
1739                cache.register_profile_with_n(profile_data, Some(*n));
1740            }
1741            if let DisplayElement::Image { params, .. } = element {
1742                register_image_iccs(&params.color_space, seen, cache);
1743            }
1744        }
1745    }
1746    let mut seen = HashSet::new();
1747    scan(list.elements(), &mut seen, cache);
1748}
1749
1750/// Convert raw image samples to RGBA for rasterization.
1751///
1752/// Handles all `ImageColorSpace` variants, producing width×height×4 RGBA bytes.
1753fn samples_to_rgba(
1754    data: &[u8],
1755    params: &ImageParams,
1756    icc: Option<&IccCache>,
1757    opm_zero_transparent: bool,
1758) -> Vec<u8> {
1759    let w = params.width as usize;
1760    let h = params.height as usize;
1761    let npixels = w * h;
1762    let bpc = params.bits_per_component;
1763    match &params.color_space {
1764        ImageColorSpace::PreconvertedRGBA => {
1765            // Already RGBA — just return as-is
1766            data.to_vec()
1767        }
1768        ImageColorSpace::DeviceGray => {
1769            let mut rgba = vec![255u8; npixels * 4];
1770            if bpc == 16 {
1771                for i in 0..npixels {
1772                    let g = data.get(i * 2).copied().unwrap_or(0);
1773                    let pi = i * 4;
1774                    rgba[pi] = g;
1775                    rgba[pi + 1] = g;
1776                    rgba[pi + 2] = g;
1777                }
1778            } else {
1779                for i in 0..npixels {
1780                    let g = data.get(i).copied().unwrap_or(0);
1781                    let pi = i * 4;
1782                    rgba[pi] = g;
1783                    rgba[pi + 1] = g;
1784                    rgba[pi + 2] = g;
1785                }
1786            }
1787            rgba
1788        }
1789        ImageColorSpace::DeviceRGB => {
1790            let mut rgba = vec![255u8; npixels * 4];
1791            if bpc == 16 {
1792                // 16 BPC: 6 bytes per pixel (R_hi R_lo G_hi G_lo B_hi B_lo)
1793                // Take high byte of each 16-bit sample
1794                for i in 0..npixels {
1795                    let si = i * 6;
1796                    let pi = i * 4;
1797                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1798                    rgba[pi + 1] = data.get(si + 2).copied().unwrap_or(0);
1799                    rgba[pi + 2] = data.get(si + 4).copied().unwrap_or(0);
1800                }
1801            } else {
1802                for i in 0..npixels {
1803                    let si = i * 3;
1804                    let pi = i * 4;
1805                    rgba[pi] = data.get(si).copied().unwrap_or(0);
1806                    rgba[pi + 1] = data.get(si + 1).copied().unwrap_or(0);
1807                    rgba[pi + 2] = data.get(si + 2).copied().unwrap_or(0);
1808                }
1809            }
1810            rgba
1811        }
1812        ImageColorSpace::DeviceCMYK => {
1813            // Try ICC-based CMYK→RGB conversion via system CMYK profile.
1814            // Convert as many complete pixels as the data allows; PLRM-fallback
1815            // for any remaining pixels with insufficient data.
1816            if let Some(cache) = icc
1817                && let Some(cmyk_hash) = cache.default_cmyk_hash()
1818            {
1819                let avail_pixels = data.len() / 4;
1820                let icc_pixels = avail_pixels.min(npixels);
1821                if icc_pixels > 0
1822                    && let Some(rgb) = cache.convert_image_8bit(cmyk_hash, data, icc_pixels)
1823                {
1824                    let mut rgba = vec![255u8; npixels * 4];
1825                    for i in 0..icc_pixels {
1826                        rgba[i * 4] = rgb[i * 3];
1827                        rgba[i * 4 + 1] = rgb[i * 3 + 1];
1828                        rgba[i * 4 + 2] = rgb[i * 3 + 2];
1829                        // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1830                        if opm_zero_transparent {
1831                            let si = i * 4;
1832                            if data[si] == 0
1833                                && data[si + 1] == 0
1834                                && data[si + 2] == 0
1835                                && data[si + 3] == 0
1836                            {
1837                                rgba[i * 4 + 3] = 0;
1838                            }
1839                        }
1840                    }
1841                    // Remaining pixels (if data was short) stay white (0xFF)
1842                    return rgba;
1843                }
1844            }
1845            // Fallback: PLRM CMYK→RGB formula
1846            let mut rgba = vec![255u8; npixels * 4];
1847            for i in 0..npixels {
1848                let si = i * 4;
1849                let c = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1850                let m = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1851                let y = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1852                let k = data.get(si + 3).copied().unwrap_or(0) as f64 / 255.0;
1853                let r = (1.0 - c.min(1.0)) * (1.0 - k.min(1.0));
1854                let g = (1.0 - m.min(1.0)) * (1.0 - k.min(1.0));
1855                let b = (1.0 - y.min(1.0)) * (1.0 - k.min(1.0));
1856                let pi = i * 4;
1857                rgba[pi] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
1858                rgba[pi + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
1859                rgba[pi + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
1860                // OPM=1: CMYK(0,0,0,0) = no ink = transparent
1861                if opm_zero_transparent
1862                    && data.get(si).copied().unwrap_or(0) == 0
1863                    && data.get(si + 1).copied().unwrap_or(0) == 0
1864                    && data.get(si + 2).copied().unwrap_or(0) == 0
1865                    && data.get(si + 3).copied().unwrap_or(0) == 0
1866                {
1867                    rgba[pi + 3] = 0;
1868                }
1869            }
1870            rgba
1871        }
1872        ImageColorSpace::ICCBased {
1873            n,
1874            profile_hash,
1875            profile_data,
1876        } => {
1877            // Try ICC-based conversion if cache is available. Routes through
1878            // the proofing chain (`chain_per_intent_8bit[intent]`) when the
1879            // chain has been populated for this intent — the proofing chain
1880            // is what `convert_color_with_intent` uses for vector paints,
1881            // so images need it too to match. Without this, an Adobe-RGB
1882            // image renders via the source profile's direct RGB→sRGB while
1883            // the surrounding CMYK paint goes through the OutputIntent
1884            // CMYK→sRGB; the two sRGB outputs diverge. GWG 17.2 calibrates
1885            // both so they match under correct CMS, and the test's "X"
1886            // appears whenever the image bypasses the OI roundtrip.
1887            let intent = stet_graphics::icc::intent_from_pdf_byte(params.rendering_intent);
1888            if let Some(cache) = icc
1889                && cache.has_profile(profile_hash)
1890                && let Some(rgb) =
1891                    cache.convert_image_8bit_with_intent(profile_hash, data, npixels, intent)
1892            {
1893                let mut rgba = vec![255u8; npixels * 4];
1894                for i in 0..npixels {
1895                    rgba[i * 4] = rgb[i * 3];
1896                    rgba[i * 4 + 1] = rgb[i * 3 + 1];
1897                    rgba[i * 4 + 2] = rgb[i * 3 + 2];
1898                    // OPM=1 on 4-component (CMYK) ICC profiles
1899                    if opm_zero_transparent && *n == 4 {
1900                        let si = i * *n as usize;
1901                        if si + 3 < data.len()
1902                            && data[si] == 0
1903                            && data[si + 1] == 0
1904                            && data[si + 2] == 0
1905                            && data[si + 3] == 0
1906                        {
1907                            rgba[i * 4 + 3] = 0;
1908                        }
1909                    }
1910                }
1911                return rgba;
1912            }
1913            // Fallback to device equivalent based on component count
1914            let _ = (profile_hash, profile_data);
1915            let fallback = match n {
1916                1 => ImageColorSpace::DeviceGray,
1917                4 => ImageColorSpace::DeviceCMYK,
1918                _ => ImageColorSpace::DeviceRGB,
1919            };
1920            let p = ImageParams {
1921                color_space: fallback,
1922                bits_per_component: 8,
1923                ..params.clone()
1924            };
1925            samples_to_rgba(data, &p, icc, opm_zero_transparent)
1926        }
1927        ImageColorSpace::Indexed {
1928            base,
1929            hival,
1930            lookup,
1931        } => {
1932            let base_ncomp = base.num_components() as usize;
1933            // Expand indexed samples to base color space, then convert
1934            let mut expanded = Vec::with_capacity(npixels * base_ncomp);
1935            for i in 0..npixels {
1936                let idx = data.get(i).copied().unwrap_or(0) as usize;
1937                let idx = idx.min(*hival as usize);
1938                let offset = idx * base_ncomp;
1939                for c in 0..base_ncomp {
1940                    expanded.push(lookup.get(offset + c).copied().unwrap_or(0));
1941                }
1942            }
1943            let p = ImageParams {
1944                color_space: *base.clone(),
1945                bits_per_component: 8,
1946                ..params.clone()
1947            };
1948            samples_to_rgba(&expanded, &p, icc, opm_zero_transparent)
1949        }
1950        ImageColorSpace::CIEBasedABC { params: cie_params } => {
1951            let mut rgba = vec![255u8; npixels * 4];
1952            for i in 0..npixels {
1953                let si = i * 3;
1954                let a = data.get(si).copied().unwrap_or(0) as f64 / 255.0;
1955                let b = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
1956                let c = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
1957                let color = DeviceColor::from_cie_abc(a, b, c, cie_params);
1958                let pi = i * 4;
1959                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1960                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1961                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1962            }
1963            rgba
1964        }
1965        ImageColorSpace::CIEBasedA { params: cie_params } => {
1966            let mut rgba = vec![255u8; npixels * 4];
1967            for i in 0..npixels {
1968                let val = data.get(i).copied().unwrap_or(0) as f64 / 255.0;
1969                let color = DeviceColor::from_cie_a(val, cie_params);
1970                let pi = i * 4;
1971                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1972                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1973                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1974            }
1975            rgba
1976        }
1977        ImageColorSpace::Lab { range, .. } => {
1978            let mut rgba = vec![255u8; npixels * 4];
1979            let a_span = range[1] - range[0];
1980            let b_span = range[3] - range[2];
1981            for i in 0..npixels {
1982                let si = i * 3;
1983                let l = data.get(si).copied().unwrap_or(0) as f64 / 255.0 * 100.0;
1984                let a = data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0 * a_span + range[0];
1985                let b = data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0 * b_span + range[2];
1986                let color = DeviceColor::from_lab(l, a, b, range);
1987                let pi = i * 4;
1988                rgba[pi] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
1989                rgba[pi + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
1990                rgba[pi + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
1991            }
1992            rgba
1993        }
1994        ImageColorSpace::Separation {
1995            alt_space,
1996            tint_table,
1997            ..
1998        } => {
1999            // 1 byte per pixel → lookup in tint table → convert alt space to RGB
2000            // For CMYK alt space with ICC, build bulk CMYK data and convert via ICC
2001            if matches!(alt_space.as_ref(), ImageColorSpace::DeviceCMYK)
2002                && let Some(rgba) = tint_separation_via_icc(data, npixels, tint_table, icc)
2003            {
2004                return rgba;
2005            }
2006            let mut rgba = vec![255u8; npixels * 4];
2007            let no = tint_table.num_outputs as usize;
2008            let mut alt_comps = vec![0.0f32; no];
2009            for i in 0..npixels {
2010                let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
2011                tint_table.lookup_1d(tint, &mut alt_comps);
2012                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
2013                let pi = i * 4;
2014                rgba[pi] = r;
2015                rgba[pi + 1] = g;
2016                rgba[pi + 2] = b;
2017            }
2018            rgba
2019        }
2020        ImageColorSpace::DeviceN {
2021            alt_space,
2022            tint_table,
2023            ..
2024        } => {
2025            let ni = tint_table.num_inputs as usize;
2026            let no = tint_table.num_outputs as usize;
2027            // For CMYK alt space with ICC, build bulk CMYK data and convert via ICC
2028            if matches!(alt_space.as_ref(), ImageColorSpace::DeviceCMYK)
2029                && let Some(rgba) = tint_devicen_via_icc(data, npixels, ni, tint_table, icc)
2030            {
2031                return rgba;
2032            }
2033            let mut rgba = vec![255u8; npixels * 4];
2034            let mut inputs = vec![0.0f32; ni];
2035            let mut alt_comps = vec![0.0f32; no];
2036            for i in 0..npixels {
2037                let si = i * ni;
2038                for (c, inp) in inputs.iter_mut().enumerate() {
2039                    *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
2040                }
2041                tint_table.lookup_nd(&inputs, &mut alt_comps);
2042                let (r, g, b) = alt_comps_to_rgb(&alt_comps, alt_space);
2043                let pi = i * 4;
2044                rgba[pi] = r;
2045                rgba[pi + 1] = g;
2046                rgba[pi + 2] = b;
2047            }
2048            rgba
2049        }
2050        ImageColorSpace::Mask { color, polarity } => {
2051            let mut rgba = vec![0u8; npixels * 4];
2052            let r = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
2053            let g = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
2054            let b = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
2055            let bytes_per_row = (w).div_ceil(8);
2056            for row in 0..h {
2057                for col in 0..w {
2058                    let byte_idx = row * bytes_per_row + col / 8;
2059                    let bit_offset = 7 - (col % 8);
2060                    let bit = if byte_idx < data.len() {
2061                        (data[byte_idx] >> bit_offset) & 1
2062                    } else {
2063                        0
2064                    };
2065                    let paint = if *polarity { bit == 1 } else { bit == 0 };
2066                    if paint {
2067                        let pi = (row * w + col) * 4;
2068                        rgba[pi] = r;
2069                        rgba[pi + 1] = g;
2070                        rgba[pi + 2] = b;
2071                        rgba[pi + 3] = 255;
2072                    }
2073                }
2074            }
2075            rgba
2076        }
2077        _ => vec![0u8; npixels * 4],
2078    }
2079}
2080
2081/// Convert Separation (1-input) tint table output through ICC CMYK profile.
2082/// Builds 4-byte CMYK data from tint table, then bulk-converts via ICC 8-bit transform.
2083fn tint_separation_via_icc(
2084    data: &[u8],
2085    npixels: usize,
2086    tint_table: &TintLookupTable,
2087    icc: Option<&IccCache>,
2088) -> Option<Vec<u8>> {
2089    let cache = icc?;
2090    let cmyk_hash = cache.default_cmyk_hash()?;
2091    // Build CMYK byte buffer from tint table
2092    let mut cmyk_data = vec![0u8; npixels * 4];
2093    let mut alt_comps = [0.0f32; 4];
2094    for i in 0..npixels {
2095        let tint = data.get(i).copied().unwrap_or(0) as f32 / 255.0;
2096        tint_table.lookup_1d(tint, &mut alt_comps);
2097        let si = i * 4;
2098        cmyk_data[si] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2099        cmyk_data[si + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2100        cmyk_data[si + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2101        cmyk_data[si + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2102    }
2103    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2104    let mut rgba = vec![255u8; npixels * 4];
2105    for i in 0..npixels {
2106        rgba[i * 4] = rgb[i * 3];
2107        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2108        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2109    }
2110    Some(rgba)
2111}
2112
2113/// Convert DeviceN (N-input) tint table output through ICC CMYK profile.
2114fn tint_devicen_via_icc(
2115    data: &[u8],
2116    npixels: usize,
2117    ni: usize,
2118    tint_table: &TintLookupTable,
2119    icc: Option<&IccCache>,
2120) -> Option<Vec<u8>> {
2121    let cache = icc?;
2122    let cmyk_hash = cache.default_cmyk_hash()?;
2123    let mut cmyk_data = vec![0u8; npixels * 4];
2124    let mut inputs = vec![0.0f32; ni];
2125    let mut alt_comps = [0.0f32; 4];
2126    for i in 0..npixels {
2127        let si = i * ni;
2128        for (c, inp) in inputs.iter_mut().enumerate() {
2129            *inp = data.get(si + c).copied().unwrap_or(0) as f32 / 255.0;
2130        }
2131        tint_table.lookup_nd(&inputs, &mut alt_comps);
2132        let di = i * 4;
2133        cmyk_data[di] = (alt_comps[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2134        cmyk_data[di + 1] = (alt_comps[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2135        cmyk_data[di + 2] = (alt_comps[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2136        cmyk_data[di + 3] = (alt_comps[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2137    }
2138    let rgb = cache.convert_image_8bit(cmyk_hash, &cmyk_data, npixels)?;
2139    let mut rgba = vec![255u8; npixels * 4];
2140    for i in 0..npixels {
2141        rgba[i * 4] = rgb[i * 3];
2142        rgba[i * 4 + 1] = rgb[i * 3 + 1];
2143        rgba[i * 4 + 2] = rgb[i * 3 + 2];
2144    }
2145    Some(rgba)
2146}
2147
2148/// Convert alt-space f32 component values to RGB bytes.
2149fn alt_comps_to_rgb(comps: &[f32], alt_space: &ImageColorSpace) -> (u8, u8, u8) {
2150    match alt_space {
2151        ImageColorSpace::DeviceGray => {
2152            let g = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2153            (g, g, g)
2154        }
2155        ImageColorSpace::DeviceRGB => {
2156            let r = (comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2157            let g = (comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2158            let b = (comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8;
2159            (r, g, b)
2160        }
2161        ImageColorSpace::DeviceCMYK => {
2162            let c = comps.first().copied().unwrap_or(0.0).clamp(0.0, 1.0);
2163            let m = comps.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2164            let y = comps.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2165            let k = comps.get(3).copied().unwrap_or(0.0).clamp(0.0, 1.0);
2166            let r = ((1.0 - (c + k).min(1.0)) * 255.0).round() as u8;
2167            let g = ((1.0 - (m + k).min(1.0)) * 255.0).round() as u8;
2168            let b = ((1.0 - (y + k).min(1.0)) * 255.0).round() as u8;
2169            (r, g, b)
2170        }
2171        _ => (0, 0, 0),
2172    }
2173}
2174
2175/// Apply ImageType 4 mask color transparency to RGBA data.
2176fn apply_mask_color_rgba(rgba: &mut [u8], sample_data: &[u8], params: &ImageParams) {
2177    let mask_color = match &params.mask_color {
2178        Some(mc) => mc,
2179        None => return,
2180    };
2181    let ncomp = params.color_space.num_components() as usize;
2182    let npixels = params.width as usize * params.height as usize;
2183    let is_range = mask_color.len() == 2 * ncomp;
2184
2185    for i in 0..npixels {
2186        let si = i * ncomp;
2187        let matched = if is_range {
2188            (0..ncomp).all(|c| {
2189                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2190                let min_val = mask_color.get(c * 2).copied().unwrap_or(0);
2191                let max_val = mask_color.get(c * 2 + 1).copied().unwrap_or(0);
2192                sample >= min_val && sample <= max_val
2193            })
2194        } else {
2195            (0..ncomp).all(|c| {
2196                let sample = sample_data.get(si + c).copied().unwrap_or(0);
2197                let target = mask_color.get(c).copied().unwrap_or(0);
2198                sample == target
2199            })
2200        };
2201        if matched {
2202            let pi = i * 4;
2203            if pi + 3 < rgba.len() {
2204                rgba[pi] = 0;
2205                rgba[pi + 1] = 0;
2206                rgba[pi + 2] = 0;
2207                rgba[pi + 3] = 0;
2208            }
2209        }
2210    }
2211}
2212
2213/// Choose filter quality for image drawing.
2214///
2215/// When `interpolate` is false, use Nearest for upscaling (crisp pixel edges)
2216/// and Bilinear only for downscaling (proper area averaging). When `interpolate`
2217/// is true, use Bilinear for any scaling.
2218fn image_filter_quality(transform: Transform, interpolate: bool) -> stet_tiny_skia::FilterQuality {
2219    let eff_sx = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2220    let eff_sy = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2221    let min_scale = eff_sx.min(eff_sy);
2222    // Near-exact 1:1: Nearest is pixel-perfect and faster
2223    if (eff_sx - 1.0).abs() < 0.01 && (eff_sy - 1.0).abs() < 0.01 {
2224        stet_tiny_skia::FilterQuality::Nearest
2225    } else if !interpolate && min_scale >= 0.95 {
2226        // Non-interpolated upscaling: nearest-neighbor for crisp pixel edges
2227        stet_tiny_skia::FilterQuality::Nearest
2228    } else {
2229        stet_tiny_skia::FilterQuality::Bilinear
2230    }
2231}
2232
2233/// For rotated/sheared transforms: integer box-filter pre-downsample, leaving
2234/// the fractional remainder to tiny-skia's bilinear.
2235///
2236/// Returns `None` if no pre-scaling is needed.
2237fn prescale_image(
2238    rgba_data: &[u8],
2239    w: u32,
2240    h: u32,
2241    transform: Transform,
2242    interpolate: bool,
2243) -> Option<(Vec<u8>, u32, u32, Transform)> {
2244    // Compute effective scale factors from the 2×2 part of the transform.
2245    let scale_x = (transform.sx * transform.sx + transform.ky * transform.ky).sqrt();
2246    let scale_y = (transform.kx * transform.kx + transform.sy * transform.sy).sqrt();
2247    let min_scale = scale_x.min(scale_y);
2248
2249    // Upscaling: only apply bicubic resampling when Interpolate is true.
2250    // Per PLRM/PDF spec, non-interpolated images should use nearest-neighbor
2251    // for upscaling (crisp pixel boundaries, no smoothing).
2252    if min_scale > 1.05 {
2253        if interpolate {
2254            let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2255            if is_axis_aligned && w >= 2 && h >= 2 {
2256                let dw = (w as f32 * transform.sx.abs()).round().max(1.0) as u32;
2257                let dh = (h as f32 * transform.sy.abs()).round().max(1.0) as u32;
2258                if dw > w || dh > h {
2259                    let resampled = bicubic_resample(rgba_data, w, h, dw, dh);
2260                    let new_sx = transform.sx * w as f32 / dw as f32;
2261                    let new_sy = transform.sy * h as f32 / dh as f32;
2262                    let adjusted = Transform::from_row(
2263                        new_sx,
2264                        transform.ky,
2265                        transform.kx,
2266                        new_sy,
2267                        transform.tx,
2268                        transform.ty,
2269                    );
2270                    return Some((resampled, dw, dh, adjusted));
2271                }
2272            }
2273        }
2274        return None;
2275    }
2276
2277    // Near 1:1 — no prescaling needed.
2278    if min_scale >= 0.95 {
2279        return None;
2280    }
2281
2282    // Axis-aligned: use area-average box filter to target dimensions.
2283    // Much faster than Lanczos3 and produces equally good results for downscaling.
2284    let is_axis_aligned = transform.kx.abs() < 1e-4 && transform.ky.abs() < 1e-4;
2285    if is_axis_aligned && w >= 2 && h >= 2 {
2286        let dw = (w as f32 * transform.sx.abs()).ceil().max(1.0) as u32;
2287        let dh = (h as f32 * transform.sy.abs()).ceil().max(1.0) as u32;
2288        if dw < w || dh < h {
2289            let resampled = box_resample(rgba_data, w, h, dw, dh);
2290            // Adjust transform so scale ≈ ±1 (sign preserved), same translation.
2291            let new_sx = transform.sx * w as f32 / dw as f32;
2292            let new_sy = transform.sy * h as f32 / dh as f32;
2293            let adjusted = Transform::from_row(
2294                new_sx,
2295                transform.ky,
2296                transform.kx,
2297                new_sy,
2298                transform.tx,
2299                transform.ty,
2300            );
2301            return Some((resampled, dw, dh, adjusted));
2302        }
2303    }
2304
2305    // Fallback for rotated/sheared: integer box filter.
2306    let factor = (1.0 / min_scale) as u32;
2307    if factor < 2 || w < factor || h < factor {
2308        return None;
2309    }
2310    let nw = w / factor;
2311    let nh = h / factor;
2312    if nw == 0 || nh == 0 {
2313        return None;
2314    }
2315    let area = factor * factor;
2316    let half = area / 2;
2317    let stride = w as usize * 4;
2318    let mut out = vec![0u8; (nw * nh * 4) as usize];
2319    for dy in 0..nh {
2320        for dx in 0..nw {
2321            let (mut r, mut g, mut b, mut a) = (0u32, 0u32, 0u32, 0u32);
2322            let sy0 = (dy * factor) as usize;
2323            let sx0 = (dx * factor) as usize;
2324            for iy in 0..factor as usize {
2325                let row = (sy0 + iy) * stride + sx0 * 4;
2326                for ix in 0..factor as usize {
2327                    let i = row + ix * 4;
2328                    r += rgba_data[i] as u32;
2329                    g += rgba_data[i + 1] as u32;
2330                    b += rgba_data[i + 2] as u32;
2331                    a += rgba_data[i + 3] as u32;
2332                }
2333            }
2334            let di = (dy * nw + dx) as usize * 4;
2335            out[di] = ((r + half) / area) as u8;
2336            out[di + 1] = ((g + half) / area) as u8;
2337            out[di + 2] = ((b + half) / area) as u8;
2338            out[di + 3] = ((a + half) / area) as u8;
2339        }
2340    }
2341    let f = factor as f32;
2342    let adjusted = Transform::from_row(
2343        transform.sx * f,
2344        transform.ky * f,
2345        transform.kx * f,
2346        transform.sy * f,
2347        transform.tx,
2348        transform.ty,
2349    );
2350    Some((out, nw, nh, adjusted))
2351}
2352
2353/// Translate a device-space ClipRect into band-local coordinates.
2354fn translate_clip_rect(rect: &ClipRect, y_start: u32, band_h: u32) -> ClipRect {
2355    ClipRect {
2356        x0: rect.x0,
2357        y0: rect.y0.saturating_sub(y_start).min(band_h),
2358        x1: rect.x1,
2359        y1: rect.y1.saturating_sub(y_start).min(band_h),
2360    }
2361}
2362
2363/// Ensure an image transform maps to at least 1 device pixel in each dimension.
2364///
2365/// PDFs commonly draw rules and borders using tiny image masks (1×1 or 4×1 pixels)
2366/// scaled via the CTM to thin rectangles. At low DPI these can map to sub-pixel
2367/// device dimensions and vanish. This adjusts the transform's scale components
2368/// so the image covers at least 1 pixel in each direction.
2369fn enforce_min_image_size(transform: Transform, img_w: u32, img_h: u32) -> Transform {
2370    // Effective device-space dimensions
2371    let eff_w =
2372        ((transform.sx * img_w as f32).powi(2) + (transform.ky * img_w as f32).powi(2)).sqrt();
2373    let eff_h =
2374        ((transform.kx * img_h as f32).powi(2) + (transform.sy * img_h as f32).powi(2)).sqrt();
2375
2376    if eff_w >= 1.0 && eff_h >= 1.0 {
2377        return transform;
2378    }
2379
2380    // Only boost if the image is a thin rule (large aspect ratio).
2381    // Small images that are sub-pixel in both dimensions (e.g. tiny dots)
2382    // are left as-is — boosting them would create visible artifacts.
2383    let ratio = eff_w.max(eff_h) / eff_w.min(eff_h).max(0.001);
2384    if ratio < 3.0 {
2385        return transform;
2386    }
2387
2388    let mut t = transform;
2389    if eff_w < 1.0 && eff_w > 0.001 {
2390        let boost = 1.0 / eff_w;
2391        t.sx *= boost;
2392        t.ky *= boost;
2393    }
2394    if eff_h < 1.0 && eff_h > 0.001 {
2395        let boost = 1.0 / eff_h;
2396        t.kx *= boost;
2397        t.sy *= boost;
2398    }
2399    t
2400}
2401
2402/// Compute minimum line width for hairline strokes at a given DPI and CTM.
2403/// Returns the minimum width in user-space units that ensures at least
2404/// 0.5 device pixels at ≤150 DPI or 1.0 device pixel above 150 DPI.
2405fn hairline_min_width(ctm: &Matrix, dpi: f64) -> f64 {
2406    let (a, b, c, d) = (ctm.a, ctm.b, ctm.c, ctm.d);
2407    let sum_sq = a * a + b * b + c * c + d * d;
2408    let diff = ((a * a + b * b - c * c - d * d).powi(2) + 4.0 * (a * c + b * d).powi(2)).sqrt();
2409    let s_max = (0.5 * (sum_sq + diff)).max(0.0).sqrt();
2410    let min_px = if dpi <= 150.0 { 0.5 } else { 1.0 };
2411    if s_max > 1e-10 {
2412        min_px / s_max
2413    } else {
2414        min_px
2415    }
2416}
2417
2418/// True when the paint's source CMYK is K-only (C=M=Y=0, any K).
2419/// Used to route OPM 0 DeviceCMYK paints that encode "K-only" — like
2420/// `0 0 0 0.5 k` — through the per-pixel overprint path, so the no-op delta
2421/// skip can preserve a spot-painted backdrop at pixels where K already equals
2422/// the source value.
2423fn is_k_only_src(color: &DeviceColor) -> bool {
2424    if let Some((c, m, y, _k)) = color.native_cmyk {
2425        c == 0.0 && m == 0.0 && y == 0.0
2426    } else {
2427        false
2428    }
2429}
2430
2431/// Detect a DeviceGray paint that should be promoted to CMYK_K for overprint.
2432///
2433/// DeviceGray `g` sets `painted_channels = 0` and leaves `native_cmyk = None`,
2434/// so overprint dispatch can't see it as a K-ink paint. When overprint is
2435/// active we re-describe the paint as DeviceCMYK `(0, 0, 0, 1-g)` with
2436/// `painted_channels = CMYK_K`: it flows through the subset path, only the K
2437/// plate is touched, and the pixmap is updated multiplicatively so any
2438/// backdrop spot contribution survives.
2439fn needs_gray_promotion(
2440    overprint: bool,
2441    painted_channels: u8,
2442    is_device_cmyk: bool,
2443    color: &DeviceColor,
2444) -> Option<f64> {
2445    if !overprint
2446        || painted_channels != 0
2447        || is_device_cmyk
2448        || color.native_cmyk.is_some()
2449        || color.process_cmyk.is_some()
2450    {
2451        return None;
2452    }
2453    let r = color.r;
2454    if (r - color.g).abs() > f64::EPSILON || (r - color.b).abs() > f64::EPSILON {
2455        return None;
2456    }
2457    Some(r.clamp(0.0, 1.0))
2458}
2459
2460/// Promote a gray `FillParams` to a DeviceCMYK K-only overprint description if
2461/// the paint qualifies (see [`needs_gray_promotion`]).
2462fn maybe_promote_gray_fill<'a>(
2463    params: &'a FillParams,
2464    buf: &'a mut Option<FillParams>,
2465) -> &'a FillParams {
2466    if let Some(gray) = needs_gray_promotion(
2467        params.overprint,
2468        params.painted_channels,
2469        params.is_device_cmyk,
2470        &params.color,
2471    ) {
2472        let mut promoted = params.clone();
2473        promoted.is_device_cmyk = true;
2474        promoted.painted_channels = stet_graphics::device::CMYK_K;
2475        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2476        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2477        *buf = Some(promoted);
2478        return buf.as_ref().unwrap();
2479    }
2480    params
2481}
2482
2483/// Promote a gray `StrokeParams` to a DeviceCMYK K-only overprint description.
2484fn maybe_promote_gray_stroke<'a>(
2485    params: &'a StrokeParams,
2486    buf: &'a mut Option<StrokeParams>,
2487) -> &'a StrokeParams {
2488    if let Some(gray) = needs_gray_promotion(
2489        params.overprint,
2490        params.painted_channels,
2491        params.is_device_cmyk,
2492        &params.color,
2493    ) {
2494        let mut promoted = params.clone();
2495        promoted.is_device_cmyk = true;
2496        promoted.painted_channels = stet_graphics::device::CMYK_K;
2497        promoted.color.native_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2498        promoted.color.process_cmyk = Some((0.0, 0.0, 0.0, 1.0 - gray));
2499        *buf = Some(promoted);
2500        return buf.as_ref().unwrap();
2501    }
2502    params
2503}
2504
2505/// Build a stroke with minimum line-width enforcement (shared by trait impl and band rendering).
2506/// `dpi` is the device resolution, used to select the hairline minimum width:
2507/// at ≤150 DPI use 0.6 device pixels; above 150 DPI use 1.0 device pixel.
2508fn build_stroke(params: &StrokeParams, dpi: f64) -> Stroke {
2509    let min_lw = hairline_min_width(&params.ctm, dpi);
2510    let mut stroke = Stroke {
2511        width: (params.line_width as f32).max(min_lw as f32),
2512        line_cap: to_line_cap(params.line_cap),
2513        line_join: to_line_join(params.line_join),
2514        miter_limit: params.miter_limit as f32,
2515        ..Stroke::default()
2516    };
2517    if !params.dash_pattern.array.is_empty() {
2518        let mut dash_array: Vec<f32> = params
2519            .dash_pattern
2520            .array
2521            .iter()
2522            .map(|&v| v as f32)
2523            .collect();
2524        // PostScript allows odd-length dash arrays (implicitly doubled),
2525        // but tiny-skia requires even length. Double odd arrays to match PS semantics.
2526        if dash_array.len() % 2 == 1 {
2527            let clone = dash_array.clone();
2528            dash_array.extend_from_slice(&clone);
2529        }
2530        if let Some(dash) = StrokeDash::new(dash_array, params.dash_pattern.offset as f32) {
2531            stroke.dash = Some(dash);
2532        }
2533    }
2534    stroke
2535}
2536
2537/// Apply stroke adjustment: snap axis-aligned path segments to device pixel
2538/// centers so thin strokes render with consistent weight.
2539///
2540/// For a stroke of width W in device pixels:
2541/// - Odd-integer width (1, 3, ...): snap to half-pixel (floor(x) + 0.5)
2542/// - Even-integer width or non-integer: snap to pixel edge (round(x))
2543/// - For hairlines (device width < 1.5): always snap to half-pixel
2544///
2545/// Only axis-aligned segments (horizontal/vertical lines) are snapped.
2546/// Diagonal/curved segments are left as-is since snapping would distort them.
2547///
2548/// Check whether a CTM indicates the path is already in device space (identity
2549/// or simple Y-flip/translation). Stroke adjustment snaps coordinates to pixel
2550/// boundaries, which only makes sense when path coordinates are device pixels.
2551/// PDF Form XObjects with large scale factors (e.g. [405, 0, 0, 283, ...]) would
2552/// cause catastrophic snapping if treated as device-space paths.
2553fn ctm_is_device_space(ctm: &Matrix) -> bool {
2554    (ctm.a.abs() - 1.0).abs() < 0.01
2555        && ctm.b.abs() < 0.01
2556        && ctm.c.abs() < 0.01
2557        && (ctm.d.abs() - 1.0).abs() < 0.01
2558}
2559
2560/// Apply stroke adjustment for viewport rendering.
2561///
2562/// Path coordinates are in reference-DPI device space. The viewport transform
2563/// maps them to output pixels: out = (ref - vp_origin) * scale.
2564/// We snap in output pixel space then map back to reference space.
2565fn stroke_adjust_path_viewport(
2566    path: &PsPath,
2567    device_width: f64,
2568    scale_x: f64,
2569    scale_y: f64,
2570    vp_x: f64,
2571    vp_y: f64,
2572) -> PsPath {
2573    let use_half_pixel = device_width < 1.5 || (device_width.round() as i32) % 2 == 1;
2574
2575    // Snap a reference-space coordinate to the output pixel grid, then map back
2576    let snap_x = |v: f64| -> f64 {
2577        let out = (v - vp_x) * scale_x;
2578        let snapped = if use_half_pixel {
2579            out.floor() + 0.5
2580        } else {
2581            out.round()
2582        };
2583        snapped / scale_x + vp_x
2584    };
2585    let snap_y = |v: f64| -> f64 {
2586        let out = (v - vp_y) * scale_y;
2587        let snapped = if use_half_pixel {
2588            out.floor() + 0.5
2589        } else {
2590            out.round()
2591        };
2592        snapped / scale_y + vp_y
2593    };
2594
2595    let mut result = PsPath::new();
2596    let mut prev_x = 0.0_f64;
2597    let mut prev_y = 0.0_f64;
2598
2599    for seg in &path.segments {
2600        match *seg {
2601            PathSegment::MoveTo(x, y) => {
2602                prev_x = x;
2603                prev_y = y;
2604                result.segments.push(PathSegment::MoveTo(x, y));
2605            }
2606            PathSegment::LineTo(x, y) => {
2607                let is_horizontal = (y - prev_y).abs() < 1e-6;
2608                let is_vertical = (x - prev_x).abs() < 1e-6;
2609
2610                if is_horizontal {
2611                    let snapped_y = snap_y(y);
2612                    if let Some(PathSegment::MoveTo(_, ly) | PathSegment::LineTo(_, ly)) =
2613                        result.segments.last_mut()
2614                    {
2615                        *ly = snapped_y;
2616                    }
2617                    result.segments.push(PathSegment::LineTo(x, snapped_y));
2618                    prev_x = x;
2619                    prev_y = snapped_y;
2620                } else if is_vertical {
2621                    let snapped_x = snap_x(x);
2622                    if let Some(PathSegment::MoveTo(lx, _) | PathSegment::LineTo(lx, _)) =
2623                        result.segments.last_mut()
2624                    {
2625                        *lx = snapped_x;
2626                    }
2627                    result.segments.push(PathSegment::LineTo(snapped_x, y));
2628                    prev_x = snapped_x;
2629                    prev_y = y;
2630                } else {
2631                    result.segments.push(PathSegment::LineTo(x, y));
2632                    prev_x = x;
2633                    prev_y = y;
2634                }
2635            }
2636            PathSegment::CurveTo {
2637                x1,
2638                y1,
2639                x2,
2640                y2,
2641                x3,
2642                y3,
2643            } => {
2644                result.segments.push(PathSegment::CurveTo {
2645                    x1,
2646                    y1,
2647                    x2,
2648                    y2,
2649                    x3,
2650                    y3,
2651                });
2652                prev_x = x3;
2653                prev_y = y3;
2654            }
2655            PathSegment::ClosePath => {
2656                result.segments.push(PathSegment::ClosePath);
2657            }
2658        }
2659    }
2660    result
2661}
2662
2663/// Process a single display list element into a pixmap using the given render context.
2664///
2665/// This unified function handles both band rendering (scale=1.0) and viewport
2666/// rendering (arbitrary scale). Band rendering is viewport rendering with
2667/// `scale_x = scale_y = 1.0`.
2668fn render_element(
2669    pixmap: &mut Pixmap,
2670    band_state: &mut BandState,
2671    element: &DisplayElement,
2672    ctx: &RenderContext<'_>,
2673) {
2674    match element {
2675        DisplayElement::Fill { path, params } => {
2676            // DeviceGray with overprint behaves as a K-only process paint —
2677            // promote it to DeviceCMYK (0, 0, 0, 1-gray) with painted_channels
2678            // set to CMYK_K so it flows through the overprint subset path,
2679            // preserving backdrop CMY plates and the spot-derived visual
2680            // instead of knocking the pixmap out with plain RGB gray.
2681            let mut promoted_fill: Option<FillParams> = None;
2682            let params = maybe_promote_gray_fill(params, &mut promoted_fill);
2683            // Use the overprint compositing path whenever the fill needs
2684            // per-channel CMYK rendering. Five cases trigger it:
2685            //   1. Subset painted_channels (Separation /Magenta, DeviceN, etc.)
2686            //      — only the named channels touch the buffer; the rest are
2687            //      preserved from the backdrop.
2688            //   2. DeviceCMYK + OPM 1 — zero-valued components don't paint, so
2689            //      a per-pixel filter is required.
2690            //   3. Custom spot (painted_channels=0, non-CMYK, with native_cmyk)
2691            //      under overprint — process plates must be preserved; the
2692            //      spot's alt-CMYK only contributes multiplicatively to RGB.
2693            //   4. DeviceCMYK + overprint (any OPM) with CMYK_ALL — the per-
2694            //      pixel path lets us recognise a "no-op" overprint (src CMYK
2695            //      == backdrop CMYK) and leave the pixmap untouched, which
2696            //      preserves any spot-derived colour already visible there.
2697            //   5. (Combinations of the above.)
2698            // Only fires for Normal blend; non-Normal blend modes handle zero
2699            // values through their blend math, not through overprint filtering.
2700            // Includes text glyphs: when overprint is meaningful (the test
2701            // suite's GWG 1.0 swatches f/a use Separation /Magenta + glyphs),
2702            // correctness wins over the slight AA difference vs tiny-skia.
2703            let painted = params.painted_channels;
2704            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2705            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2706            // Real Separation/DeviceN custom spots set `process_cmyk` (even pure
2707            // spots set it to `(0, 0, 0, 0)`); ICCBased RGB routed through the
2708            // proofing chain has `native_cmyk` populated but leaves
2709            // `process_cmyk == None`. Per PDF 1.7 §11.7.4.5 a non-process source
2710            // colour space (CalGray/CalRGB/Lab/ICCBased) must paint as if /OP
2711            // were false — gating on `process_cmyk.is_some()` keeps ICCBased RGB
2712            // out of the overprint path so GWG 13.3 (ICC RGB X over CMYK BG)
2713            // knocks out instead of preserving the backdrop's CMYK plates.
2714            let custom_spot = painted == 0
2715                && !params.is_device_cmyk
2716                && params.color.native_cmyk.is_some()
2717                && params.color.process_cmyk.is_some();
2718            // A "near-K-only" DeviceCMYK paint under OPM 0 — e.g. `0 0 0 0.5 k`
2719            // — matches the Black-component plate of a DeviceN [Black, spot]
2720            // backdrop exactly. Routing it through the per-pixel path lets the
2721            // no-op-delta skip preserve the spot-derived colour instead of
2722            // wiping it with plain grey (GWG 3.0 "50% K over spot").
2723            let is_k_only_cmyk =
2724                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2725            let needs_overprint = params.overprint
2726                && band_state.cmyk_buffer.is_some()
2727                && params.blend_mode == 0
2728                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2729
2730            if needs_overprint {
2731                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2732                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2733                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2734                render_overprint_fill(
2735                    pixmap,
2736                    &mut cmyk_buf,
2737                    &mut op_bg,
2738                    &mut op_touched,
2739                    &spot_mask,
2740                    band_state,
2741                    path,
2742                    params,
2743                    ctx.vp_x,
2744                    ctx.vp_y,
2745                    ctx.scale_x,
2746                    ctx.scale_y,
2747                    ctx.out_w,
2748                    ctx.out_h,
2749                    ctx.icc,
2750                    ctx.no_aa,
2751                );
2752                band_state.cmyk_buffer = Some(cmyk_buf);
2753                band_state.restore_op_buffers(op_bg, op_touched);
2754                band_state.restore_spot_mask(spot_mask);
2755            } else {
2756                let Some(skia_path) = build_skia_path(path) else {
2757                    return;
2758                };
2759                let mut temp_mask = None;
2760                let Some(mask_ref) = resolve_clip_mask(
2761                    &band_state.clip_region,
2762                    &mut temp_mask,
2763                    ctx.out_w,
2764                    ctx.out_h,
2765                ) else {
2766                    return;
2767                };
2768                let paint =
2769                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2770                let transform = ctx.transform(&params.ctm);
2771
2772                // Detect degenerate fill paths: rectangles/lines with zero extent
2773                // in one dimension. These are commonly used in PDFs to draw table
2774                // grid lines as zero-width or zero-height filled rectangles.
2775                // Since they have no area, fill_path produces nothing. Render them
2776                // as hairline strokes instead.
2777                if is_degenerate_fill(path) {
2778                    let stroke = Stroke {
2779                        width: 1.0,
2780                        ..Stroke::default()
2781                    };
2782                    pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2783                } else {
2784                    let fill_rule = to_fill_rule(&params.fill_rule);
2785                    pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
2786                }
2787
2788                // Update CMYK tracking buffer for non-overprint fills
2789                if band_state.cmyk_buffer.is_some() {
2790                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2791                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2792                    update_cmyk_buffer_for_fill(
2793                        &mut cmyk_buf,
2794                        &mut spot_mask,
2795                        path,
2796                        params,
2797                        ctx.vp_x,
2798                        ctx.vp_y,
2799                        ctx.scale_x,
2800                        ctx.scale_y,
2801                        ctx.out_w,
2802                        ctx.out_h,
2803                        &band_state.clip_region,
2804                        ctx.no_aa,
2805                        ctx.icc,
2806                    );
2807                    band_state.cmyk_buffer = Some(cmyk_buf);
2808                    band_state.restore_spot_mask(spot_mask);
2809                }
2810            }
2811        }
2812        DisplayElement::Stroke { path, params } => {
2813            let mut promoted_stroke: Option<StrokeParams> = None;
2814            let params = maybe_promote_gray_stroke(params, &mut promoted_stroke);
2815            let transform = ctx.transform(&params.ctm);
2816            // Build stroke using the composited transform so hairline width
2817            // calculations account for the actual output resolution.
2818            let vp_ctm = Matrix {
2819                a: transform.sx as f64,
2820                b: transform.ky as f64,
2821                c: transform.kx as f64,
2822                d: transform.sy as f64,
2823                tx: 0.0,
2824                ty: 0.0,
2825            };
2826            let vp_params = StrokeParams {
2827                ctm: vp_ctm,
2828                ..params.clone()
2829            };
2830            let stroke = build_stroke(&vp_params, ctx.effective_dpi);
2831
2832            // Apply stroke adjustment — snap in output device space
2833            let adjusted;
2834            let draw_path = if params.stroke_adjust
2835                && stroke.width <= 2.0
2836                && ctm_is_device_space(&params.ctm)
2837            {
2838                adjusted = stroke_adjust_path_viewport(
2839                    path,
2840                    stroke.width as f64,
2841                    ctx.scale_x as f64,
2842                    ctx.scale_y as f64,
2843                    ctx.vp_x as f64,
2844                    ctx.vp_y as f64,
2845                );
2846                &adjusted
2847            } else {
2848                path
2849            };
2850
2851            // Mirror the Fill gating: per-channel CMYK rendering kicks in for
2852            // subset painted_channels (Separation /Magenta, DeviceN, etc.), for
2853            // DeviceCMYK + OPM 1 (zero-valued source components don't paint),
2854            // or for a custom spot (painted=0, non-CMYK) under overprint — so
2855            // the spot applies multiplicatively to RGB without disturbing the
2856            // process plates. GWG 1.0 swatch a/b/f/g need this for the magenta
2857            // X stroke that overlays the same path the fill already drew.
2858            let painted = params.painted_channels;
2859            let subset_channels = painted != 0 && painted != stet_graphics::device::CMYK_ALL;
2860            let opm1_cmyk = params.is_device_cmyk && params.overprint_mode == 1;
2861            // Mirror the Fill custom-spot gate: ICCBased RGB (proofing-chain
2862            // `native_cmyk`, no `process_cmyk`) must not reach the overprint
2863            // path. PDF 1.7 §11.7.4.5: non-process source spaces paint as if
2864            // /OP were false.
2865            let custom_spot = painted == 0
2866                && !params.is_device_cmyk
2867                && params.color.native_cmyk.is_some()
2868                && params.color.process_cmyk.is_some();
2869            let is_k_only_cmyk =
2870                params.is_device_cmyk && params.overprint_mode == 0 && is_k_only_src(&params.color);
2871            let needs_overprint = params.overprint
2872                && band_state.cmyk_buffer.is_some()
2873                && params.blend_mode == 0
2874                && (subset_channels || opm1_cmyk || custom_spot || is_k_only_cmyk);
2875
2876            let Some(skia_path) = build_skia_path(draw_path) else {
2877                return;
2878            };
2879            let mut temp_mask = None;
2880            let Some(mask_ref) = resolve_clip_mask(
2881                &band_state.clip_region,
2882                &mut temp_mask,
2883                ctx.out_w,
2884                ctx.out_h,
2885            ) else {
2886                return;
2887            };
2888
2889            if needs_overprint {
2890                // Convert the stroke outline to a fill path and route it
2891                // through the same per-channel CMYK compositing logic the
2892                // fill path uses, so the post-overprint result lands in the
2893                // pixmap (not the raw source colour).
2894                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2895                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2896                let spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2897                render_overprint_stroke(
2898                    pixmap,
2899                    &mut cmyk_buf,
2900                    &mut op_bg,
2901                    &mut op_touched,
2902                    &spot_mask,
2903                    band_state,
2904                    &skia_path,
2905                    &stroke,
2906                    transform,
2907                    params,
2908                    ctx.out_w,
2909                    ctx.out_h,
2910                    ctx.icc,
2911                    ctx.no_aa,
2912                );
2913                band_state.cmyk_buffer = Some(cmyk_buf);
2914                band_state.restore_op_buffers(op_bg, op_touched);
2915                band_state.restore_spot_mask(spot_mask);
2916            } else {
2917                let paint =
2918                    to_paint_alpha(&params.color, params.alpha, params.blend_mode, ctx.no_aa);
2919                pixmap.stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
2920
2921                if band_state.cmyk_buffer.is_some() {
2922                    let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2923                    let mut spot_mask = band_state.take_spot_mask(ctx.out_w, ctx.out_h);
2924                    update_cmyk_buffer_for_stroke(
2925                        &mut cmyk_buf,
2926                        &mut spot_mask,
2927                        draw_path,
2928                        params,
2929                        &stroke,
2930                        transform,
2931                        ctx.out_w,
2932                        ctx.out_h,
2933                        &band_state.clip_region,
2934                        ctx.no_aa,
2935                        ctx.icc,
2936                    );
2937                    band_state.cmyk_buffer = Some(cmyk_buf);
2938                    band_state.restore_spot_mask(spot_mask);
2939                }
2940            }
2941        }
2942        DisplayElement::Clip { path, params } => {
2943            clip_path_unified(band_state, path, params, ctx);
2944        }
2945        DisplayElement::InitClip => {
2946            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2947                band_state.recycle_mask(mask);
2948            }
2949            band_state.clip_region = None;
2950        }
2951        DisplayElement::ErasePage => {
2952            pixmap.fill(Color::TRANSPARENT);
2953            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
2954                band_state.recycle_mask(mask);
2955            }
2956            band_state.clip_region = None;
2957        }
2958        DisplayElement::Image {
2959            sample_data,
2960            params,
2961        } => {
2962            let iw = params.width;
2963            let ih = params.height;
2964            if iw == 0 || ih == 0 {
2965                return;
2966            }
2967
2968            let needs_overprint = params.overprint
2969                && band_state.cmyk_buffer.is_some()
2970                && image_supports_overprint(&params.color_space);
2971
2972            if needs_overprint {
2973                let mut cmyk_buf = band_state.cmyk_buffer.take().unwrap();
2974                let (mut op_bg, mut op_touched) = band_state.take_op_buffers(ctx.out_w, ctx.out_h);
2975                render_overprint_image(
2976                    pixmap,
2977                    &mut cmyk_buf,
2978                    &mut op_bg,
2979                    &mut op_touched,
2980                    band_state,
2981                    sample_data,
2982                    params,
2983                    ctx.vp_x,
2984                    ctx.vp_y,
2985                    ctx.scale_x,
2986                    ctx.scale_y,
2987                    ctx.out_w,
2988                    ctx.out_h,
2989                    ctx.icc,
2990                );
2991                band_state.cmyk_buffer = Some(cmyk_buf);
2992                band_state.restore_op_buffers(op_bg, op_touched);
2993            } else if let Some(pp) = ctx
2994                .preprocessed
2995                .and_then(|pp| pp.get(ctx.elem_idx))
2996                .and_then(|e| e.as_ref())
2997            {
2998                // Fast path: use pre-converted and prescaled image data.
2999                // Only the per-band translation differs; scale factors are cached.
3000                let Some(image_inv) = params.image_matrix.invert() else {
3001                    return;
3002                };
3003                let combined = params.ctm.concat(&image_inv);
3004                let raw_transform = ctx.transform(&combined);
3005                let transform = Transform::from_row(
3006                    pp.adj_sx,
3007                    pp.adj_ky,
3008                    pp.adj_kx,
3009                    pp.adj_sy,
3010                    raw_transform.tx,
3011                    raw_transform.ty,
3012                );
3013
3014                let Some(img_pixmap) =
3015                    stet_tiny_skia::PixmapRef::from_bytes(&pp.data, pp.width, pp.height)
3016                else {
3017                    return;
3018                };
3019                #[allow(unused_assignments)]
3020                let mut temp_mask = None;
3021                let mask_ref = match &band_state.clip_region {
3022                    None => None,
3023                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
3024                    Some(ClipRegion::Rect(rect)) => {
3025                        if rect.is_empty() {
3026                            return;
3027                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
3028                            None
3029                        } else {
3030                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
3031                            temp_mask.as_ref()
3032                        }
3033                    }
3034                };
3035                let img_paint = stet_tiny_skia::PixmapPaint {
3036                    quality: pp.quality,
3037                    opacity: params.alpha as f32,
3038                    blend_mode: u8_to_blend_mode(params.blend_mode),
3039                };
3040                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
3041
3042                // Update CMYK tracking buffer for non-overprint images on the
3043                // fast path. Reading from the post-draw pixmap means the same
3044                // helper handles native-CMYK and non-CMYK source images, even
3045                // though `pp.data` is prescaled and we no longer have a
3046                // matching native RGBA buffer.
3047                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
3048                    update_cmyk_buffer_for_image(
3049                        cmyk_buf,
3050                        sample_data,
3051                        pixmap.data(),
3052                        params,
3053                        ctx.vp_x,
3054                        ctx.vp_y,
3055                        ctx.scale_x,
3056                        ctx.scale_y,
3057                        ctx.out_w,
3058                        ctx.out_h,
3059                        &band_state.clip_region,
3060                        ctx.icc,
3061                    );
3062                }
3063            } else {
3064                // Use pre-converted RGBA from image cache when available
3065                let owned_rgba;
3066                let rgba_data: &[u8] = if let Some(cached) =
3067                    ctx.image_cache.and_then(|c| c.get(ctx.elem_idx))
3068                {
3069                    cached
3070                } else {
3071                    owned_rgba = {
3072                        let mut rgba =
3073                            samples_to_rgba(sample_data, params, ctx.icc, ctx.opm_zero_transparent);
3074                        if params.mask_color.is_some() {
3075                            apply_mask_color_rgba(&mut rgba, sample_data, params);
3076                        }
3077                        rgba
3078                    };
3079                    &owned_rgba
3080                };
3081                let expected = (iw * ih * 4) as usize;
3082                if rgba_data.len() < expected {
3083                    return;
3084                }
3085                let Some(image_inv) = params.image_matrix.invert() else {
3086                    return;
3087                };
3088                let combined = params.ctm.concat(&image_inv);
3089                let raw_transform = enforce_min_image_size(ctx.transform(&combined), iw, ih);
3090
3091                // Pre-scale images that are being downscaled. Even non-interpolated
3092                // images need proper area averaging when shrinking — "no interpolation"
3093                // means don't smooth when *upscaling*, but downscaling without averaging
3094                // produces aliased garbage.
3095                let prescaled =
3096                    prescale_image(rgba_data, iw, ih, raw_transform, params.interpolate);
3097                let (img_data, img_w, img_h, transform) = match &prescaled {
3098                    Some((data, w, h, t)) => (data.as_slice(), *w, *h, *t),
3099                    None => (rgba_data, iw, ih, raw_transform),
3100                };
3101
3102                let Some(img_pixmap) =
3103                    stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h)
3104                else {
3105                    return;
3106                };
3107                #[allow(unused_assignments)]
3108                let mut temp_mask = None;
3109                let mask_ref = match &band_state.clip_region {
3110                    None => None,
3111                    Some(ClipRegion::Mask(m)) => Some(m as &Mask),
3112                    Some(ClipRegion::Rect(rect)) => {
3113                        if rect.is_empty() {
3114                            return;
3115                        } else if rect.is_full_page(ctx.out_w, ctx.out_h) {
3116                            None
3117                        } else {
3118                            temp_mask = rect.make_mask(ctx.out_w, ctx.out_h);
3119                            temp_mask.as_ref()
3120                        }
3121                    }
3122                };
3123                let img_paint = stet_tiny_skia::PixmapPaint {
3124                    quality: image_filter_quality(transform, params.interpolate),
3125                    opacity: params.alpha as f32,
3126                    blend_mode: u8_to_blend_mode(params.blend_mode),
3127                };
3128                pixmap.draw_pixmap(0, 0, img_pixmap, &img_paint, transform, mask_ref);
3129
3130                // Update CMYK tracking buffer for non-overprint images. Sample
3131                // the now-composited pixmap so non-CMYK source images can be
3132                // reverse-converted to CMYK via the system profile.
3133                if let Some(ref mut cmyk_buf) = band_state.cmyk_buffer {
3134                    update_cmyk_buffer_for_image(
3135                        cmyk_buf,
3136                        sample_data,
3137                        pixmap.data(),
3138                        params,
3139                        ctx.vp_x,
3140                        ctx.vp_y,
3141                        ctx.scale_x,
3142                        ctx.scale_y,
3143                        ctx.out_w,
3144                        ctx.out_h,
3145                        &band_state.clip_region,
3146                        ctx.icc,
3147                    );
3148                }
3149            }
3150        }
3151        DisplayElement::AxialShading { params } => {
3152            let mut temp_mask = None;
3153            let Some(mask_ref) = resolve_clip_mask(
3154                &band_state.clip_region,
3155                &mut temp_mask,
3156                ctx.out_w,
3157                ctx.out_h,
3158            ) else {
3159                return;
3160            };
3161            render_axial_shading(
3162                pixmap,
3163                params,
3164                ctx.vp_x,
3165                ctx.vp_y,
3166                ctx.scale_x,
3167                ctx.scale_y,
3168                mask_ref,
3169                ctx.no_aa,
3170                band_state.cmyk_buffer.as_deref_mut(),
3171                ctx.icc,
3172            );
3173        }
3174        DisplayElement::RadialShading { params } => {
3175            let mut temp_mask = None;
3176            let Some(mask_ref) = resolve_clip_mask(
3177                &band_state.clip_region,
3178                &mut temp_mask,
3179                ctx.out_w,
3180                ctx.out_h,
3181            ) else {
3182                return;
3183            };
3184            render_radial_shading(
3185                pixmap,
3186                params,
3187                ctx.vp_x,
3188                ctx.vp_y,
3189                ctx.scale_x,
3190                ctx.scale_y,
3191                mask_ref,
3192                ctx.no_aa,
3193                band_state.cmyk_buffer.as_deref_mut(),
3194                ctx.icc,
3195            );
3196        }
3197        DisplayElement::MeshShading { params } => {
3198            let mut temp_mask = None;
3199            let Some(mask_ref) = resolve_clip_mask(
3200                &band_state.clip_region,
3201                &mut temp_mask,
3202                ctx.out_w,
3203                ctx.out_h,
3204            ) else {
3205                return;
3206            };
3207            render_mesh_shading(
3208                pixmap,
3209                params,
3210                ctx.vp_x,
3211                ctx.vp_y,
3212                ctx.scale_x,
3213                ctx.scale_y,
3214                mask_ref,
3215                band_state.cmyk_buffer.as_deref_mut(),
3216                ctx.icc,
3217            );
3218        }
3219        DisplayElement::PatchShading { params } => {
3220            let mut temp_mask = None;
3221            let Some(mask_ref) = resolve_clip_mask(
3222                &band_state.clip_region,
3223                &mut temp_mask,
3224                ctx.out_w,
3225                ctx.out_h,
3226            ) else {
3227                return;
3228            };
3229            render_patch_shading(
3230                pixmap,
3231                params,
3232                ctx.vp_x,
3233                ctx.vp_y,
3234                ctx.scale_x,
3235                ctx.scale_y,
3236                mask_ref,
3237                band_state.cmyk_buffer.as_deref_mut(),
3238                ctx.icc,
3239            );
3240        }
3241        DisplayElement::PatternFill { params } => {
3242            render_pattern_fill(pixmap, band_state, params, ctx);
3243        }
3244        DisplayElement::Group { elements, params } => {
3245            render_group(pixmap, band_state, elements, params, ctx);
3246        }
3247        DisplayElement::SoftMasked {
3248            mask,
3249            content,
3250            params,
3251            mask_cache,
3252        } => {
3253            render_soft_masked(pixmap, band_state, mask, content, params, mask_cache, ctx);
3254        }
3255        DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
3256        DisplayElement::OcgGroup {
3257            elements,
3258            visibility,
3259        } => {
3260            // Visible groups render every child. OFF-by-default groups still
3261            // apply Clip/InitClip so the band's clip state stays in sync —
3262            // otherwise a transient clip from the previous group would leak
3263            // into the next visible one. Paint ops are skipped; that's what
3264            // "hidden layer" means.
3265            let visible = ctx.layer_set.evaluate(visibility);
3266            for (idx, elem) in elements.elements().iter().enumerate() {
3267                if !visible
3268                    && !matches!(elem, DisplayElement::Clip { .. } | DisplayElement::InitClip)
3269                {
3270                    continue;
3271                }
3272                let elem_ctx = RenderContext {
3273                    elem_idx: idx,
3274                    ..*ctx
3275                };
3276                render_element(pixmap, band_state, elem, &elem_ctx);
3277            }
3278        }
3279        _ => {}
3280    }
3281}
3282
3283/// Compute the cropped output-pixel region for a group's device-space bounding box.
3284///
3285/// Returns `(crop_x, crop_y, crop_w, crop_h)` in output pixels, or `None` if
3286/// the group is entirely outside the viewport or cropping isn't worthwhile.
3287fn compute_group_crop(bbox: &[f64; 4], ctx: &RenderContext<'_>) -> Option<(i32, i32, u32, u32)> {
3288    // Transform device-space bbox to output pixel coords
3289    let px_min = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
3290    let py_min = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
3291    let px_max = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
3292    let py_max = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
3293
3294    // Clip to output bounds
3295    let x0 = px_min.max(0);
3296    let y0 = py_min.max(0);
3297    let x1 = px_max.min(ctx.out_w as i32);
3298    let y1 = py_max.min(ctx.out_h as i32);
3299
3300    if x0 >= x1 || y0 >= y1 {
3301        return None;
3302    }
3303
3304    let crop_w = (x1 - x0) as u32;
3305    let crop_h = (y1 - y0) as u32;
3306
3307    // Only crop if it saves at least 25% of pixels
3308    let crop_pixels = crop_w as u64 * crop_h as u64;
3309    let full_pixels = ctx.out_w as u64 * ctx.out_h as u64;
3310    if crop_pixels * 4 >= full_pixels * 3 {
3311        return None;
3312    }
3313
3314    Some((x0, y0, crop_w, crop_h))
3315}
3316
3317/// Apply a separable PDF blend mode in DeviceCMYK using the spec's "effective"
3318/// inversion convention (PDF 1.7 §11.3.5.2): the inverse value `1−c` is used as
3319/// input to the RGB-style blend function, and the result is inverted back.
3320fn blend_cmyk_separable_channel(cb: f64, cs: f64, mode: u8) -> f64 {
3321    let cbi = 1.0 - cb;
3322    let csi = 1.0 - cs;
3323    let result_inv = match mode {
3324        1 => cbi * csi,             // Multiply
3325        2 => cbi + csi - cbi * csi, // Screen
3326        3 => {
3327            // Overlay(b, s) = HardLight(s, b)
3328            if cbi <= 0.5 {
3329                2.0 * cbi * csi
3330            } else {
3331                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3332            }
3333        }
3334        4 => cbi.min(csi), // Darken
3335        5 => cbi.max(csi), // Lighten
3336        6 => {
3337            // ColorDodge
3338            if csi >= 1.0 {
3339                1.0
3340            } else {
3341                (cbi / (1.0 - csi)).min(1.0)
3342            }
3343        }
3344        7 => {
3345            // ColorBurn
3346            if csi <= 0.0 {
3347                0.0
3348            } else {
3349                1.0 - ((1.0 - cbi) / csi).min(1.0)
3350            }
3351        }
3352        8 => {
3353            // HardLight
3354            if csi <= 0.5 {
3355                2.0 * cbi * csi
3356            } else {
3357                1.0 - 2.0 * (1.0 - cbi) * (1.0 - csi)
3358            }
3359        }
3360        9 => {
3361            // SoftLight (Adobe formulation)
3362            let d = if cbi <= 0.25 {
3363                ((16.0 * cbi - 12.0) * cbi + 4.0) * cbi
3364            } else {
3365                cbi.sqrt()
3366            };
3367            if csi <= 0.5 {
3368                cbi - (1.0 - 2.0 * csi) * cbi * (1.0 - cbi)
3369            } else {
3370                cbi + (2.0 * csi - 1.0) * (d - cbi)
3371            }
3372        }
3373        10 => (cbi - csi).abs(),           // Difference
3374        11 => cbi + csi - 2.0 * cbi * csi, // Exclusion
3375        _ => csi,                          // Normal/fallback
3376    };
3377    1.0 - result_inv.clamp(0.0, 1.0)
3378}
3379
3380/// Apply a non-separable HSL-style PDF blend mode (Hue, Saturation, Color,
3381/// Luminosity) in DeviceCMYK. Per the spec, the inverted CMY components are
3382/// treated as "effective RGB" and the standard non-separable formulas are
3383/// applied; the K channel is taken from the source (it acts as the source's
3384/// luminosity contribution for the purposes of the blend).
3385fn blend_cmyk_nonseparable(cb: [f64; 4], cs: [f64; 4], mode: u8) -> [f64; 4] {
3386    fn lum(c: [f64; 3]) -> f64 {
3387        0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
3388    }
3389    fn clip_color(mut c: [f64; 3]) -> [f64; 3] {
3390        let l = lum(c);
3391        let n = c[0].min(c[1]).min(c[2]);
3392        let x = c[0].max(c[1]).max(c[2]);
3393        if n < 0.0 {
3394            for ci in c.iter_mut() {
3395                *ci = l + (*ci - l) * l / (l - n);
3396            }
3397        }
3398        if x > 1.0 {
3399            for ci in c.iter_mut() {
3400                *ci = l + (*ci - l) * (1.0 - l) / (x - l);
3401            }
3402        }
3403        c
3404    }
3405    fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
3406        let d = l - lum(c);
3407        clip_color([c[0] + d, c[1] + d, c[2] + d])
3408    }
3409    fn sat(c: [f64; 3]) -> f64 {
3410        c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
3411    }
3412    fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
3413        // Index components by rank: min, mid, max.
3414        let mut idx = [0usize, 1, 2];
3415        idx.sort_by(|a, b| {
3416            c[*a]
3417                .partial_cmp(&c[*b])
3418                .unwrap_or(std::cmp::Ordering::Equal)
3419        });
3420        let (i_min, i_mid, i_max) = (idx[0], idx[1], idx[2]);
3421        let mut out = c;
3422        if c[i_max] > c[i_min] {
3423            out[i_mid] = (c[i_mid] - c[i_min]) * s / (c[i_max] - c[i_min]);
3424            out[i_max] = s;
3425        } else {
3426            out[i_mid] = 0.0;
3427            out[i_max] = 0.0;
3428        }
3429        out[i_min] = 0.0;
3430        out
3431    }
3432
3433    let cb_rgb = [1.0 - cb[0], 1.0 - cb[1], 1.0 - cb[2]];
3434    let cs_rgb = [1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2]];
3435    let result_rgb = match mode {
3436        12 => set_lum(set_sat(cs_rgb, sat(cb_rgb)), lum(cb_rgb)), // Hue
3437        13 => set_lum(set_sat(cb_rgb, sat(cs_rgb)), lum(cb_rgb)), // Saturation
3438        14 => set_lum(cs_rgb, lum(cb_rgb)),                       // Color
3439        15 => set_lum(cb_rgb, lum(cs_rgb)),                       // Luminosity
3440        _ => cs_rgb,
3441    };
3442    // Hue/Saturation/Color preserve the backdrop's luminosity, which in CMYK
3443    // is carried primarily by the K channel. Luminosity transfers the source's
3444    // luminosity, so it takes K from the source.
3445    let result_k = if mode == 15 { cs[3] } else { cb[3] };
3446    [
3447        (1.0 - result_rgb[0]).clamp(0.0, 1.0),
3448        (1.0 - result_rgb[1]).clamp(0.0, 1.0),
3449        (1.0 - result_rgb[2]).clamp(0.0, 1.0),
3450        result_k,
3451    ]
3452}
3453
3454/// Render a transparency group into a pixmap.
3455/// Device-space axis-aligned bbox of a path, computed from its segment
3456/// endpoints and curve control points. Returned as (x0, y0, x1, y1) with
3457/// x0 ≤ x1, y0 ≤ y1. Returns `None` for an empty path.
3458fn ps_path_bbox(path: &PsPath) -> Option<(f64, f64, f64, f64)> {
3459    let mut it = path.segments.iter().filter_map(|seg| match *seg {
3460        PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => Some(vec![(x, y)]),
3461        PathSegment::CurveTo {
3462            x1,
3463            y1,
3464            x2,
3465            y2,
3466            x3,
3467            y3,
3468        } => Some(vec![(x1, y1), (x2, y2), (x3, y3)]),
3469        PathSegment::ClosePath => None,
3470    });
3471    let first = it.next()?.into_iter().next()?;
3472    let (mut x0, mut y0) = first;
3473    let (mut x1, mut y1) = first;
3474    for seg_points in std::iter::once(vec![first]).chain(it) {
3475        for (x, y) in seg_points {
3476            x0 = x0.min(x);
3477            y0 = y0.min(y);
3478            x1 = x1.max(x);
3479            y1 = y1.max(y);
3480        }
3481    }
3482    Some((x0, y0, x1, y1))
3483}
3484
3485/// True when rectangle `inner` fits inside `outer` with `tolerance` slack
3486/// (positive tolerance = inner may protrude by up to `tolerance` units).
3487fn bbox_contains(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64), tolerance: f64) -> bool {
3488    inner.0 >= outer.0 - tolerance
3489        && inner.1 >= outer.1 - tolerance
3490        && inner.2 <= outer.2 + tolerance
3491        && inner.3 <= outer.3 + tolerance
3492}
3493
3494/// Detect the GWG "reference-under-test" authoring pattern: a parent Fill
3495/// that will be fully covered by the first Fill of a following isolated
3496/// transparency group. When detected, the parent's Fill can be skipped —
3497/// its AA edges otherwise bleed into the dest under the group's partial-
3498/// alpha source during composite-back, producing a visible outline where
3499/// Acrobat shows none (see GWG 16.2 Opacity(0%) analysis in
3500/// `project_icc_profile_stability.md`).
3501///
3502/// Returns indices in `elements` that should be skipped. Safety conditions:
3503///   1. Parent fill is fully opaque, Normal blend.
3504///   2. Next paint (ignoring Clip/InitClip) is an isolated, alpha-1,
3505///      Normal-blend Group whose first paint is a Fill with matching
3506///      path (within tolerance) and the same opacity/blend conditions.
3507///   3. The group's declared bbox fully contains the parent path's bbox
3508///      — i.e. the form's own BBox clip won't carve the fill away.
3509///   4. Every Clip element between the parent fill and the group, and
3510///      every Clip between the group's start and its first fill, has a
3511///      bbox that also fully contains the parent path — so no additional
3512///      clip can cut the group's first fill to a subset of the parent's
3513///      extent.
3514///   5. PDF's isolated transparency semantics guarantee that once the
3515///      first fill establishes alpha=1 at the parent-path pixels, later
3516///      Normal-blend paints can only add colour there; alpha can't
3517///      decrease. So nothing in the group's tail can re-expose backdrop,
3518///      even without auditing those elements explicitly.
3519fn compute_obscured_fill_skips(elements: &DisplayList) -> Vec<usize> {
3520    let mut skips = Vec::new();
3521    let els = elements.elements();
3522    for i in 0..els.len() {
3523        let DisplayElement::Fill {
3524            path: parent_path,
3525            params: parent_params,
3526        } = &els[i]
3527        else {
3528            continue;
3529        };
3530        if (parent_params.alpha - 1.0).abs() > 1e-6 || parent_params.blend_mode != 0 {
3531            continue;
3532        }
3533        let Some(parent_bbox) = ps_path_bbox(parent_path) else {
3534            continue;
3535        };
3536        // Walk forward past Clip/InitClip between parent fill and the
3537        // group. Each such clip must contain the parent's extent; any
3538        // other element type ends the scan.
3539        let mut j = i + 1;
3540        let mut clips_ok = true;
3541        while j < els.len() {
3542            match &els[j] {
3543                DisplayElement::InitClip => {}
3544                DisplayElement::Clip {
3545                    path: clip_path, ..
3546                } => match ps_path_bbox(clip_path) {
3547                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3548                    _ => {
3549                        clips_ok = false;
3550                        break;
3551                    }
3552                },
3553                _ => break,
3554            }
3555            j += 1;
3556        }
3557        if !clips_ok {
3558            continue;
3559        }
3560        let Some(DisplayElement::Group {
3561            elements: group_elements,
3562            params: group_params,
3563        }) = els.get(j)
3564        else {
3565            continue;
3566        };
3567        if !group_params.isolated
3568            || (group_params.alpha - 1.0).abs() > 1e-6
3569            || group_params.blend_mode != 0
3570        {
3571            continue;
3572        }
3573        // The form's declared BBox acts as a clip inside the group; the
3574        // parent's fill must fit inside it or the group's output will be
3575        // carved away where we'd rely on coverage.
3576        let group_bbox = (
3577            group_params.bbox[0],
3578            group_params.bbox[1],
3579            group_params.bbox[2],
3580            group_params.bbox[3],
3581        );
3582        if !bbox_contains(group_bbox, parent_bbox, 0.5) {
3583            continue;
3584        }
3585        // Walk past Clip/InitClip inside the group to its first paint,
3586        // requiring each clip to contain the parent's extent.
3587        let inner_els = group_elements.elements();
3588        let mut k = 0;
3589        let mut inner_clips_ok = true;
3590        while k < inner_els.len() {
3591            match &inner_els[k] {
3592                DisplayElement::InitClip => {}
3593                DisplayElement::Clip {
3594                    path: clip_path, ..
3595                } => match ps_path_bbox(clip_path) {
3596                    Some(cb) if bbox_contains(cb, parent_bbox, 0.5) => {}
3597                    _ => {
3598                        inner_clips_ok = false;
3599                        break;
3600                    }
3601                },
3602                _ => break,
3603            }
3604            k += 1;
3605        }
3606        if !inner_clips_ok {
3607            continue;
3608        }
3609        let Some(DisplayElement::Fill {
3610            path: group_path,
3611            params: group_fill_params,
3612        }) = inner_els.get(k)
3613        else {
3614            continue;
3615        };
3616        if (group_fill_params.alpha - 1.0).abs() > 1e-6 || group_fill_params.blend_mode != 0 {
3617            continue;
3618        }
3619        if paths_approximately_equal(parent_path, group_path, 0.5) {
3620            skips.push(i);
3621        }
3622    }
3623    skips
3624}
3625
3626/// True when two device-space paths have the same segment sequence and
3627/// matching endpoints within `tolerance` device pixels per coordinate.
3628/// Used by `compute_obscured_fill_skips` to recognise PDF-authored patterns
3629/// where the same logical X path is emitted twice with sub-unit rounding
3630/// differences (GWG test suite authoring style from InDesign CS6).
3631fn paths_approximately_equal(a: &PsPath, b: &PsPath, tolerance: f64) -> bool {
3632    if a.segments.len() != b.segments.len() {
3633        return false;
3634    }
3635    for (sa, sb) in a.segments.iter().zip(b.segments.iter()) {
3636        let close_pair = |(x1, y1): (f64, f64), (x2, y2): (f64, f64)| -> bool {
3637            (x1 - x2).abs() <= tolerance && (y1 - y2).abs() <= tolerance
3638        };
3639        match (sa, sb) {
3640            (PathSegment::MoveTo(x1, y1), PathSegment::MoveTo(x2, y2)) => {
3641                if !close_pair((*x1, *y1), (*x2, *y2)) {
3642                    return false;
3643                }
3644            }
3645            (PathSegment::LineTo(x1, y1), PathSegment::LineTo(x2, y2)) => {
3646                if !close_pair((*x1, *y1), (*x2, *y2)) {
3647                    return false;
3648                }
3649            }
3650            (
3651                PathSegment::CurveTo {
3652                    x1: ax1,
3653                    y1: ay1,
3654                    x2: ax2,
3655                    y2: ay2,
3656                    x3: ax3,
3657                    y3: ay3,
3658                },
3659                PathSegment::CurveTo {
3660                    x1: bx1,
3661                    y1: by1,
3662                    x2: bx2,
3663                    y2: by2,
3664                    x3: bx3,
3665                    y3: by3,
3666                },
3667            ) => {
3668                if !close_pair((*ax1, *ay1), (*bx1, *by1))
3669                    || !close_pair((*ax2, *ay2), (*bx2, *by2))
3670                    || !close_pair((*ax3, *ay3), (*bx3, *by3))
3671                {
3672                    return false;
3673                }
3674            }
3675            (PathSegment::ClosePath, PathSegment::ClosePath) => {}
3676            _ => return false,
3677        }
3678    }
3679    true
3680}
3681
3682///
3683/// Creates an offscreen pixmap, renders the group's child elements into it,
3684/// then composites back onto the parent with the group's blend mode and alpha.
3685fn render_group(
3686    pixmap: &mut Pixmap,
3687    band_state: &mut BandState,
3688    elements: &DisplayList,
3689    params: &stet_graphics::display_list::GroupParams,
3690    ctx: &RenderContext<'_>,
3691) {
3692    if params.knockout {
3693        render_knockout_group(pixmap, band_state, elements, params, ctx);
3694        return;
3695    }
3696
3697    let crop = compute_group_crop(&params.bbox, ctx);
3698
3699    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
3700        Some((cx, cy, cw, ch)) => (
3701            cw,
3702            ch,
3703            cx,
3704            cy,
3705            ctx.vp_x + cx as f32 / ctx.scale_x,
3706            ctx.vp_y + cy as f32 / ctx.scale_y,
3707        ),
3708        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
3709    };
3710
3711    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
3712        return;
3713    };
3714
3715    // Decide upfront whether the composite-back will run in CMYK. The CMYK
3716    // path needs the parent backdrop pre-loaded into the offscreen so that
3717    // per-element painting accumulates in the right starting state. The
3718    // sRGB contribution-extraction path renders against an empty offscreen
3719    // for non-Normal BMs to avoid anti-aliased clip artifacts at the BBox
3720    // edges (the diff-against-backdrop logic mishandles partially-blended
3721    // edge pixels otherwise).
3722    use stet_graphics::display_list::GroupColorSpace;
3723
3724    // Allocate a CMYK buffer for the group when:
3725    //   - it tracks overprint, OR
3726    //   - the parent already has one (CMYK context inheritance), OR
3727    //   - this group itself or one of its descendants declares an explicit
3728    //     `/CS DeviceCMYK`, meaning compositing within it needs CMYK math.
3729    let needs_group_cmyk = has_overprint_elements(elements)
3730        || band_state.cmyk_buffer.is_some()
3731        || params.color_space == GroupColorSpace::DeviceCMYK
3732        || has_cmyk_group(elements);
3733
3734    // Decide whether to run the per-pixel CMYK composite-back. The default
3735    // (gated) rule restricts it to the cases the prior rendering session
3736    // explicitly validated. The `STET_FORCE_CMYK_COMPOSITE_BACK=1` env var
3737    // bypasses both gates and switches to the principled rule that the rest
3738    // of this plan will adopt — useful for A/B-comparing the broader fix
3739    // before flipping the default in Step 9.
3740    let force_cmyk_compose =
3741        std::env::var_os("STET_FORCE_CMYK_COMPOSITE_BACK").as_deref() == Some("1".as_ref());
3742    // The knockout group's coverage pass disables CMYK composite-back so the
3743    // painter falls through to the simple sRGB draw_pixmap path. Without this,
3744    // a white-source painter (CMYK 0,0,0,0) would be skipped by the
3745    // composite-back's "source==backdrop" guard against the transparent
3746    // coverage backdrop, and pass 2 wouldn't capture the painter's coverage.
3747    //
3748    // The color pass widens the gate to all non-Normal blend modes so a
3749    // `/CS DeviceCMYK` knockout group's painters with separable blends like
3750    // Screen / ColorDodge / Overlay / SoftLight blend in CMYK math (matching
3751    // the spec) instead of in tiny-skia's sRGB blend.
3752    let plan_cmyk_compose = match ctx.knockout_painter_pass {
3753        KnockoutPainterPass::CoveragePass => false,
3754        KnockoutPainterPass::ColorPass => {
3755            !params.isolated
3756                && params.blend_mode != 0
3757                && needs_group_cmyk
3758                && band_state.cmyk_buffer.is_some()
3759                && group_content_is_native_cmyk(elements)
3760        }
3761        KnockoutPainterPass::None if force_cmyk_compose => {
3762            // Principled rule: non-isolated group with an inversion-sensitive
3763            // blend mode (Difference, Exclusion, Hue, Saturation, Color,
3764            // Luminosity) whose painters all supply native CMYK source colors.
3765            //
3766            // The blend-mode restriction is intentional: bm 10..=15 produce
3767            // visibly *wrong* results in sRGB (the GWG 16.0 transparency test
3768            // exists exactly to expose this), so CMYK math is unambiguously
3769            // correct there. The separable modes 1..=9 (Multiply, Screen, etc.)
3770            // are spec-defensible in either color space but look noticeably
3771            // different — most renderers blend them in sRGB, and PDFs authored
3772            // for that look "wrong" if we suddenly switch them to CMYK math.
3773            //
3774            // The painter-set restriction (no shadings, no non-CMYK content)
3775            // exists because the parallel CMYK buffer can only faithfully track
3776            // single-CMYK-value-per-pixel painters; gradients interpolate
3777            // differently in pixmap RGB vs buffer CMYK and the divergence makes
3778            // the composite-back read stale source values.
3779            !params.isolated
3780                && matches!(params.blend_mode, 10..=15)
3781                && needs_group_cmyk
3782                && band_state.cmyk_buffer.is_some()
3783                && group_content_is_native_cmyk(elements)
3784        }
3785        KnockoutPainterPass::None => {
3786            // Default rule: only the inversion-sensitive blend modes
3787            // (Difference, Exclusion, HSL non-separable) need CMYK math; the
3788            // separable modes 1..=9 are spec-defensible in either color space
3789            // and most sRGB-authored PDFs expect them to blend in sRGB.
3790            let inversion_sensitive = !params.isolated
3791                && matches!(params.blend_mode, 10..=15)
3792                && group_only_native_cmyk_fills(elements);
3793            // GWG 16.2 ("Transparency Basic Blend Modes — DeviceCMYK,
3794            // Isolated") nests non-isolated `/CS DeviceCMYK` painter sub-groups
3795            // inside an isolated `/CS DeviceCMYK` group, with the swatch's
3796            // blend mode applied at the inner Do. Per PDF spec §11.6.7 the
3797            // compositing for those inner groups must happen in DeviceCMYK,
3798            // not sRGB — otherwise their colored X-shape produces the wrong
3799            // color and fails to cover the painter-A black X. The explicit
3800            // `/CS DeviceCMYK` declaration plus the isolated parent are the
3801            // spec signal that the author wants CMYK-space compositing for
3802            // a fresh transparent backdrop. The `parent_group_isolated`
3803            // gate keeps the rule from firing for non-isolated parents like
3804            // 907 page 28's chart panels, where the existing sRGB
3805            // contribution-extraction path correctly preserves anti-aliased
3806            // gray strokes.
3807            //
3808            // GWG 16.1 ("Transparency Basic Blend Modes — ICCBasedRGB")
3809            // exercises the same DeviceCMYK page group but the parent is
3810            // *non-isolated*, so the `parent_group_isolated` gate refused
3811            // to fire and every separable blend swatch fell back to sRGB
3812            // blending (visible as the test's "X" markers). PDF/X
3813            // workflows already declare their target compositing space via
3814            // `/OutputIntents`, and the proofing chain in
3815            // `register_profile_with_n` flips `IccCache::proofing_enabled`
3816            // on once that's been honoured. Use that as the PDF/X-specific
3817            // signal for "blend in DeviceCMYK regardless of group
3818            // isolation"; non-proofing documents (907 p28 et al.) keep
3819            // the original `parent_group_isolated` requirement.
3820            let proofing_enabled = ctx.icc.is_some_and(|c| c.proofing_enabled());
3821            // Per PDF 1.7 §11.6.6, a transparency group with no `/CS` inherits
3822            // its color space from the enclosing group. When the parent has
3823            // already allocated a CMYK buffer (the only way `cmyk_buffer` is
3824            // `Some` on this band_state when we enter `render_group`), the
3825            // parent's effective compositing space is DeviceCMYK and an
3826            // `Inherited` child should join it. Without this, GWG 16.4 swatch
3827            // groups (no `/CS`) fell back to sRGB blending and the Multiply /
3828            // Color Burn blends produced visible X markers.
3829            let effective_cs_is_cmyk = params.color_space == GroupColorSpace::DeviceCMYK
3830                || (params.color_space == GroupColorSpace::Inherited
3831                    && band_state.cmyk_buffer.is_some());
3832            let cmyk_group_blend = !params.isolated
3833                && (ctx.parent_group_isolated || proofing_enabled)
3834                && params.blend_mode != 0
3835                && effective_cs_is_cmyk
3836                && needs_group_cmyk
3837                && band_state.cmyk_buffer.is_some()
3838                && group_content_is_native_cmyk(elements);
3839            inversion_sensitive || cmyk_group_blend
3840        }
3841    };
3842    // Non-isolated groups with non-Normal blend modes on the sRGB path
3843    // need a two-pass render: once against the backdrop (for correct
3844    // internal blending) and once against transparent (to extract the
3845    // group's shape/alpha for the proper source-contribution formula).
3846    let needs_alpha_extraction = !params.isolated
3847        && params.blend_mode != 0
3848        && !plan_cmyk_compose
3849        && !ctx.alpha_extraction_pass;
3850    let needs_backdrop_preload =
3851        !params.isolated && (params.blend_mode == 0 || plan_cmyk_compose || needs_alpha_extraction);
3852    let backdrop = if needs_backdrop_preload {
3853        let data = if crop.is_some() {
3854            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
3855        } else {
3856            pixmap.data().to_vec()
3857        };
3858        offscreen.data_mut().copy_from_slice(&data);
3859        Some(data)
3860    } else {
3861        None
3862    };
3863    let group_cmyk = if needs_group_cmyk {
3864        let buf_size = eff_w as usize * eff_h as usize * 4;
3865        let mut buf = vec![0.0f32; buf_size];
3866        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
3867            let parent_stride = ctx.out_w as usize * 4;
3868            let group_stride = eff_w as usize * 4;
3869            for gy in 0..eff_h as usize {
3870                let py = crop_y as usize + gy;
3871                if py < ctx.out_h as usize {
3872                    let p_start = py * parent_stride + crop_x as usize * 4;
3873                    let g_start = gy * group_stride;
3874                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
3875                    buf[g_start..g_start + copy_len]
3876                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
3877                }
3878            }
3879        }
3880        Some(buf)
3881    } else {
3882        None
3883    };
3884
3885    // Snapshot the pre-load CMYK so the composite-back can identify pixels
3886    // the group actually modified. Without a separate snapshot we'd have to
3887    // diff against the parent CMYK buffer, which would lose any in-place
3888    // updates to the parent across the group's lifetime.
3889    let backdrop_cmyk: Option<Vec<f32>> = if !params.isolated {
3890        group_cmyk.clone()
3891    } else {
3892        None
3893    };
3894
3895    let mut group_band = BandState {
3896        clip_region: None,
3897        spare_mask: None,
3898        clip_mask_cache: HashMap::new(),
3899        clip_mask_seen: HashSet::new(),
3900        mask_pool: Vec::new(),
3901        cmyk_buffer: group_cmyk,
3902        op_bg_snapshot: None,
3903        op_touched: None,
3904        spot_mask: None,
3905    };
3906
3907    let group_ctx = RenderContext {
3908        vp_x: eff_vp_x,
3909        vp_y: eff_vp_y,
3910        scale_x: ctx.scale_x,
3911        scale_y: ctx.scale_y,
3912        out_w: eff_w,
3913        out_h: eff_h,
3914        effective_dpi: ctx.effective_dpi,
3915        icc: ctx.icc,
3916        image_cache: None, // Group elements don't use parent image cache
3917        preprocessed: None,
3918        elem_idx: 0,
3919        no_aa: ctx.no_aa,
3920        opm_zero_transparent: ctx.opm_zero_transparent,
3921        knockout_painter_pass: ctx.knockout_painter_pass,
3922        // The children of this group see *this* group as their parent.
3923        parent_group_isolated: params.isolated,
3924        alpha_extraction_pass: ctx.alpha_extraction_pass,
3925        layer_set: ctx.layer_set,
3926    };
3927
3928    let skip_indices = compute_obscured_fill_skips(elements);
3929    for (idx, elem) in elements.elements().iter().enumerate() {
3930        if skip_indices.contains(&idx) {
3931            continue;
3932        }
3933        let elem_ctx = RenderContext {
3934            elem_idx: idx,
3935            ..group_ctx
3936        };
3937        render_element(&mut offscreen, &mut group_band, elem, &elem_ctx);
3938    }
3939
3940    // Second pass: render against transparent to extract the group's
3941    // shape/alpha.  Only needed for the sRGB two-pass composite-back
3942    // path (non-isolated, non-Normal blend, no CMYK compose).
3943    let alpha_offscreen = if needs_alpha_extraction {
3944        let mut iso = Pixmap::new(eff_w, eff_h);
3945        if let Some(ref mut iso_pm) = iso {
3946            let mut iso_band = BandState {
3947                clip_region: None,
3948                spare_mask: None,
3949                clip_mask_cache: HashMap::new(),
3950                clip_mask_seen: HashSet::new(),
3951                mask_pool: Vec::new(),
3952                cmyk_buffer: None,
3953                op_bg_snapshot: None,
3954                op_touched: None,
3955                spot_mask: None,
3956            };
3957            let iso_ctx = RenderContext {
3958                parent_group_isolated: true,
3959                alpha_extraction_pass: true,
3960                ..group_ctx
3961            };
3962            for (idx, elem) in elements.elements().iter().enumerate() {
3963                let elem_ctx = RenderContext {
3964                    elem_idx: idx,
3965                    ..iso_ctx
3966                };
3967                render_element(iso_pm, &mut iso_band, elem, &elem_ctx);
3968            }
3969        }
3970        iso
3971    } else {
3972        None
3973    };
3974
3975    let mut temp_mask = None;
3976    let mask_ref = match resolve_clip_mask(
3977        &band_state.clip_region,
3978        &mut temp_mask,
3979        ctx.out_w,
3980        ctx.out_h,
3981    ) {
3982        None => return, // empty clip → nothing visible
3983        Some(m) => m,
3984    };
3985
3986    // Coverage pass override: force opacity 1.0 + Normal blend so the
3987    // painter's shape reaches the coverage offscreen even when the
3988    // original alpha was 0 (Opacity 0% test) or the blend mode would
3989    // erase the source against the transparent coverage backdrop.
3990    let coverage_params;
3991    let effective_params: &stet_graphics::display_list::GroupParams =
3992        if ctx.knockout_painter_pass == KnockoutPainterPass::CoveragePass {
3993            coverage_params = stet_graphics::display_list::GroupParams {
3994                alpha: 1.0,
3995                blend_mode: 0,
3996                ..params.clone()
3997            };
3998            &coverage_params
3999        } else {
4000            params
4001        };
4002
4003    let mut cmyk_compose_done = false;
4004    if let Some(backdrop) = &backdrop {
4005        // Non-isolated group. For the inversion-sensitive blend modes
4006        // (Difference, Exclusion) and the HSL non-separable modes (Hue,
4007        // Saturation, Color, Luminosity), tiny-skia's sRGB blend math gives
4008        // visibly wrong results for the GWG 16.0 transparency test, where
4009        // the source colors are chosen so that, in CMYK, the blend produces
4010        // the backdrop color exactly. Run the composite-back per pixel in
4011        // CMYK for those modes when the inner content is exclusively
4012        // native-CMYK fills (so the inner CMYK buffer faithfully represents
4013        // the source). The other separable modes (Multiply / Lighten /
4014        // Darken / etc.) and non-CMYK content stay on the existing sRGB
4015        // contribution-extraction path because their CMYK pipeline currently
4016        // depends on `interpolate_cmyk_from_stops`, which derives CMYK from
4017        // sRGB via the lossy `(1−r,1−g,1−b,0)` inverse for shadings/images
4018        // and would shift their colors. Lifting that restriction requires
4019        // computing exact CMYK from each shading/image's source color space
4020        // (e.g. running the DeviceN tint transform), which is a larger
4021        // change than this fix attempts.
4022        let inner_cmyk = group_band.cmyk_buffer.as_deref();
4023        let pre_cmyk = backdrop_cmyk.as_deref();
4024        if plan_cmyk_compose && let (Some(inner), Some(pre)) = (inner_cmyk, pre_cmyk) {
4025            composite_non_isolated_cmyk(
4026                pixmap,
4027                band_state.cmyk_buffer.as_deref_mut(),
4028                &offscreen,
4029                inner,
4030                pre,
4031                backdrop,
4032                effective_params,
4033                mask_ref,
4034                crop_x,
4035                crop_y,
4036                ctx.icc,
4037            );
4038            cmyk_compose_done = true;
4039        } else if let Some(ref alpha_os) = alpha_offscreen {
4040            composite_non_isolated_extracted(
4041                pixmap,
4042                &offscreen,
4043                alpha_os,
4044                backdrop,
4045                effective_params,
4046                mask_ref,
4047                crop_x,
4048                crop_y,
4049            );
4050        } else {
4051            composite_non_isolated_group_cropped(
4052                pixmap,
4053                &offscreen,
4054                backdrop,
4055                effective_params,
4056                mask_ref,
4057                crop_x,
4058                crop_y,
4059            );
4060        }
4061    } else {
4062        let paint = stet_tiny_skia::PixmapPaint {
4063            opacity: effective_params.alpha as f32,
4064            blend_mode: u8_to_blend_mode(effective_params.blend_mode),
4065            quality: stet_tiny_skia::FilterQuality::Nearest,
4066        };
4067        pixmap.draw_pixmap(
4068            crop_x,
4069            crop_y,
4070            offscreen.as_ref(),
4071            &paint,
4072            Transform::identity(),
4073            mask_ref,
4074        );
4075    }
4076
4077    // Write group CMYK buffer back to parent. Skip when the CMYK composite-back
4078    // already wrote the blended values into the parent CMYK buffer — running
4079    // `copy_cmyk_buffer_to_parent` afterwards would overwrite those blended
4080    // values with the inner buffer's raw source colors, breaking subsequent
4081    // siblings that read the parent CMYK as their backdrop.
4082    if !cmyk_compose_done
4083        && let (Some(group_cmyk), Some(parent_cmyk)) =
4084            (&group_band.cmyk_buffer, &mut band_state.cmyk_buffer)
4085    {
4086        copy_cmyk_buffer_to_parent(
4087            parent_cmyk,
4088            group_cmyk,
4089            offscreen.data(),
4090            crop_x as usize,
4091            crop_y as usize,
4092            eff_w as usize,
4093            eff_h as usize,
4094            ctx.out_w as usize,
4095            ctx.out_h as usize,
4096        );
4097    }
4098}
4099
4100/// CMYK-aware composite-back for a non-isolated transparency group.
4101///
4102/// For each pixel in the group's region:
4103///   1. If the inner CMYK buffer matches the snapshot taken when the group
4104///      started, the group painted nothing there → leave the parent unchanged.
4105///   2. Otherwise apply the group blend mode in DeviceCMYK using the spec's
4106///      effective inversion formulas (`blend_cmyk_separable_channel` or
4107///      `blend_cmyk_nonseparable`), convert the result to sRGB through the
4108///      ICC system CMYK profile so it sits seamlessly next to the rest of the
4109///      page, and write the result to both the parent pixmap and (when
4110///      present) the parent CMYK buffer.
4111#[allow(clippy::too_many_arguments)]
4112fn composite_non_isolated_cmyk(
4113    target: &mut Pixmap,
4114    parent_cmyk: Option<&mut [f32]>,
4115    source: &Pixmap,
4116    source_cmyk: &[f32],
4117    backdrop_cmyk: &[f32],
4118    backdrop_pixels: &[u8],
4119    params: &stet_graphics::display_list::GroupParams,
4120    clip_mask: Option<&stet_tiny_skia::Mask>,
4121    crop_x: i32,
4122    crop_y: i32,
4123    icc: Option<&IccCache>,
4124) {
4125    let cw = source.width() as usize;
4126    let ch = source.height() as usize;
4127    let target_w = target.width() as usize;
4128    let target_h = target.height() as usize;
4129
4130    let opacity = params.alpha.clamp(0.0, 1.0);
4131    let blend_mode = params.blend_mode;
4132    let is_nonseparable = matches!(blend_mode, 12..=15);
4133
4134    let target_data = target.data_mut();
4135    let target_stride = target_w * 4;
4136    let group_stride = cw * 4;
4137
4138    let clip_data = clip_mask.map(|m| m.data());
4139
4140    for gy in 0..ch {
4141        let ty = crop_y + gy as i32;
4142        if ty < 0 || ty as usize >= target_h {
4143            continue;
4144        }
4145        let ty = ty as usize;
4146        let group_row = gy * group_stride;
4147        let target_row = ty * target_stride;
4148
4149        for gx in 0..cw {
4150            let tx = crop_x + gx as i32;
4151            if tx < 0 || tx as usize >= target_w {
4152                continue;
4153            }
4154            let tx = tx as usize;
4155            let gi = group_row + gx * 4;
4156            let ti = target_row + tx * 4;
4157
4158            // Did the group actually paint this pixel?
4159            let bc = backdrop_cmyk[gi] as f64;
4160            let bm = backdrop_cmyk[gi + 1] as f64;
4161            let by_ = backdrop_cmyk[gi + 2] as f64;
4162            let bk = backdrop_cmyk[gi + 3] as f64;
4163            let sc = source_cmyk[gi] as f64;
4164            let sm = source_cmyk[gi + 1] as f64;
4165            let sy_ = source_cmyk[gi + 2] as f64;
4166            let sk = source_cmyk[gi + 3] as f64;
4167            if (sc - bc).abs() < 1.0 / 255.0
4168                && (sm - bm).abs() < 1.0 / 255.0
4169                && (sy_ - by_).abs() < 1.0 / 255.0
4170                && (sk - bk).abs() < 1.0 / 255.0
4171            {
4172                continue;
4173            }
4174
4175            // Clip mask coverage in target coordinates.
4176            let cov = if let Some(cd) = clip_data {
4177                cd[ty * target_w + tx] as f64 / 255.0
4178            } else {
4179                1.0
4180            };
4181            if cov <= 0.0 {
4182                continue;
4183            }
4184
4185            // Transparent-backdrop fast path: when the backdrop pixmap's alpha
4186            // is 0 the parent group hasn't painted this pixel, so PDF spec
4187            // §11.4.6 says the blended result reduces to α_s · source — the
4188            // blend formula must NOT be applied. Without this check, formulas
4189            // like ColorBurn / ColorDodge / Lighten / Screen produce visibly
4190            // wrong colors (yellow instead of orange-yellow, white instead of
4191            // the source) because an all-zero CMYK backdrop is identical to
4192            // opaque white in CMYK terms. Using the pixmap alpha as the
4193            // sentinel correctly distinguishes "truly nothing painted"
4194            // (alpha 0) from "white painted" (alpha 1, CMYK 0,0,0,0).
4195            //
4196            // For this branch we composite the source pixmap directly via
4197            // SourceOver (rather than converting source CMYK→sRGB) so the
4198            // source's per-pixel alpha — including anti-aliased edges and
4199            // partially-transparent paint like 907 page 28's gray rules —
4200            // is preserved. The CMYK→sRGB direct path used the un-modulated
4201            // painter color and the group opacity, which forced antialiased
4202            // gray strokes to opaque black.
4203            let backdrop_alpha = backdrop_pixels[gi + 3];
4204            let backdrop_transparent = backdrop_alpha == 0;
4205
4206            let mix = cov * opacity;
4207            let dst_a = target_data[ti + 3] as f64 / 255.0;
4208
4209            if backdrop_transparent {
4210                // SourceOver of the source pixmap (already correctly rendered
4211                // for transparent-backdrop semantics) modulated by the group's
4212                // mix factor. To ensure inner-group AA edges don't leave
4213                // sliver gaps where the outer parent pixmap had previously
4214                // drawn a near-identical path (GWG 16.2 directly-drawn black
4215                // X covered by Painter B's slightly-offset colored X), we
4216                // promote any non-zero source alpha to the painter's full
4217                // unpremultiplied source CMYK converted to sRGB. This
4218                // produces fully-opaque coverage at edge pixels matching
4219                // what the inner painter would render at the path interior,
4220                // so the inner group can fully knock out the outer's AA
4221                // edge when composited back to its parent.
4222                let src_data = source.data();
4223                let src_a_pm = src_data[gi + 3] as f64 / 255.0;
4224                if src_a_pm <= 0.0 {
4225                    continue;
4226                }
4227                // Convert source CMYK directly to sRGB. The CMYK at this
4228                // pixel was written by the inner painter at its full
4229                // un-modulated value (the cmyk_buf doesn't track AA), so
4230                // this is the pure painter color regardless of AA cov.
4231                let (full_r, full_g, full_b) = icc
4232                    .and_then(|i| i.convert_cmyk_readonly(sc, sm, sy_, sk))
4233                    .unwrap_or_else(|| cmyk_to_rgb_plrm(sc, sm, sy_, sk));
4234                let alpha_s = mix;
4235                let inv_sa = 1.0 - alpha_s;
4236                let dst_r_pm = target_data[ti] as f64 / 255.0;
4237                let dst_g_pm = target_data[ti + 1] as f64 / 255.0;
4238                let dst_b_pm = target_data[ti + 2] as f64 / 255.0;
4239                let out_r = full_r * alpha_s + dst_r_pm * inv_sa;
4240                let out_g = full_g * alpha_s + dst_g_pm * inv_sa;
4241                let out_b = full_b * alpha_s + dst_b_pm * inv_sa;
4242                let out_a = alpha_s + dst_a * inv_sa;
4243                target_data[ti] = (out_r * 255.0).round().clamp(0.0, 255.0) as u8;
4244                target_data[ti + 1] = (out_g * 255.0).round().clamp(0.0, 255.0) as u8;
4245                target_data[ti + 2] = (out_b * 255.0).round().clamp(0.0, 255.0) as u8;
4246                target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4247                continue;
4248            }
4249
4250            // Apply the group's blend mode in CMYK.
4251            let (rc, rm, ry, rk) = if is_nonseparable {
4252                let r = blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4253                (r[0], r[1], r[2], r[3])
4254            } else {
4255                (
4256                    blend_cmyk_separable_channel(bc, sc, blend_mode),
4257                    blend_cmyk_separable_channel(bm, sm, blend_mode),
4258                    blend_cmyk_separable_channel(by_, sy_, blend_mode),
4259                    blend_cmyk_separable_channel(bk, sk, blend_mode),
4260                )
4261            };
4262
4263            let (new_r, new_g, new_b) = icc
4264                .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
4265                .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
4266
4267            // tiny-skia stores premultiplied sRGB. Apply the PDF
4268            // §11.4.6 result formula in straight-color form. We force the
4269            // source alpha to 1 (subject to clip + group opacity) at any
4270            // pixel where the source CMYK was written by the inner painter
4271            // — the cmyk_buf flags coverage at the path's full extent, even
4272            // at AA edges. Using full alpha here ensures the inner group
4273            // fully covers the outer parent's previously-drawn content
4274            // when both reference near-identical paths (GWG 16.2 directly-
4275            // drawn outer X path covered by Painter B's slightly-offset
4276            // colored X path). Without this, the formula's partial-cover
4277            // mix produces a 1-pixel sliver of darker color where the two
4278            // paths' rasterizations diverge sub-pixel-wise.
4279            let alpha_s = mix;
4280            let alpha_b = dst_a;
4281            let out_a = alpha_s + alpha_b * (1.0 - alpha_s);
4282            if out_a <= 0.0 {
4283                continue;
4284            }
4285            let (dst_r, dst_g, dst_b) = if alpha_b > 0.0 {
4286                let inv_a = 1.0 / alpha_b;
4287                (
4288                    (target_data[ti] as f64 / 255.0) * inv_a,
4289                    (target_data[ti + 1] as f64 / 255.0) * inv_a,
4290                    (target_data[ti + 2] as f64 / 255.0) * inv_a,
4291                )
4292            } else {
4293                (0.0, 0.0, 0.0)
4294            };
4295            // Spec §11.4.6 result computation:
4296            //   C_o = (α_s·(1−α_b)·C_s + α_s·α_b·B(C_b,C_s) + (1−α_s)·α_b·C_b) / α_o
4297            // Here we already have B(C_b,C_s) computed in CMYK and converted
4298            // to sRGB as (new_r, new_g, new_b). The "C_s" term — the source
4299            // color un-blended — uses the same value because the spec says
4300            // when α_b = 0 the formula reduces to source-as-is, which the
4301            // (1−α_b) coefficient already handles.
4302            let coef_b = alpha_s * alpha_b;
4303            let coef_s = alpha_s * (1.0 - alpha_b);
4304            let coef_d = (1.0 - alpha_s) * alpha_b;
4305            let out_r = (coef_s * new_r + coef_b * new_r + coef_d * dst_r) / out_a;
4306            let out_g = (coef_s * new_g + coef_b * new_g + coef_d * dst_g) / out_a;
4307            let out_b = (coef_s * new_b + coef_b * new_b + coef_d * dst_b) / out_a;
4308
4309            target_data[ti] = (out_r * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4310            target_data[ti + 1] = (out_g * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4311            target_data[ti + 2] = (out_b * out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4312            target_data[ti + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
4313        }
4314    }
4315
4316    // Write the blended CMYK back to the parent CMYK buffer so subsequent
4317    // sibling groups see consistent backdrop values. We re-walk the same
4318    // region — keeps the inner loop above tight (no double-borrow on the
4319    // parent buffer) and only touches pixels we actually modified.
4320    if let Some(parent_cmyk) = parent_cmyk {
4321        for gy in 0..ch {
4322            let ty = crop_y + gy as i32;
4323            if ty < 0 || ty as usize >= target_h {
4324                continue;
4325            }
4326            let ty = ty as usize;
4327            let group_row = gy * group_stride;
4328            let parent_row = ty * target_stride;
4329
4330            for gx in 0..cw {
4331                let tx = crop_x + gx as i32;
4332                if tx < 0 || tx as usize >= target_w {
4333                    continue;
4334                }
4335                let tx = tx as usize;
4336                let gi = group_row + gx * 4;
4337                let pi = parent_row + tx * 4;
4338
4339                let bc = backdrop_cmyk[gi] as f64;
4340                let bm = backdrop_cmyk[gi + 1] as f64;
4341                let by_ = backdrop_cmyk[gi + 2] as f64;
4342                let bk = backdrop_cmyk[gi + 3] as f64;
4343                let sc = source_cmyk[gi] as f64;
4344                let sm = source_cmyk[gi + 1] as f64;
4345                let sy_ = source_cmyk[gi + 2] as f64;
4346                let sk = source_cmyk[gi + 3] as f64;
4347                if (sc - bc).abs() < 1.0 / 255.0
4348                    && (sm - bm).abs() < 1.0 / 255.0
4349                    && (sy_ - by_).abs() < 1.0 / 255.0
4350                    && (sk - bk).abs() < 1.0 / 255.0
4351                {
4352                    continue;
4353                }
4354
4355                // Same transparent-backdrop fast path as above: use source
4356                // as-is. We read the original backdrop alpha from the saved
4357                // backdrop_pixels slice, NOT the live target — the live
4358                // target's alpha was already updated by the first loop's
4359                // composite-back writes.
4360                let backdrop_transparent = backdrop_pixels[gi + 3] == 0;
4361                let (rc, rm, ry, rk) = if backdrop_transparent {
4362                    (sc, sm, sy_, sk)
4363                } else if is_nonseparable {
4364                    let r =
4365                        blend_cmyk_nonseparable([bc, bm, by_, bk], [sc, sm, sy_, sk], blend_mode);
4366                    (r[0], r[1], r[2], r[3])
4367                } else {
4368                    (
4369                        blend_cmyk_separable_channel(bc, sc, blend_mode),
4370                        blend_cmyk_separable_channel(bm, sm, blend_mode),
4371                        blend_cmyk_separable_channel(by_, sy_, blend_mode),
4372                        blend_cmyk_separable_channel(bk, sk, blend_mode),
4373                    )
4374                };
4375                parent_cmyk[pi] = rc as f32;
4376                parent_cmyk[pi + 1] = rm as f32;
4377                parent_cmyk[pi + 2] = ry as f32;
4378                parent_cmyk[pi + 3] = rk as f32;
4379            }
4380        }
4381    }
4382}
4383
4384/// Render a knockout transparency group into a pixmap.
4385///
4386/// In a knockout group, each element composites against the group's initial
4387/// backdrop (not the accumulated result of previous elements).
4388fn render_knockout_group(
4389    pixmap: &mut Pixmap,
4390    band_state: &mut BandState,
4391    elements: &DisplayList,
4392    params: &stet_graphics::display_list::GroupParams,
4393    ctx: &RenderContext<'_>,
4394) {
4395    let crop = compute_group_crop(&params.bbox, ctx);
4396
4397    let (eff_w, eff_h, crop_x, crop_y, eff_vp_x, eff_vp_y) = match crop {
4398        Some((cx, cy, cw, ch)) => (
4399            cw,
4400            ch,
4401            cx,
4402            cy,
4403            ctx.vp_x + cx as f32 / ctx.scale_x,
4404            ctx.vp_y + cy as f32 / ctx.scale_y,
4405        ),
4406        None => (ctx.out_w, ctx.out_h, 0, 0, ctx.vp_x, ctx.vp_y),
4407    };
4408
4409    let Some(mut offscreen) = Pixmap::new(eff_w, eff_h) else {
4410        return;
4411    };
4412
4413    let initial_backdrop = if !params.isolated {
4414        if crop.is_some() {
4415            copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h)
4416        } else {
4417            pixmap.data().to_vec()
4418        }
4419    } else {
4420        vec![0u8; (eff_w * eff_h * 4) as usize]
4421    };
4422
4423    let Some(mut accumulated) = Pixmap::new(eff_w, eff_h) else {
4424        return;
4425    };
4426    accumulated.data_mut().copy_from_slice(&initial_backdrop);
4427
4428    // Initial CMYK values for the knockout group
4429    let needs_cmyk = has_overprint_elements(elements) || band_state.cmyk_buffer.is_some();
4430    let initial_cmyk = if needs_cmyk {
4431        let buf_size = eff_w as usize * eff_h as usize * 4;
4432        let mut buf = vec![0.0f32; buf_size];
4433        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4434            let parent_stride = ctx.out_w as usize * 4;
4435            let group_stride = eff_w as usize * 4;
4436            for gy in 0..eff_h as usize {
4437                let py = crop_y as usize + gy;
4438                if py < ctx.out_h as usize {
4439                    let p_start = py * parent_stride + crop_x as usize * 4;
4440                    let g_start = gy * group_stride;
4441                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4442                    buf[g_start..g_start + copy_len]
4443                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
4444                }
4445            }
4446        }
4447        Some(buf)
4448    } else {
4449        None
4450    };
4451
4452    let mut accumulated_cmyk = initial_cmyk.clone();
4453
4454    // Disable anti-aliasing in knockout groups to prevent seam artifacts.
4455    // Each element composites independently against the backdrop, so adjacent
4456    // fills' AA edges don't mesh — both blend toward the backdrop color,
4457    // creating visible 1px white lines at shared boundaries.
4458    let group_ctx = RenderContext {
4459        vp_x: eff_vp_x,
4460        vp_y: eff_vp_y,
4461        scale_x: ctx.scale_x,
4462        scale_y: ctx.scale_y,
4463        out_w: eff_w,
4464        out_h: eff_h,
4465        effective_dpi: ctx.effective_dpi,
4466        icc: ctx.icc,
4467        image_cache: None,
4468        preprocessed: None,
4469        elem_idx: 0,
4470        no_aa: true,
4471        opm_zero_transparent: ctx.opm_zero_transparent,
4472        knockout_painter_pass: ctx.knockout_painter_pass,
4473        // Knockout groups composite each element against the initial backdrop;
4474        // children effectively see this group's "fresh" backdrop. Treat the
4475        // knockout group as isolated for the purposes of the inner CMYK rule.
4476        parent_group_isolated: true,
4477        alpha_extraction_pass: false,
4478        layer_set: ctx.layer_set,
4479    };
4480
4481    // Persistent band state for clip tracking — clips must accumulate across
4482    // elements in the knockout group (each paint element still composites
4483    // against the initial backdrop, but it must respect the current clip).
4484    let mut ko_band = BandState {
4485        clip_region: None,
4486        spare_mask: None,
4487        clip_mask_cache: HashMap::new(),
4488        clip_mask_seen: HashSet::new(),
4489        mask_pool: Vec::new(),
4490        cmyk_buffer: None,
4491        op_bg_snapshot: None,
4492        op_touched: None,
4493        spot_mask: None,
4494    };
4495
4496    // Coverage offscreen for two-pass painter rendering of nested transparency
4497    // groups. Reused (zeroed) across painters; allocated lazily on first need.
4498    let mut coverage_offscreen: Option<Pixmap> = None;
4499
4500    for elem in elements.elements() {
4501        match elem {
4502            // State-only elements: update persistent clip, no knockout compositing
4503            DisplayElement::Clip { .. } | DisplayElement::InitClip => {
4504                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4505            }
4506            // Group painters need two-pass rendering. Knockout semantics
4507            // require each painter to overwrite previous siblings within its
4508            // coverage area, even when the painter's blend mode happens to
4509            // produce a result that equals the initial backdrop (e.g.
4510            // Darken(red, white)=red, SoftLight(red, black)=red,
4511            // Multiply(red, magenta)=red — which is exactly what GWG 16.1
4512            // tests). The single-pass change-against-backdrop check used for
4513            // simpler painter types would miss those pixels, and earlier
4514            // siblings' contributions would bleed through.
4515            DisplayElement::Group { .. } => {
4516                // Pass 1: render painter against initial_backdrop to compute
4517                // the blended-color result (the painter's contribution).
4518                // Use ColorPass mode so any non-Normal blend mode goes through
4519                // the per-pixel CMYK composite-back — required for separable
4520                // blends like Screen / ColorDodge / Overlay / SoftLight whose
4521                // sRGB result drifts away from the CMYK-math result.
4522                let pass1_ctx = RenderContext {
4523                    knockout_painter_pass: KnockoutPainterPass::ColorPass,
4524                    ..group_ctx
4525                };
4526                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4527                ko_band.cmyk_buffer = initial_cmyk.clone();
4528                render_element(&mut offscreen, &mut ko_band, elem, &pass1_ctx);
4529                let pass1_cmyk = ko_band.cmyk_buffer.take();
4530
4531                // Pass 2: render painter into a fresh transparent offscreen so
4532                // the alpha channel captures the painter's coverage, which the
4533                // result-color comparison cannot recover when the blend mode
4534                // outputs the backdrop color exactly.
4535                let cov = match coverage_offscreen.as_mut() {
4536                    Some(p) => {
4537                        p.data_mut().fill(0);
4538                        p
4539                    }
4540                    None => {
4541                        let Some(p) = Pixmap::new(eff_w, eff_h) else {
4542                            // Out of memory for coverage buffer — fall back
4543                            // to the change-detection path so the painter
4544                            // still appears (just without proper knockout).
4545                            replace_changed_pixels(
4546                                accumulated.data_mut(),
4547                                offscreen.data(),
4548                                &initial_backdrop,
4549                            );
4550                            if let (Some(p1), Some(acc)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4551                                replace_changed_cmyk(acc, p1, offscreen.data(), &initial_backdrop);
4552                            }
4553                            continue;
4554                        };
4555                        coverage_offscreen = Some(p);
4556                        coverage_offscreen.as_mut().unwrap()
4557                    }
4558                };
4559                ko_band.cmyk_buffer = None;
4560                // Coverage pass: render through the simple sRGB path with
4561                // alpha forced to 1.0 and Normal blend so the painter's
4562                // shape reaches the coverage offscreen even for white-source
4563                // CMYK painters and zero-alpha painters (Opacity 0% test).
4564                let coverage_ctx = RenderContext {
4565                    knockout_painter_pass: KnockoutPainterPass::CoveragePass,
4566                    ..group_ctx
4567                };
4568                render_element(cov, &mut ko_band, elem, &coverage_ctx);
4569
4570                // Use the coverage offscreen's alpha as a knockout mask: the
4571                // painter's contribution from pass 1 source-overs onto
4572                // accumulated weighted by the coverage alpha.
4573                replace_with_coverage_mask(accumulated.data_mut(), offscreen.data(), cov.data());
4574
4575                if let (Some(p1_cmyk), Some(acc_cmyk)) = (&pass1_cmyk, &mut accumulated_cmyk) {
4576                    replace_cmyk_with_coverage_mask(acc_cmyk, p1_cmyk, cov.data());
4577                }
4578                ko_band.cmyk_buffer = None;
4579            }
4580            // Other paint elements: single-pass with change-against-backdrop.
4581            // Direct path/image/shading paints always change pixels they cover,
4582            // so the simpler detection works and avoids the second-pass cost.
4583            _ => {
4584                offscreen.data_mut().copy_from_slice(&initial_backdrop);
4585
4586                ko_band.cmyk_buffer = initial_cmyk.clone();
4587
4588                render_element(&mut offscreen, &mut ko_band, elem, &group_ctx);
4589
4590                if let (Some(elem_cmyk), Some(acc_cmyk)) =
4591                    (&ko_band.cmyk_buffer, &mut accumulated_cmyk)
4592                {
4593                    replace_changed_cmyk(acc_cmyk, elem_cmyk, offscreen.data(), &initial_backdrop);
4594                }
4595                ko_band.cmyk_buffer = None;
4596
4597                replace_changed_pixels(accumulated.data_mut(), offscreen.data(), &initial_backdrop);
4598            }
4599        }
4600    }
4601
4602    let mut temp_mask = None;
4603    let mask_ref = resolve_clip_mask(
4604        &band_state.clip_region,
4605        &mut temp_mask,
4606        ctx.out_w,
4607        ctx.out_h,
4608    );
4609    let mask_ref = match mask_ref {
4610        None => return,
4611        Some(m) => m,
4612    };
4613
4614    composite_non_isolated_group_cropped(
4615        pixmap,
4616        &accumulated,
4617        &initial_backdrop,
4618        params,
4619        mask_ref,
4620        crop_x,
4621        crop_y,
4622    );
4623
4624    if let (Some(acc_cmyk), Some(parent_cmyk)) = (&accumulated_cmyk, &mut band_state.cmyk_buffer) {
4625        copy_cmyk_buffer_to_parent(
4626            parent_cmyk,
4627            acc_cmyk,
4628            accumulated.data(),
4629            crop_x as usize,
4630            crop_y as usize,
4631            eff_w as usize,
4632            eff_h as usize,
4633            ctx.out_w as usize,
4634            ctx.out_h as usize,
4635        );
4636    }
4637}
4638/// Source-over `source` onto `target` weighted by `coverage`'s alpha channel.
4639/// Used for the two-pass knockout group rendering: `coverage` is rendered
4640/// into a transparent offscreen so its alpha records the painter's coverage
4641/// regardless of whether the painter's blend mode produced backdrop-equal
4642/// pixels in the color pass. Both `source` and `target` are assumed fully
4643/// opaque pixmaps (alpha=255 everywhere) since the knockout offscreens are
4644/// pre-loaded with the opaque initial backdrop.
4645fn replace_with_coverage_mask(target: &mut [u8], source: &[u8], coverage: &[u8]) {
4646    for i in (0..target.len()).step_by(4) {
4647        let cov_a = coverage[i + 3];
4648        if cov_a == 0 {
4649            continue;
4650        }
4651        if cov_a == 255 {
4652            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4653            continue;
4654        }
4655        let a = cov_a as u32;
4656        let inv = 255 - a;
4657        for c in 0..4 {
4658            let s = source[i + c] as u32;
4659            let t = target[i + c] as u32;
4660            target[i + c] = ((s * a + t * inv + 127) / 255) as u8;
4661        }
4662    }
4663}
4664
4665/// Source-over CMYK values from `source` onto `target` weighted by the
4666/// coverage offscreen's alpha channel. Companion to
4667/// `replace_with_coverage_mask` for the parallel CMYK buffer.
4668fn replace_cmyk_with_coverage_mask(target: &mut [f32], source: &[f32], coverage: &[u8]) {
4669    let pixel_count = target.len() / 4;
4670    for i in 0..pixel_count {
4671        let pi = i * 4;
4672        let cov_a = coverage[pi + 3];
4673        if cov_a == 0 {
4674            continue;
4675        }
4676        if cov_a == 255 {
4677            target[pi..pi + 4].copy_from_slice(&source[pi..pi + 4]);
4678            continue;
4679        }
4680        let a = cov_a as f32 / 255.0;
4681        let inv = 1.0 - a;
4682        for c in 0..4 {
4683            target[pi + c] = source[pi + c] * a + target[pi + c] * inv;
4684        }
4685    }
4686}
4687
4688/// Replace pixels in `target` with pixels from `source` wherever `source`
4689/// differs from `backdrop`. Used for knockout group per-element compositing
4690/// where each element replaces (not blends with) previous elements.
4691fn replace_changed_pixels(target: &mut [u8], source: &[u8], backdrop: &[u8]) {
4692    for i in (0..target.len()).step_by(4) {
4693        if source[i] != backdrop[i]
4694            || source[i + 1] != backdrop[i + 1]
4695            || source[i + 2] != backdrop[i + 2]
4696            || source[i + 3] != backdrop[i + 3]
4697        {
4698            target[i..i + 4].copy_from_slice(&source[i..i + 4]);
4699        }
4700    }
4701}
4702
4703/// Copy a group's CMYK buffer back to the parent's CMYK buffer after compositing.
4704/// Only copies values for pixels where the group offscreen has non-zero alpha,
4705/// indicating the group actually painted something at that position.
4706#[allow(clippy::too_many_arguments)]
4707fn copy_cmyk_buffer_to_parent(
4708    parent_cmyk: &mut [f32],
4709    group_cmyk: &[f32],
4710    group_pixels: &[u8],
4711    crop_x: usize,
4712    crop_y: usize,
4713    group_w: usize,
4714    group_h: usize,
4715    parent_w: usize,
4716    parent_h: usize,
4717) {
4718    let parent_stride = parent_w * 4;
4719    let group_stride = group_w * 4;
4720    for gy in 0..group_h {
4721        let py = crop_y + gy;
4722        if py >= parent_h {
4723            break;
4724        }
4725        for gx in 0..group_w {
4726            let px = crop_x + gx;
4727            if px >= parent_w {
4728                break;
4729            }
4730            // Only copy if the group pixel has non-zero alpha AND
4731            // the group's cmyk at that pixel is non-zero.
4732            // Zero cmyk means "not tracked by a CMYK fill in this group"
4733            // — writing it back would erase the parent's tracked values.
4734            let g_pixel_idx = (gy * group_w + gx) * 4;
4735            let g_cmyk_idx = gy * group_stride + gx * 4;
4736            if group_pixels[g_pixel_idx + 3] > 0
4737                && (group_cmyk[g_cmyk_idx] != 0.0
4738                    || group_cmyk[g_cmyk_idx + 1] != 0.0
4739                    || group_cmyk[g_cmyk_idx + 2] != 0.0
4740                    || group_cmyk[g_cmyk_idx + 3] != 0.0)
4741            {
4742                let p_cmyk_idx = py * parent_stride + px * 4;
4743                parent_cmyk[p_cmyk_idx..p_cmyk_idx + 4]
4744                    .copy_from_slice(&group_cmyk[g_cmyk_idx..g_cmyk_idx + 4]);
4745            }
4746        }
4747    }
4748}
4749
4750/// Copy CMYK values for pixels that changed in a knockout element.
4751/// Used alongside replace_changed_pixels to keep CMYK in sync with RGB.
4752fn replace_changed_cmyk(
4753    target_cmyk: &mut [f32],
4754    source_cmyk: &[f32],
4755    source_pixels: &[u8],
4756    backdrop_pixels: &[u8],
4757) {
4758    let pixel_count = target_cmyk.len() / 4;
4759    for i in 0..pixel_count {
4760        let pi = i * 4;
4761        if source_pixels[pi] != backdrop_pixels[pi]
4762            || source_pixels[pi + 1] != backdrop_pixels[pi + 1]
4763            || source_pixels[pi + 2] != backdrop_pixels[pi + 2]
4764            || source_pixels[pi + 3] != backdrop_pixels[pi + 3]
4765        {
4766            target_cmyk[pi..pi + 4].copy_from_slice(&source_cmyk[pi..pi + 4]);
4767        }
4768    }
4769}
4770
4771/// Render soft-masked content.
4772///
4773/// 1. Renders the mask display list to an offscreen pixmap.
4774/// 2. Extracts a grayscale mask (luminosity or alpha).
4775/// 3. Renders content into another offscreen pixmap.
4776/// 4. Multiplies content alpha by the mask values.
4777/// 5. Composites the masked content onto the parent.
4778#[allow(clippy::too_many_arguments)]
4779fn render_soft_masked(
4780    pixmap: &mut Pixmap,
4781    band_state: &mut BandState,
4782    mask_list: &DisplayList,
4783    content_list: &DisplayList,
4784    params: &stet_graphics::display_list::SoftMaskParams,
4785    mask_cache: &Arc<Mutex<Option<Option<stet_graphics::display_list::MaskRaster>>>>,
4786    ctx: &RenderContext<'_>,
4787) {
4788    // The SoftMask's display list elements are in absolute device space (page coords).
4789    // params.bbox is the SoftMasked element's compositing bounds, derived
4790    // from the form's /BBox transformed by the gs-time CTM. The mask raster
4791    // (built lazily by `rasterize_mask` and cached on the display-list
4792    // element) is anchored independently to the *actual* mask paint bounds,
4793    // which may differ from params.bbox when the form's internal `cm`
4794    // operators translated paint elements outside the form bbox.
4795    //
4796    // The cached-raster path can produce truncated output when the
4797    // SoftMasked is rendered inside an outer offscreen (a Group, an
4798    // outer SoftMasked, etc.) — the nested offscreen's coordinate
4799    // system clips the mask raster's right edge unexpectedly. Detect
4800    // "nested" via `ctx.vp_x != 0.0` (top-level banded rendering uses
4801    // vp_x = 0; nested rendering inherits the parent offscreen's vp).
4802    // For nested cases, fall back to the inline band-local mask
4803    // rendering that worked before Step 4 of cosmic-masking-bird.
4804    let use_inline_mask = ctx.vp_x != 0.0;
4805    let bbox = &params.bbox;
4806    let smask_px_x0 = ((bbox[0] as f32 - ctx.vp_x) * ctx.scale_x).floor() as i32;
4807    let smask_px_y0 = ((bbox[1] as f32 - ctx.vp_y) * ctx.scale_y).floor() as i32;
4808    let smask_px_x1 = ((bbox[2] as f32 - ctx.vp_x) * ctx.scale_x).ceil() as i32;
4809    let smask_px_y1 = ((bbox[3] as f32 - ctx.vp_y) * ctx.scale_y).ceil() as i32;
4810
4811    // Clip to parent output bounds
4812    let crop_x = smask_px_x0.max(0);
4813    let crop_y = smask_px_y0.max(0);
4814    let crop_x1 = smask_px_x1.min(ctx.out_w as i32);
4815    let crop_y1 = smask_px_y1.min(ctx.out_h as i32);
4816    if crop_x >= crop_x1 || crop_y >= crop_y1 {
4817        return;
4818    }
4819    let eff_w = (crop_x1 - crop_x) as u32;
4820    let eff_h = (crop_y1 - crop_y) as u32;
4821
4822    // Viewport for the content offscreen: derived from the SoftMask's bbox
4823    // position relative to the parent's viewport. The content offscreen
4824    // still uses params.bbox because params.bbox correctly bounds where
4825    // the content can paint.
4826    let eff_vp_x = ctx.vp_x + crop_x as f32 / ctx.scale_x;
4827    let eff_vp_y = ctx.vp_y + crop_y as f32 / ctx.scale_y;
4828
4829    let sub_ctx = RenderContext {
4830        vp_x: eff_vp_x,
4831        vp_y: eff_vp_y,
4832        scale_x: ctx.scale_x,
4833        scale_y: ctx.scale_y,
4834        out_w: eff_w,
4835        out_h: eff_h,
4836        effective_dpi: ctx.effective_dpi,
4837        icc: ctx.icc,
4838        image_cache: None,
4839        preprocessed: None,
4840        elem_idx: 0,
4841        no_aa: ctx.no_aa,
4842        opm_zero_transparent: ctx.opm_zero_transparent,
4843        knockout_painter_pass: ctx.knockout_painter_pass,
4844        parent_group_isolated: ctx.parent_group_isolated,
4845        // Soft masks render into their own independent offscreen and must
4846        // not inherit the alpha extraction pass — their groups need normal
4847        // backdrop preloading regardless of the outer extraction context.
4848        alpha_extraction_pass: false,
4849        layer_set: ctx.layer_set,
4850    };
4851
4852    // 1a. INLINE PATH: Mask form contains nested offscreens.
4853    // Render the mask form into a band-local offscreen sized to the
4854    // SoftMasked's bbox crop. This matches the pre-Step-4 behavior.
4855    let mut mask_values_inline: Vec<u8> = Vec::new();
4856    if use_inline_mask {
4857        let Some(mut mask_pixmap) = Pixmap::new(eff_w, eff_h) else {
4858            return;
4859        };
4860        let mut mask_band = BandState {
4861            clip_region: None,
4862            spare_mask: None,
4863            clip_mask_cache: HashMap::new(),
4864            clip_mask_seen: HashSet::new(),
4865            mask_pool: Vec::new(),
4866            cmyk_buffer: None,
4867            op_bg_snapshot: None,
4868            op_touched: None,
4869            spot_mask: None,
4870        };
4871        for (idx, elem) in mask_list.elements().iter().enumerate() {
4872            let elem_ctx = RenderContext {
4873                elem_idx: idx,
4874                ..sub_ctx
4875            };
4876            render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
4877        }
4878        if params.has_nested_mask_scope
4879            && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
4880        {
4881            let bc = params.backdrop_color.as_ref();
4882            let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4883            let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4884            let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
4885            for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
4886                let a = chunk[3] as u16;
4887                if a == 255 {
4888                    continue;
4889                }
4890                let inv_a = 255 - a;
4891                chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
4892                chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
4893                chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
4894                chunk[3] = 255;
4895            }
4896        }
4897        mask_values_inline = vec![0u8; (eff_w * eff_h) as usize];
4898        extract_soft_mask_values(mask_pixmap.data(), &mut mask_values_inline, params);
4899    }
4900
4901    // 1b. CACHED RASTER PATH: simple masks (no nested offscreens).
4902    let raster_owned: Option<stet_graphics::display_list::MaskRaster> = if use_inline_mask {
4903        None
4904    } else {
4905        let mut guard = mask_cache.lock().unwrap();
4906        let needs_build = match guard.as_ref() {
4907            None => true,
4908            Some(None) => false, // memoized "no mask"
4909            Some(Some(r)) => {
4910                (r.scale_x - ctx.scale_x).abs() > 1e-4 || (r.scale_y - ctx.scale_y).abs() > 1e-4
4911            }
4912        };
4913        if needs_build {
4914            let built = rasterize_mask(
4915                mask_list,
4916                params,
4917                ctx.icc,
4918                ctx.no_aa,
4919                ctx.effective_dpi,
4920                ctx.scale_x,
4921                ctx.scale_y,
4922                ctx.layer_set,
4923            );
4924            *guard = Some(built);
4925        }
4926        guard.as_ref().and_then(|inner| inner.clone())
4927    };
4928
4929    // Default mask value for content pixels that fall outside the mask
4930    // raster (e.g. backdrop region for a Luminosity mask with non-black
4931    // /BC, or always 0 for Alpha masks).
4932    let fallback_mask = out_of_bounds_mask_value(params) as i32;
4933
4934    // 2. Render content into an offscreen, initialized with the parent's
4935    // backdrop so non-isolated groups with blend modes (e.g. Multiply) see
4936    // the correct background and produce the right composited result.
4937    let Some(mut content_pixmap) = Pixmap::new(eff_w, eff_h) else {
4938        return;
4939    };
4940    let backdrop = copy_backdrop_crop(pixmap, crop_x, crop_y, eff_w, eff_h);
4941    content_pixmap.data_mut().copy_from_slice(&backdrop);
4942
4943    let content_cmyk = if has_overprint_elements(content_list) || band_state.cmyk_buffer.is_some() {
4944        let buf_size = eff_w as usize * eff_h as usize * 4;
4945        let mut buf = vec![0.0f32; buf_size];
4946        if let Some(ref parent_cmyk) = band_state.cmyk_buffer {
4947            let parent_stride = ctx.out_w as usize * 4;
4948            let group_stride = eff_w as usize * 4;
4949            for gy in 0..eff_h as usize {
4950                let py = crop_y as usize + gy;
4951                if py < ctx.out_h as usize {
4952                    let p_start = py * parent_stride + crop_x as usize * 4;
4953                    let g_start = gy * group_stride;
4954                    let copy_len = group_stride.min(parent_stride - crop_x as usize * 4);
4955                    buf[g_start..g_start + copy_len]
4956                        .copy_from_slice(&parent_cmyk[p_start..p_start + copy_len]);
4957                }
4958            }
4959        }
4960        Some(buf)
4961    } else {
4962        None
4963    };
4964    // Snapshot the pre-content CMYK state so the mask blend can run in CMYK
4965    // space. Without this, the downstream sRGB blend interpolates between
4966    // CMYK backdrop and source after each has been ICC-converted separately,
4967    // which shifts the midtones away from the CMYK-interpolated result the
4968    // source was authored against (pink cast vs warm peach on GWG 16.10
4969    // inner-glow in PDFX-ready_Output-Test_X4.pdf).
4970    let backdrop_cmyk: Option<Vec<f32>> = content_cmyk.clone();
4971    let mut content_band = BandState {
4972        clip_region: None,
4973        spare_mask: None,
4974        clip_mask_cache: HashMap::new(),
4975        clip_mask_seen: HashSet::new(),
4976        mask_pool: Vec::new(),
4977        cmyk_buffer: content_cmyk,
4978        op_bg_snapshot: None,
4979        op_touched: None,
4980        spot_mask: None,
4981    };
4982    for (idx, elem) in content_list.elements().iter().enumerate() {
4983        let elem_ctx = RenderContext {
4984            elem_idx: idx,
4985            ..sub_ctx
4986        };
4987        render_element(&mut content_pixmap, &mut content_band, elem, &elem_ctx);
4988    }
4989
4990    // 3. Apply soft mask: compute per-pixel masked contribution and write
4991    // to parent. result[c] = parent[c] + m * (content_on_backdrop[c] - backdrop[c]) / 255
4992    //
4993    // Mask sampling: the mask raster is in page-pixel coordinates at the
4994    // current render scale, anchored at `(raster.origin_x, raster.origin_y)`.
4995    // The combine loop iterates over content pixel `(x, y)` band-local in
4996    // the content offscreen. To translate to a mask raster index:
4997    //
4998    //   page_x = vp_x_pixels + crop_x + x
4999    //   page_y = vp_y_pixels + crop_y + y
5000    //   mask_x = page_x - raster.origin_x
5001    //   mask_y = page_y - raster.origin_y
5002    //
5003    // where `vp_x_pixels = round(ctx.vp_x * ctx.scale_x)` is the page-pixel
5004    // offset of the band's top-left. For banded rendering this is exact
5005    // (vp = 0, scale = 1, so vp_x_pixels = 0). For viewport rendering with
5006    // a fractional `vp_x`, there is at most a 0.5-pixel sub-pixel offset
5007    // between the content render grid and the cached mask grid; this is
5008    // bounded and visually acceptable for nearest-neighbor sampling.
5009    let vp_x_pixels = (ctx.vp_x * ctx.scale_x).round() as i32;
5010    let vp_y_pixels = (ctx.vp_y * ctx.scale_y).round() as i32;
5011
5012    let mut temp_mask = None;
5013    let clip_ref = resolve_clip_mask(
5014        &band_state.clip_region,
5015        &mut temp_mask,
5016        ctx.out_w,
5017        ctx.out_h,
5018    );
5019    let clip_ref = match clip_ref {
5020        None => return,
5021        Some(m) => m,
5022    };
5023
5024    // Decide whether to interpolate the masked delta in CMYK (with ICC→sRGB
5025    // on the way out) instead of sRGB. The CMYK path matches Acrobat's
5026    // behaviour when the transparency group declares /CS DeviceCMYK and all
5027    // content is native CMYK — the blend color space is then CMYK, and
5028    // sRGB-space interpolation on ICC-converted endpoints loses the warm
5029    // midtone that M+Y mixing produces under a proper CMYK profile.
5030    //
5031    // Gate strictly: content_list must be a flat list of native-CMYK fills
5032    // or strokes with Normal blend and full opacity. Any nested Group,
5033    // SoftMasked, Image, or blend-mode-modulated paint means the parallel
5034    // cmyk_buffer can't be trusted to match the pixmap — running CMYK
5035    // interpolation against a mismatched CMYK snapshot produced wrong
5036    // colors on GWG 16.10 outer-glow C (Fm5 is a Screen-blend white rect
5037    // inside a Group; cmyk_buffer held raw white while pixmap held the
5038    // screen-blended light gray).
5039    let use_cmyk_blend = ctx.icc.is_some()
5040        && backdrop_cmyk.is_some()
5041        && content_band.cmyk_buffer.is_some()
5042        && content_list_is_simple_native_cmyk(content_list);
5043
5044    let content_data = content_pixmap.data();
5045    let parent_data = pixmap.data_mut();
5046    let parent_stride = ctx.out_w as usize * 4;
5047    let content_stride = eff_w as usize * 4;
5048
5049    for y in 0..eff_h as usize {
5050        let py = crop_y as usize + y;
5051        if py >= ctx.out_h as usize {
5052            break;
5053        }
5054        let ci_row = y * content_stride;
5055        let pi_row = py * parent_stride;
5056        let page_y = vp_y_pixels + crop_y + y as i32;
5057
5058        for x in 0..eff_w as usize {
5059            let px = crop_x as usize + x;
5060            if px >= ctx.out_w as usize {
5061                break;
5062            }
5063
5064            // Check clip mask (in parent coordinates)
5065            if let Some(clip) = clip_ref {
5066                if clip.data()[py * ctx.out_w as usize + px] == 0 {
5067                    continue;
5068                }
5069            }
5070
5071            // Sample the mask: inline-rendered values for masks with
5072            // nested offscreens, cached raster for simple masks.
5073            let m = if use_inline_mask {
5074                mask_values_inline[y * eff_w as usize + x] as i32
5075            } else if let Some(ref raster) = raster_owned {
5076                let page_x = vp_x_pixels + crop_x + x as i32;
5077                let mx = page_x - raster.origin_x;
5078                let my = page_y - raster.origin_y;
5079                if mx >= 0 && (mx as u32) < raster.width && my >= 0 && (my as u32) < raster.height {
5080                    raster.data[my as usize * raster.width as usize + mx as usize] as i32
5081                } else {
5082                    fallback_mask
5083                }
5084            } else {
5085                fallback_mask
5086            };
5087            if m == 0 {
5088                continue;
5089            }
5090
5091            let ci = ci_row + x * 4;
5092            let pi = pi_row + px * 4;
5093
5094            // Per-pixel gate: CMYK interpolation is only safe when both
5095            // endpoints are faithfully tracked. ICC-convert both cmyk
5096            // snapshots and compare with the sRGB endpoints; only take
5097            // the CMYK path if BOTH agree within tolerance. The backdrop
5098            // check catches image/RGB paints upstream (tile_clamp_bug.pdf
5099            // photo background) where cmyk_buffer is an approximate
5100            // reverse-transform. The content check catches cases where
5101            // non-CMYK paints inside content leave the cmyk_buffer stale
5102            // relative to the sRGB content pixmap.
5103            let ci_cmyk = (y * eff_w as usize + x) * 4;
5104            let cmyk_path_ok = use_cmyk_blend && {
5105                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5106                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5107                let icc_match = |cmyk: &[f32], rgb: &[u8]| -> bool {
5108                    let (r, g, b) = ctx
5109                        .icc
5110                        .and_then(|i| {
5111                            i.convert_cmyk_readonly(
5112                                cmyk[0] as f64,
5113                                cmyk[1] as f64,
5114                                cmyk[2] as f64,
5115                                cmyk[3] as f64,
5116                            )
5117                        })
5118                        .unwrap_or_else(|| {
5119                            cmyk_to_rgb_plrm(
5120                                cmyk[0] as f64,
5121                                cmyk[1] as f64,
5122                                cmyk[2] as f64,
5123                                cmyk[3] as f64,
5124                            )
5125                        });
5126                    let r = (r * 255.0).round() as i32;
5127                    let g = (g * 255.0).round() as i32;
5128                    let b = (b * 255.0).round() as i32;
5129                    (r - rgb[0] as i32).abs() <= 3
5130                        && (g - rgb[1] as i32).abs() <= 3
5131                        && (b - rgb[2] as i32).abs() <= 3
5132                };
5133                icc_match(bc_cmyk, &backdrop[ci..ci + 3])
5134                    && icc_match(cc_cmyk, &content_data[ci..ci + 3])
5135            };
5136
5137            if cmyk_path_ok {
5138                // CMYK-space mask blend: result_cmyk = backdrop + m*(content - backdrop)
5139                let bc_cmyk = &backdrop_cmyk.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5140                let cc_cmyk = &content_band.cmyk_buffer.as_ref().unwrap()[ci_cmyk..ci_cmyk + 4];
5141                let mf = m as f64 / 255.0;
5142                let rc = bc_cmyk[0] as f64 + mf * (cc_cmyk[0] as f64 - bc_cmyk[0] as f64);
5143                let rm = bc_cmyk[1] as f64 + mf * (cc_cmyk[1] as f64 - bc_cmyk[1] as f64);
5144                let ry = bc_cmyk[2] as f64 + mf * (cc_cmyk[2] as f64 - bc_cmyk[2] as f64);
5145                let rk = bc_cmyk[3] as f64 + mf * (cc_cmyk[3] as f64 - bc_cmyk[3] as f64);
5146                let (fr, fg, fb) = ctx
5147                    .icc
5148                    .and_then(|i| i.convert_cmyk_readonly(rc, rm, ry, rk))
5149                    .unwrap_or_else(|| cmyk_to_rgb_plrm(rc, rm, ry, rk));
5150                parent_data[pi] = (fr * 255.0).round().clamp(0.0, 255.0) as u8;
5151                parent_data[pi + 1] = (fg * 255.0).round().clamp(0.0, 255.0) as u8;
5152                parent_data[pi + 2] = (fb * 255.0).round().clamp(0.0, 255.0) as u8;
5153                // Alpha channel: keep sRGB delta blend.
5154                let content_a = content_data[ci + 3] as i32;
5155                let backdrop_a = backdrop[ci + 3] as i32;
5156                let delta = content_a - backdrop_a;
5157                if delta != 0 {
5158                    let masked_delta = if delta > 0 {
5159                        (delta * m + 128) / 255
5160                    } else {
5161                        (delta * m - 128) / 255
5162                    };
5163                    let result = (parent_data[pi + 3] as i32 + masked_delta).clamp(0, 255);
5164                    parent_data[pi + 3] = result as u8;
5165                }
5166                // The parent's cmyk_buffer is deliberately NOT written here.
5167                // Writing back mask-blended CMYK would overwrite backdrop
5168                // tracking that downstream CMYK consumers (outer groups,
5169                // subsequent masks) depend on and cause them to render
5170                // nearby pixels as pure CMYK channels (e.g. the outer-glow
5171                // C regression: adjacent gray pixels ICC-resolved to a
5172                // black K silhouette). The sRGB pixmap carries the mask-
5173                // blended color; parent_cmyk stays untouched.
5174            } else {
5175                for c in 0..4 {
5176                    let content_val = content_data[ci + c] as i32;
5177                    let backdrop_val = backdrop[ci + c] as i32;
5178                    let delta = content_val - backdrop_val;
5179                    if delta != 0 {
5180                        let masked_delta = if delta > 0 {
5181                            (delta * m + 128) / 255
5182                        } else {
5183                            (delta * m - 128) / 255
5184                        };
5185                        let result = (parent_data[pi + c] as i32 + masked_delta).clamp(0, 255);
5186                        parent_data[pi + c] = result as u8;
5187                    }
5188                }
5189            }
5190        }
5191    }
5192
5193    // Write content CMYK buffer back to parent. Skip when the CMYK blend
5194    // loop already updated band_state.cmyk_buffer with mask-blended values
5195    // — copying the unmodulated content CMYK here would overwrite them.
5196    if !use_cmyk_blend {
5197        if let (Some(content_cmyk), Some(parent_cmyk)) =
5198            (&content_band.cmyk_buffer, &mut band_state.cmyk_buffer)
5199        {
5200            copy_cmyk_buffer_to_parent(
5201                parent_cmyk,
5202                content_cmyk,
5203                content_pixmap.data(),
5204                crop_x as usize,
5205                crop_y as usize,
5206                eff_w as usize,
5207                eff_h as usize,
5208                ctx.out_w as usize,
5209                ctx.out_h as usize,
5210            );
5211        }
5212    }
5213}
5214/// Extract grayscale mask values from rendered RGBA pixels.
5215fn extract_soft_mask_values(
5216    rgba: &[u8],
5217    out: &mut [u8],
5218    params: &stet_graphics::display_list::SoftMaskParams,
5219) {
5220    use stet_graphics::display_list::SoftMaskSubtype;
5221    let pixel_count = out.len();
5222
5223    match params.subtype {
5224        SoftMaskSubtype::Alpha => {
5225            for i in 0..pixel_count {
5226                let a = rgba[i * 4 + 3]; // alpha channel
5227                out[i] = if params.transfer_invert { 255 - a } else { a };
5228            }
5229        }
5230        SoftMaskSubtype::Luminosity => {
5231            // Backdrop luminosity for transparent pixels
5232            let backdrop_lum = if let Some(bc) = &params.backdrop_color {
5233                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5234            } else {
5235                0.0 // black backdrop
5236            };
5237            let backdrop_byte = (backdrop_lum * 255.0 + 0.5) as u8;
5238
5239            #[allow(clippy::needless_range_loop)]
5240            for i in 0..pixel_count {
5241                let off = i * 4;
5242                let a = rgba[off + 3];
5243                let lum_byte = if a == 0 {
5244                    backdrop_byte
5245                } else if a < 255 {
5246                    // Composite premultiplied RGB onto backdrop before computing
5247                    // luminosity (PDF spec 11.6.5.3): premul_rgb + BC × (1 - α/255)
5248                    let af = a as f64;
5249                    let bd = backdrop_lum * 255.0;
5250                    let r = rgba[off] as f64 + bd * (255.0 - af) / 255.0;
5251                    let g = rgba[off + 1] as f64 + bd * (255.0 - af) / 255.0;
5252                    let b = rgba[off + 2] as f64 + bd * (255.0 - af) / 255.0;
5253                    let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
5254                    (lum + 0.5).clamp(0.0, 255.0) as u8
5255                } else {
5256                    // Fully opaque: premultiplied == straight RGB
5257                    let lum = 0.2126 * rgba[off] as f64
5258                        + 0.7152 * rgba[off + 1] as f64
5259                        + 0.0722 * rgba[off + 2] as f64;
5260                    (lum + 0.5).clamp(0.0, 255.0) as u8
5261                };
5262                // Apply transfer function inversion: {1 exch sub} → 255 - value
5263                out[i] = if params.transfer_invert {
5264                    255 - lum_byte
5265                } else {
5266                    lum_byte
5267                };
5268            }
5269        }
5270    }
5271}
5272
5273/// Compute the byte the mask sample loop should use for content pixels
5274/// that fall outside the rasterized mask raster.
5275///
5276/// For Luminosity masks, transparent pixels (no rendered mask paint)
5277/// composite onto the backdrop color, so the effective mask value is the
5278/// backdrop's luminosity. For Alpha masks, transparent = 0 = mask off.
5279/// Both subtypes apply the `/TR {1 exch sub}` transfer inversion.
5280fn out_of_bounds_mask_value(params: &stet_graphics::display_list::SoftMaskParams) -> u8 {
5281    use stet_graphics::display_list::SoftMaskSubtype;
5282    let raw = match params.subtype {
5283        SoftMaskSubtype::Alpha => 0u8,
5284        SoftMaskSubtype::Luminosity => {
5285            let lum = if let Some(bc) = &params.backdrop_color {
5286                (0.2126 * bc[0] + 0.7152 * bc[1] + 0.0722 * bc[2]).clamp(0.0, 1.0)
5287            } else {
5288                0.0
5289            };
5290            (lum * 255.0 + 0.5) as u8
5291        }
5292    };
5293    if params.transfer_invert {
5294        255 - raw
5295    } else {
5296        raw
5297    }
5298}
5299
5300/// Maximum mask raster area in pixels.  A malformed PDF that asks for a
5301/// gigantic mask form would otherwise OOM. 64 megapixels = 64 MB for
5302/// grayscale or 256 MB for RGBA — generous but bounded.  Using an area
5303/// limit instead of a per-dimension limit correctly handles narrow-but-tall
5304/// pages (e.g. infographics that exceed 8192 pixels in height while being
5305/// only ~1000 pixels wide).
5306const MAX_MASK_RASTER_PIXELS: u64 = 64 * 1024 * 1024;
5307
5308/// Rasterize a soft mask form's display list into a `MaskRaster`.
5309///
5310/// Walks the mask display list to compute its actual paint bounds (which
5311/// may differ from the SoftMasked element's `params.bbox` because the
5312/// form's internal `cm` operators may translate paint elements outside
5313/// the form's `/BBox`), allocates a pixmap that exactly covers those
5314/// bounds in device-space pixels, and renders the mask elements with the
5315/// viewport set to the bounds origin so each element rasterizes at
5316/// `(device_x - origin_x, device_y - origin_y)`.
5317///
5318/// Returns `None` when the mask paints nothing.
5319fn rasterize_mask(
5320    mask_list: &DisplayList,
5321    params: &stet_graphics::display_list::SoftMaskParams,
5322    icc: Option<&IccCache>,
5323    no_aa: bool,
5324    effective_dpi: f64,
5325    scale_x: f32,
5326    scale_y: f32,
5327    layer_set: &LayerSet,
5328) -> Option<stet_graphics::display_list::MaskRaster> {
5329    // 1. Find the actual paint bounds in device space, then cap them to
5330    // the parent gstate's clip path bbox if known. The cap is critical
5331    // for masks whose form contains an unbounded shading inside a
5332    // sentinel-sized internal clip — without it, the raster blows past
5333    // the size limit and produces no output. Pixels outside the parent
5334    // clip can never affect the final image, so the cap is safe.
5335    let mut bounds = compute_paint_bounds(mask_list, effective_dpi)?;
5336    if let Some(cap) = params.parent_clip_bbox {
5337        let cap_bbox = BBox2D {
5338            x_min: cap[0],
5339            y_min: cap[1],
5340            x_max: cap[2],
5341            y_max: cap[3],
5342        };
5343        bounds = intersect_bbox(&bounds, &cap_bbox)?;
5344    }
5345
5346    // 2. Snap to integer device pixels at the current render scale, with a
5347    // 1-pixel pad on each side to avoid antialiasing edge clipping.
5348    let px_x_min = (bounds.x_min as f32 * scale_x).floor() as i32 - 1;
5349    let px_y_min = (bounds.y_min as f32 * scale_y).floor() as i32 - 1;
5350    let px_x_max = (bounds.x_max as f32 * scale_x).ceil() as i32 + 1;
5351    let px_y_max = (bounds.y_max as f32 * scale_y).ceil() as i32 + 1;
5352    if px_x_min >= px_x_max || px_y_min >= px_y_max {
5353        return None;
5354    }
5355    let raster_w = (px_x_max - px_x_min) as u32;
5356    let raster_h = (px_y_max - px_y_min) as u32;
5357    if raster_w == 0 || raster_h == 0 {
5358        return None;
5359    }
5360    if (raster_w as u64) * (raster_h as u64) > MAX_MASK_RASTER_PIXELS {
5361        return None;
5362    }
5363
5364    // 3. Allocate the offscreen pixmap (transparent backdrop).
5365    let mut mask_pixmap = Pixmap::new(raster_w, raster_h)?;
5366
5367    // 4. Build a RenderContext that maps device pixel `(dx, dy)` to
5368    // raster pixel `(dx - px_x_min, dy - px_y_min)`. The viewport is in
5369    // device-space units (not pixels), so divide by scale.
5370    let sub_ctx = RenderContext {
5371        vp_x: px_x_min as f32 / scale_x,
5372        vp_y: px_y_min as f32 / scale_y,
5373        scale_x,
5374        scale_y,
5375        out_w: raster_w,
5376        out_h: raster_h,
5377        effective_dpi,
5378        icc,
5379        image_cache: None,
5380        preprocessed: None,
5381        elem_idx: 0,
5382        no_aa,
5383        opm_zero_transparent: false,
5384        knockout_painter_pass: KnockoutPainterPass::None,
5385        parent_group_isolated: false,
5386        alpha_extraction_pass: false,
5387        layer_set,
5388    };
5389
5390    // 5. Mask rendering doesn't participate in CMYK overprint compositing.
5391    let mut mask_band = BandState {
5392        clip_region: None,
5393        spare_mask: None,
5394        clip_mask_cache: HashMap::new(),
5395        clip_mask_seen: HashSet::new(),
5396        mask_pool: Vec::new(),
5397        cmyk_buffer: None,
5398        op_bg_snapshot: None,
5399        op_touched: None,
5400        spot_mask: None,
5401    };
5402
5403    // 6. Render every element of the mask display list into the offscreen.
5404    for (idx, elem) in mask_list.elements().iter().enumerate() {
5405        let elem_ctx = RenderContext {
5406            elem_idx: idx,
5407            ..sub_ctx
5408        };
5409        render_element(&mut mask_pixmap, &mut mask_band, elem, &elem_ctx);
5410    }
5411
5412    // 7. If the mask form contained nested gs-set SMask scopes, composite
5413    // the rendered mask onto the backdrop color before extracting
5414    // luminosity. Nested masks produce semi-transparent pixels where
5415    // alpha encodes the mask modulation; without compositing,
5416    // un-premultiplying would amplify the color and lose the modulation.
5417    // Only Luminosity: Alpha masks extract the alpha channel directly,
5418    // so forcing alpha=255 via compositing would destroy the mask info.
5419    if params.has_nested_mask_scope
5420        && params.subtype == stet_graphics::display_list::SoftMaskSubtype::Luminosity
5421    {
5422        let bc = params.backdrop_color.as_ref();
5423        let bd_r = bc.map_or(0u8, |c| (c[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5424        let bd_g = bc.map_or(0u8, |c| (c[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5425        let bd_b = bc.map_or(0u8, |c| (c[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
5426        for chunk in mask_pixmap.data_mut().chunks_exact_mut(4) {
5427            let a = chunk[3] as u16;
5428            if a == 255 {
5429                continue;
5430            }
5431            let inv_a = 255 - a;
5432            chunk[0] = ((chunk[0] as u16 * 255 + bd_r as u16 * inv_a + 127) / 255) as u8;
5433            chunk[1] = ((chunk[1] as u16 * 255 + bd_g as u16 * inv_a + 127) / 255) as u8;
5434            chunk[2] = ((chunk[2] as u16 * 255 + bd_b as u16 * inv_a + 127) / 255) as u8;
5435            chunk[3] = 255;
5436        }
5437    }
5438
5439    // 8. Extract grayscale mask values into a flat single-channel buffer.
5440    let pixel_count = (raster_w * raster_h) as usize;
5441    let mut data = vec![0u8; pixel_count];
5442    extract_soft_mask_values(mask_pixmap.data(), &mut data, params);
5443
5444    Some(stet_graphics::display_list::MaskRaster {
5445        data,
5446        width: raster_w,
5447        height: raster_h,
5448        origin_x: px_x_min,
5449        origin_y: px_y_min,
5450        scale_x,
5451        scale_y,
5452    })
5453}
5454
5455/// Transform a display element's CTM through a matrix so that pattern-space
5456/// coordinates map to device space.  Recursively transforms children of
5457/// Group and SoftMasked elements, and adjusts their bboxes.
5458fn transform_element_ctm(elem: &DisplayElement, pm: &Matrix) -> DisplayElement {
5459    match elem {
5460        DisplayElement::Fill { path, params } => {
5461            let mut p = params.clone();
5462            p.ctm = pm.concat(&p.ctm);
5463            DisplayElement::Fill {
5464                path: path.clone(),
5465                params: p,
5466            }
5467        }
5468        DisplayElement::Stroke { path, params } => {
5469            let mut p = params.clone();
5470            p.ctm = pm.concat(&p.ctm);
5471            DisplayElement::Stroke {
5472                path: path.clone(),
5473                params: p,
5474            }
5475        }
5476        DisplayElement::Clip { path, params } => {
5477            let mut p = params.clone();
5478            p.ctm = pm.concat(&p.ctm);
5479            if let Some(ref mut sp) = p.stroke_params {
5480                sp.ctm = pm.concat(&sp.ctm);
5481            }
5482            DisplayElement::Clip {
5483                path: path.clone(),
5484                params: p,
5485            }
5486        }
5487        DisplayElement::Image {
5488            sample_data,
5489            params,
5490        } => {
5491            let mut p = params.clone();
5492            p.ctm = pm.concat(&p.ctm);
5493            DisplayElement::Image {
5494                sample_data: sample_data.clone(),
5495                params: p,
5496            }
5497        }
5498        DisplayElement::MeshShading { params } => {
5499            let mut p = params.clone();
5500            p.ctm = pm.concat(&p.ctm);
5501            DisplayElement::MeshShading { params: p }
5502        }
5503        DisplayElement::PatchShading { params } => {
5504            let mut p = params.clone();
5505            p.ctm = pm.concat(&p.ctm);
5506            DisplayElement::PatchShading { params: p }
5507        }
5508        DisplayElement::AxialShading { params } => {
5509            let mut p = params.clone();
5510            p.ctm = pm.concat(&p.ctm);
5511            DisplayElement::AxialShading { params: p }
5512        }
5513        DisplayElement::RadialShading { params } => {
5514            let mut p = params.clone();
5515            p.ctm = pm.concat(&p.ctm);
5516            DisplayElement::RadialShading { params: p }
5517        }
5518        DisplayElement::Group { elements, params } => {
5519            let mut t = DisplayList::new();
5520            for child in elements.elements() {
5521                t.push(transform_element_ctm(child, pm));
5522            }
5523            let mut p = params.clone();
5524            let corners = [
5525                pm.transform_point(p.bbox[0], p.bbox[1]),
5526                pm.transform_point(p.bbox[2], p.bbox[1]),
5527                pm.transform_point(p.bbox[0], p.bbox[3]),
5528                pm.transform_point(p.bbox[2], p.bbox[3]),
5529            ];
5530            p.bbox = [
5531                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5532                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5533                corners
5534                    .iter()
5535                    .map(|c| c.0)
5536                    .fold(f64::NEG_INFINITY, f64::max),
5537                corners
5538                    .iter()
5539                    .map(|c| c.1)
5540                    .fold(f64::NEG_INFINITY, f64::max),
5541            ];
5542            DisplayElement::Group {
5543                elements: t,
5544                params: p,
5545            }
5546        }
5547        DisplayElement::SoftMasked {
5548            mask,
5549            content,
5550            params,
5551            ..
5552        } => {
5553            let mut t_mask = DisplayList::new();
5554            for child in mask.elements() {
5555                t_mask.push(transform_element_ctm(child, pm));
5556            }
5557            let mut t_content = DisplayList::new();
5558            for child in content.elements() {
5559                t_content.push(transform_element_ctm(child, pm));
5560            }
5561            let mut p = params.clone();
5562            let corners = [
5563                pm.transform_point(p.bbox[0], p.bbox[1]),
5564                pm.transform_point(p.bbox[2], p.bbox[1]),
5565                pm.transform_point(p.bbox[0], p.bbox[3]),
5566                pm.transform_point(p.bbox[2], p.bbox[3]),
5567            ];
5568            p.bbox = [
5569                corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min),
5570                corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min),
5571                corners
5572                    .iter()
5573                    .map(|c| c.0)
5574                    .fold(f64::NEG_INFINITY, f64::max),
5575                corners
5576                    .iter()
5577                    .map(|c| c.1)
5578                    .fold(f64::NEG_INFINITY, f64::max),
5579            ];
5580            // parent_clip_bbox was captured in the original (pattern)
5581            // coordinate system. Transform it through pm to match the
5582            // device-space coords that mask/content elements were just
5583            // moved into; otherwise the renderer would intersect a
5584            // device-space mask bbox with a pattern-space clip and get
5585            // an empty raster.
5586            if let Some(pcb) = p.parent_clip_bbox {
5587                let pcb_corners = [
5588                    pm.transform_point(pcb[0], pcb[1]),
5589                    pm.transform_point(pcb[2], pcb[1]),
5590                    pm.transform_point(pcb[0], pcb[3]),
5591                    pm.transform_point(pcb[2], pcb[3]),
5592                ];
5593                p.parent_clip_bbox = Some([
5594                    pcb_corners
5595                        .iter()
5596                        .map(|c| c.0)
5597                        .fold(f64::INFINITY, f64::min),
5598                    pcb_corners
5599                        .iter()
5600                        .map(|c| c.1)
5601                        .fold(f64::INFINITY, f64::min),
5602                    pcb_corners
5603                        .iter()
5604                        .map(|c| c.0)
5605                        .fold(f64::NEG_INFINITY, f64::max),
5606                    pcb_corners
5607                        .iter()
5608                        .map(|c| c.1)
5609                        .fold(f64::NEG_INFINITY, f64::max),
5610                ]);
5611            }
5612            // The transformed element's coordinate system is different
5613            // from the original; the original cache (if any) is invalid.
5614            // Allocate a fresh cache cell.
5615            DisplayElement::SoftMasked {
5616                mask: t_mask,
5617                content: t_content,
5618                params: p,
5619                mask_cache: Arc::new(Mutex::new(None)),
5620            }
5621        }
5622        DisplayElement::PatternFill { params } => {
5623            let mut p = params.clone();
5624            p.pattern_matrix = pm.concat(&p.pattern_matrix);
5625            // Transform the fill path (device-space coordinates)
5626            p.path = transform_path_by_matrix(&p.path, pm);
5627            if let Some(ref mut sp) = p.stroke_params {
5628                sp.ctm = pm.concat(&sp.ctm);
5629            }
5630            DisplayElement::PatternFill { params: p }
5631        }
5632        DisplayElement::OcgGroup {
5633            elements,
5634            visibility,
5635        } => {
5636            let mut t = DisplayList::new();
5637            for child in elements.elements() {
5638                t.push(transform_element_ctm(child, pm));
5639            }
5640            DisplayElement::OcgGroup {
5641                elements: t,
5642                visibility: visibility.clone(),
5643            }
5644        }
5645        other => other.clone(),
5646    }
5647}
5648
5649/// Transform all points in a path through a matrix.
5650fn transform_path_by_matrix(path: &PsPath, m: &Matrix) -> PsPath {
5651    use stet_fonts::geometry::PathSegment;
5652    let mut out = PsPath::new();
5653    for seg in &path.segments {
5654        out.segments.push(match *seg {
5655            PathSegment::MoveTo(x, y) => {
5656                let (nx, ny) = m.transform_point(x, y);
5657                PathSegment::MoveTo(nx, ny)
5658            }
5659            PathSegment::LineTo(x, y) => {
5660                let (nx, ny) = m.transform_point(x, y);
5661                PathSegment::LineTo(nx, ny)
5662            }
5663            PathSegment::CurveTo {
5664                x1,
5665                y1,
5666                x2,
5667                y2,
5668                x3,
5669                y3,
5670            } => {
5671                let (nx1, ny1) = m.transform_point(x1, y1);
5672                let (nx2, ny2) = m.transform_point(x2, y2);
5673                let (nx3, ny3) = m.transform_point(x3, y3);
5674                PathSegment::CurveTo {
5675                    x1: nx1,
5676                    y1: ny1,
5677                    x2: nx2,
5678                    y2: ny2,
5679                    x3: nx3,
5680                    y3: ny3,
5681                }
5682            }
5683            PathSegment::ClosePath => PathSegment::ClosePath,
5684        });
5685    }
5686    out
5687}
5688
5689/// Render a tiled pattern fill.
5690/// Bilinear downscale of premultiplied RGBA image data.
5691///
5692/// Used to pre-scale pattern tile images when the device-space tile is smaller
5693/// than the image resolution, since tiny-skia's `draw_pixmap` doesn't handle
5694/// sub-1.0 scale transforms.
5695fn bilinear_prescale(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
5696    let mut dst = vec![0u8; (dw * dh * 4) as usize];
5697    for dy in 0..dh {
5698        let sy_f = (dy as f64 + 0.5) * sh as f64 / dh as f64 - 0.5;
5699        let sy0 = sy_f.floor().max(0.0) as u32;
5700        let sy1 = (sy0 + 1).min(sh - 1);
5701        let fy = (sy_f - sy0 as f64) as f32;
5702        let ify = 1.0 - fy;
5703        for dx in 0..dw {
5704            let sx_f = (dx as f64 + 0.5) * sw as f64 / dw as f64 - 0.5;
5705            let sx0 = sx_f.floor().max(0.0) as u32;
5706            let sx1 = (sx0 + 1).min(sw - 1);
5707            let fx = (sx_f - sx0 as f64) as f32;
5708            let ifx = 1.0 - fx;
5709
5710            let i00 = (sy0 * sw + sx0) as usize * 4;
5711            let i10 = (sy0 * sw + sx1) as usize * 4;
5712            let i01 = (sy1 * sw + sx0) as usize * 4;
5713            let i11 = (sy1 * sw + sx1) as usize * 4;
5714            let di = (dy * dw + dx) as usize * 4;
5715            for c in 0..4 {
5716                dst[di + c] = (src[i00 + c] as f32 * ifx * ify
5717                    + src[i10 + c] as f32 * fx * ify
5718                    + src[i01 + c] as f32 * ifx * fy
5719                    + src[i11 + c] as f32 * fx * fy)
5720                    .round() as u8;
5721            }
5722        }
5723    }
5724    dst
5725}
5726
5727fn render_pattern_fill(
5728    pixmap: &mut Pixmap,
5729    band_state: &mut BandState,
5730    params: &stet_graphics::device::PatternFillParams,
5731    ctx: &RenderContext<'_>,
5732) {
5733    let mut temp_mask = None;
5734    let Some(mask_ref) = resolve_clip_mask(
5735        &band_state.clip_region,
5736        &mut temp_mask,
5737        ctx.out_w,
5738        ctx.out_h,
5739    ) else {
5740        return;
5741    };
5742
5743    let pm = &params.pattern_matrix;
5744
5745    // Tile step vectors in device space (handles rotation/shear)
5746    let (step_ux, step_uy) = pm.transform_delta(params.xstep, 0.0);
5747    let (step_vx, step_vy) = pm.transform_delta(0.0, params.ystep);
5748
5749    let step_u_len = (step_ux * step_ux + step_uy * step_uy).sqrt();
5750    let step_v_len = (step_vx * step_vx + step_vy * step_vy).sqrt();
5751    if step_u_len < 0.01 || step_v_len < 0.01 {
5752        return;
5753    }
5754
5755    let origin_x = pm.tx;
5756    let origin_y = pm.ty;
5757
5758    // Viewport bounds in device space
5759    let dev_vp_x = ctx.vp_x as f64;
5760    let dev_vp_y = ctx.vp_y as f64;
5761    let dev_vp_w = ctx.out_w as f64 / ctx.scale_x as f64;
5762    let dev_vp_h = ctx.out_h as f64 / ctx.scale_y as f64;
5763
5764    let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
5765    for seg in &params.path.segments {
5766        let (x, y) = match seg {
5767            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => (*x, *y),
5768            PathSegment::CurveTo { x3, y3, .. } => (*x3, *y3),
5769            PathSegment::ClosePath => continue,
5770        };
5771        min_x = min_x.min(x);
5772        min_y = min_y.min(y);
5773        max_x = max_x.max(x);
5774        max_y = max_y.max(y);
5775    }
5776
5777    // For stroke patterns, the path extends beyond the centerline by half
5778    // the stroke width.  The path is in user space; transform the bbox
5779    // corners through the CTM to get device-space bounds.
5780    if let Some(ref sp) = params.stroke_params {
5781        // Transform user-space bbox corners through CTM to device space
5782        let ctm = &sp.ctm;
5783        let corners = [
5784            ctm.transform_point(min_x, min_y),
5785            ctm.transform_point(max_x, min_y),
5786            ctm.transform_point(min_x, max_y),
5787            ctm.transform_point(max_x, max_y),
5788        ];
5789        min_x = f64::MAX;
5790        min_y = f64::MAX;
5791        max_x = f64::MIN;
5792        max_y = f64::MIN;
5793        for (cx, cy) in &corners {
5794            min_x = min_x.min(*cx);
5795            min_y = min_y.min(*cy);
5796            max_x = max_x.max(*cx);
5797            max_y = max_y.max(*cy);
5798        }
5799        // Expand by half stroke width in device space
5800        let half_w = sp.line_width
5801            * 0.5
5802            * (ctm.a * ctm.a + ctm.b * ctm.b)
5803                .sqrt()
5804                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
5805        min_x -= half_w;
5806        min_y -= half_w;
5807        max_x += half_w;
5808        max_y += half_w;
5809    }
5810
5811    // Clamp to viewport bounds in device space
5812    min_x = min_x.max(dev_vp_x);
5813    min_y = min_y.max(dev_vp_y);
5814    max_x = max_x.min(dev_vp_x + dev_vp_w);
5815    max_y = max_y.min(dev_vp_y + dev_vp_h);
5816    if min_x >= max_x || min_y >= max_y {
5817        return;
5818    }
5819
5820    let det = step_ux * step_vy - step_uy * step_vx;
5821    if det.abs() < 1e-10 {
5822        return;
5823    }
5824    let inv_det = 1.0 / det;
5825
5826    let mut tu_min = f64::MAX;
5827    let mut tu_max = f64::MIN;
5828    let mut tv_min = f64::MAX;
5829    let mut tv_max = f64::MIN;
5830    for &(cx, cy) in &[
5831        (min_x, min_y),
5832        (max_x, min_y),
5833        (min_x, max_y),
5834        (max_x, max_y),
5835    ] {
5836        let dx = cx - origin_x;
5837        let dy = cy - origin_y;
5838        let tu = (dx * step_vy - dy * step_vx) * inv_det;
5839        let tv = (-dx * step_uy + dy * step_ux) * inv_det;
5840        tu_min = tu_min.min(tu);
5841        tu_max = tu_max.max(tu);
5842        tv_min = tv_min.min(tv);
5843        tv_max = tv_max.max(tv);
5844    }
5845
5846    let tile_x_start = tu_min.floor() as i32 - 1;
5847    let tile_x_end = tu_max.ceil() as i32 + 1;
5848    let tile_y_start = tv_min.floor() as i32 - 1;
5849    let tile_y_end = tv_max.ceil() as i32 + 1;
5850
5851    let tile_count = (tile_x_end - tile_x_start) as i64 * (tile_y_end - tile_y_start) as i64;
5852    if tile_count > 10000 {
5853        return;
5854    }
5855
5856    let Some(mut tile_buf) = Pixmap::new(ctx.out_w, ctx.out_h) else {
5857        return;
5858    };
5859
5860    let sx_f = ctx.scale_x as f64;
5861    let sy_f = ctx.scale_y as f64;
5862
5863    if params.device_space_tile {
5864        // Device-space tile path: tile elements have CTMs in device space
5865        // (pattern matrix baked in). Use the full render_element pipeline
5866        // which handles all element types (clips, soft masks, shadings,
5867        // groups). For each tile position, shift the viewport origin by the
5868        // tile offset in device space.
5869        for tv in tile_y_start..tile_y_end {
5870            for tu in tile_x_start..tile_x_end {
5871                let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
5872                let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
5873
5874                let tile_ctx = RenderContext {
5875                    vp_x: ctx.vp_x - offset_x as f32,
5876                    vp_y: ctx.vp_y - offset_y as f32,
5877                    scale_x: ctx.scale_x,
5878                    scale_y: ctx.scale_y,
5879                    out_w: ctx.out_w,
5880                    out_h: ctx.out_h,
5881                    effective_dpi: ctx.effective_dpi,
5882                    icc: ctx.icc,
5883                    image_cache: None,
5884                    preprocessed: None,
5885                    elem_idx: 0,
5886                    no_aa: ctx.no_aa,
5887                    opm_zero_transparent: params.overprint_mode == 1,
5888                    knockout_painter_pass: ctx.knockout_painter_pass,
5889                    parent_group_isolated: ctx.parent_group_isolated,
5890                    alpha_extraction_pass: ctx.alpha_extraction_pass,
5891                    layer_set: ctx.layer_set,
5892                };
5893
5894                let mut tile_band = BandState {
5895                    clip_region: None,
5896                    spare_mask: None,
5897                    clip_mask_cache: HashMap::new(),
5898                    clip_mask_seen: HashSet::new(),
5899                    mask_pool: Vec::new(),
5900                    cmyk_buffer: None,
5901                    op_bg_snapshot: None,
5902                    op_touched: None,
5903                    spot_mask: None,
5904                };
5905
5906                for (idx, elem) in params.tile.elements().iter().enumerate() {
5907                    let elem_ctx = RenderContext {
5908                        elem_idx: idx,
5909                        ..tile_ctx
5910                    };
5911                    render_element(&mut tile_buf, &mut tile_band, elem, &elem_ctx);
5912                }
5913            }
5914        }
5915    } else if params.tile.elements().iter().any(|e| {
5916        !matches!(
5917            e,
5918            DisplayElement::Fill { .. }
5919                | DisplayElement::Stroke { .. }
5920                | DisplayElement::Image { .. }
5921                | DisplayElement::Clip { .. }
5922                | DisplayElement::InitClip
5923        )
5924    }) {
5925        // Complex tile path: pre-render one tile into a small pixmap using
5926        // the full render_element pipeline (handles shadings, groups,
5927        // soft masks, etc.), then stamp copies at each tile position.
5928        let bbox = &params.bbox;
5929        let corners_dev = [
5930            pm.transform_point(bbox[0], bbox[1]),
5931            pm.transform_point(bbox[2], bbox[1]),
5932            pm.transform_point(bbox[0], bbox[3]),
5933            pm.transform_point(bbox[2], bbox[3]),
5934        ];
5935        let (mut td_x0, mut td_y0) = (f64::MAX, f64::MAX);
5936        let (mut td_x1, mut td_y1) = (f64::MIN, f64::MIN);
5937        for (x, y) in &corners_dev {
5938            td_x0 = td_x0.min(*x);
5939            td_y0 = td_y0.min(*y);
5940            td_x1 = td_x1.max(*x);
5941            td_y1 = td_y1.max(*y);
5942        }
5943        let tile_pw = ((td_x1 - td_x0) * sx_f).ceil().max(1.0) as u32;
5944        let tile_ph = ((td_y1 - td_y0) * sy_f).ceil().max(1.0) as u32;
5945        let tile_pw = tile_pw.min(8192);
5946        let tile_ph = tile_ph.min(8192);
5947
5948        if let Some(mut one_tile) = Pixmap::new(tile_pw, tile_ph) {
5949            let tile_render_ctx = RenderContext {
5950                vp_x: td_x0 as f32,
5951                vp_y: td_y0 as f32,
5952                scale_x: ctx.scale_x,
5953                scale_y: ctx.scale_y,
5954                out_w: tile_pw,
5955                out_h: tile_ph,
5956                effective_dpi: ctx.effective_dpi,
5957                icc: ctx.icc,
5958                image_cache: None,
5959                preprocessed: None,
5960                elem_idx: 0,
5961                no_aa: ctx.no_aa,
5962                opm_zero_transparent: params.overprint_mode == 1,
5963                knockout_painter_pass: ctx.knockout_painter_pass,
5964                parent_group_isolated: ctx.parent_group_isolated,
5965                alpha_extraction_pass: ctx.alpha_extraction_pass,
5966                layer_set: ctx.layer_set,
5967            };
5968            let mut tile_bs = BandState {
5969                clip_region: None,
5970                spare_mask: None,
5971                clip_mask_cache: HashMap::new(),
5972                clip_mask_seen: HashSet::new(),
5973                mask_pool: Vec::new(),
5974                cmyk_buffer: None,
5975                op_bg_snapshot: None,
5976                op_touched: None,
5977                spot_mask: None,
5978            };
5979            for (idx, elem) in params.tile.elements().iter().enumerate() {
5980                let transformed = transform_element_ctm(elem, pm);
5981                let elem_ctx = RenderContext {
5982                    elem_idx: idx,
5983                    ..tile_render_ctx
5984                };
5985                render_element(&mut one_tile, &mut tile_bs, &transformed, &elem_ctx);
5986            }
5987            // Stamp pre-rendered tile at each position
5988            for tv in tile_y_start..tile_y_end {
5989                for tu in tile_x_start..tile_x_end {
5990                    let offset_x = tu as f64 * step_ux + tv as f64 * step_vx;
5991                    let offset_y = tu as f64 * step_uy + tv as f64 * step_vy;
5992                    let px = ((td_x0 + offset_x - dev_vp_x) * sx_f) as i32;
5993                    let py = ((td_y0 + offset_y - dev_vp_y) * sy_f) as i32;
5994                    let paint = stet_tiny_skia::PixmapPaint {
5995                        opacity: 1.0,
5996                        blend_mode: BlendMode::SourceOver,
5997                        quality: stet_tiny_skia::FilterQuality::Nearest,
5998                    };
5999                    tile_buf.draw_pixmap(
6000                        px,
6001                        py,
6002                        one_tile.as_ref(),
6003                        &paint,
6004                        Transform::identity(),
6005                        None,
6006                    );
6007                }
6008            }
6009        }
6010    } else {
6011        // Simple tile path: tile elements have identity CTMs.
6012        // Manually apply the pattern matrix + tile offset for each element.
6013        // Only handles Fill, Stroke, Image, and Clip.
6014
6015        // Pre-process Image elements: convert to RGBA once and pre-scale if
6016        // the combined transform would require downscaling (scale < 1.0).
6017        // tiny-skia's draw_pixmap doesn't handle sub-1.0 scale transforms.
6018        struct PreprocessedImage {
6019            rgba: Vec<u8>,
6020            width: u32,
6021            height: u32,
6022            /// Transform from pixel coords to pattern space, possibly adjusted
6023            /// to account for pre-scaling.
6024            img_transform: Transform,
6025        }
6026        let tile_elements = params.tile.elements();
6027        let mut preprocessed: Vec<Option<PreprocessedImage>> =
6028            Vec::with_capacity(tile_elements.len());
6029        // Tile transform scale components (constant across all tiles)
6030        let tt_sx = (pm.a * sx_f) as f32;
6031        let tt_sy = (pm.d * sy_f) as f32;
6032        let tt_kx = (pm.c * sx_f) as f32;
6033        let tt_ky = (pm.b * sy_f) as f32;
6034        for elem in tile_elements {
6035            if let DisplayElement::Image {
6036                sample_data,
6037                params: ip,
6038            } = elem
6039            {
6040                let iw = ip.width;
6041                let ih = ip.height;
6042                if iw > 0 && ih > 0 {
6043                    let mut rgba =
6044                        samples_to_rgba(sample_data, ip, ctx.icc, ctx.opm_zero_transparent);
6045                    if ip.mask_color.is_some() {
6046                        apply_mask_color_rgba(&mut rgba, sample_data, ip);
6047                    }
6048                    let expected = (iw * ih * 4) as usize;
6049                    if rgba.len() >= expected {
6050                        if let Some(inv) = ip.image_matrix.invert() {
6051                            let combined_mat = ip.ctm.concat(&inv);
6052                            let t = to_transform(&combined_mat);
6053                            // Check effective scale: t maps image pixels → pattern space,
6054                            // tile_transform maps pattern space → device space.
6055                            let test = t.post_concat(Transform::from_row(
6056                                tt_sx, tt_ky, tt_kx, tt_sy, 0.0, 0.0,
6057                            ));
6058                            let eff_sx = (test.sx * test.sx + test.ky * test.ky).sqrt();
6059                            let eff_sy = (test.kx * test.kx + test.sy * test.sy).sqrt();
6060                            if eff_sx < 0.99 || eff_sy < 0.99 {
6061                                // Pre-scale image to avoid sub-1.0 draw_pixmap transform.
6062                                // Use floor so the scaled image is smaller than the
6063                                // device-space tile, ensuring the adjusted scale >= 1.0.
6064                                let tw = (iw as f32 * eff_sx).floor().max(1.0) as u32;
6065                                let th = (ih as f32 * eff_sy).floor().max(1.0) as u32;
6066                                let scaled = bilinear_prescale(&rgba, iw, ih, tw, th);
6067                                // Adjust transform: pre-multiply a scale that maps new
6068                                // pixel coords back to original pixel coords
6069                                let adj = Transform::from_scale(
6070                                    iw as f32 / tw as f32,
6071                                    ih as f32 / th as f32,
6072                                );
6073                                preprocessed.push(Some(PreprocessedImage {
6074                                    rgba: scaled,
6075                                    width: tw,
6076                                    height: th,
6077                                    img_transform: t.pre_concat(adj),
6078                                }));
6079                            } else {
6080                                preprocessed.push(Some(PreprocessedImage {
6081                                    rgba,
6082                                    width: iw,
6083                                    height: ih,
6084                                    img_transform: t,
6085                                }));
6086                            }
6087                        } else {
6088                            preprocessed.push(None);
6089                        }
6090                    } else {
6091                        preprocessed.push(None);
6092                    }
6093                } else {
6094                    preprocessed.push(None);
6095                }
6096                // Note: only Image elements push to preprocessed, so img_idx
6097                // in the tile loop correctly indexes this array.
6098            }
6099        }
6100
6101        for tv in tile_y_start..tile_y_end {
6102            for tu in tile_x_start..tile_x_end {
6103                let pat_offset_x = tu as f64 * params.xstep;
6104                let pat_offset_y = tv as f64 * params.ystep;
6105
6106                let tile_transform = Transform::from_row(
6107                    tt_sx,
6108                    tt_ky,
6109                    tt_kx,
6110                    tt_sy,
6111                    ((pm.a * pat_offset_x + pm.c * pat_offset_y + pm.tx - dev_vp_x) * sx_f) as f32,
6112                    ((pm.b * pat_offset_x + pm.d * pat_offset_y + pm.ty - dev_vp_y) * sy_f) as f32,
6113                );
6114
6115                // Clip tile elements to BBox (PDF spec 8.7.4.2)
6116                let bbox_clip = {
6117                    let bb = &params.bbox;
6118                    let mut bp = stet_tiny_skia::PathBuilder::new();
6119                    bp.move_to(bb[0] as f32, bb[1] as f32);
6120                    bp.line_to(bb[2] as f32, bb[1] as f32);
6121                    bp.line_to(bb[2] as f32, bb[3] as f32);
6122                    bp.line_to(bb[0] as f32, bb[3] as f32);
6123                    bp.close();
6124                    bp.finish().and_then(|sp| {
6125                        let mut m = Mask::new(ctx.out_w, ctx.out_h)?;
6126                        m.fill_path(
6127                            &sp,
6128                            stet_tiny_skia::FillRule::Winding,
6129                            false,
6130                            tile_transform,
6131                        );
6132                        Some(m)
6133                    })
6134                };
6135                let mut tile_clip: Option<Mask> = bbox_clip;
6136                let mut img_idx = 0usize;
6137                for elem in tile_elements {
6138                    let clip_ref = tile_clip.as_ref();
6139                    match elem {
6140                        DisplayElement::Clip { path, params: cp } => {
6141                            if let Some(sp) = build_skia_path(path) {
6142                                let t = to_transform(&cp.ctm);
6143                                let combined = t.post_concat(tile_transform);
6144                                let mut mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6145                                mask.fill_path(&sp, to_fill_rule(&cp.fill_rule), false, combined);
6146                                if let Some(prev) = tile_clip.take() {
6147                                    intersect_masks(&mut mask, &prev);
6148                                }
6149                                tile_clip = Some(mask);
6150                            }
6151                        }
6152                        DisplayElement::InitClip => {
6153                            tile_clip = None;
6154                        }
6155                        DisplayElement::Fill { path, params: fp } => {
6156                            if let Some(sp) = build_skia_path(path) {
6157                                let mut paint = if params.paint_type == 1 {
6158                                    to_paint(&fp.color)
6159                                } else {
6160                                    to_paint(
6161                                        params
6162                                            .underlying_color
6163                                            .as_ref()
6164                                            .unwrap_or(&DeviceColor::black()),
6165                                    )
6166                                };
6167                                paint.anti_alias = false;
6168                                let t = to_transform(&fp.ctm);
6169                                let combined = t.post_concat(tile_transform);
6170                                let fr = to_fill_rule(&fp.fill_rule);
6171                                tile_buf.fill_path(&sp, &paint, fr, combined, clip_ref);
6172                            }
6173                        }
6174                        DisplayElement::Stroke { path, params: sp } => {
6175                            if let Some(skp) = build_skia_path(path) {
6176                                // Compose element CTM with pattern matrix so
6177                                // hairline_min_width sees the real device scale,
6178                                // not the tile's identity CTM.
6179                                let effective_ctm = pm.concat(&sp.ctm);
6180                                let mut sp_adj = sp.clone();
6181                                sp_adj.ctm = effective_ctm;
6182                                let stroke = build_stroke(&sp_adj, ctx.effective_dpi);
6183                                let paint = if params.paint_type == 1 {
6184                                    to_paint(&sp.color)
6185                                } else {
6186                                    to_paint(
6187                                        params
6188                                            .underlying_color
6189                                            .as_ref()
6190                                            .unwrap_or(&DeviceColor::black()),
6191                                    )
6192                                };
6193                                let t = to_transform(&sp.ctm);
6194                                let combined = t.post_concat(tile_transform);
6195                                tile_buf.stroke_path(&skp, &paint, &stroke, combined, clip_ref);
6196                            }
6197                        }
6198                        DisplayElement::Image { .. } => {
6199                            if let Some(ref pi) = preprocessed[img_idx] {
6200                                let combined = pi.img_transform.post_concat(tile_transform);
6201                                if let Some(img_ref) = stet_tiny_skia::PixmapRef::from_bytes(
6202                                    &pi.rgba, pi.width, pi.height,
6203                                ) {
6204                                    let paint = stet_tiny_skia::PixmapPaint {
6205                                        opacity: 1.0,
6206                                        blend_mode: BlendMode::SourceOver,
6207                                        quality: stet_tiny_skia::FilterQuality::Nearest,
6208                                    };
6209                                    tile_buf.draw_pixmap(0, 0, img_ref, &paint, combined, clip_ref);
6210                                }
6211                            }
6212                            img_idx += 1;
6213                        }
6214                        _ => {}
6215                    }
6216                }
6217            }
6218        }
6219    }
6220
6221    // Composite tile_buf onto main pixmap through the fill/stroke path
6222    let Some(fill_skia_path) = build_skia_path(&params.path) else {
6223        return;
6224    };
6225    let fill_rule = to_fill_rule(&params.fill_rule);
6226    let mut fill_mask = Mask::new(ctx.out_w, ctx.out_h).expect("mask");
6227    let path_transform = viewport_transform(
6228        Transform::identity(),
6229        ctx.vp_x,
6230        ctx.vp_y,
6231        ctx.scale_x,
6232        ctx.scale_y,
6233    );
6234    if let Some(ref sp) = params.stroke_params {
6235        // Stroke pattern: expand the centerline path to a fill outline
6236        // using the stroke parameters (width, cap, join, miter, dash).
6237        // Apply dash pattern first (Path::stroke doesn't handle dashing).
6238        let stroke = build_stroke(sp, ctx.effective_dpi);
6239        let ctm_transform = to_transform(&sp.ctm);
6240        let combined = ctm_transform.post_concat(path_transform);
6241        let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&combined);
6242        let dashed;
6243        let stroke_path = if let Some(ref dash) = stroke.dash {
6244            dashed = fill_skia_path.dash(dash, res_scale);
6245            match dashed.as_ref() {
6246                Some(p) => p,
6247                None => &fill_skia_path,
6248            }
6249        } else {
6250            &fill_skia_path
6251        };
6252        if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6253            fill_mask.fill_path(
6254                &outline,
6255                stet_tiny_skia::FillRule::Winding,
6256                !ctx.no_aa,
6257                combined,
6258            );
6259        }
6260    } else {
6261        fill_mask.fill_path(&fill_skia_path, fill_rule, !ctx.no_aa, path_transform);
6262    }
6263
6264    if let Some(clip_mask) = mask_ref {
6265        intersect_masks(&mut fill_mask, clip_mask);
6266    }
6267
6268    let img_paint = stet_tiny_skia::PixmapPaint::default();
6269    pixmap.draw_pixmap(
6270        0,
6271        0,
6272        tile_buf.as_ref(),
6273        &img_paint,
6274        Transform::identity(),
6275        Some(&fill_mask),
6276    );
6277}
6278
6279/// Unified clip path handling for both band and viewport rendering.
6280///
6281/// For band rendering (scale=1.0), includes rect fast-path and Y-bbox early exit.
6282/// For viewport rendering (scale!=1.0), uses the general mask path.
6283fn clip_path_unified(
6284    band_state: &mut BandState,
6285    path: &PsPath,
6286    params: &ClipParams,
6287    ctx: &RenderContext<'_>,
6288) {
6289    let is_unit_scale = ctx.scale_x == 1.0 && ctx.scale_y == 1.0;
6290
6291    // Band-mode optimizations (scale=1.0): Y-bbox early exit and rect fast-path
6292    if is_unit_scale {
6293        let y_start = ctx.vp_y as u32;
6294        let x_start = ctx.vp_x as u32;
6295
6296        // Y-bbox early exit: if clip path doesn't overlap this band, set empty clip
6297        // (only valid when CTM is identity — path coords must be in device space).
6298        // Skip when stroke_params is present: the path is in user space and
6299        // needs the stroke CTM transform, so raw Y bounds are meaningless here.
6300        if x_start == 0
6301            && params.stroke_params.is_none()
6302            && params.ctm.a == 1.0
6303            && params.ctm.d == 1.0
6304            && params.ctm.tx == 0.0
6305            && params.ctm.ty == 0.0
6306            && let Some(bbox) = path_y_bbox(path)
6307            && (bbox.y_max <= y_start as f64 || bbox.y_min >= (y_start + ctx.out_h) as f64)
6308        {
6309            if let Some(ClipRegion::Mask(mask)) = band_state.clip_region.take() {
6310                band_state.recycle_mask(mask);
6311            }
6312            band_state.clip_region = Some(ClipRegion::Rect(ClipRect {
6313                x0: 0,
6314                y0: 0,
6315                x1: 0,
6316                y1: 0,
6317            }));
6318            return;
6319        }
6320
6321        // Rect fast-path (only when x_start==0 and CTM is identity —
6322        // detect_rect uses raw path coords which are only in device space
6323        // when the CTM is identity)
6324        let ctm_is_identity = params.ctm.a == 1.0
6325            && params.ctm.b == 0.0
6326            && params.ctm.c == 0.0
6327            && params.ctm.d == 1.0
6328            && params.ctm.tx == 0.0
6329            && params.ctm.ty == 0.0;
6330        if x_start == 0
6331            && ctm_is_identity
6332            && params.stroke_params.is_none()
6333            && let Some(dev_rect) = detect_rect(path, ctx.out_w, u32::MAX)
6334        {
6335            let new_rect = translate_clip_rect(&dev_rect, y_start, ctx.out_h);
6336            match band_state.clip_region.take() {
6337                None => {
6338                    band_state.clip_region = Some(ClipRegion::Rect(new_rect));
6339                }
6340                Some(ClipRegion::Rect(existing)) => {
6341                    band_state.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6342                }
6343                Some(ClipRegion::Mask(mut mask)) => {
6344                    intersect_mask_with_rect(&mut mask, &new_rect, ctx.out_w, ctx.out_h);
6345                    band_state.clip_region = Some(ClipRegion::Mask(mask));
6346                }
6347            }
6348            return;
6349        }
6350    }
6351
6352    // General path: non-rectangular clip with cache + mask reuse
6353    let fill_rule = to_fill_rule(&params.fill_rule);
6354    let path_hash = hash_clip_path(path, &params.fill_rule);
6355    let prev_region = band_state.clip_region.take();
6356
6357    let mut mask = band_state.take_mask(ctx.out_w, ctx.out_h);
6358
6359    let path_mask = if let Some(cached) = band_state.clip_mask_cache.get(&path_hash) {
6360        mask.data_mut().copy_from_slice(cached.data());
6361        mask
6362    } else {
6363        let Some(skia_path) = build_skia_path(path) else {
6364            band_state.recycle_mask(mask);
6365            band_state.clip_region = prev_region;
6366            return;
6367        };
6368        mask.data_mut().fill(0);
6369        if let Some(ref sp) = params.stroke_params {
6370            // Stroke-based clip: expand centerline to stroke outline.
6371            // Apply dash pattern first (Path::stroke doesn't handle dashing).
6372            let stroke = build_stroke(sp, ctx.effective_dpi);
6373            let transform = ctx.transform(&sp.ctm);
6374            let res_scale = stet_tiny_skia::PathStroker::compute_resolution_scale(&transform);
6375            let dashed;
6376            let stroke_path = if let Some(ref dash) = stroke.dash {
6377                dashed = skia_path.dash(dash, res_scale);
6378                match dashed.as_ref() {
6379                    Some(p) => p,
6380                    None => &skia_path,
6381                }
6382            } else {
6383                &skia_path
6384            };
6385            if let Some(outline) = stroke_path.stroke(&stroke, res_scale) {
6386                mask.fill_path(
6387                    &outline,
6388                    stet_tiny_skia::FillRule::Winding,
6389                    false,
6390                    transform,
6391                );
6392            }
6393        } else {
6394            let transform = ctx.transform(&params.ctm);
6395            mask.fill_path(&skia_path, fill_rule, false, transform);
6396        }
6397        if !band_state.clip_mask_seen.insert(path_hash) {
6398            band_state.clip_mask_cache.insert(path_hash, mask.clone());
6399        }
6400        mask
6401    };
6402
6403    match prev_region {
6404        None => {
6405            band_state.clip_region = Some(ClipRegion::Mask(path_mask));
6406        }
6407        Some(ClipRegion::Rect(rect)) => {
6408            if rect.is_empty() {
6409                band_state.recycle_mask(path_mask);
6410                // Intersection with empty clip is still empty — preserve empty state.
6411                // Without this, clip_region stays None (= no clip = paint everything).
6412                band_state.clip_region = Some(ClipRegion::Rect(rect));
6413            } else {
6414                let mut mask = path_mask;
6415                intersect_mask_with_rect(&mut mask, &rect, ctx.out_w, ctx.out_h);
6416                band_state.clip_region = Some(ClipRegion::Mask(mask));
6417            }
6418        }
6419        Some(ClipRegion::Mask(mut existing)) => {
6420            intersect_masks(&mut existing, &path_mask);
6421            band_state.recycle_mask(path_mask);
6422            band_state.clip_region = Some(ClipRegion::Mask(existing));
6423        }
6424    }
6425}
6426impl OutputDevice for SkiaDevice {
6427    fn fill_path(&mut self, path: &PsPath, params: &FillParams) {
6428        self.ensure_full_pixmap();
6429        let Some(skia_path) = build_skia_path(path) else {
6430            return;
6431        };
6432        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6433        let mut temp_mask = None;
6434        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6435            return; // empty clip
6436        };
6437
6438        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6439        let transform = to_transform(&params.ctm);
6440        let fill_rule = to_fill_rule(&params.fill_rule);
6441
6442        self.pixmap
6443            .fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
6444    }
6445
6446    fn stroke_path(&mut self, path: &PsPath, params: &StrokeParams) {
6447        self.ensure_full_pixmap();
6448        let stroke = build_stroke(params, self.dpi);
6449        let adjusted;
6450        let draw_path =
6451            if params.stroke_adjust && stroke.width <= 2.0 && ctm_is_device_space(&params.ctm) {
6452                adjusted =
6453                    stroke_adjust_path_viewport(path, stroke.width as f64, 1.0, 1.0, 0.0, 0.0);
6454                &adjusted
6455            } else {
6456                path
6457            };
6458        let Some(skia_path) = build_skia_path(draw_path) else {
6459            return;
6460        };
6461        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, self.no_aa);
6462        let transform = to_transform(&params.ctm);
6463
6464        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6465        let mut temp_mask = None;
6466        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6467            return; // empty clip
6468        };
6469
6470        self.pixmap
6471            .stroke_path(&skia_path, &paint, &stroke, transform, mask_ref);
6472    }
6473
6474    fn clip_path(&mut self, path: &PsPath, params: &ClipParams) {
6475        self.ensure_full_pixmap();
6476        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6477
6478        // Fast path: detect axis-aligned rectangle
6479        if let Some(new_rect) = detect_rect(path, w, h) {
6480            match self.clip_region.take() {
6481                None => {
6482                    self.clip_region = Some(ClipRegion::Rect(new_rect));
6483                }
6484                Some(ClipRegion::Rect(existing)) => {
6485                    // O(1) rect-rect intersection
6486                    self.clip_region = Some(ClipRegion::Rect(existing.intersect(&new_rect)));
6487                }
6488                Some(ClipRegion::Mask(mut mask)) => {
6489                    // Zero mask pixels outside rect
6490                    intersect_mask_with_rect(&mut mask, &new_rect, w, h);
6491                    self.clip_region = Some(ClipRegion::Mask(mask));
6492                }
6493            }
6494            return;
6495        }
6496
6497        // Slow path: non-rectangular clip with mask caching + allocation reuse.
6498        let fill_rule = to_fill_rule(&params.fill_rule);
6499        let path_hash = hash_clip_path(path, &params.fill_rule);
6500        let prev_region = self.clip_region.take();
6501
6502        // Reuse a spare mask buffer if available (avoids alloc/dealloc per tile).
6503        macro_rules! take_spare {
6504            ($self:expr, $w:expr, $h:expr) => {
6505                $self
6506                    .spare_mask
6507                    .take()
6508                    .unwrap_or_else(|| Mask::new($w, $h).expect("Failed to create mask"))
6509            };
6510        }
6511
6512        // Try cache first; rasterize only on miss
6513        let path_mask = if let Some(cached) = self.clip_mask_cache.get(&path_hash) {
6514            // Cache hit: copy cached data into reused buffer (memcpy, no alloc)
6515            let mut mask = take_spare!(self, w, h);
6516            mask.data_mut().copy_from_slice(cached.data());
6517            mask
6518        } else {
6519            let Some(skia_path) = build_skia_path(path) else {
6520                self.clip_region = prev_region;
6521                return;
6522            };
6523            let transform = to_transform(&params.ctm);
6524            let mut mask = take_spare!(self, w, h);
6525            mask.data_mut().fill(0); // zero before rasterizing (spare may have old data)
6526            mask.fill_path(&skia_path, fill_rule, false, transform);
6527            // Cache on second sight: first time just record, second time store
6528            if !self.clip_mask_seen.insert(path_hash) {
6529                // Seen before — cache it (this clone only happens once per unique path)
6530                self.clip_mask_cache.insert(path_hash, mask.clone());
6531            }
6532            mask
6533        };
6534
6535        match prev_region {
6536            None => {
6537                self.clip_region = Some(ClipRegion::Mask(path_mask));
6538            }
6539            Some(ClipRegion::Rect(rect)) => {
6540                if rect.is_empty() {
6541                    self.spare_mask = Some(path_mask); // recycle
6542                } else {
6543                    let mut mask = path_mask;
6544                    intersect_mask_with_rect(&mut mask, &rect, w, h);
6545                    self.clip_region = Some(ClipRegion::Mask(mask));
6546                }
6547            }
6548            Some(ClipRegion::Mask(mut existing)) => {
6549                intersect_masks(&mut existing, &path_mask);
6550                self.spare_mask = Some(path_mask); // recycle the copy
6551                self.clip_region = Some(ClipRegion::Mask(existing));
6552            }
6553        }
6554    }
6555
6556    fn init_clip(&mut self) {
6557        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6558            self.spare_mask = Some(mask);
6559        }
6560        self.clip_region = None;
6561    }
6562
6563    fn erase_page(&mut self) {
6564        // Only fill the full pixmap when it's actually allocated (non-banded path).
6565        // During banding, self.pixmap is a 1×1 placeholder — filling it is harmless.
6566        self.pixmap.fill(Color::WHITE);
6567        if let Some(ClipRegion::Mask(mask)) = self.clip_region.take() {
6568            self.spare_mask = Some(mask);
6569        }
6570        self.clip_region = None;
6571    }
6572
6573    fn show_page(&mut self, output_path: &str) -> Result<(), String> {
6574        let w = self.pixmap.width();
6575        let h = self.pixmap.height();
6576        // Composite onto white background before output
6577        composite_onto_white(self.pixmap.data_mut());
6578        let mut sink = self.sink_factory.create_sink(output_path)?;
6579        sink.begin_page(w, h)?;
6580        sink.write_rows(self.pixmap.data(), h)?;
6581        sink.end_page()
6582    }
6583
6584    fn draw_image(&mut self, sample_data: &[u8], params: &ImageParams) {
6585        self.ensure_full_pixmap();
6586        let w = params.width;
6587        let h = params.height;
6588        if w == 0 || h == 0 {
6589            return;
6590        }
6591        let mut rgba_data =
6592            samples_to_rgba(sample_data, params, self.render_icc_cache.as_ref(), false);
6593        if params.mask_color.is_some() {
6594            apply_mask_color_rgba(&mut rgba_data, sample_data, params);
6595        }
6596        let expected = (w * h * 4) as usize;
6597        if rgba_data.len() < expected {
6598            return;
6599        }
6600
6601        let Some(image_inv) = params.image_matrix.invert() else {
6602            return;
6603        };
6604        let combined = params.ctm.concat(&image_inv);
6605        let raw_transform = enforce_min_image_size(to_transform(&combined), w, h);
6606
6607        let prescaled = prescale_image(&rgba_data, w, h, raw_transform, params.interpolate);
6608        let (img_data, img_w, img_h, transform) = match &prescaled {
6609            Some((data, pw, ph, t)) => (data.as_slice(), *pw, *ph, *t),
6610            None => (rgba_data.as_slice(), w, h, raw_transform),
6611        };
6612
6613        let Some(img_pixmap) = stet_tiny_skia::PixmapRef::from_bytes(img_data, img_w, img_h) else {
6614            return;
6615        };
6616
6617        let (pw, ph) = (self.pixmap.width(), self.pixmap.height());
6618        let mut temp_mask = None;
6619        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, pw, ph) else {
6620            return;
6621        };
6622
6623        let paint = stet_tiny_skia::PixmapPaint {
6624            quality: image_filter_quality(transform, params.interpolate),
6625            opacity: params.alpha as f32,
6626            blend_mode: u8_to_blend_mode(params.blend_mode),
6627        };
6628        self.pixmap
6629            .draw_pixmap(0, 0, img_pixmap, &paint, transform, mask_ref);
6630    }
6631
6632    fn paint_axial_shading(&mut self, params: &AxialShadingParams) {
6633        self.ensure_full_pixmap();
6634        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6635        let mut temp_mask = None;
6636        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6637            return;
6638        };
6639        render_axial_shading(
6640            &mut self.pixmap,
6641            params,
6642            0.0,
6643            0.0,
6644            1.0,
6645            1.0,
6646            mask_ref,
6647            self.no_aa,
6648            None,
6649            None,
6650        );
6651    }
6652
6653    fn paint_radial_shading(&mut self, params: &RadialShadingParams) {
6654        self.ensure_full_pixmap();
6655        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6656        let mut temp_mask = None;
6657        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6658            return;
6659        };
6660        render_radial_shading(
6661            &mut self.pixmap,
6662            params,
6663            0.0,
6664            0.0,
6665            1.0,
6666            1.0,
6667            mask_ref,
6668            self.no_aa,
6669            None,
6670            None,
6671        );
6672    }
6673
6674    fn paint_mesh_shading(&mut self, params: &MeshShadingParams) {
6675        self.ensure_full_pixmap();
6676        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6677        let mut temp_mask = None;
6678        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6679            return;
6680        };
6681        render_mesh_shading(
6682            &mut self.pixmap,
6683            params,
6684            0.0,
6685            0.0,
6686            1.0,
6687            1.0,
6688            mask_ref,
6689            None,
6690            None,
6691        );
6692    }
6693
6694    fn paint_patch_shading(&mut self, params: &PatchShadingParams) {
6695        self.ensure_full_pixmap();
6696        let (w, h) = (self.pixmap.width(), self.pixmap.height());
6697        let mut temp_mask = None;
6698        let Some(mask_ref) = resolve_clip_mask(&self.clip_region, &mut temp_mask, w, h) else {
6699            return;
6700        };
6701        render_patch_shading(
6702            &mut self.pixmap,
6703            params,
6704            0.0,
6705            0.0,
6706            1.0,
6707            1.0,
6708            mask_ref,
6709            None,
6710            None,
6711        );
6712    }
6713
6714    fn paint_pattern_fill(&mut self, params: &stet_graphics::device::PatternFillParams) {
6715        self.ensure_full_pixmap();
6716        let w = self.pixmap.width();
6717        let h = self.pixmap.height();
6718        let mut band_state = BandState {
6719            clip_region: self.clip_region.take(),
6720            spare_mask: self.spare_mask.take(),
6721            clip_mask_cache: HashMap::new(),
6722            clip_mask_seen: HashSet::new(),
6723            mask_pool: Vec::new(),
6724            cmyk_buffer: None,
6725            op_bg_snapshot: None,
6726            op_touched: None,
6727            spot_mask: None,
6728        };
6729        {
6730            let ctx = RenderContext {
6731                vp_x: 0.0,
6732                vp_y: 0.0,
6733                scale_x: 1.0,
6734                scale_y: 1.0,
6735                out_w: w,
6736                out_h: h,
6737                effective_dpi: self.dpi,
6738                icc: None,
6739                image_cache: None,
6740                preprocessed: None,
6741                elem_idx: 0,
6742                no_aa: self.no_aa,
6743                opm_zero_transparent: false,
6744                knockout_painter_pass: KnockoutPainterPass::None,
6745                parent_group_isolated: false,
6746                alpha_extraction_pass: false,
6747                layer_set: &self.layer_set,
6748            };
6749            render_pattern_fill(&mut self.pixmap, &mut band_state, params, &ctx);
6750        }
6751        self.clip_region = band_state.clip_region.take();
6752        if let Some(mask) = band_state.spare_mask.take() {
6753            self.spare_mask = Some(mask);
6754        }
6755    }
6756
6757    fn page_size(&self) -> (u32, u32) {
6758        (self.page_w, self.page_h)
6759    }
6760
6761    fn replay_and_show(&mut self, list: DisplayList, output_path: &str) -> Result<(), String> {
6762        // Wait for any previous background render to complete
6763        self.join_pending()?;
6764
6765        let (page_w, page_h) = self.page_size();
6766
6767        // Audit mode: re-render through the viewport pipeline so visual tests
6768        // can catch viewport-only bugs against the same baselines. Same
6769        // `render_element`, same display list — differs only in how culling
6770        // and epochs are computed.
6771        if self.use_viewport_path {
6772            let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref(), false);
6773            let rgba = render_to_rgba_viewport(
6774                &list,
6775                page_w,
6776                page_h,
6777                self.dpi,
6778                Some(&icc_cache),
6779                self.no_aa,
6780            );
6781            let mut sink = self.sink_factory.create_sink(output_path)?;
6782            sink.begin_page(page_w, page_h)?;
6783            sink.write_rows(&rgba, page_h)?;
6784            sink.end_page()?;
6785            return Ok(());
6786        }
6787
6788        let band_h = select_band_height(page_w, page_h);
6789
6790        // Build ICC cache for this page's display list
6791        let icc_cache = build_icc_cache_for_list(&list, self.system_cmyk_bytes.as_ref(), false);
6792
6793        // If banding not worthwhile, render the full page as a single band.
6794        // This still uses render_element (same as banded path) so that Group
6795        // and SoftMasked elements get proper offscreen compositing.
6796        if band_h >= page_h {
6797            self.ensure_full_pixmap();
6798            let ctx = RenderContext {
6799                vp_x: 0.0,
6800                vp_y: 0.0,
6801                scale_x: 1.0,
6802                scale_y: 1.0,
6803                out_w: page_w,
6804                out_h: page_h,
6805                effective_dpi: self.dpi,
6806                icc: Some(&icc_cache),
6807                image_cache: None,
6808                preprocessed: None,
6809                elem_idx: 0,
6810                no_aa: self.no_aa,
6811                opm_zero_transparent: false,
6812                knockout_painter_pass: KnockoutPainterPass::None,
6813                parent_group_isolated: false,
6814                alpha_extraction_pass: false,
6815                layer_set: &self.layer_set,
6816            };
6817            let mut band_state = BandState {
6818                clip_region: None,
6819                spare_mask: None,
6820                clip_mask_cache: HashMap::new(),
6821                clip_mask_seen: HashSet::new(),
6822                mask_pool: Vec::new(),
6823                cmyk_buffer: None,
6824                op_bg_snapshot: None,
6825                op_touched: None,
6826                spot_mask: None,
6827            };
6828            for (idx, elem) in list.elements().iter().enumerate() {
6829                let elem_ctx = RenderContext {
6830                    elem_idx: idx,
6831                    ..ctx
6832                };
6833                render_element(&mut self.pixmap, &mut band_state, elem, &elem_ctx);
6834            }
6835            return self.show_page(output_path);
6836        }
6837
6838        // Banded path: shrink self.pixmap to free memory — we use a
6839        // band-sized pixmap instead. This avoids holding a multi-GB
6840        // full-page buffer during rendering.
6841        if self.pixmap.width() > 1 {
6842            self.pixmap = Pixmap::new(1, 1).expect("Failed to create placeholder pixmap");
6843        }
6844
6845        // Create the sink for this page before spawning background work
6846        let mut sink = self.sink_factory.create_sink(output_path)?;
6847        let dpi = self.dpi;
6848        let layer_set = self.layer_set.clone();
6849
6850        #[cfg(feature = "parallel")]
6851        {
6852            // Spawn banded rendering on rayon's thread pool, overlapping with
6853            // interpretation of the next page. Using rayon::spawn avoids OS thread
6854            // creation overhead and keeps work on the warmed-up pool.
6855            let no_aa = self.no_aa;
6856            let (tx, rx) = std::sync::mpsc::sync_channel(1);
6857            rayon::spawn(move || {
6858                let result = render_banded_to_sink(
6859                    page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, no_aa, &layer_set,
6860                );
6861                let _ = tx.send(result);
6862            });
6863            self.pending_render = Some(rx);
6864        }
6865        #[cfg(not(feature = "parallel"))]
6866        {
6867            render_banded_to_sink(
6868                page_w, page_h, band_h, dpi, &list, &mut *sink, &icc_cache, self.no_aa, &layer_set,
6869            )?;
6870        }
6871
6872        Ok(())
6873    }
6874
6875    fn finish(&mut self) -> Result<(), String> {
6876        self.join_pending()
6877    }
6878}
6879
6880impl Drop for SkiaDevice {
6881    fn drop(&mut self) {
6882        // Safety net: ensure background render completes before device is destroyed.
6883        if let Some(rx) = self.pending_render.take() {
6884            let _ = rx.recv();
6885        }
6886    }
6887}
6888
6889impl SkiaDevice {
6890    /// Wait for the pending background render to complete, if any.
6891    fn join_pending(&mut self) -> Result<(), String> {
6892        if let Some(rx) = self.pending_render.take() {
6893            match rx.recv() {
6894                Ok(result) => result?,
6895                Err(_) => return Err("Background render task failed".to_string()),
6896            }
6897        }
6898        Ok(())
6899    }
6900}
6901
6902/// Returns true if any descendant transparency group declares an explicit
6903/// `/CS DeviceCMYK`. The renderer uses this to decide whether to allocate a
6904/// parallel CMYK buffer for the band/page so that compositing inside CMYK
6905/// groups can read the exact backdrop CMYK rather than rounding-trip via sRGB.
6906fn has_cmyk_group(list: &DisplayList) -> bool {
6907    use stet_graphics::display_list::GroupColorSpace;
6908    for elem in list.elements() {
6909        match elem {
6910            DisplayElement::Group { elements, params } => {
6911                if params.color_space == GroupColorSpace::DeviceCMYK {
6912                    return true;
6913                }
6914                if has_cmyk_group(elements) {
6915                    return true;
6916                }
6917            }
6918            DisplayElement::SoftMasked { content, mask, .. } => {
6919                if has_cmyk_group(content) || has_cmyk_group(mask) {
6920                    return true;
6921                }
6922            }
6923            DisplayElement::OcgGroup { elements, .. } => {
6924                if has_cmyk_group(elements) {
6925                    return true;
6926                }
6927            }
6928            _ => {}
6929        }
6930    }
6931    false
6932}
6933
6934/// Returns true if every visible element in `elements` is a `Fill` whose
6935/// color carries `native_cmyk`. Clip and `InitClip` ops are skipped (they
6936/// don't paint). Returns `false` for any other shape (shadings, images,
6937/// patterns, nested groups, etc.) where the inner CMYK buffer would be
6938/// derived from sRGB via the lossy `interpolate_cmyk_from_stops` /
6939/// `(1-r,1-g,1-b,0)` inverse rather than tracked from the source CMYK.
6940fn group_only_native_cmyk_fills(elements: &DisplayList) -> bool {
6941    let mut found_paint = false;
6942    for elem in elements.elements() {
6943        match elem {
6944            DisplayElement::InitClip => continue,
6945            DisplayElement::Clip { .. } => continue,
6946            DisplayElement::Fill { params, .. } => {
6947                if params.color.native_cmyk.is_none() {
6948                    return false;
6949                }
6950                found_paint = true;
6951            }
6952            DisplayElement::Stroke { params, .. } => {
6953                // Strokes write a single CMYK value per painted pixel just
6954                // like fills, so the parallel CMYK buffer stays in sync with
6955                // the pixmap. Including strokes here is required by GWG 16.1
6956                // painters whose X path is both filled and stroked with the
6957                // same registration color.
6958                if params.color.native_cmyk.is_none() {
6959                    return false;
6960                }
6961                found_paint = true;
6962            }
6963            _ => return false,
6964        }
6965    }
6966    found_paint
6967}
6968
6969/// Stronger predicate: returns `true` when every paint operation in `elements`
6970/// supplies its color directly as CMYK with one CMYK value per painted pixel
6971/// — i.e. the parallel CMYK buffer is *guaranteed* to match the rendered
6972/// pixmap on a per-pixel basis. When this holds, the per-pixel CMYK
6973/// composite-back can run safely.
6974///
6975/// Importantly, this excludes **shadings** even when their declared color
6976/// space is DeviceCMYK. The pixmap rasterizer interpolates the per-stop
6977/// `.color` (RGB) linearly across the gradient via [`build_gradient_lut`],
6978/// while [`interpolate_cmyk_from_stops`] interpolates the per-stop CMYK
6979/// `raw_components` linearly. Because the system CMYK ICC profile is
6980/// non-linear, the two interpolation strategies produce different intermediate
6981/// colors at each gradient pixel — the buffer no longer represents what the
6982/// pixmap shows, and feeding that into the composite-back yields visibly
6983/// shifted colors. Until the per-pixel rasterizer is taught to interpolate
6984/// CMYK directly (or the buffer is filled by ICC-reversing the pixmap), keep
6985/// shadings on the existing sRGB compositing path.
6986///
6987/// Recurses into nested groups and soft masks. Returns `false` if the group
6988/// contains no paint operations at all (so the composite-back has no work).
6989fn group_content_is_native_cmyk(elements: &DisplayList) -> bool {
6990    let mut found_paint = false;
6991    for elem in elements.elements() {
6992        match elem {
6993            DisplayElement::InitClip => continue,
6994            DisplayElement::Clip { .. } => continue,
6995            DisplayElement::Text { .. } => continue,
6996            DisplayElement::ErasePage => continue,
6997            DisplayElement::Fill { params, .. } => {
6998                if params.color.native_cmyk.is_none() {
6999                    return false;
7000                }
7001                found_paint = true;
7002            }
7003            DisplayElement::Stroke { params, .. } => {
7004                if params.color.native_cmyk.is_none() {
7005                    return false;
7006                }
7007                found_paint = true;
7008            }
7009            DisplayElement::Image { params, .. } => {
7010                if !is_cmyk_color_space(&params.color_space) {
7011                    return false;
7012                }
7013                found_paint = true;
7014            }
7015            DisplayElement::AxialShading { .. }
7016            | DisplayElement::RadialShading { .. }
7017            | DisplayElement::MeshShading { .. }
7018            | DisplayElement::PatchShading { .. } => {
7019                // See doc comment above: shading interpolation strategies
7020                // diverge between pixmap and buffer.
7021                return false;
7022            }
7023            DisplayElement::PatternFill { .. } => {
7024                // Pattern tiles render through their own BandState with
7025                // `cmyk_buffer: None`, so the parallel CMYK buffer can't track
7026                // per-tile source CMYK. Treat patterns as non-CMYK content.
7027                return false;
7028            }
7029            DisplayElement::Group { elements: sub, .. } => {
7030                if !group_content_is_native_cmyk(sub) {
7031                    return false;
7032                }
7033                found_paint = true;
7034            }
7035            DisplayElement::SoftMasked { .. } => {
7036                // Soft masks apply a per-pixel alpha modulation that the
7037                // parallel CMYK buffer cannot represent: the buffer holds raw
7038                // source CMYK while the pixmap holds the soft-masked blend
7039                // (`backdrop * (1 − mask) + source * mask`). Running
7040                // `composite_non_isolated_cmyk` over a soft-masked region
7041                // would feed the unmodulated source CMYK into the blend
7042                // formula and produce the wrong result for any non-Normal
7043                // parent blend mode (5310.pdf phone highlight regression).
7044                // Fall back to the sRGB contribution-extraction path, which
7045                // handles soft masks correctly.
7046                return false;
7047            }
7048            DisplayElement::OcgGroup { elements: sub, .. } => {
7049                if !group_content_is_native_cmyk(sub) {
7050                    return false;
7051                }
7052                found_paint = true;
7053            }
7054            _ => return false,
7055        }
7056    }
7057    found_paint
7058}
7059
7060/// True when `list` is a flat sequence of native-CMYK Fill/Stroke paints
7061/// with Normal blend and full opacity — i.e. the cmyk_buffer's content
7062/// faithfully represents what the pixmap shows. Used by `render_soft_masked`
7063/// to decide whether to interpolate the mask blend in CMYK (ICC→sRGB).
7064/// Rejects Group/SoftMasked/Image/Shading/Pattern and any blend-mode-modulated
7065/// paint because those would diverge from the parallel CMYK snapshot.
7066fn content_list_is_simple_native_cmyk(list: &DisplayList) -> bool {
7067    let mut found_paint = false;
7068    for elem in list.elements() {
7069        match elem {
7070            DisplayElement::InitClip
7071            | DisplayElement::Clip { .. }
7072            | DisplayElement::Text { .. }
7073            | DisplayElement::ErasePage => continue,
7074            DisplayElement::Fill { params, .. } => {
7075                if params.color.native_cmyk.is_none() {
7076                    return false;
7077                }
7078                if params.blend_mode != 0 || params.alpha != 1.0 {
7079                    return false;
7080                }
7081                found_paint = true;
7082            }
7083            DisplayElement::Stroke { params, .. } => {
7084                if params.color.native_cmyk.is_none() {
7085                    return false;
7086                }
7087                if params.blend_mode != 0 || params.alpha != 1.0 {
7088                    return false;
7089                }
7090                found_paint = true;
7091            }
7092            // Recurse into a transparency Group only when the group itself is
7093            // Normal-blend / full-opacity AND its contents are themselves
7094            // simple native CMYK. This lets gradient-feather-style content
7095            // (a Group wrapping a single CMYK fill, GWG 16.11) qualify for
7096            // CMYK-domain mask blending while the prior outer-glow C
7097            // regression (a Group wrapping a Screen-blend white rect, GWG
7098            // 16.10) still gets rejected on the inner blend_mode check.
7099            DisplayElement::Group { params, elements } => {
7100                if params.blend_mode != 0 || params.alpha != 1.0 {
7101                    return false;
7102                }
7103                if !content_list_is_simple_native_cmyk(elements) {
7104                    return false;
7105                }
7106                // A Group whose contents are all clip/text without paint
7107                // adds no paint of its own; don't flip `found_paint` here —
7108                // the recursive call already counted any inner paints.
7109                if elements.elements().iter().any(|e| {
7110                    matches!(
7111                        e,
7112                        DisplayElement::Fill { .. } | DisplayElement::Stroke { .. }
7113                    )
7114                }) {
7115                    found_paint = true;
7116                }
7117            }
7118            _ => return false,
7119        }
7120    }
7121    found_paint
7122}
7123
7124/// Scan a display list for any overprint fill/stroke elements that need CMYK simulation.
7125fn has_overprint_elements(list: &DisplayList) -> bool {
7126    for elem in list.elements() {
7127        match elem {
7128            DisplayElement::Fill { params, .. } => {
7129                if params.overprint {
7130                    return true;
7131                }
7132            }
7133            DisplayElement::Stroke { params, .. } => {
7134                if params.overprint {
7135                    return true;
7136                }
7137            }
7138            DisplayElement::Image { params, .. } => {
7139                if params.overprint {
7140                    return true;
7141                }
7142            }
7143            DisplayElement::AxialShading { params } => {
7144                if params.overprint {
7145                    return true;
7146                }
7147            }
7148            DisplayElement::RadialShading { params } => {
7149                if params.overprint {
7150                    return true;
7151                }
7152            }
7153            DisplayElement::MeshShading { params } => {
7154                if params.overprint {
7155                    return true;
7156                }
7157            }
7158            DisplayElement::PatchShading { params } => {
7159                if params.overprint {
7160                    return true;
7161                }
7162            }
7163            DisplayElement::Group { elements, .. } => {
7164                if has_overprint_elements(elements) {
7165                    return true;
7166                }
7167            }
7168            DisplayElement::SoftMasked { content, mask, .. } => {
7169                if has_overprint_elements(content) || has_overprint_elements(mask) {
7170                    return true;
7171                }
7172            }
7173            DisplayElement::OcgGroup { elements, .. } => {
7174                if has_overprint_elements(elements) {
7175                    return true;
7176                }
7177            }
7178            _ => {}
7179        }
7180    }
7181    false
7182}
7183
7184/// Render an overprint fill: rasterize path to coverage mask, then composite
7185/// at the CMYK level, converting the result to RGB for the pixmap.
7186#[allow(clippy::too_many_arguments)]
7187fn render_overprint_fill(
7188    pixmap: &mut Pixmap,
7189    cmyk_buf: &mut [f32],
7190    op_bg: &mut [u8],
7191    op_touched: &mut [u8],
7192    spot_mask: &[u8],
7193    band_state: &mut BandState,
7194    path: &PsPath,
7195    params: &FillParams,
7196    vp_x: f32,
7197    vp_y: f32,
7198    scale_x: f32,
7199    scale_y: f32,
7200    out_w: u32,
7201    out_h: u32,
7202    icc: Option<&IccCache>,
7203    no_aa: bool,
7204) {
7205    let Some(skia_path) = build_skia_path(path) else {
7206        return;
7207    };
7208    let fill_rule = to_fill_rule(&params.fill_rule);
7209
7210    let mut coverage_mask = match Mask::new(out_w, out_h) {
7211        Some(m) => m,
7212        None => return,
7213    };
7214    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7215    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7216
7217    // Compute path bbox for constrained iteration
7218    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7219        path_device_bbox(&skia_path, transform, out_w, out_h);
7220
7221    // Intersect with clip mask
7222    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7223        None => None,
7224        Some(ClipRegion::Rect(r)) => {
7225            // Only zero coverage within the path bbox (not the full page)
7226            let data = coverage_mask.data_mut();
7227            let stride = out_w as usize;
7228            for y in bbox_y0..bbox_y1 {
7229                let row_start = y * stride;
7230                for x in bbox_x0..bbox_x1 {
7231                    let yu = y as u32;
7232                    let xu = x as u32;
7233                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7234                        data[row_start + x] = 0;
7235                    }
7236                }
7237            }
7238            None
7239        }
7240        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7241    };
7242
7243    // Custom spot paints (Separation/DeviceN whose named colorants don't include
7244    // any process channel) go to a separation plate, not CMYK. In the composite
7245    // preview we layer the spot's alt-CMYK onto the pixmap via multiplicative
7246    // ink stacking and leave the cmyk_buffer untouched — otherwise a later OPM 1
7247    // overprint would see the spot's alt-CMYK as "backdrop" and knock it out.
7248    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7249
7250    // Source CMYK preference: for paints with a process colorant in the mix
7251    // (Separation /Black, DeviceN [Black, …]), prefer `process_cmyk` — it
7252    // carries the named-colorant tint at full f64 precision (e.g. `(0, 0, 0,
7253    // 0.5)` for 50% /Black), matching what `update_cmyk_buffer_for_fill` writes
7254    // into the process buffer. Without this, the X paint reads native (e.g.
7255    // 0.502 from an 8-bit-quantized sampled Function) while the BG wrote
7256    // process (0.500), the per-pixel delta clears the 1e-4 no-op skip
7257    // threshold, and the X over-paints the spot backdrop with plain ICC-grey
7258    // (GWG 3.0 swatches c/i, "50% sep. black over spot").
7259    //
7260    // Custom spots (no process colorant) keep reading `native_cmyk` — that's
7261    // the spot's visual alt-CMYK representation, while `process_cmyk` is
7262    // `(0, 0, 0, 0)` for pure spots (the process buffer should not record
7263    // their tint). Falling back to native here keeps spot-coloured text
7264    // visible (1307.pdf "Business of the Meeting" in PANTONE 7427 C).
7265    let (src_c, src_m, src_y, src_k) = if !is_custom_spot && let Some(c) = params.color.process_cmyk
7266    {
7267        c
7268    } else if let Some(c) = params.color.native_cmyk {
7269        c
7270    } else {
7271        let r = params.color.r;
7272        let g = params.color.g;
7273        let b = params.color.b;
7274        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7275    };
7276
7277    let mut channels = params.painted_channels;
7278    // Non-CMYK fills (painted_channels=0, e.g. Separation spot colors, RGB, Gray)
7279    // replace all color at each pixel — update all CMYK channels to keep buffer in sync.
7280    if channels == 0 {
7281        channels = stet_graphics::device::CMYK_ALL;
7282    }
7283    // OPM 1 per-pixel zero filtering only applies to DeviceCMYK, not DeviceN/Separation
7284    if params.overprint_mode == 1
7285        && channels == stet_graphics::device::CMYK_ALL
7286        && params.is_device_cmyk
7287    {
7288        channels = 0;
7289        if src_c != 0.0 {
7290            channels |= stet_graphics::device::CMYK_C;
7291        }
7292        if src_m != 0.0 {
7293            channels |= stet_graphics::device::CMYK_M;
7294        }
7295        if src_y != 0.0 {
7296            channels |= stet_graphics::device::CMYK_Y;
7297        }
7298        if src_k != 0.0 {
7299            channels |= stet_graphics::device::CMYK_K;
7300        }
7301        // PDF 1.7 §7.6.4.5: OPM 1 with /op true preserves zero-source
7302        // components — leave `channels = 0` for an all-zero CMYK source only
7303        // when the gstate signals "strict overprint": /OPM and /op|/OP were
7304        // set together in the same ExtGState dict (as Adobe Illustrator
7305        // emits) OR /OP and /op were paired in one dict (legacy old-style
7306        // overprint, e.g. GWG 12.0 White Overprint where /GS6 sets both).
7307        // When the current /op was set standalone and OPM was merely
7308        // inherited (e.g. 2495.pdf page 5 page-icon, where /R20 has only
7309        // /op and OPM=1 came from /R11), fall back to legacy knockout so
7310        // a `0 0 0 0 k` paint still acts as a white knockout.
7311        if channels == 0 && !params.opm_paired {
7312            channels = stet_graphics::device::CMYK_ALL;
7313        }
7314    }
7315
7316    // Bulk tiny-skia fast path for the plain CMYK_ALL replace case. Skipped
7317    // only for K-only DeviceCMYK paints under OPM 0 (C=M=Y=0, any K) because
7318    // those match the Black plate of a DeviceN [Black, spot] backdrop and
7319    // need the per-pixel no-op-delta skip to preserve spot-derived colour —
7320    // the bulk fill_path here would otherwise wipe the spot. Other CMYK
7321    // overprints (teal, full-colour, etc.) stay on the fast path to avoid
7322    // AA drift vs the non-overprint rasteriser.
7323    let is_k_only_cmyk = params.is_device_cmyk
7324        && params.overprint_mode == 0
7325        && src_c == 0.0
7326        && src_m == 0.0
7327        && src_y == 0.0;
7328    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7329        let cov_data = coverage_mask.data();
7330        let stride = out_w as usize;
7331        for y in bbox_y0..bbox_y1 {
7332            for x in bbox_x0..bbox_x1 {
7333                let mi = y * stride + x;
7334                let mut cov = cov_data[mi] as f32 / 255.0;
7335                if let Some(clip) = clip_coverage {
7336                    cov *= clip[mi] as f32 / 255.0;
7337                }
7338                if cov > 0.0 {
7339                    let ci = mi * 4;
7340                    cmyk_buf[ci] = src_c as f32;
7341                    cmyk_buf[ci + 1] = src_m as f32;
7342                    cmyk_buf[ci + 2] = src_y as f32;
7343                    cmyk_buf[ci + 3] = src_k as f32;
7344                }
7345            }
7346        }
7347        let mut temp_mask = None;
7348        let Some(mask_ref) =
7349            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
7350        else {
7351            return;
7352        };
7353        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
7354        pixmap.fill_path(&skia_path, &paint, fill_rule, transform, mask_ref);
7355        return;
7356    }
7357
7358    let cov_data = coverage_mask.data();
7359    let stride = out_w as usize;
7360    let px_data = pixmap.data_mut();
7361    let px_stride = out_w as usize * 4;
7362
7363    for y in bbox_y0..bbox_y1 {
7364        for x in bbox_x0..bbox_x1 {
7365            let mi = y * stride + x;
7366            let mut cov = cov_data[mi] as f32 / 255.0;
7367            if let Some(clip) = clip_coverage {
7368                cov *= clip[mi] as f32 / 255.0;
7369            }
7370            if cov <= 0.0 {
7371                continue;
7372            }
7373
7374            let ci = mi * 4;
7375            let pi = y * px_stride + x * 4;
7376            // Snapshot-based AA blending: on the first overprint touch of a
7377            // pixel that already has a backdrop (alpha > 0), capture the
7378            // pre-paint pixmap RGBA. Subsequent overprints at the same pixel
7379            // blend against the snapshot rather than the current pixmap, so
7380            // AA edges of stacked OPM-1 overprints do not leak colour from
7381            // earlier paints into later ones.
7382            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
7383                op_bg[pi] = px_data[pi];
7384                op_bg[pi + 1] = px_data[pi + 1];
7385                op_bg[pi + 2] = px_data[pi + 2];
7386                op_bg[pi + 3] = px_data[pi + 3];
7387                op_touched[mi] = 1;
7388            }
7389            let cur_c = cmyk_buf[ci] as f64;
7390            let cur_m = cmyk_buf[ci + 1] as f64;
7391            let cur_y = cmyk_buf[ci + 2] as f64;
7392            let cur_k = cmyk_buf[ci + 3] as f64;
7393            // Switch to multiplicative ink-stacking when the pixmap carries a
7394            // contribution not reflected in cmyk_buffer: either this paint is
7395            // itself a custom spot (painted_channels=0, non-CMYK) or the
7396            // process-ink state is empty while the pixmap shows colour *and*
7397            // is actually opaque — that signals a spot (or RGB) paint landed
7398            // here and the "replace" CMYK→RGB model would erase the
7399            // contribution for the channels being overwritten. Fully
7400            // transparent pixels are stored as premultiplied (0,0,0,0), so we
7401            // must require alpha>0 before trusting the RGB — otherwise fresh
7402            // paper (alpha=0) looks like "black backdrop" and multiplicative
7403            // darkening would paint the fill pure black.
7404            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
7405            let pixmap_has_colour = px_data[pi + 3] > 0
7406                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
7407            // Multiplicative ink-stacking only when the pixmap carries a real
7408            // backdrop: either this paint is a custom spot landing on an
7409            // already-coloured pixel, or the process-ink buffer is empty but
7410            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
7411            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
7412            // the fill to pure black, so those pixels fall through to the
7413            // replace path where the source RGB paints normally.
7414            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
7415
7416            // Promoted DeviceGray on a non-spot backdrop: fall back to a
7417            // plain knockout that replaces all four CMYK plates. The
7418            // `maybe_promote_gray_fill` path describes the paint as a
7419            // K-only subset so spot-backed swatches can preserve the spot
7420            // plate (GWG 3.0 "50% gray over spot"), but on a plain CMYK
7421            // backdrop that would preserve the old CMY values and turn the
7422            // cross into the bg colour (GWG 3.0 "50% gray over CMYK" e/k).
7423            // Expanding to CMYK_ALL here restores the regular-fill result
7424            // at those pixels.
7425            //
7426            // Gate on `params.painted_channels == CMYK_K` so this only fires
7427            // for genuinely-promoted DeviceGray. A `0 0 0 0.5 k` DeviceCMYK
7428            // paint filtered to CMYK_K by OPM 1 has `params.painted_channels
7429            // = CMYK_ALL`, and must stay K-subset so its CMY=0 values do
7430            // not wipe a CMYK backdrop (GWG 3.0 "50% K over CMYK" j/d).
7431            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
7432                && channels == stet_graphics::device::CMYK_K
7433                && params.is_device_cmyk
7434                && src_c == 0.0
7435                && src_m == 0.0
7436                && src_y == 0.0;
7437            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
7438                stet_graphics::device::CMYK_ALL
7439            } else {
7440                channels
7441            };
7442
7443            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
7444                src_c
7445            } else {
7446                cur_c
7447            };
7448            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
7449                src_m
7450            } else {
7451                cur_m
7452            };
7453            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
7454                src_y
7455            } else {
7456                cur_y
7457            };
7458            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
7459                src_k
7460            } else {
7461                cur_k
7462            };
7463
7464            // Custom spot paints live on a separation plate — skip the
7465            // cmyk_buffer write so a later OPM 1 overprint still sees the
7466            // original process-ink state as backdrop.
7467            if !is_custom_spot {
7468                cmyk_buf[ci] = new_c as f32;
7469                cmyk_buf[ci + 1] = new_m as f32;
7470                cmyk_buf[ci + 2] = new_y as f32;
7471                cmyk_buf[ci + 3] = new_k as f32;
7472            }
7473
7474            // No-op overprint: the paint's effective CMYK equals the existing
7475            // process state, so no plate actually changes. Skip the pixmap
7476            // write entirely — otherwise ICC(new_cmyk) paints a plain process
7477            // composite that erases any spot-derived colour already visible
7478            // at this pixel (GWG 3.0 "50% K over spot" swatches where the
7479            // backdrop's Black component and the cross's K value match).
7480            //
7481            // Only fire when a DeviceN/Separation paint with spot colorants
7482            // actually landed on this pixel (spot_mask[mi] != 0). On plain
7483            // CMYK backdrops, ICC(cmyk_buf) == pixmap_rgb already, and
7484            // skipping vs replacing produces the same result — but making
7485            // the skip unconditional subtly drifts AA edges because prior
7486            // stroke/fill precision accumulates (regressed GWG 1.0/1.1).
7487            let delta = (new_c - cur_c)
7488                .abs()
7489                .max((new_m - cur_m).abs())
7490                .max((new_y - cur_y).abs())
7491                .max((new_k - cur_k).abs());
7492            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
7493                continue;
7494            }
7495
7496            let (r, g, b) =
7497                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
7498                    // Promoted DeviceGray collapsing to a full replace — use the
7499                    // paint's RGB directly so the pixmap matches the colour a
7500                    // regular non-overprint gray fill would paint at the same
7501                    // pixel. Going through ICC(CMYK) here would produce a
7502                    // slightly different gray (e.g. 151 vs 127) and leave a
7503                    // darker outline where a subsequent non-promoted gray
7504                    // stroke overpaints on top of it.
7505                    //
7506                    // Checked before `use_multiplicative` because a white gray
7507                    // paint (`1 g`, native CMYK (0,0,0,0)) on a coloured RGB
7508                    // backdrop (e.g. the red `Reset Form` button in 682.pdf
7509                    // page 2) would otherwise hit the multiplicative branch
7510                    // with all-zero source CMYK, which leaves the backdrop
7511                    // unchanged — hiding the white label.
7512                    (params.color.r, params.color.g, params.color.b)
7513                } else if use_multiplicative {
7514                    // Multiplicative ink stacking: each painted channel attenuates
7515                    // the corresponding RGB component; preserved channels leave
7516                    // the pixmap's existing colour untouched. This keeps any spot
7517                    // contribution already in the pixmap visible under overprints
7518                    // whose zero-valued CMYK components should not erase it.
7519                    let bg_r = px_data[pi] as f64 / 255.0;
7520                    let bg_g = px_data[pi + 1] as f64 / 255.0;
7521                    let bg_b = px_data[pi + 2] as f64 / 255.0;
7522                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
7523                        1.0 - src_c
7524                    } else {
7525                        1.0
7526                    };
7527                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
7528                        1.0 - src_m
7529                    } else {
7530                        1.0
7531                    };
7532                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
7533                        1.0 - src_y
7534                    } else {
7535                        1.0
7536                    };
7537                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
7538                        1.0 - src_k
7539                    } else {
7540                        1.0
7541                    };
7542                    (
7543                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
7544                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
7545                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
7546                    )
7547                } else if let Some(icc_cache) = icc {
7548                    icc_cache
7549                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
7550                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
7551                } else {
7552                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
7553                };
7554
7555            let a = (cov * params.alpha as f32).min(1.0);
7556            // Blend backdrop: prefer the pre-overprint snapshot only when
7557            // this paint's colour is close to the snapshot — that signals
7558            // the paint effectively returns the pixel to its original
7559            // backdrop (e.g. the almost-white cross in GWG 4.1 cancelling
7560            // the red cross's M/Y contributions). In that case blending
7561            // against the snapshot keeps AA edges clean.
7562            //
7563            // When the paint introduces colour (e.g. a magenta stroke
7564            // following a magenta fill — both lay down ink that should
7565            // stack), fall through to the current pixmap so repeated
7566            // same-colour paints keep compounding at edges instead of
7567            // snapping back to bg.
7568            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
7569                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
7570                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
7571                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
7572                let dr = (op_bg[pi] as f32 - new_r).abs();
7573                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
7574                let db = (op_bg[pi + 2] as f32 - new_b).abs();
7575                if dr.max(dg).max(db) <= 4.0 {
7576                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
7577                } else {
7578                    (
7579                        px_data[pi],
7580                        px_data[pi + 1],
7581                        px_data[pi + 2],
7582                        px_data[pi + 3],
7583                    )
7584                }
7585            } else {
7586                (
7587                    px_data[pi],
7588                    px_data[pi + 1],
7589                    px_data[pi + 2],
7590                    px_data[pi + 3],
7591                )
7592            };
7593            let dst_a = bk_a as f32 / 255.0;
7594            let one_minus_a = 1.0 - a;
7595            let out_a = a + dst_a * one_minus_a;
7596            if out_a > 0.0 {
7597                // tiny-skia stores premultiplied RGBA. Use the standard
7598                // src-over formula in premul space: result_pre = src*a + dst_pre*(1-a).
7599                // The backdrop values are already premultiplied, so no
7600                // additional divide-by-out_a step is needed.
7601                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
7602                    .clamp(0.0, 255.0)
7603                    .round() as u8;
7604                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
7605                    .clamp(0.0, 255.0)
7606                    .round() as u8;
7607                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
7608                    .clamp(0.0, 255.0)
7609                    .round() as u8;
7610                px_data[pi + 3] = (out_a * 255.0).round() as u8;
7611            }
7612        }
7613    }
7614}
7615/// PLRM CMYK-to-RGB formula fallback.
7616fn cmyk_to_rgb_plrm(c: f64, m: f64, y: f64, k: f64) -> (f64, f64, f64) {
7617    (
7618        1.0 - (c + k).min(1.0),
7619        1.0 - (m + k).min(1.0),
7620        1.0 - (y + k).min(1.0),
7621    )
7622}
7623
7624/// Update the CMYK buffer for a non-overprint fill (to track backdrop for future overprints).
7625#[allow(clippy::too_many_arguments)]
7626/// Compute the device-space bounding box of a tiny-skia path after transform,
7627/// clamped to `(0, 0, w, h)`. Returns `(x0, y0, x1, y1)` as pixel indices.
7628fn path_device_bbox(
7629    skia_path: &stet_tiny_skia::Path,
7630    transform: Transform,
7631    w: u32,
7632    h: u32,
7633) -> (usize, usize, usize, usize) {
7634    let b = skia_path.bounds();
7635    let mut corners = [
7636        stet_tiny_skia::Point {
7637            x: b.left(),
7638            y: b.top(),
7639        },
7640        stet_tiny_skia::Point {
7641            x: b.right(),
7642            y: b.top(),
7643        },
7644        stet_tiny_skia::Point {
7645            x: b.right(),
7646            y: b.bottom(),
7647        },
7648        stet_tiny_skia::Point {
7649            x: b.left(),
7650            y: b.bottom(),
7651        },
7652    ];
7653    transform.map_points(&mut corners);
7654    let min_x = corners.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
7655    let min_y = corners.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
7656    let max_x = corners
7657        .iter()
7658        .map(|p| p.x)
7659        .fold(f32::NEG_INFINITY, f32::max);
7660    let max_y = corners
7661        .iter()
7662        .map(|p| p.y)
7663        .fold(f32::NEG_INFINITY, f32::max);
7664    // Floor/ceil + clamp to output dimensions (with 1px margin for AA)
7665    let x0 = (min_x.floor() as i32 - 1).max(0) as usize;
7666    let y0 = (min_y.floor() as i32 - 1).max(0) as usize;
7667    let x1 = (max_x.ceil() as i32 + 1).clamp(0, w as i32) as usize;
7668    let y1 = (max_y.ceil() as i32 + 1).clamp(0, h as i32) as usize;
7669    (x0, y0, x1, y1)
7670}
7671
7672fn update_cmyk_buffer_for_fill(
7673    cmyk_buf: &mut [f32],
7674    spot_mask: &mut [u8],
7675    path: &PsPath,
7676    params: &FillParams,
7677    vp_x: f32,
7678    vp_y: f32,
7679    scale_x: f32,
7680    scale_y: f32,
7681    out_w: u32,
7682    out_h: u32,
7683    clip_region: &Option<ClipRegion>,
7684    no_aa: bool,
7685    icc: Option<&IccCache>,
7686) {
7687    // Custom spot paints (Separation/DeviceN naming no process channel) go to
7688    // their own separation plate — the process CMYK buffer must be zeroed
7689    // under the paint (knockout) so a later overprint sees "no process ink"
7690    // and falls into the multiplicative-blend branch that preserves the
7691    // spot's visible contribution in the pixmap.
7692    //
7693    // The `process_cmyk.is_some()` guard distinguishes "Separation/DeviceN
7694    // custom spot" (where `process_cmyk` is `Some((0,0,0,0))` per
7695    // `separation_process_cmyk`) from "any other non-CMYK fill that
7696    // happens to satisfy `painted_channels == 0 && !is_device_cmyk`" —
7697    // notably DeviceRGB, DeviceGray, and ICCBased RGB. The latter need to
7698    // deposit their full process CMYK into the buffer (via `native_cmyk`
7699    // from the proofing chain or via the ICC reverse) so the
7700    // `cmyk_group_blend` composite-back in `composite_non_isolated_cmyk`
7701    // can blend them correctly. Without this guard, GWG 16.1's
7702    // ICCBased-RGB swatches landed `(0,0,0,0)` in the form's CMYK
7703    // buffer; every separable blend then composited the X mark against a
7704    // zero source CMYK, painting the X with the form's source pixmap
7705    // RGB unchanged and producing the test's "X visible" failure.
7706    let is_custom_spot = params.painted_channels == 0
7707        && !params.is_device_cmyk
7708        && params.color.process_cmyk.is_some();
7709
7710    // A DeviceN/Separation paint leaves "spot contribution" on the pixmap
7711    // when its full alt-CMYK (`native_cmyk`) differs from the process-only
7712    // tint (`process_cmyk`) — the extra RGB in the pixmap comes from a spot
7713    // plate that `cmyk_buf` cannot reflect. Pure DeviceCMYK paints have
7714    // `process_cmyk == None` (fall back to native), so no spot contribution.
7715    //
7716    // A "real" custom spot paint (`is_custom_spot && native_cmyk.is_some()`)
7717    // also deposits spot RGB that `cmyk_buf` loses (it's zeroed by the
7718    // custom-spot branch). Exclude DeviceRGB / DeviceGray / ICCBased-RGB
7719    // paints — those also satisfy `is_custom_spot = painted==0 &&
7720    // !is_device_cmyk` but carry no spot-plate contribution, and flagging
7721    // them would gate later OPM-1 cancel skips on a signal that doesn't
7722    // actually mean anything.
7723    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
7724        || matches!(
7725            (params.color.native_cmyk, params.color.process_cmyk),
7726            (Some(nat), Some(proc_))
7727                if (nat.0 - proc_.0).abs() > 1e-6
7728                    || (nat.1 - proc_.1).abs() > 1e-6
7729                    || (nat.2 - proc_.2).abs() > 1e-6
7730                    || (nat.3 - proc_.3).abs() > 1e-6
7731        );
7732
7733    // Source CMYK preference: process-only CMYK (from Separation/DeviceN paints
7734    // so spot-colorant tint contributions stay out of the process buffer) >
7735    // native CMYK (full alt-CMYK tint, fine for pure DeviceCMYK paints) > ICC
7736    // reverse (sRGB→CMYK via the system CMYK profile) > PLRM (1−r, 1−g, 1−b, 0)
7737    // fallback. The ICC reverse keeps non-CMYK fills (RGB/Gray/Lab/etc.)
7738    // representable as accurate CMYK in the parallel buffer so the
7739    // non-isolated CMYK composite-back can blend them correctly.
7740    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
7741        (0.0, 0.0, 0.0, 0.0)
7742    } else if let Some(c) = params.color.process_cmyk {
7743        c
7744    } else if let Some(c) = params.color.native_cmyk {
7745        c
7746    } else if let Some(cmyk) = icc.and_then(|i| {
7747        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
7748    }) {
7749        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
7750    } else {
7751        (
7752            (1.0 - params.color.r).clamp(0.0, 1.0),
7753            (1.0 - params.color.g).clamp(0.0, 1.0),
7754            (1.0 - params.color.b).clamp(0.0, 1.0),
7755            0.0,
7756        )
7757    };
7758    let Some(skia_path) = build_skia_path(path) else {
7759        return;
7760    };
7761
7762    let mut coverage_mask = match Mask::new(out_w, out_h) {
7763        Some(m) => m,
7764        None => return,
7765    };
7766    let transform = viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
7767    let fill_rule = to_fill_rule(&params.fill_rule);
7768    coverage_mask.fill_path(&skia_path, fill_rule, !no_aa, transform);
7769
7770    let cov_data = coverage_mask.data();
7771    let clip_data: Option<&[u8]> = match clip_region {
7772        Some(ClipRegion::Mask(m)) => Some(m.data()),
7773        _ => None,
7774    };
7775
7776    // Constrain iteration to the path's device-space bounding box
7777    let (mut bx0, mut by0, mut bx1, mut by1) =
7778        path_device_bbox(&skia_path, transform, out_w, out_h);
7779    if let Some(ClipRegion::Rect(r)) = clip_region {
7780        bx0 = bx0.max(r.x0 as usize);
7781        by0 = by0.max(r.y0 as usize);
7782        bx1 = bx1.min(r.x1 as usize);
7783        by1 = by1.min(r.y1 as usize);
7784    }
7785
7786    let stride = out_w as usize;
7787    for y in by0..by1 {
7788        for x in bx0..bx1 {
7789            let mi = y * stride + x;
7790            let mut cov = cov_data[mi] as f32 / 255.0;
7791            if let Some(clip) = clip_data {
7792                cov *= clip[mi] as f32 / 255.0;
7793            }
7794            if cov > 0.0 {
7795                let ci = mi * 4;
7796                cmyk_buf[ci] = src_c as f32;
7797                cmyk_buf[ci + 1] = src_m as f32;
7798                cmyk_buf[ci + 2] = src_y as f32;
7799                cmyk_buf[ci + 3] = src_k as f32;
7800                if has_spot_contrib {
7801                    spot_mask[mi] = 1;
7802                }
7803            }
7804        }
7805    }
7806}
7807
7808/// Render an overprint stroke: convert the stroke outline to a fill path,
7809/// rasterize a coverage mask, then composite per-pixel in CMYK so the painted
7810/// channels of the stroke colour replace the matching backdrop channels and
7811/// the result lands in the pixmap as RGB. Mirrors `render_overprint_fill`.
7812#[allow(clippy::too_many_arguments)]
7813fn render_overprint_stroke(
7814    pixmap: &mut Pixmap,
7815    cmyk_buf: &mut [f32],
7816    op_bg: &mut [u8],
7817    op_touched: &mut [u8],
7818    spot_mask: &[u8],
7819    band_state: &mut BandState,
7820    skia_path: &stet_tiny_skia::Path,
7821    stroke: &Stroke,
7822    transform: Transform,
7823    params: &StrokeParams,
7824    out_w: u32,
7825    out_h: u32,
7826    icc: Option<&IccCache>,
7827    no_aa: bool,
7828) {
7829    // Convert stroke outline to fill path. Mirrors update_cmyk_buffer_for_stroke_overprint.
7830    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
7831        .sqrt()
7832        .max(1.0);
7833    let dashed_op;
7834    let stroke_src = if let Some(ref dash) = stroke.dash {
7835        dashed_op = skia_path.dash(dash, resolution_scale);
7836        match dashed_op.as_ref() {
7837            Some(p) => p,
7838            None => skia_path,
7839        }
7840    } else {
7841        skia_path
7842    };
7843    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
7844        return;
7845    };
7846    let Some(stroked) = stroked_user.transform(transform) else {
7847        return;
7848    };
7849
7850    let mut coverage_mask = match Mask::new(out_w, out_h) {
7851        Some(m) => m,
7852        None => return,
7853    };
7854    coverage_mask.fill_path(
7855        &stroked,
7856        SkiaFillRule::Winding,
7857        !no_aa,
7858        Transform::identity(),
7859    );
7860
7861    let (bbox_x0, bbox_y0, bbox_x1, bbox_y1) =
7862        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
7863
7864    // Intersect with clip mask (same logic as render_overprint_fill).
7865    let clip_coverage: Option<&[u8]> = match &band_state.clip_region {
7866        None => None,
7867        Some(ClipRegion::Rect(r)) => {
7868            let data = coverage_mask.data_mut();
7869            let stride = out_w as usize;
7870            for y in bbox_y0..bbox_y1 {
7871                let row_start = y * stride;
7872                for x in bbox_x0..bbox_x1 {
7873                    let yu = y as u32;
7874                    let xu = x as u32;
7875                    if yu < r.y0 || yu >= r.y1 || xu < r.x0 || xu >= r.x1 {
7876                        data[row_start + x] = 0;
7877                    }
7878                }
7879            }
7880            None
7881        }
7882        Some(ClipRegion::Mask(clip_mask)) => Some(clip_mask.data()),
7883    };
7884
7885    // See render_overprint_fill for the rationale: a custom spot stroke must
7886    // preserve the process CMYK buffer and blend multiplicatively in RGB so
7887    // later OPM 1 overprints don't knock out the spot's visible colour.
7888    let is_custom_spot = params.painted_channels == 0 && !params.is_device_cmyk;
7889
7890    // Source CMYK preference: for paints with a process colorant in the mix,
7891    // prefer `process_cmyk` so the no-op-delta skip in the per-pixel loop sees
7892    // the same exact value the BG paint wrote into `cmyk_buf`. Custom spots
7893    // keep reading `native_cmyk` (the spot's visual alt-CMYK; process_cmyk is
7894    // (0,0,0,0) for pure spots). See `render_overprint_fill` for the full
7895    // rationale (GWG 3.0 swatches c/i, 1307.pdf spot text).
7896    let (src_c, src_m, src_y, src_k) = if !is_custom_spot && let Some(c) = params.color.process_cmyk
7897    {
7898        c
7899    } else if let Some(c) = params.color.native_cmyk {
7900        c
7901    } else {
7902        let r = params.color.r;
7903        let g = params.color.g;
7904        let b = params.color.b;
7905        (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
7906    };
7907
7908    let mut channels = params.painted_channels;
7909    if channels == 0 {
7910        channels = stet_graphics::device::CMYK_ALL;
7911    }
7912    if params.overprint_mode == 1
7913        && channels == stet_graphics::device::CMYK_ALL
7914        && params.is_device_cmyk
7915    {
7916        channels = 0;
7917        if src_c != 0.0 {
7918            channels |= stet_graphics::device::CMYK_C;
7919        }
7920        if src_m != 0.0 {
7921            channels |= stet_graphics::device::CMYK_M;
7922        }
7923        if src_y != 0.0 {
7924            channels |= stet_graphics::device::CMYK_Y;
7925        }
7926        if src_k != 0.0 {
7927            channels |= stet_graphics::device::CMYK_K;
7928        }
7929        // See render_overprint_fill: an all-zero CMYK source preserves the
7930        // backdrop only when /OPM and /op|/OP were set together (paired) in
7931        // the same ExtGState. Inherited-OPM cases fall back to legacy
7932        // knockout.
7933        if channels == 0 && !params.opm_paired {
7934            channels = stet_graphics::device::CMYK_ALL;
7935        }
7936    }
7937
7938    let is_k_only_cmyk = params.is_device_cmyk
7939        && params.overprint_mode == 0
7940        && src_c == 0.0
7941        && src_m == 0.0
7942        && src_y == 0.0;
7943    if channels == stet_graphics::device::CMYK_ALL && !is_custom_spot && !is_k_only_cmyk {
7944        // Full-channel replacement: write source CMYK to buffer for covered
7945        // pixels and let tiny-skia stroke the pixmap with the source colour.
7946        // Only K-only DeviceCMYK OPM 0 paints are routed to the per-pixel
7947        // path (see render_overprint_fill).
7948        let cov_data = coverage_mask.data();
7949        let stride = out_w as usize;
7950        for y in bbox_y0..bbox_y1 {
7951            for x in bbox_x0..bbox_x1 {
7952                let mi = y * stride + x;
7953                let mut cov = cov_data[mi] as f32 / 255.0;
7954                if let Some(clip) = clip_coverage {
7955                    cov *= clip[mi] as f32 / 255.0;
7956                }
7957                if cov > 0.0 {
7958                    let ci = mi * 4;
7959                    cmyk_buf[ci] = src_c as f32;
7960                    cmyk_buf[ci + 1] = src_m as f32;
7961                    cmyk_buf[ci + 2] = src_y as f32;
7962                    cmyk_buf[ci + 3] = src_k as f32;
7963                }
7964            }
7965        }
7966        let mut temp_mask = None;
7967        let Some(mask_ref) =
7968            resolve_clip_mask(&band_state.clip_region, &mut temp_mask, out_w, out_h)
7969        else {
7970            return;
7971        };
7972        let paint = to_paint_alpha(&params.color, params.alpha, params.blend_mode, no_aa);
7973        pixmap.stroke_path(skia_path, &paint, stroke, transform, mask_ref);
7974        return;
7975    }
7976
7977    let cov_data = coverage_mask.data();
7978    let stride = out_w as usize;
7979    let px_data = pixmap.data_mut();
7980    let px_stride = out_w as usize * 4;
7981
7982    for y in bbox_y0..bbox_y1 {
7983        for x in bbox_x0..bbox_x1 {
7984            let mi = y * stride + x;
7985            let mut cov = cov_data[mi] as f32 / 255.0;
7986            if let Some(clip) = clip_coverage {
7987                cov *= clip[mi] as f32 / 255.0;
7988            }
7989            if cov <= 0.0 {
7990                continue;
7991            }
7992
7993            let ci = mi * 4;
7994            let pi = y * px_stride + x * 4;
7995            // Snapshot-based AA blending — see render_overprint_fill for the
7996            // rationale. Capture the pre-paint pixmap on first overprint touch
7997            // so stacked overprints at the same pixel blend against the
7998            // original backdrop rather than each other.
7999            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8000                op_bg[pi] = px_data[pi];
8001                op_bg[pi + 1] = px_data[pi + 1];
8002                op_bg[pi + 2] = px_data[pi + 2];
8003                op_bg[pi + 3] = px_data[pi + 3];
8004                op_touched[mi] = 1;
8005            }
8006            let cur_c = cmyk_buf[ci] as f64;
8007            let cur_m = cmyk_buf[ci + 1] as f64;
8008            let cur_y = cmyk_buf[ci + 2] as f64;
8009            let cur_k = cmyk_buf[ci + 3] as f64;
8010            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8011            let pixmap_has_colour = px_data[pi + 3] > 0
8012                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
8013            // Multiplicative ink-stacking only when the pixmap carries a real
8014            // backdrop: either this paint is a custom spot landing on an
8015            // already-coloured pixel, or the process-ink buffer is empty but
8016            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
8017            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
8018            // the fill to pure black, so those pixels fall through to the
8019            // replace path where the source RGB paints normally.
8020            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
8021
8022            // Promoted DeviceGray on non-spot backdrop: replace all channels
8023            // (see render_overprint_fill).
8024            let is_promoted_gray = params.painted_channels == stet_graphics::device::CMYK_K
8025                && channels == stet_graphics::device::CMYK_K
8026                && params.is_device_cmyk
8027                && src_c == 0.0
8028                && src_m == 0.0
8029                && src_y == 0.0;
8030            let effective_channels = if is_promoted_gray && spot_mask[mi] == 0 {
8031                stet_graphics::device::CMYK_ALL
8032            } else {
8033                channels
8034            };
8035
8036            let new_c = if effective_channels & stet_graphics::device::CMYK_C != 0 {
8037                src_c
8038            } else {
8039                cur_c
8040            };
8041            let new_m = if effective_channels & stet_graphics::device::CMYK_M != 0 {
8042                src_m
8043            } else {
8044                cur_m
8045            };
8046            let new_y = if effective_channels & stet_graphics::device::CMYK_Y != 0 {
8047                src_y
8048            } else {
8049                cur_y
8050            };
8051            let new_k = if effective_channels & stet_graphics::device::CMYK_K != 0 {
8052                src_k
8053            } else {
8054                cur_k
8055            };
8056
8057            if !is_custom_spot {
8058                cmyk_buf[ci] = new_c as f32;
8059                cmyk_buf[ci + 1] = new_m as f32;
8060                cmyk_buf[ci + 2] = new_y as f32;
8061                cmyk_buf[ci + 3] = new_k as f32;
8062            }
8063
8064            // No-op overprint skip — see render_overprint_fill for rationale.
8065            let delta = (new_c - cur_c)
8066                .abs()
8067                .max((new_m - cur_m).abs())
8068                .max((new_y - cur_y).abs())
8069                .max((new_k - cur_k).abs());
8070            if delta < 1e-4 && spot_mask[mi] != 0 && pixmap_has_colour && !is_custom_spot {
8071                continue;
8072            }
8073
8074            let (r, g, b) =
8075                if is_promoted_gray && effective_channels == stet_graphics::device::CMYK_ALL {
8076                    // Promoted DeviceGray collapsing to a full replace — see
8077                    // render_overprint_fill for the rationale (must run before
8078                    // the multiplicative branch so a `1 g` / `1 G` white paint
8079                    // doesn't get folded into the backdrop via zero-source
8080                    // multiplication).
8081                    (params.color.r, params.color.g, params.color.b)
8082                } else if use_multiplicative {
8083                    let bg_r = px_data[pi] as f64 / 255.0;
8084                    let bg_g = px_data[pi + 1] as f64 / 255.0;
8085                    let bg_b = px_data[pi + 2] as f64 / 255.0;
8086                    let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
8087                        1.0 - src_c
8088                    } else {
8089                        1.0
8090                    };
8091                    let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
8092                        1.0 - src_m
8093                    } else {
8094                        1.0
8095                    };
8096                    let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
8097                        1.0 - src_y
8098                    } else {
8099                        1.0
8100                    };
8101                    let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
8102                        1.0 - src_k
8103                    } else {
8104                        1.0
8105                    };
8106                    (
8107                        (bg_r * over_r * k_fac).clamp(0.0, 1.0),
8108                        (bg_g * over_g * k_fac).clamp(0.0, 1.0),
8109                        (bg_b * over_b * k_fac).clamp(0.0, 1.0),
8110                    )
8111                } else if let Some(icc_cache) = icc {
8112                    icc_cache
8113                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8114                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8115                } else {
8116                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8117                };
8118
8119            let a = (cov * params.alpha as f32).min(1.0);
8120            // Blend backdrop: prefer snapshot only when this paint's colour
8121            // closely matches the snapshot — see render_overprint_fill for
8122            // the rationale (keeps aw-on-red-style cancel paints clean at
8123            // edges while preserving additive same-colour stacking).
8124            let (bk_r, bk_g, bk_b, bk_a) = if op_touched[mi] != 0 {
8125                let new_r = (r as f32 * 255.0).clamp(0.0, 255.0);
8126                let new_g = (g as f32 * 255.0).clamp(0.0, 255.0);
8127                let new_b = (b as f32 * 255.0).clamp(0.0, 255.0);
8128                let dr = (op_bg[pi] as f32 - new_r).abs();
8129                let dg = (op_bg[pi + 1] as f32 - new_g).abs();
8130                let db = (op_bg[pi + 2] as f32 - new_b).abs();
8131                if dr.max(dg).max(db) <= 4.0 {
8132                    (op_bg[pi], op_bg[pi + 1], op_bg[pi + 2], op_bg[pi + 3])
8133                } else {
8134                    (
8135                        px_data[pi],
8136                        px_data[pi + 1],
8137                        px_data[pi + 2],
8138                        px_data[pi + 3],
8139                    )
8140                }
8141            } else {
8142                (
8143                    px_data[pi],
8144                    px_data[pi + 1],
8145                    px_data[pi + 2],
8146                    px_data[pi + 3],
8147                )
8148            };
8149            let dst_a = bk_a as f32 / 255.0;
8150            let one_minus_a = 1.0 - a;
8151            let out_a = a + dst_a * one_minus_a;
8152            if out_a > 0.0 {
8153                // tiny-skia stores premultiplied RGBA (see render_overprint_fill).
8154                px_data[pi] = ((r as f32 * a + (bk_r as f32 / 255.0) * one_minus_a) * 255.0)
8155                    .clamp(0.0, 255.0)
8156                    .round() as u8;
8157                px_data[pi + 1] = ((g as f32 * a + (bk_g as f32 / 255.0) * one_minus_a) * 255.0)
8158                    .clamp(0.0, 255.0)
8159                    .round() as u8;
8160                px_data[pi + 2] = ((b as f32 * a + (bk_b as f32 / 255.0) * one_minus_a) * 255.0)
8161                    .clamp(0.0, 255.0)
8162                    .round() as u8;
8163                px_data[pi + 3] = (out_a * 255.0).round() as u8;
8164            }
8165        }
8166    }
8167}
8168
8169/// Update the CMYK buffer for a non-overprint stroke. Mirrors
8170/// [`update_cmyk_buffer_for_fill`] but rasterizes a stroked outline path
8171/// instead of a filled one. Source-CMYK selection follows the same
8172/// native_cmyk → ICC reverse → PLRM cascade.
8173#[allow(clippy::too_many_arguments)]
8174fn update_cmyk_buffer_for_stroke(
8175    cmyk_buf: &mut [f32],
8176    spot_mask: &mut [u8],
8177    path: &PsPath,
8178    params: &StrokeParams,
8179    stroke: &Stroke,
8180    transform: Transform,
8181    out_w: u32,
8182    out_h: u32,
8183    clip_region: &Option<ClipRegion>,
8184    no_aa: bool,
8185    icc: Option<&IccCache>,
8186) {
8187    // Custom spot strokes knockout the process CMYK plates — zero the buffer
8188    // under the stroke so later overprints fall into the multiplicative-blend
8189    // branch (see update_cmyk_buffer_for_fill, including the
8190    // `process_cmyk.is_some()` carve-out that keeps DeviceRGB / ICCBased-RGB
8191    // strokes off this branch so their proofing-chain CMYK reaches the
8192    // buffer).
8193    let is_custom_spot = params.painted_channels == 0
8194        && !params.is_device_cmyk
8195        && params.color.process_cmyk.is_some();
8196    // See update_cmyk_buffer_for_fill for rationale.
8197    let has_spot_contrib = (is_custom_spot && params.color.native_cmyk.is_some())
8198        || matches!(
8199            (params.color.native_cmyk, params.color.process_cmyk),
8200            (Some(nat), Some(proc_))
8201                if (nat.0 - proc_.0).abs() > 1e-6
8202                    || (nat.1 - proc_.1).abs() > 1e-6
8203                    || (nat.2 - proc_.2).abs() > 1e-6
8204                    || (nat.3 - proc_.3).abs() > 1e-6
8205        );
8206
8207    let (src_c, src_m, src_y, src_k) = if is_custom_spot {
8208        (0.0, 0.0, 0.0, 0.0)
8209    } else if let Some(c) = params.color.process_cmyk {
8210        c
8211    } else if let Some(c) = params.color.native_cmyk {
8212        c
8213    } else if let Some(cmyk) = icc.and_then(|i| {
8214        i.convert_rgb_to_cmyk_readonly(params.color.r, params.color.g, params.color.b)
8215    }) {
8216        (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
8217    } else {
8218        (
8219            (1.0 - params.color.r).clamp(0.0, 1.0),
8220            (1.0 - params.color.g).clamp(0.0, 1.0),
8221            (1.0 - params.color.b).clamp(0.0, 1.0),
8222            0.0,
8223        )
8224    };
8225
8226    let Some(skia_path) = build_skia_path(path) else {
8227        return;
8228    };
8229
8230    // Convert the stroke outline into a fill path so we can rasterize it via
8231    // Mask::fill_path. Mirrors the dance in the overprint stroke branch:
8232    // dash → stroke-to-outline (in user space) → device transform.
8233    let resolution_scale = (transform.sx * transform.sx + transform.sy * transform.sy)
8234        .sqrt()
8235        .max(1.0);
8236    let dashed_op;
8237    let stroke_src = if let Some(ref dash) = stroke.dash {
8238        dashed_op = skia_path.dash(dash, resolution_scale);
8239        match dashed_op.as_ref() {
8240            Some(p) => p,
8241            None => &skia_path,
8242        }
8243    } else {
8244        &skia_path
8245    };
8246    let Some(stroked_user) = stroke_src.stroke(stroke, resolution_scale) else {
8247        return;
8248    };
8249    let Some(stroked) = stroked_user.transform(transform) else {
8250        return;
8251    };
8252
8253    let mut coverage_mask = match Mask::new(out_w, out_h) {
8254        Some(m) => m,
8255        None => return,
8256    };
8257    coverage_mask.fill_path(
8258        &stroked,
8259        SkiaFillRule::Winding,
8260        !no_aa,
8261        Transform::identity(),
8262    );
8263
8264    let cov_data = coverage_mask.data();
8265    let clip_data: Option<&[u8]> = match clip_region {
8266        Some(ClipRegion::Mask(m)) => Some(m.data()),
8267        _ => None,
8268    };
8269
8270    let (mut bx0, mut by0, mut bx1, mut by1) =
8271        path_device_bbox(&stroked, Transform::identity(), out_w, out_h);
8272    if let Some(ClipRegion::Rect(r)) = clip_region {
8273        bx0 = bx0.max(r.x0 as usize);
8274        by0 = by0.max(r.y0 as usize);
8275        bx1 = bx1.min(r.x1 as usize);
8276        by1 = by1.min(r.y1 as usize);
8277    }
8278
8279    let stride = out_w as usize;
8280    for y in by0..by1 {
8281        for x in bx0..bx1 {
8282            let mi = y * stride + x;
8283            let mut cov = cov_data[mi] as f32 / 255.0;
8284            if let Some(clip) = clip_data {
8285                cov *= clip[mi] as f32 / 255.0;
8286            }
8287            if cov > 0.0 {
8288                let ci = mi * 4;
8289                cmyk_buf[ci] = src_c as f32;
8290                cmyk_buf[ci + 1] = src_m as f32;
8291                cmyk_buf[ci + 2] = src_y as f32;
8292                cmyk_buf[ci + 3] = src_k as f32;
8293                if has_spot_contrib {
8294                    spot_mask[mi] = 1;
8295                }
8296            }
8297        }
8298    }
8299}
8300
8301/// Render an overprint image with viewport params.
8302#[allow(clippy::too_many_arguments)]
8303fn render_overprint_image(
8304    pixmap: &mut Pixmap,
8305    cmyk_buf: &mut [f32],
8306    op_bg: &mut [u8],
8307    op_touched: &mut [u8],
8308    band_state: &mut BandState,
8309    sample_data: &[u8],
8310    params: &ImageParams,
8311    vp_x: f32,
8312    vp_y: f32,
8313    scale_x: f32,
8314    scale_y: f32,
8315    out_w: u32,
8316    out_h: u32,
8317    icc: Option<&IccCache>,
8318) {
8319    let iw = params.width as usize;
8320    let ih = params.height as usize;
8321    let Some(image_inv) = params.image_matrix.invert() else {
8322        return;
8323    };
8324    let combined = params.ctm.concat(&image_inv);
8325    let Some(inv_combined) = combined.invert() else {
8326        return;
8327    };
8328
8329    let px_data = pixmap.data_mut();
8330    let stride = out_w as usize;
8331    let inv_sx = 1.0 / scale_x as f64;
8332    let inv_sy = 1.0 / scale_y as f64;
8333
8334    let clip_data: Option<&[u8]> = match &band_state.clip_region {
8335        Some(ClipRegion::Mask(m)) => Some(m.data()),
8336        _ => None,
8337    };
8338    let clip_rect = match &band_state.clip_region {
8339        Some(ClipRegion::Rect(r)) => Some(*r),
8340        _ => None,
8341    };
8342
8343    let mask_info = if let ImageColorSpace::Mask { color, polarity } = &params.color_space {
8344        let (src_c, src_m, src_y, src_k) = color.native_cmyk.unwrap_or_else(|| {
8345            let r = color.r;
8346            let g = color.g;
8347            let b = color.b;
8348            (1.0 - r, 1.0 - g, 1.0 - b, 0.0)
8349        });
8350        Some((src_c, src_m, src_y, src_k, *polarity, iw.div_ceil(8)))
8351    } else {
8352        None
8353    };
8354
8355    for by in 0..out_h as usize {
8356        for bx in 0..out_w as usize {
8357            if let Some(ref r) = clip_rect
8358                && ((by as u32) < r.y0
8359                    || (by as u32) >= r.y1
8360                    || (bx as u32) < r.x0
8361                    || (bx as u32) >= r.x1)
8362            {
8363                continue;
8364            }
8365            if let Some(clip) = clip_data {
8366                let ci_clip = by * stride + bx;
8367                if clip[ci_clip] == 0 {
8368                    let bh = out_h as usize;
8369                    let has_neighbor = (bx > 0 && clip[ci_clip - 1] != 0)
8370                        || (bx + 1 < stride && clip[ci_clip + 1] != 0)
8371                        || (by > 0 && clip[ci_clip - stride] != 0)
8372                        || (by + 1 < bh && clip[ci_clip + stride] != 0)
8373                        || (bx > 0 && by > 0 && clip[ci_clip - stride - 1] != 0)
8374                        || (bx + 1 < stride && by > 0 && clip[ci_clip - stride + 1] != 0)
8375                        || (bx > 0 && by + 1 < bh && clip[ci_clip + stride - 1] != 0)
8376                        || (bx + 1 < stride && by + 1 < bh && clip[ci_clip + stride + 1] != 0);
8377                    if !has_neighbor {
8378                        continue;
8379                    }
8380                }
8381            }
8382
8383            // Map output pixel to device space, then to image space
8384            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8385            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8386            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8387            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8388
8389            let col = ix.floor() as i64;
8390            let row = iy.floor() as i64;
8391            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8392                continue;
8393            }
8394            let col = col as usize;
8395            let row = row as usize;
8396
8397            let (src_c, src_m, src_y, src_k) =
8398                if let Some((mc, mm, my, mk, polarity, bytes_per_row)) = mask_info {
8399                    let byte_idx = row * bytes_per_row + col / 8;
8400                    let bit_offset = 7 - (col % 8);
8401                    let bit = if byte_idx < sample_data.len() {
8402                        (sample_data[byte_idx] >> bit_offset) & 1
8403                    } else {
8404                        0
8405                    };
8406                    let paint = if polarity { bit == 1 } else { bit == 0 };
8407                    if !paint {
8408                        continue;
8409                    }
8410                    (mc, mm, my, mk)
8411                } else if let Some(cmyk) =
8412                    sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8413                {
8414                    cmyk
8415                } else {
8416                    continue;
8417                };
8418
8419            let mi = by * stride + bx;
8420            let ci = mi * 4;
8421            let pi = mi * 4;
8422
8423            // Spot-tint images (Separation / DeviceN with CMYK alt and at
8424            // least one non-process colorant): per PDF spec 11.7.4.5 the
8425            // image affects only the device colorants identified by its color
8426            // space.  In composite preview that means:
8427            //   * Where the CMYK buffer is empty (fresh paper or a custom
8428            //     spot painted earlier whose alt-CMYK we never tracked),
8429            //     paint the pixel directly from the image's tint output —
8430            //     the spot's full alt-CMYK contribution shows up, and a
8431            //     same-spot underlying paint (e.g. a /GWG-Green X under an
8432            //     image whose GWG-Green is zero) is knocked out because
8433            //     ICC(0,0,0,0) is white.
8434            //   * Where the CMYK buffer carries prior CMYK (a `1 0 1 0.5 k`
8435            //     ✓ underneath), REPLACE only the NAMED PROCESS plates with
8436            //     the image's tint output and PRESERVE the rest, then
8437            //     recompose the pixmap.  A duotone DeviceN [Black, Green]
8438            //     image's "no ink" pixel knocks the ✓'s K=0.5 down to 0 —
8439            //     lightening it to (C=1, M=0, Y=1, K=0) — while leaving its
8440            //     C=1, Y=1 untouched.
8441            if image_cs_has_spot_tint_transform(&params.color_space) {
8442                let cur_c = cmyk_buf[ci] as f64;
8443                let cur_m = cmyk_buf[ci + 1] as f64;
8444                let cur_y = cmyk_buf[ci + 2] as f64;
8445                let cur_k = cmyk_buf[ci + 3] as f64;
8446                let cur_is_zero = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8447                let named = params.painted_channels;
8448                // OPM=1 zero-source preservation: when the image's tint
8449                // output for a named plate is zero, the underlying value is
8450                // preserved instead of replaced.  Without this, a duotone
8451                // DeviceN [Black, GWG-Green] image's "no ink" pixel
8452                // overwrote the K=0.5 of an underlying CMYK ✓ with 0,
8453                // rendering the checkmark too light versus Adobe Acrobat.
8454                let opm1 = params.overprint_mode == 1;
8455                let (new_c, new_m, new_y, new_k) = if cur_is_zero {
8456                    (src_c, src_m, src_y, src_k)
8457                } else {
8458                    let nc =
8459                        if named & stet_graphics::device::CMYK_C != 0 && !(opm1 && src_c == 0.0) {
8460                            src_c
8461                        } else {
8462                            cur_c
8463                        };
8464                    let nm =
8465                        if named & stet_graphics::device::CMYK_M != 0 && !(opm1 && src_m == 0.0) {
8466                            src_m
8467                        } else {
8468                            cur_m
8469                        };
8470                    let ny =
8471                        if named & stet_graphics::device::CMYK_Y != 0 && !(opm1 && src_y == 0.0) {
8472                            src_y
8473                        } else {
8474                            cur_y
8475                        };
8476                    let nk =
8477                        if named & stet_graphics::device::CMYK_K != 0 && !(opm1 && src_k == 0.0) {
8478                            src_k
8479                        } else {
8480                            cur_k
8481                        };
8482                    (nc, nm, ny, nk)
8483                };
8484                cmyk_buf[ci] = new_c as f32;
8485                cmyk_buf[ci + 1] = new_m as f32;
8486                cmyk_buf[ci + 2] = new_y as f32;
8487                cmyk_buf[ci + 3] = new_k as f32;
8488                // When the alt space is non-CMYK (e.g., DeviceN with Lab alt),
8489                // src_* came from named-colorant extraction and only describes
8490                // the named process plates — spot contributions are missing.
8491                // For fresh-paper pixels (cur_is_zero), reconstruct the visual
8492                // via the tint transform's alt → RGB output instead so the
8493                // spot's true colour shows through. Composite cells (cur not
8494                // zero) still go through CMYK → RGB on the plate-replaced
8495                // values so process plates from the underlay are honoured.
8496                let alt_is_non_cmyk = image_cs_alt_is_non_cmyk(&params.color_space);
8497                let (r, g, b) = if cur_is_zero
8498                    && alt_is_non_cmyk
8499                    && let Some(rgb) =
8500                        sample_pixel_visual_rgb(sample_data, &params.color_space, iw, row, col)
8501                {
8502                    rgb
8503                } else if let Some(icc_cache) = icc {
8504                    icc_cache
8505                        .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8506                        .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8507                } else {
8508                    cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8509                };
8510                if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8511                    op_bg[pi] = px_data[pi];
8512                    op_bg[pi + 1] = px_data[pi + 1];
8513                    op_bg[pi + 2] = px_data[pi + 2];
8514                    op_bg[pi + 3] = px_data[pi + 3];
8515                    op_touched[mi] = 1;
8516                }
8517                px_data[pi] = (r * 255.0).round() as u8;
8518                px_data[pi + 1] = (g * 255.0).round() as u8;
8519                px_data[pi + 2] = (b * 255.0).round() as u8;
8520                px_data[pi + 3] = 255;
8521                continue;
8522            }
8523
8524            let mut channels = params.painted_channels;
8525            // Non-CMYK images (painted_channels=0, e.g. Separation/DeviceN spot colors)
8526            // replace all CMYK channels with the tinted equivalent.
8527            if channels == 0 {
8528                channels = stet_graphics::device::CMYK_ALL;
8529            }
8530            let is_direct_cmyk = matches!(
8531                &params.color_space,
8532                ImageColorSpace::DeviceCMYK
8533                    | ImageColorSpace::ICCBased { n: 4, .. }
8534                    | ImageColorSpace::Mask { .. }
8535            );
8536            // Custom spot image: process plates stay untouched and the per-pixel
8537            // sampled CMYK is the spot's alt-CMYK, which we layer multiplicatively
8538            // onto the pixmap. For image masks, the spot identity lives on the
8539            // fill color (recognise them via painted_channels=0 paired with a
8540            // native-CMYK fill color from the alt-space conversion). Indexed
8541            // images inherit the base space, so an Indexed /DeviceCMYK palette
8542            // is NOT a custom spot even when painted_channels=0. Plain DeviceCMYK
8543            // / ICCBased(4) images keep is_custom_spot=false so standard OPM 1
8544            // behaviour still applies.
8545            let is_custom_spot = params.painted_channels == 0
8546                && !is_cmyk_color_space(&params.color_space)
8547                && match &params.color_space {
8548                    ImageColorSpace::Mask { color, .. } => color.native_cmyk.is_some(),
8549                    _ => true,
8550                };
8551            if params.overprint_mode == 1
8552                && channels == stet_graphics::device::CMYK_ALL
8553                && is_direct_cmyk
8554            {
8555                channels = 0;
8556                if src_c != 0.0 {
8557                    channels |= stet_graphics::device::CMYK_C;
8558                }
8559                if src_m != 0.0 {
8560                    channels |= stet_graphics::device::CMYK_M;
8561                }
8562                if src_y != 0.0 {
8563                    channels |= stet_graphics::device::CMYK_Y;
8564                }
8565                if src_k != 0.0 {
8566                    channels |= stet_graphics::device::CMYK_K;
8567                }
8568            }
8569
8570            let cur_c = cmyk_buf[ci] as f64;
8571            let cur_m = cmyk_buf[ci + 1] as f64;
8572            let cur_y = cmyk_buf[ci + 2] as f64;
8573            let cur_k = cmyk_buf[ci + 3] as f64;
8574            let cur_is_clean = cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
8575            let pixmap_has_colour = px_data[pi + 3] > 0
8576                && (px_data[pi] < 250 || px_data[pi + 1] < 250 || px_data[pi + 2] < 250);
8577            // Multiplicative ink-stacking only when the pixmap carries a real
8578            // backdrop: either this paint is a custom spot landing on an
8579            // already-coloured pixel, or the process-ink buffer is empty but
8580            // the pixmap shows colour (prior spot/RGB paint). On fresh paper
8581            // (alpha=0 → premultiplied (0,0,0,0)) multiplicative would darken
8582            // the fill to pure black, so those pixels fall through to the
8583            // replace path where the source RGB paints normally.
8584            let use_multiplicative = (is_custom_spot || cur_is_clean) && pixmap_has_colour;
8585
8586            let new_c = if channels & stet_graphics::device::CMYK_C != 0 {
8587                src_c
8588            } else {
8589                cur_c
8590            };
8591            let new_m = if channels & stet_graphics::device::CMYK_M != 0 {
8592                src_m
8593            } else {
8594                cur_m
8595            };
8596            let new_y = if channels & stet_graphics::device::CMYK_Y != 0 {
8597                src_y
8598            } else {
8599                cur_y
8600            };
8601            let new_k = if channels & stet_graphics::device::CMYK_K != 0 {
8602                src_k
8603            } else {
8604                cur_k
8605            };
8606
8607            if !is_custom_spot {
8608                cmyk_buf[ci] = new_c as f32;
8609                cmyk_buf[ci + 1] = new_m as f32;
8610                cmyk_buf[ci + 2] = new_y as f32;
8611                cmyk_buf[ci + 3] = new_k as f32;
8612            }
8613
8614            let (r, g, b) = if use_multiplicative {
8615                let bg_r = px_data[pi] as f64 / 255.0;
8616                let bg_g = px_data[pi + 1] as f64 / 255.0;
8617                let bg_b = px_data[pi + 2] as f64 / 255.0;
8618                let over_r = if channels & stet_graphics::device::CMYK_C != 0 {
8619                    1.0 - src_c
8620                } else {
8621                    1.0
8622                };
8623                let over_g = if channels & stet_graphics::device::CMYK_M != 0 {
8624                    1.0 - src_m
8625                } else {
8626                    1.0
8627                };
8628                let over_b = if channels & stet_graphics::device::CMYK_Y != 0 {
8629                    1.0 - src_y
8630                } else {
8631                    1.0
8632                };
8633                let k_fac = if channels & stet_graphics::device::CMYK_K != 0 {
8634                    1.0 - src_k
8635                } else {
8636                    1.0
8637                };
8638                (
8639                    (bg_r * over_r * k_fac).clamp(0.0, 1.0),
8640                    (bg_g * over_g * k_fac).clamp(0.0, 1.0),
8641                    (bg_b * over_b * k_fac).clamp(0.0, 1.0),
8642                )
8643            } else if let Some(icc_cache) = icc {
8644                icc_cache
8645                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
8646                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
8647            } else {
8648                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
8649            };
8650
8651            // Snapshot the pre-paint pixmap so a later overprint fill/stroke
8652            // at this pixel can blend against it (see render_overprint_fill).
8653            if op_touched[mi] == 0 && px_data[pi + 3] > 0 {
8654                op_bg[pi] = px_data[pi];
8655                op_bg[pi + 1] = px_data[pi + 1];
8656                op_bg[pi + 2] = px_data[pi + 2];
8657                op_bg[pi + 3] = px_data[pi + 3];
8658                op_touched[mi] = 1;
8659            }
8660
8661            px_data[pi] = (r * 255.0).round() as u8;
8662            px_data[pi + 1] = (g * 255.0).round() as u8;
8663            px_data[pi + 2] = (b * 255.0).round() as u8;
8664            px_data[pi + 3] = 255;
8665        }
8666    }
8667}
8668
8669/// Update CMYK buffer for a non-overprint image.
8670///
8671/// For native-CMYK image color spaces (DeviceCMYK / ICCBased(4) / Separation
8672/// or DeviceN with CMYK alt), the source CMYK is sampled directly via
8673/// `sample_pixel_cmyk`. For non-CMYK source spaces (RGB/Gray/Lab/etc.), the
8674/// already-composited pixmap pixel is read and reverse-converted to CMYK via
8675/// the system CMYK ICC profile, falling back to the PLRM formula. This keeps
8676/// the parallel CMYK buffer faithful for any image painter inside a
8677/// CMYK-tracked context.
8678#[allow(clippy::too_many_arguments)]
8679fn update_cmyk_buffer_for_image(
8680    cmyk_buf: &mut [f32],
8681    sample_data: &[u8],
8682    pixmap_rgba: &[u8],
8683    params: &ImageParams,
8684    vp_x: f32,
8685    vp_y: f32,
8686    scale_x: f32,
8687    scale_y: f32,
8688    out_w: u32,
8689    out_h: u32,
8690    clip_region: &Option<ClipRegion>,
8691    icc: Option<&IccCache>,
8692) {
8693    let iw = params.width as usize;
8694    let ih = params.height as usize;
8695    let Some(image_inv) = params.image_matrix.invert() else {
8696        return;
8697    };
8698    let combined = params.ctm.concat(&image_inv);
8699    let Some(inv_combined) = combined.invert() else {
8700        return;
8701    };
8702    let stride = out_w as usize;
8703    let inv_sx = 1.0 / scale_x as f64;
8704    let inv_sy = 1.0 / scale_y as f64;
8705
8706    let mask_info = if let ImageColorSpace::Mask { color, polarity } = &params.color_space {
8707        let Some((c, m, y, k)) = color.native_cmyk else {
8708            return;
8709        };
8710        Some((
8711            c as f32,
8712            m as f32,
8713            y as f32,
8714            k as f32,
8715            *polarity,
8716            iw.div_ceil(8),
8717        ))
8718    } else {
8719        None
8720    };
8721
8722    let clip_data: Option<&[u8]> = match clip_region {
8723        Some(ClipRegion::Mask(m)) => Some(m.data()),
8724        _ => None,
8725    };
8726    let clip_rect = match clip_region {
8727        Some(ClipRegion::Rect(r)) => Some(*r),
8728        _ => None,
8729    };
8730
8731    for by in 0..out_h as usize {
8732        for bx in 0..out_w as usize {
8733            if let Some(ref r) = clip_rect
8734                && ((by as u32) < r.y0
8735                    || (by as u32) >= r.y1
8736                    || (bx as u32) < r.x0
8737                    || (bx as u32) >= r.x1)
8738            {
8739                continue;
8740            }
8741            if let Some(clip) = clip_data
8742                && clip[by * stride + bx] == 0
8743            {
8744                continue;
8745            }
8746
8747            let dx = (bx as f64 + 0.5) * inv_sx + vp_x as f64;
8748            let dy = (by as f64 + 0.5) * inv_sy + vp_y as f64;
8749            let ix = inv_combined.a * dx + inv_combined.c * dy + inv_combined.tx;
8750            let iy = inv_combined.b * dx + inv_combined.d * dy + inv_combined.ty;
8751
8752            let col = ix.floor() as i64;
8753            let row = iy.floor() as i64;
8754            if col < 0 || col >= iw as i64 || row < 0 || row >= ih as i64 {
8755                continue;
8756            }
8757            let col = col as usize;
8758            let row = row as usize;
8759
8760            let ci = (by * stride + bx) * 4;
8761            if let Some((sc, sm, sy, sk, polarity, bytes_per_row)) = mask_info {
8762                let byte_idx = row * bytes_per_row + col / 8;
8763                let bit_offset = 7 - (col % 8);
8764                let bit = if byte_idx < sample_data.len() {
8765                    (sample_data[byte_idx] >> bit_offset) & 1
8766                } else {
8767                    0
8768                };
8769                let paint = if polarity { bit == 1 } else { bit == 0 };
8770                if paint {
8771                    cmyk_buf[ci] = sc;
8772                    cmyk_buf[ci + 1] = sm;
8773                    cmyk_buf[ci + 2] = sy;
8774                    cmyk_buf[ci + 3] = sk;
8775                }
8776            } else if let Some((sc, sm, sy, sk)) =
8777                sample_pixel_cmyk(sample_data, &params.color_space, iw, row, col)
8778            {
8779                cmyk_buf[ci] = sc as f32;
8780                cmyk_buf[ci + 1] = sm as f32;
8781                cmyk_buf[ci + 2] = sy as f32;
8782                cmyk_buf[ci + 3] = sk as f32;
8783            } else if ci + 3 < pixmap_rgba.len() && pixmap_rgba[ci + 3] > 0 {
8784                // Non-CMYK source space: reverse-convert the composited pixmap
8785                // pixel to CMYK via the system profile. Falls back to PLRM
8786                // (1 − r, 1 − g, 1 − b, 0) when no ICC reverse is available.
8787                let r = pixmap_rgba[ci] as f64 / 255.0;
8788                let g = pixmap_rgba[ci + 1] as f64 / 255.0;
8789                let b = pixmap_rgba[ci + 2] as f64 / 255.0;
8790                let cmyk =
8791                    if let Some(c) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
8792                        c
8793                    } else {
8794                        [
8795                            (1.0 - r).clamp(0.0, 1.0),
8796                            (1.0 - g).clamp(0.0, 1.0),
8797                            (1.0 - b).clamp(0.0, 1.0),
8798                            0.0,
8799                        ]
8800                    };
8801                cmyk_buf[ci] = cmyk[0] as f32;
8802                cmyk_buf[ci + 1] = cmyk[1] as f32;
8803                cmyk_buf[ci + 2] = cmyk[2] as f32;
8804                cmyk_buf[ci + 3] = cmyk[3] as f32;
8805            }
8806        }
8807    }
8808}
8809/// Check if an image color space can be rendered through the overprint path.
8810/// Image masks always work (they use the fill color's native CMYK).
8811/// Other color spaces must be CMYK-resolvable via `sample_pixel_cmyk`.
8812fn image_supports_overprint(cs: &ImageColorSpace) -> bool {
8813    use stet_graphics::device::cmyk_channel_for_name;
8814    match cs {
8815        ImageColorSpace::Mask { .. } => true,
8816        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => true,
8817        ImageColorSpace::Separation {
8818            alt_space, name, ..
8819        } => {
8820            matches!(
8821                alt_space.as_ref(),
8822                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8823            ) || cmyk_channel_for_name(name) != 0
8824        }
8825        ImageColorSpace::DeviceN {
8826            alt_space, names, ..
8827        } => {
8828            matches!(
8829                alt_space.as_ref(),
8830                ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8831            ) || names.iter().any(|n| cmyk_channel_for_name(n) != 0)
8832        }
8833        ImageColorSpace::Indexed { base, .. } => image_supports_overprint(base),
8834        _ => false,
8835    }
8836}
8837
8838/// Check if an image color space is CMYK-based (DeviceCMYK, ICCBased 4-component, or Indexed over CMYK).
8839fn is_cmyk_color_space(cs: &ImageColorSpace) -> bool {
8840    match cs {
8841        ImageColorSpace::DeviceCMYK => true,
8842        ImageColorSpace::ICCBased { n: 4, .. } => true,
8843        ImageColorSpace::Indexed { base, .. } => is_cmyk_color_space(base),
8844        _ => false,
8845    }
8846}
8847
8848/// True when an image's color space is a Separation/DeviceN with at least
8849/// one non-process spot colorant. These images represent paint that affects
8850/// a virtual spot plate; the per-pixel CMYK produced by the tint transform
8851/// (when alt is CMYK) — or extracted directly from named process colorants
8852/// (when alt is non-CMYK) — must blend with the tracked CMYK buffer per
8853/// OPM=1: named process plates are replaced and unnamed plates are preserved.
8854fn image_cs_has_spot_tint_transform(cs: &ImageColorSpace) -> bool {
8855    use stet_graphics::device::cmyk_channel_for_name;
8856    let is_cmyk_alt = |alt: &ImageColorSpace| {
8857        matches!(
8858            alt,
8859            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8860        )
8861    };
8862    match cs {
8863        ImageColorSpace::Separation {
8864            name, alt_space, ..
8865        } => cmyk_channel_for_name(name) == 0 && is_cmyk_alt(alt_space.as_ref()),
8866        ImageColorSpace::DeviceN {
8867            names, alt_space, ..
8868        } => {
8869            let has_spot = names.iter().any(|n| cmyk_channel_for_name(n) == 0);
8870            let has_process = names.iter().any(|n| cmyk_channel_for_name(n) != 0);
8871            has_spot && (is_cmyk_alt(alt_space.as_ref()) || has_process)
8872        }
8873        ImageColorSpace::Indexed { base, .. } => image_cs_has_spot_tint_transform(base),
8874        _ => false,
8875    }
8876}
8877
8878/// True when the image's tint transform alt is non-CMYK (Lab/RGB/Gray/etc.).
8879/// In that case the per-pixel CMYK from `sample_pixel_cmyk` only carries the
8880/// named process colorants extracted directly — it doesn't capture spot
8881/// colorant contributions, so visual painting (when the buffer is fresh)
8882/// must come from `sample_pixel_visual_rgb` instead of CMYK→RGB conversion.
8883fn image_cs_alt_is_non_cmyk(cs: &ImageColorSpace) -> bool {
8884    let is_cmyk_alt = |alt: &ImageColorSpace| {
8885        matches!(
8886            alt,
8887            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
8888        )
8889    };
8890    match cs {
8891        ImageColorSpace::Separation { alt_space, .. }
8892        | ImageColorSpace::DeviceN { alt_space, .. } => !is_cmyk_alt(alt_space.as_ref()),
8893        ImageColorSpace::Indexed { base, .. } => image_cs_alt_is_non_cmyk(base),
8894        _ => false,
8895    }
8896}
8897
8898/// Sample a pixel's visual RGB (0..1) via the tint-transform → alt-space →
8899/// RGB chain. Used by the spot-tint overprint path when the image's alt is
8900/// non-CMYK; for those images the named-colorant CMYK extraction loses the
8901/// spot contribution, but the tint table still produces the correct visual.
8902fn sample_pixel_visual_rgb(
8903    sample_data: &[u8],
8904    cs: &ImageColorSpace,
8905    iw: usize,
8906    row: usize,
8907    col: usize,
8908) -> Option<(f64, f64, f64)> {
8909    let to_f64 = |(r, g, b): (u8, u8, u8)| (r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0);
8910    match cs {
8911        ImageColorSpace::Separation {
8912            alt_space,
8913            tint_table,
8914            ..
8915        } => {
8916            let si = row * iw + col;
8917            if si >= sample_data.len() {
8918                return None;
8919            }
8920            let tint = sample_data[si] as f32 / 255.0;
8921            let no = tint_table.num_outputs as usize;
8922            let mut comps = vec![0.0f32; no];
8923            tint_table.lookup_1d(tint, &mut comps);
8924            Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
8925        }
8926        ImageColorSpace::DeviceN {
8927            alt_space,
8928            tint_table,
8929            ..
8930        } => {
8931            let ni = tint_table.num_inputs as usize;
8932            let si = (row * iw + col) * ni;
8933            if si + ni > sample_data.len() {
8934                return None;
8935            }
8936            let mut inputs = vec![0.0f32; ni];
8937            for (c, inp) in inputs.iter_mut().enumerate() {
8938                *inp = sample_data[si + c] as f32 / 255.0;
8939            }
8940            let no = tint_table.num_outputs as usize;
8941            let mut comps = vec![0.0f32; no];
8942            tint_table.lookup_nd(&inputs, &mut comps);
8943            Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
8944        }
8945        ImageColorSpace::Indexed {
8946            base,
8947            hival,
8948            lookup,
8949        } => {
8950            let pi = row * iw + col;
8951            if pi >= sample_data.len() {
8952                return None;
8953            }
8954            let idx = (sample_data[pi] as usize).min(*hival as usize);
8955            let base_ncomp = base.num_components() as usize;
8956            let li = idx * base_ncomp;
8957            if li + base_ncomp > lookup.len() {
8958                return None;
8959            }
8960            match base.as_ref() {
8961                ImageColorSpace::Separation {
8962                    alt_space,
8963                    tint_table,
8964                    ..
8965                } => {
8966                    let tint = lookup[li] as f32 / 255.0;
8967                    let no = tint_table.num_outputs as usize;
8968                    let mut comps = vec![0.0f32; no];
8969                    tint_table.lookup_1d(tint, &mut comps);
8970                    Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
8971                }
8972                ImageColorSpace::DeviceN {
8973                    alt_space,
8974                    tint_table,
8975                    ..
8976                } => {
8977                    let ni = tint_table.num_inputs as usize;
8978                    let mut inputs = vec![0.0f32; ni];
8979                    for (c, inp) in inputs.iter_mut().enumerate() {
8980                        if c < base_ncomp {
8981                            *inp = lookup[li + c] as f32 / 255.0;
8982                        }
8983                    }
8984                    let no = tint_table.num_outputs as usize;
8985                    let mut comps = vec![0.0f32; no];
8986                    tint_table.lookup_nd(&inputs, &mut comps);
8987                    Some(to_f64(alt_comps_to_rgb(&comps, alt_space)))
8988                }
8989                _ => None,
8990            }
8991        }
8992        _ => None,
8993    }
8994}
8995
8996/// Extract CMYK values from DeviceN colorant inputs by mapping each named
8997/// process colorant directly to its CMYK channel. Spot colorants and `/None`
8998/// don't contribute. Used when the DeviceN's alt is non-CMYK so the tint
8999/// transform can't produce CMYK; the named-colorant inputs are themselves the
9000/// per-pixel ink amounts for the named process plates.
9001fn devicen_named_cmyk(names: &[Vec<u8>], inputs: &[u8]) -> (f64, f64, f64, f64) {
9002    use stet_graphics::device::{CMYK_C, CMYK_K, CMYK_M, CMYK_Y, cmyk_channel_for_name};
9003    let mut c = 0.0;
9004    let mut m = 0.0;
9005    let mut y = 0.0;
9006    let mut k = 0.0;
9007    for (i, name) in names.iter().enumerate() {
9008        let bit = cmyk_channel_for_name(name);
9009        if bit == 0 {
9010            continue;
9011        }
9012        let v = inputs.get(i).copied().unwrap_or(0) as f64 / 255.0;
9013        if bit & CMYK_C != 0 {
9014            c = v;
9015        }
9016        if bit & CMYK_M != 0 {
9017            m = v;
9018        }
9019        if bit & CMYK_Y != 0 {
9020            y = v;
9021        }
9022        if bit & CMYK_K != 0 {
9023            k = v;
9024        }
9025    }
9026    (c, m, y, k)
9027}
9028
9029/// Sample a single pixel's CMYK values from image data, handling DeviceCMYK,
9030/// ICCBased(4), Separation/DeviceN (CMYK alt via tint table, or non-CMYK alt
9031/// via named-colorant extraction), and Indexed color spaces. Returns None for
9032/// non-CMYK images.
9033fn sample_pixel_cmyk(
9034    sample_data: &[u8],
9035    cs: &ImageColorSpace,
9036    iw: usize,
9037    row: usize,
9038    col: usize,
9039) -> Option<(f64, f64, f64, f64)> {
9040    use stet_graphics::device::cmyk_channel_for_name;
9041    let is_cmyk_alt = |alt: &ImageColorSpace| {
9042        matches!(
9043            alt,
9044            ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. }
9045        )
9046    };
9047    match cs {
9048        ImageColorSpace::DeviceCMYK | ImageColorSpace::ICCBased { n: 4, .. } => {
9049            let si = (row * iw + col) * 4;
9050            if si + 3 < sample_data.len() {
9051                Some((
9052                    sample_data[si] as f64 / 255.0,
9053                    sample_data[si + 1] as f64 / 255.0,
9054                    sample_data[si + 2] as f64 / 255.0,
9055                    sample_data[si + 3] as f64 / 255.0,
9056                ))
9057            } else {
9058                None
9059            }
9060        }
9061        ImageColorSpace::Separation {
9062            alt_space,
9063            tint_table,
9064            name,
9065        } => {
9066            let si = row * iw + col;
9067            if si >= sample_data.len() {
9068                return None;
9069            }
9070            let tint = sample_data[si] as f32 / 255.0;
9071            if is_cmyk_alt(alt_space.as_ref()) {
9072                let mut alt = [0.0f32; 4];
9073                tint_table.lookup_1d(tint, &mut alt);
9074                return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
9075            }
9076            // Non-CMYK alt: only a named process colorant is recoverable.
9077            let bit = cmyk_channel_for_name(name);
9078            if bit == 0 {
9079                return None;
9080            }
9081            let names = vec![name.clone()];
9082            let inputs = [(tint * 255.0).round() as u8];
9083            Some(devicen_named_cmyk(&names, &inputs))
9084        }
9085        ImageColorSpace::DeviceN {
9086            alt_space,
9087            tint_table,
9088            names,
9089        } => {
9090            let ni = tint_table.num_inputs as usize;
9091            let si = (row * iw + col) * ni;
9092            if si + ni > sample_data.len() {
9093                return None;
9094            }
9095            if is_cmyk_alt(alt_space.as_ref()) {
9096                let mut inputs = vec![0.0f32; ni];
9097                for (c, inp) in inputs.iter_mut().enumerate() {
9098                    *inp = sample_data[si + c] as f32 / 255.0;
9099                }
9100                let mut alt = [0.0f32; 4];
9101                tint_table.lookup_nd(&inputs, &mut alt);
9102                return Some((alt[0] as f64, alt[1] as f64, alt[2] as f64, alt[3] as f64));
9103            }
9104            // Non-CMYK alt: extract from named process colorants directly.
9105            if !names.iter().any(|n| cmyk_channel_for_name(n) != 0) {
9106                return None;
9107            }
9108            Some(devicen_named_cmyk(names, &sample_data[si..si + ni]))
9109        }
9110        ImageColorSpace::Indexed {
9111            base,
9112            hival,
9113            lookup,
9114        } => {
9115            let pi = row * iw + col;
9116            if pi >= sample_data.len() {
9117                return None;
9118            }
9119            let idx = sample_data[pi] as usize;
9120            let idx = idx.min(*hival as usize);
9121            let base_ncomp = base.num_components() as usize;
9122            let li = idx * base_ncomp;
9123            // For direct CMYK base (4 components): read CMYK from lookup table
9124            if is_cmyk_color_space(base) && base_ncomp == 4 && li + 3 < lookup.len() {
9125                return Some((
9126                    lookup[li] as f64 / 255.0,
9127                    lookup[li + 1] as f64 / 255.0,
9128                    lookup[li + 2] as f64 / 255.0,
9129                    lookup[li + 3] as f64 / 255.0,
9130                ));
9131            }
9132            // For Separation/DeviceN base: extract base components from lookup, then tint
9133            if li + base_ncomp <= lookup.len() {
9134                match base.as_ref() {
9135                    ImageColorSpace::Separation {
9136                        alt_space,
9137                        tint_table,
9138                        name,
9139                    } => {
9140                        let tint = lookup[li] as f32 / 255.0;
9141                        if is_cmyk_alt(alt_space.as_ref()) {
9142                            let mut alt = [0.0f32; 4];
9143                            tint_table.lookup_1d(tint, &mut alt);
9144                            return Some((
9145                                alt[0] as f64,
9146                                alt[1] as f64,
9147                                alt[2] as f64,
9148                                alt[3] as f64,
9149                            ));
9150                        }
9151                        // Non-CMYK alt: only named process colorants extractable.
9152                        let bit = cmyk_channel_for_name(name);
9153                        if bit == 0 {
9154                            return None;
9155                        }
9156                        let names = vec![name.clone()];
9157                        let inputs = [(tint * 255.0).round() as u8];
9158                        return Some(devicen_named_cmyk(&names, &inputs));
9159                    }
9160                    ImageColorSpace::DeviceN {
9161                        alt_space,
9162                        tint_table,
9163                        names,
9164                    } => {
9165                        let ni = tint_table.num_inputs as usize;
9166                        if is_cmyk_alt(alt_space.as_ref()) {
9167                            let mut inputs = vec![0.0f32; ni];
9168                            for (c, inp) in inputs.iter_mut().enumerate() {
9169                                if c < base_ncomp {
9170                                    *inp = lookup[li + c] as f32 / 255.0;
9171                                }
9172                            }
9173                            let mut alt = [0.0f32; 4];
9174                            tint_table.lookup_nd(&inputs, &mut alt);
9175                            return Some((
9176                                alt[0] as f64,
9177                                alt[1] as f64,
9178                                alt[2] as f64,
9179                                alt[3] as f64,
9180                            ));
9181                        }
9182                        // Non-CMYK alt: extract from named process colorants directly.
9183                        if !names.iter().any(|n| cmyk_channel_for_name(n) != 0) {
9184                            return None;
9185                        }
9186                        let take = ni.min(base_ncomp);
9187                        return Some(devicen_named_cmyk(names, &lookup[li..li + take]));
9188                    }
9189                    _ => {}
9190                }
9191            }
9192            None
9193        }
9194        _ => None,
9195    }
9196}
9197/// Banded rendering as a free function — runs on a background thread.
9198///
9199/// Renders the display list in horizontal bands and streams the output
9200/// to a `PageSink`. This function is self-contained: it creates its own
9201/// band pixmaps, clip state, and streams rows to the sink.
9202#[allow(clippy::too_many_arguments)]
9203fn render_banded_to_sink(
9204    page_w: u32,
9205    page_h: u32,
9206    band_h: u32,
9207    dpi: f64,
9208    list: &DisplayList,
9209    sink: &mut dyn stet_graphics::device::PageSink,
9210    icc_cache: &IccCache,
9211    no_aa: bool,
9212    layer_set: &LayerSet,
9213) -> Result<(), String> {
9214    // Precompute Y bounding boxes for culling
9215    let bboxes = precompute_bboxes(list, dpi);
9216
9217    // Build clip epochs — groups of elements between InitClip boundaries.
9218    // Epochs whose paint elements don't overlap a band can be skipped entirely,
9219    // avoiding both the per-element iteration AND clip mask rasterization.
9220    let epochs = build_clip_epochs(list, &bboxes);
9221
9222    // Pre-populate clip_mask_seen so repeated clip paths get cached from first band
9223    let clip_seen = precompute_clip_seen(list);
9224
9225    // Allocate a CMYK buffer at the page level when CMYK math is needed:
9226    // overprint simulation, an explicit DeviceCMYK page-level transparency
9227    // group (PDF spec §11.6.7), or any descendant group that declares its own
9228    // DeviceCMYK transparency CS.
9229    use stet_graphics::display_list::GroupColorSpace;
9230    let needs_cmyk_buffer = has_overprint_elements(list)
9231        || list.page_group_color_space() == GroupColorSpace::DeviceCMYK
9232        || has_cmyk_group(list);
9233
9234    // Pre-convert and prescale images once (instead of per-band)
9235    let preprocessed_images = preprocess_images_for_bands(list, Some(icc_cache));
9236
9237    // Extra rows rendered above and below each band to provide anti-aliasing
9238    // context at band seams. Without this, tiny-skia clips geometry at the
9239    // pixmap edge, producing visible discontinuities in thin diagonal strokes.
9240    const BAND_OVERLAP: u32 = 6;
9241
9242    let render_h = band_h + 2 * BAND_OVERLAP;
9243
9244    // Initialize the sink for this page
9245    sink.begin_page(page_w, page_h)?;
9246
9247    let num_bands = page_h.div_ceil(band_h);
9248    let elements = list.elements();
9249    let row_bytes = page_w as usize * 4;
9250    let icc_ref = Some(icc_cache);
9251
9252    // Closure that renders a single band and returns its RGBA pixels.
9253    let render_band = |band_idx: u32| -> Vec<u8> {
9254        let y_start = band_idx * band_h;
9255        let actual_h = (page_h - y_start).min(band_h);
9256
9257        let render_y_start = y_start.saturating_sub(BAND_OVERLAP);
9258        let render_y_end_f = ((y_start + actual_h + BAND_OVERLAP).min(page_h)) as f64;
9259        let band_offset = y_start - render_y_start;
9260
9261        let mut band_pixmap = Pixmap::new(page_w, render_h).expect("Failed to create band pixmap");
9262        // Start transparent — white background composited after content rendering
9263        band_pixmap.as_mut().data_mut().fill(0x00);
9264
9265        let cmyk_buf = if needs_cmyk_buffer {
9266            // CMYK buffer for the render region (including overlap)
9267            Some(vec![0.0f32; page_w as usize * render_h as usize * 4])
9268        } else {
9269            None
9270        };
9271
9272        let mut band_state = BandState {
9273            clip_region: None,
9274            spare_mask: None,
9275            clip_mask_cache: HashMap::new(),
9276            clip_mask_seen: clip_seen.clone(),
9277            mask_pool: Vec::new(),
9278            cmyk_buffer: cmyk_buf,
9279            op_bg_snapshot: None,
9280            op_touched: None,
9281            spot_mask: None,
9282        };
9283
9284        // Epoch-based replay
9285        for epoch in &epochs {
9286            if !epoch.has_erase_page {
9287                match epoch.paint_bbox {
9288                    Some(ref pb)
9289                        if pb.y_max <= render_y_start as f64 || pb.y_min >= render_y_end_f =>
9290                    {
9291                        continue;
9292                    }
9293                    None => continue,
9294                    _ => {}
9295                }
9296            }
9297
9298            for i in epoch.start_idx..epoch.end_idx {
9299                // OcgGroups containing Clip/InitClip must always be
9300                // processed so their clip-state changes apply for every
9301                // band — per-element Y culling would strand clip mutations
9302                // inside a group whose paint content doesn't touch the
9303                // current band.
9304                let force_process = matches!(
9305                    &elements[i],
9306                    DisplayElement::OcgGroup { elements: inner, .. }
9307                        if contains_clip_op(inner)
9308                );
9309                if !force_process
9310                    && let Some(ref bbox) = bboxes[i]
9311                    && (bbox.y_max <= render_y_start as f64 || bbox.y_min >= render_y_end_f)
9312                {
9313                    continue;
9314                }
9315                let ctx = RenderContext {
9316                    vp_x: 0.0,
9317                    vp_y: render_y_start as f32,
9318                    scale_x: 1.0,
9319                    scale_y: 1.0,
9320                    out_w: page_w,
9321                    out_h: render_h,
9322                    effective_dpi: dpi,
9323                    icc: icc_ref,
9324                    image_cache: None,
9325                    preprocessed: Some(&preprocessed_images),
9326                    elem_idx: i,
9327                    no_aa,
9328                    opm_zero_transparent: false,
9329                    knockout_painter_pass: KnockoutPainterPass::None,
9330                    parent_group_isolated: false,
9331                    alpha_extraction_pass: false,
9332                    layer_set,
9333                };
9334                render_element(&mut band_pixmap, &mut band_state, &elements[i], &ctx);
9335            }
9336        }
9337
9338        // Composite content onto white background (premultiplied alpha)
9339        composite_onto_white(band_pixmap.data_mut());
9340
9341        // Extract only the actual band rows (skip overlap)
9342        let start_byte = band_offset as usize * row_bytes;
9343        let total_bytes = actual_h as usize * row_bytes;
9344        band_pixmap.data()[start_byte..start_byte + total_bytes].to_vec()
9345    };
9346
9347    // Render bands in parallel (when available), write to sink in order.
9348    #[cfg(feature = "parallel")]
9349    {
9350        // Process in chunks of `chunk_size` bands to limit peak memory
9351        // (each rendered band is ~band_h * page_w * 4 bytes).
9352        // Cap at 8 threads — sequential sink writing bottleneck means
9353        // additional cores yield no speedup (benchmarked: 8→7.8s plateau).
9354        let chunk_size = rayon::current_num_threads().max(1);
9355
9356        for chunk_start in (0..num_bands).step_by(chunk_size) {
9357            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
9358
9359            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
9360                .into_par_iter()
9361                .map(&render_band)
9362                .collect();
9363
9364            for (i, band_data) in rendered.iter().enumerate() {
9365                let band_idx = chunk_start + i as u32;
9366                let y_start = band_idx * band_h;
9367                let actual_h = (page_h - y_start).min(band_h);
9368                sink.write_rows(band_data, actual_h)?;
9369            }
9370        }
9371    }
9372    #[cfg(not(feature = "parallel"))]
9373    {
9374        // Sequential single-threaded rendering
9375        for band_idx in 0..num_bands {
9376            let band_data = render_band(band_idx);
9377            let y_start = band_idx * band_h;
9378            let actual_h = (page_h - y_start).min(band_h);
9379            sink.write_rows(&band_data, actual_h)?;
9380        }
9381    }
9382
9383    sink.end_page()
9384}
9385
9386/// 2D bounding box in device pixels.
9387#[derive(Clone, Copy)]
9388struct BBox2D {
9389    x_min: f64,
9390    y_min: f64,
9391    x_max: f64,
9392    y_max: f64,
9393}
9394
9395/// Compute full 2D bounding boxes for display list elements (for viewport culling).
9396fn precompute_full_bboxes(list: &DisplayList, dpi: f64) -> Vec<Option<BBox2D>> {
9397    list.elements()
9398        .iter()
9399        .map(|elem| match elem {
9400            DisplayElement::Fill { path, params } => fill_device_full_bbox(path, &params.ctm),
9401            DisplayElement::Stroke { path, params } => {
9402                path_full_bbox(path).map(|mut bbox| {
9403                    // Use effective line width: actual width or hairline minimum
9404                    let effective_lw = params.line_width.max(hairline_min_width(&params.ctm, dpi));
9405                    let expand = effective_lw * params.miter_limit * 0.5;
9406                    let m = &params.ctm;
9407                    let is_identity = m.a == 1.0
9408                        && m.b == 0.0
9409                        && m.c == 0.0
9410                        && m.d == 1.0
9411                        && m.tx == 0.0
9412                        && m.ty == 0.0;
9413                    if is_identity {
9414                        bbox.x_min -= expand;
9415                        bbox.x_max += expand;
9416                        bbox.y_min -= expand;
9417                        bbox.y_max += expand;
9418                    } else {
9419                        // Path is in user space — expand for stroke, then
9420                        // transform bbox corners through CTM to device space.
9421                        let col_x_len = (m.a * m.a + m.b * m.b).sqrt().max(1.0);
9422                        let col_y_len = (m.c * m.c + m.d * m.d).sqrt().max(1.0);
9423                        let expand_x = effective_lw * col_x_len * params.miter_limit * 0.5;
9424                        let expand_y = effective_lw * col_y_len * params.miter_limit * 0.5;
9425                        bbox.x_min -= expand_x;
9426                        bbox.x_max += expand_x;
9427                        bbox.y_min -= expand_y;
9428                        bbox.y_max += expand_y;
9429                        // Transform all 4 corners to device space
9430                        let corners = [
9431                            (
9432                                m.a * bbox.x_min + m.c * bbox.y_min + m.tx,
9433                                m.b * bbox.x_min + m.d * bbox.y_min + m.ty,
9434                            ),
9435                            (
9436                                m.a * bbox.x_max + m.c * bbox.y_min + m.tx,
9437                                m.b * bbox.x_max + m.d * bbox.y_min + m.ty,
9438                            ),
9439                            (
9440                                m.a * bbox.x_min + m.c * bbox.y_max + m.tx,
9441                                m.b * bbox.x_min + m.d * bbox.y_max + m.ty,
9442                            ),
9443                            (
9444                                m.a * bbox.x_max + m.c * bbox.y_max + m.tx,
9445                                m.b * bbox.x_max + m.d * bbox.y_max + m.ty,
9446                            ),
9447                        ];
9448                        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9449                        bbox.x_max = corners
9450                            .iter()
9451                            .map(|c| c.0)
9452                            .fold(f64::NEG_INFINITY, f64::max);
9453                        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9454                        bbox.y_max = corners
9455                            .iter()
9456                            .map(|c| c.1)
9457                            .fold(f64::NEG_INFINITY, f64::max);
9458                    }
9459                    bbox
9460                })
9461            }
9462            DisplayElement::Image { params, .. } => image_full_bbox(params),
9463            DisplayElement::AxialShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9464            DisplayElement::RadialShading { params } => {
9465                shading_full_bbox(&params.bbox, &params.ctm)
9466            }
9467            DisplayElement::MeshShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9468            DisplayElement::PatchShading { params } => shading_full_bbox(&params.bbox, &params.ctm),
9469            DisplayElement::PatternFill { params } => pattern_fill_full_bbox(params),
9470            DisplayElement::Group { params, .. } => Some(BBox2D {
9471                x_min: params.bbox[0],
9472                y_min: params.bbox[1],
9473                x_max: params.bbox[2],
9474                y_max: params.bbox[3],
9475            }),
9476            DisplayElement::SoftMasked { params, .. } => Some(BBox2D {
9477                x_min: params.bbox[0],
9478                y_min: params.bbox[1],
9479                x_max: params.bbox[2],
9480                y_max: params.bbox[3],
9481            }),
9482            DisplayElement::OcgGroup {
9483                elements,
9484                visibility,
9485            } => {
9486                // Hidden groups without clip ops contribute nothing. Hidden
9487                // + has clip ops is force-processed at the render-loop layer
9488                // (see the viewport render_region_prepared loop) so we still
9489                // return the paint bounds here for correct epoch bbox.
9490                if !visibility.default_visible() && !contains_clip_op(elements) {
9491                    return None;
9492                }
9493                let child_bboxes = precompute_full_bboxes(elements, dpi);
9494                let mut x_min = f64::INFINITY;
9495                let mut y_min = f64::INFINITY;
9496                let mut x_max = f64::NEG_INFINITY;
9497                let mut y_max = f64::NEG_INFINITY;
9498                for cb in child_bboxes.into_iter().flatten() {
9499                    x_min = x_min.min(cb.x_min);
9500                    y_min = y_min.min(cb.y_min);
9501                    x_max = x_max.max(cb.x_max);
9502                    y_max = y_max.max(cb.y_max);
9503                }
9504                if x_min <= x_max && y_min <= y_max {
9505                    Some(BBox2D {
9506                        x_min,
9507                        y_min,
9508                        x_max,
9509                        y_max,
9510                    })
9511                } else {
9512                    None
9513                }
9514            }
9515            _ => None, // Clip, InitClip, ErasePage: always process
9516        })
9517        .collect()
9518}
9519
9520/// Compute the device-space bounding box of a Clip element's path.
9521///
9522/// Clip paths emitted by the PDF reader use `ctm = identity`, so the path
9523/// segments are already in device space. For Clips that come from other
9524/// sources (PostScript, the pattern transform path), the `ctm` field may
9525/// be non-identity and the path is in user space — transform the path's
9526/// bbox corners through the CTM in that case. Stroke-clips are expanded
9527/// by half the line width.
9528fn clip_path_bbox(path: &PsPath, params: &ClipParams) -> Option<BBox2D> {
9529    let mut bbox = path_full_bbox(path)?;
9530    let ctm = &params.ctm;
9531    let is_identity = ctm.a == 1.0
9532        && ctm.b == 0.0
9533        && ctm.c == 0.0
9534        && ctm.d == 1.0
9535        && ctm.tx == 0.0
9536        && ctm.ty == 0.0;
9537    if !is_identity {
9538        let corners = [
9539            ctm.transform_point(bbox.x_min, bbox.y_min),
9540            ctm.transform_point(bbox.x_max, bbox.y_min),
9541            ctm.transform_point(bbox.x_min, bbox.y_max),
9542            ctm.transform_point(bbox.x_max, bbox.y_max),
9543        ];
9544        bbox.x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
9545        bbox.x_max = corners
9546            .iter()
9547            .map(|c| c.0)
9548            .fold(f64::NEG_INFINITY, f64::max);
9549        bbox.y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
9550        bbox.y_max = corners
9551            .iter()
9552            .map(|c| c.1)
9553            .fold(f64::NEG_INFINITY, f64::max);
9554    }
9555    if let Some(sp) = &params.stroke_params {
9556        let scale = (ctm.a * ctm.a + ctm.b * ctm.b)
9557            .sqrt()
9558            .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt())
9559            .max(1.0);
9560        let expand = sp.line_width * 0.5 * scale;
9561        bbox.x_min -= expand;
9562        bbox.x_max += expand;
9563        bbox.y_min -= expand;
9564        bbox.y_max += expand;
9565    }
9566    Some(bbox)
9567}
9568
9569/// Intersect two bboxes; returns `None` if they don't overlap.
9570fn intersect_bbox(a: &BBox2D, b: &BBox2D) -> Option<BBox2D> {
9571    let x_min = a.x_min.max(b.x_min);
9572    let y_min = a.y_min.max(b.y_min);
9573    let x_max = a.x_max.min(b.x_max);
9574    let y_max = a.y_max.min(b.y_max);
9575    if x_min < x_max && y_min < y_max {
9576        Some(BBox2D {
9577            x_min,
9578            y_min,
9579            x_max,
9580            y_max,
9581        })
9582    } else {
9583        None
9584    }
9585}
9586
9587/// Compute the union of all paint elements' device-space bounds in
9588/// `list`, with awareness of the active clip stack.
9589///
9590/// Used by the soft-mask rasterization path: a SoftMasked element's
9591/// `params.bbox` is derived from the form's `/BBox` transformed by the
9592/// gs-time CTM, but the form's internal `cm` operators may translate
9593/// individual paint elements outside that bbox. The mask raster needs to
9594/// be sized against the actual paint bounds, not the form bbox.
9595///
9596/// **Why clip-awareness matters**: a mask form may contain a shading
9597/// without an explicit `/BBox`, in which case `precompute_full_bboxes`
9598/// returns a sentinel "infinite" bbox (`shading_full_bbox` falls back to
9599/// `0..1e9`) so band rendering doesn't cull it. If `compute_paint_bounds`
9600/// just unioned that, the result would exceed the mask raster size cap
9601/// and `rasterize_mask` would return `None`, making the entire SoftMasked
9602/// element invisible. Tracking the active clip stack lets us bound those
9603/// shadings to their effective paint area.
9604///
9605/// Returns `None` when the list contains no paintable elements or when
9606/// no element survives clip culling.
9607fn compute_paint_bounds(list: &DisplayList, _dpi: f64) -> Option<BBox2D> {
9608    // Active clip stack: each entry is the intersection so far. The
9609    // current clip is `clip_stack.last()`; an empty stack means
9610    // "unbounded" (no clip established yet, or just after InitClip).
9611    let mut clip_stack: Vec<BBox2D> = Vec::new();
9612    let mut union: Option<BBox2D> = None;
9613
9614    let push_paint = |union: &mut Option<BBox2D>, clip_stack: &[BBox2D], bbox: BBox2D| {
9615        // Intersect against the active clip if any. If the clip is
9616        // tighter than the bbox, the visible region is the intersection;
9617        // if the bbox is fully clipped away, skip it.
9618        let visible = match clip_stack.last() {
9619            Some(clip) => match intersect_bbox(clip, &bbox) {
9620                Some(b) => b,
9621                None => return,
9622            },
9623            None => bbox,
9624        };
9625        *union = Some(match union.take() {
9626            None => visible,
9627            Some(u) => BBox2D {
9628                x_min: u.x_min.min(visible.x_min),
9629                y_min: u.y_min.min(visible.y_min),
9630                x_max: u.x_max.max(visible.x_max),
9631                y_max: u.y_max.max(visible.y_max),
9632            },
9633        });
9634    };
9635
9636    for elem in list.elements() {
9637        match elem {
9638            DisplayElement::Clip { path, params } => {
9639                if let Some(cb) = clip_path_bbox(path, params) {
9640                    let new_top = match clip_stack.last() {
9641                        Some(prev) => match intersect_bbox(prev, &cb) {
9642                            Some(b) => b,
9643                            // Clip cleared the visible region; push an
9644                            // empty bbox so subsequent paints are
9645                            // clipped away.
9646                            None => BBox2D {
9647                                x_min: 0.0,
9648                                y_min: 0.0,
9649                                x_max: 0.0,
9650                                y_max: 0.0,
9651                            },
9652                        },
9653                        None => cb,
9654                    };
9655                    clip_stack.push(new_top);
9656                }
9657            }
9658            DisplayElement::InitClip | DisplayElement::ErasePage => {
9659                clip_stack.clear();
9660            }
9661            DisplayElement::Fill { path, .. } => {
9662                if let Some(b) = path_full_bbox(path) {
9663                    push_paint(&mut union, &clip_stack, b);
9664                }
9665            }
9666            DisplayElement::Stroke { path, params } => {
9667                if let Some(mut b) = path_full_bbox(path) {
9668                    let expand = params.line_width * params.miter_limit * 0.5;
9669                    b.x_min -= expand;
9670                    b.x_max += expand;
9671                    b.y_min -= expand;
9672                    b.y_max += expand;
9673                    push_paint(&mut union, &clip_stack, b);
9674                }
9675            }
9676            DisplayElement::Image { params, .. } => {
9677                if let Some(b) = image_full_bbox(params) {
9678                    push_paint(&mut union, &clip_stack, b);
9679                }
9680            }
9681            DisplayElement::AxialShading { params } => {
9682                let b = match &params.bbox {
9683                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9684                    None => clip_stack.last().copied(),
9685                };
9686                if let Some(b) = b {
9687                    push_paint(&mut union, &clip_stack, b);
9688                }
9689            }
9690            DisplayElement::RadialShading { params } => {
9691                let b = match &params.bbox {
9692                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9693                    None => clip_stack.last().copied(),
9694                };
9695                if let Some(b) = b {
9696                    push_paint(&mut union, &clip_stack, b);
9697                }
9698            }
9699            DisplayElement::MeshShading { params } => {
9700                let b = match &params.bbox {
9701                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9702                    None => clip_stack.last().copied(),
9703                };
9704                if let Some(b) = b {
9705                    push_paint(&mut union, &clip_stack, b);
9706                }
9707            }
9708            DisplayElement::PatchShading { params } => {
9709                let b = match &params.bbox {
9710                    Some(_) => shading_full_bbox(&params.bbox, &params.ctm),
9711                    None => clip_stack.last().copied(),
9712                };
9713                if let Some(b) = b {
9714                    push_paint(&mut union, &clip_stack, b);
9715                }
9716            }
9717            DisplayElement::PatternFill { params } => {
9718                if let Some(b) = pattern_fill_full_bbox(params) {
9719                    push_paint(&mut union, &clip_stack, b);
9720                }
9721            }
9722            DisplayElement::Group { params, .. } => {
9723                push_paint(
9724                    &mut union,
9725                    &clip_stack,
9726                    BBox2D {
9727                        x_min: params.bbox[0],
9728                        y_min: params.bbox[1],
9729                        x_max: params.bbox[2],
9730                        y_max: params.bbox[3],
9731                    },
9732                );
9733            }
9734            DisplayElement::SoftMasked { params, .. } => {
9735                push_paint(
9736                    &mut union,
9737                    &clip_stack,
9738                    BBox2D {
9739                        x_min: params.bbox[0],
9740                        y_min: params.bbox[1],
9741                        x_max: params.bbox[2],
9742                        y_max: params.bbox[3],
9743                    },
9744                );
9745            }
9746            DisplayElement::Text { .. } => {} // PDF-only, ignored by rasterizer
9747            DisplayElement::OcgGroup { .. } => {
9748                // OCG groups have no inherent bbox; their children's bounds
9749                // are unknown without recursion. Conservative: skip here —
9750                // if the mask form contains OCG layers, the parent bbox cap
9751                // provides a sufficient upper bound.
9752            }
9753            _ => {}
9754        }
9755    }
9756    union
9757}
9758
9759/// Compute full 2D bounds from path segments.
9760/// Compute device-space 2D bounds for a Fill element, accounting for CTM.
9761/// Paths may be stored in device space (identity CTM) or user space
9762/// (non-identity CTM, e.g. synthesized annotation appearances).
9763fn fill_device_full_bbox(path: &PsPath, ctm: &Matrix) -> Option<BBox2D> {
9764    let bbox = path_full_bbox(path)?;
9765    let is_identity = ctm.a == 1.0
9766        && ctm.b == 0.0
9767        && ctm.c == 0.0
9768        && ctm.d == 1.0
9769        && ctm.tx == 0.0
9770        && ctm.ty == 0.0;
9771    if is_identity {
9772        return Some(bbox);
9773    }
9774    let corners = [
9775        (bbox.x_min, bbox.y_min),
9776        (bbox.x_max, bbox.y_min),
9777        (bbox.x_min, bbox.y_max),
9778        (bbox.x_max, bbox.y_max),
9779    ];
9780    let mut x_min = f64::INFINITY;
9781    let mut x_max = f64::NEG_INFINITY;
9782    let mut y_min = f64::INFINITY;
9783    let mut y_max = f64::NEG_INFINITY;
9784    for (x, y) in &corners {
9785        let dx = ctm.a * x + ctm.c * y + ctm.tx;
9786        let dy = ctm.b * x + ctm.d * y + ctm.ty;
9787        x_min = x_min.min(dx);
9788        x_max = x_max.max(dx);
9789        y_min = y_min.min(dy);
9790        y_max = y_max.max(dy);
9791    }
9792    Some(BBox2D {
9793        x_min,
9794        y_min,
9795        x_max,
9796        y_max,
9797    })
9798}
9799
9800fn path_full_bbox(path: &PsPath) -> Option<BBox2D> {
9801    let mut x_min = f64::INFINITY;
9802    let mut x_max = f64::NEG_INFINITY;
9803    let mut y_min = f64::INFINITY;
9804    let mut y_max = f64::NEG_INFINITY;
9805    for seg in &path.segments {
9806        match seg {
9807            PathSegment::MoveTo(x, y) | PathSegment::LineTo(x, y) => {
9808                x_min = x_min.min(*x);
9809                x_max = x_max.max(*x);
9810                y_min = y_min.min(*y);
9811                y_max = y_max.max(*y);
9812            }
9813            PathSegment::CurveTo {
9814                x1,
9815                y1,
9816                x2,
9817                y2,
9818                x3,
9819                y3,
9820            } => {
9821                x_min = x_min.min(*x1).min(*x2).min(*x3);
9822                x_max = x_max.max(*x1).max(*x2).max(*x3);
9823                y_min = y_min.min(*y1).min(*y2).min(*y3);
9824                y_max = y_max.max(*y1).max(*y2).max(*y3);
9825            }
9826            PathSegment::ClosePath => {}
9827        }
9828    }
9829    if x_min <= x_max {
9830        Some(BBox2D {
9831            x_min,
9832            y_min,
9833            x_max,
9834            y_max,
9835        })
9836    } else {
9837        None
9838    }
9839}
9840
9841/// Compute full 2D bounds for a PatternFill element.
9842/// For stroke patterns, the path is in user space and must be transformed
9843/// through the CTM to get device-space bounds, then expanded by half
9844/// the stroke width.
9845fn pattern_fill_full_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<BBox2D> {
9846    if let Some(ref sp) = params.stroke_params {
9847        let bbox = path_full_bbox(&params.path)?;
9848        let ctm = &sp.ctm;
9849        let corners = [
9850            ctm.transform_point(bbox.x_min, bbox.y_min),
9851            ctm.transform_point(bbox.x_max, bbox.y_min),
9852            ctm.transform_point(bbox.x_min, bbox.y_max),
9853            ctm.transform_point(bbox.x_max, bbox.y_max),
9854        ];
9855        let mut dev_bbox = BBox2D {
9856            x_min: f64::INFINITY,
9857            y_min: f64::INFINITY,
9858            x_max: f64::NEG_INFINITY,
9859            y_max: f64::NEG_INFINITY,
9860        };
9861        for (x, y) in &corners {
9862            dev_bbox.x_min = dev_bbox.x_min.min(*x);
9863            dev_bbox.y_min = dev_bbox.y_min.min(*y);
9864            dev_bbox.x_max = dev_bbox.x_max.max(*x);
9865            dev_bbox.y_max = dev_bbox.y_max.max(*y);
9866        }
9867        let half_w = sp.line_width
9868            * 0.5
9869            * (ctm.a * ctm.a + ctm.b * ctm.b)
9870                .sqrt()
9871                .max((ctm.c * ctm.c + ctm.d * ctm.d).sqrt());
9872        dev_bbox.x_min -= half_w;
9873        dev_bbox.y_min -= half_w;
9874        dev_bbox.x_max += half_w;
9875        dev_bbox.y_max += half_w;
9876        Some(dev_bbox)
9877    } else {
9878        path_full_bbox(&params.path)
9879    }
9880}
9881
9882/// Compute Y-axis bounds for a PatternFill element (banded rendering).
9883fn pattern_fill_y_bbox(params: &stet_graphics::device::PatternFillParams) -> Option<YBBox> {
9884    let bbox = pattern_fill_full_bbox(params)?;
9885    Some(YBBox {
9886        y_min: bbox.y_min,
9887        y_max: bbox.y_max,
9888    })
9889}
9890
9891/// Compute full 2D bounds for an image from its transform.
9892fn image_full_bbox(params: &ImageParams) -> Option<BBox2D> {
9893    let m = &params.ctm;
9894    let im = &params.image_matrix;
9895    let im_inv = im.invert()?;
9896    let combined = m.concat(&im_inv);
9897    // Image occupies [0, width] × [0, height] in image space
9898    let w = params.width as f64;
9899    let h = params.height as f64;
9900    let corners = [
9901        combined.transform_point(0.0, 0.0),
9902        combined.transform_point(w, 0.0),
9903        combined.transform_point(0.0, h),
9904        combined.transform_point(w, h),
9905    ];
9906    let mut x_min = f64::INFINITY;
9907    let mut x_max = f64::NEG_INFINITY;
9908    let mut y_min = f64::INFINITY;
9909    let mut y_max = f64::NEG_INFINITY;
9910    for (x, y) in &corners {
9911        x_min = x_min.min(*x);
9912        x_max = x_max.max(*x);
9913        y_min = y_min.min(*y);
9914        y_max = y_max.max(*y);
9915    }
9916    Some(BBox2D {
9917        x_min,
9918        y_min,
9919        x_max,
9920        y_max,
9921    })
9922}
9923
9924/// Compute full 2D bounds for a shading element from its BBox.
9925fn shading_full_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<BBox2D> {
9926    if let Some(bbox) = bbox {
9927        let corners = [
9928            ctm.transform_point(bbox[0], bbox[1]),
9929            ctm.transform_point(bbox[2], bbox[1]),
9930            ctm.transform_point(bbox[0], bbox[3]),
9931            ctm.transform_point(bbox[2], bbox[3]),
9932        ];
9933        let mut x_min = f64::INFINITY;
9934        let mut x_max = f64::NEG_INFINITY;
9935        let mut y_min = f64::INFINITY;
9936        let mut y_max = f64::NEG_INFINITY;
9937        for (x, y) in &corners {
9938            x_min = x_min.min(*x);
9939            x_max = x_max.max(*x);
9940            y_min = y_min.min(*y);
9941            y_max = y_max.max(*y);
9942        }
9943        Some(BBox2D {
9944            x_min,
9945            y_min,
9946            x_max,
9947            y_max,
9948        })
9949    } else {
9950        Some(BBox2D {
9951            x_min: 0.0,
9952            y_min: 0.0,
9953            x_max: 1e9,
9954            y_max: 1e9,
9955        })
9956    }
9957}
9958
9959/// Build 2D clip epochs for viewport culling.
9960fn build_viewport_epochs(list: &DisplayList, bboxes: &[Option<BBox2D>]) -> Vec<ViewportEpoch> {
9961    let elements = list.elements();
9962    let mut epochs = Vec::new();
9963    let mut epoch_start = 0;
9964    let mut x_min = f64::INFINITY;
9965    let mut x_max = f64::NEG_INFINITY;
9966    let mut y_min = f64::INFINITY;
9967    let mut y_max = f64::NEG_INFINITY;
9968    let mut has_erase = false;
9969
9970    for (i, element) in elements.iter().enumerate() {
9971        if matches!(element, DisplayElement::InitClip) && i > epoch_start {
9972            epochs.push(ViewportEpoch {
9973                start_idx: epoch_start,
9974                end_idx: i,
9975                paint_bbox: if x_min <= x_max {
9976                    Some(BBox2D {
9977                        x_min,
9978                        y_min,
9979                        x_max,
9980                        y_max,
9981                    })
9982                } else {
9983                    None
9984                },
9985                has_erase_page: has_erase,
9986            });
9987            epoch_start = i;
9988            x_min = f64::INFINITY;
9989            x_max = f64::NEG_INFINITY;
9990            y_min = f64::INFINITY;
9991            y_max = f64::NEG_INFINITY;
9992            has_erase = false;
9993        }
9994        if matches!(element, DisplayElement::ErasePage) {
9995            has_erase = true;
9996        }
9997        if let Some(ref bbox) = bboxes[i] {
9998            x_min = x_min.min(bbox.x_min);
9999            x_max = x_max.max(bbox.x_max);
10000            y_min = y_min.min(bbox.y_min);
10001            y_max = y_max.max(bbox.y_max);
10002        }
10003    }
10004    if epoch_start < elements.len() {
10005        epochs.push(ViewportEpoch {
10006            start_idx: epoch_start,
10007            end_idx: elements.len(),
10008            paint_bbox: if x_min <= x_max {
10009                Some(BBox2D {
10010                    x_min,
10011                    y_min,
10012                    x_max,
10013                    y_max,
10014                })
10015            } else {
10016                None
10017            },
10018            has_erase_page: has_erase,
10019        });
10020    }
10021    epochs
10022}
10023
10024/// Clip epoch with full 2D bounding box for viewport culling.
10025struct ViewportEpoch {
10026    start_idx: usize,
10027    end_idx: usize,
10028    paint_bbox: Option<BBox2D>,
10029    has_erase_page: bool,
10030}
10031
10032/// Pre-computed metadata for fast viewport rendering.
10033///
10034/// Compute once per display list via [`prepare_display_list()`],
10035/// reuse across all [`render_region_prepared()`] calls. This avoids
10036/// three expensive traversals (bboxes, epochs, clip_seen) on every pan.
10037pub struct PreparedDisplayList {
10038    bboxes: Vec<Option<BBox2D>>,
10039    epochs: Vec<ViewportEpoch>,
10040    clip_seen: HashSet<u64>,
10041}
10042
10043/// Precompute display list metadata for fast viewport rendering.
10044///
10045/// Uses a conservative DPI (72.0) for hairline expansion in bounding boxes,
10046/// producing safe overestimates that work at any zoom level without recomputation.
10047pub fn prepare_display_list(list: &DisplayList) -> PreparedDisplayList {
10048    let bboxes = precompute_full_bboxes(list, 72.0);
10049    let epochs = build_viewport_epochs(list, &bboxes);
10050    let clip_seen = precompute_clip_seen(list);
10051    PreparedDisplayList {
10052        bboxes,
10053        epochs,
10054        clip_seen,
10055    }
10056}
10057
10058/// Pre-converted and prescaled image for banded rendering.
10059///
10060/// Built once per page before the band loop so that expensive RGBA conversion
10061/// and box-filter prescaling run once instead of once-per-band.
10062struct PreprocessedImage {
10063    /// RGBA pixel data (prescaled if applicable).
10064    data: Vec<u8>,
10065    /// Dimensions after prescaling.
10066    width: u32,
10067    height: u32,
10068    /// Scale/rotation part of the adjusted transform.
10069    /// Per-band rendering reconstructs the full transform by combining these
10070    /// with the band-specific translation (tx, ty).
10071    adj_sx: f32,
10072    adj_ky: f32,
10073    adj_kx: f32,
10074    adj_sy: f32,
10075    /// Filter quality for draw_pixmap.
10076    quality: stet_tiny_skia::FilterQuality,
10077}
10078
10079/// Pre-converted RGBA image data cache, indexed by display list element index.
10080///
10081/// Built once per page after display list capture. Reused across all viewport
10082/// renders so that ICC color conversion (especially CMYK→sRGB) is not repeated
10083/// on every pan/zoom.
10084pub struct ImageCache {
10085    /// RGBA data per element index. `None` for non-image elements.
10086    entries: Vec<Option<Vec<u8>>>,
10087}
10088
10089impl ImageCache {
10090    /// Build cache by pre-converting all images in the display list.
10091    pub fn build(list: &DisplayList, icc: Option<&IccCache>) -> Self {
10092        let entries = list
10093            .elements()
10094            .iter()
10095            .map(|elem| {
10096                if let DisplayElement::Image {
10097                    sample_data,
10098                    params,
10099                } = elem
10100                {
10101                    if params.width == 0 || params.height == 0 {
10102                        return None;
10103                    }
10104                    let mut rgba = samples_to_rgba(sample_data, params, icc, false);
10105                    if params.mask_color.is_some() {
10106                        apply_mask_color_rgba(&mut rgba, sample_data, params);
10107                    }
10108                    Some(rgba)
10109                } else {
10110                    None
10111                }
10112            })
10113            .collect();
10114        Self { entries }
10115    }
10116
10117    /// Get pre-converted RGBA for the element at the given index.
10118    pub fn get(&self, index: usize) -> Option<&[u8]> {
10119        self.entries.get(index).and_then(|e| e.as_deref())
10120    }
10121}
10122
10123/// Build preprocessed image cache for banded rendering.
10124///
10125/// For each Image element, converts to RGBA and prescales once.
10126/// Banded rendering then only needs `draw_pixmap` per band.
10127fn preprocess_images_for_bands(
10128    list: &DisplayList,
10129    icc: Option<&IccCache>,
10130) -> Vec<Option<PreprocessedImage>> {
10131    list.elements()
10132        .iter()
10133        .map(|elem| {
10134            let DisplayElement::Image {
10135                sample_data,
10136                params,
10137            } = elem
10138            else {
10139                return None;
10140            };
10141            let iw = params.width;
10142            let ih = params.height;
10143            if iw == 0 || ih == 0 {
10144                return None;
10145            }
10146            // Skip overprint images — they use a separate rendering path
10147            if params.overprint {
10148                return None;
10149            }
10150
10151            // Convert to RGBA
10152            let mut rgba = samples_to_rgba(sample_data, params, icc, false);
10153            if params.mask_color.is_some() {
10154                apply_mask_color_rgba(&mut rgba, sample_data, params);
10155            }
10156
10157            // Compute the device-space transform (vp_y=0, scale=1.0)
10158            let image_inv = params.image_matrix.invert()?;
10159            let combined = params.ctm.concat(&image_inv);
10160            let base_transform = enforce_min_image_size(to_transform(&combined), iw, ih);
10161
10162            // Prescale
10163            let (data, width, height, adj_t) =
10164                match prescale_image(&rgba, iw, ih, base_transform, params.interpolate) {
10165                    Some((d, w, h, t)) => {
10166                        drop(rgba); // free the full-size RGBA
10167                        (d, w, h, t)
10168                    }
10169                    None => (rgba, iw, ih, base_transform),
10170                };
10171
10172            let quality = image_filter_quality(adj_t, params.interpolate);
10173
10174            Some(PreprocessedImage {
10175                data,
10176                width,
10177                height,
10178                adj_sx: adj_t.sx,
10179                adj_ky: adj_t.ky,
10180                adj_kx: adj_t.kx,
10181                adj_sy: adj_t.sy,
10182                quality,
10183            })
10184        })
10185        .collect()
10186}
10187
10188/// Render a rectangular viewport region using precomputed metadata.
10189///
10190/// Like [`render_region()`] but skips the three precomputation passes,
10191/// using the [`PreparedDisplayList`] instead. Significantly faster for
10192/// repeated renders of the same display list (e.g., panning at a fixed zoom).
10193#[allow(clippy::too_many_arguments)]
10194pub fn render_region_prepared(
10195    list: &DisplayList,
10196    prepared: &PreparedDisplayList,
10197    vp_x: f64,
10198    vp_y: f64,
10199    vp_w: f64,
10200    vp_h: f64,
10201    pixel_w: u32,
10202    pixel_h: u32,
10203    dpi: f64,
10204    icc: Option<&IccCache>,
10205    image_cache: Option<&ImageCache>,
10206    no_aa: bool,
10207) -> Vec<u8> {
10208    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
10209        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10210    }
10211
10212    let layer_set = LayerSet::new();
10213    let scale_x = pixel_w as f64 / vp_w;
10214    let scale_y = pixel_h as f64 / vp_h;
10215    let effective_dpi = dpi * scale_x;
10216
10217    // Allocate a pixmap with the same OVERLAP padding as the banded page
10218    // renderer. This is essential for matching the banded baseline: the page
10219    // pipeline always allocates `band_h + 2*BAND_OVERLAP` rows, even for a
10220    // single-band render. tiny-skia's `Mask::fill_path` chooses between
10221    // edge-clipped and unclipped rasterization based on whether the path
10222    // bounds fit within the mask, and the two paths produce subtly different
10223    // winding counts at some pixels. Without the OVERLAP padding here, the
10224    // viewport pipeline rasterizes clip paths into a tighter mask than the
10225    // banded pipeline does, producing 39 (and other counts) of edge-pixel
10226    // divergences on samples like 1915_1.pdf.
10227    const OVERLAP: u32 = 6;
10228    let render_h = pixel_h + 2 * OVERLAP;
10229    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
10230    // Start transparent — white background composited after content rendering
10231    pixmap.fill(Color::TRANSPARENT);
10232
10233    let cmyk_buf = if has_overprint_elements(list)
10234        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10235        || has_cmyk_group(list)
10236    {
10237        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10238    } else {
10239        None
10240    };
10241
10242    let mut state = BandState {
10243        clip_region: None,
10244        spare_mask: None,
10245        clip_mask_cache: HashMap::new(),
10246        clip_mask_seen: prepared.clip_seen.clone(),
10247        mask_pool: Vec::new(),
10248        cmyk_buffer: cmyk_buf,
10249        op_bg_snapshot: None,
10250        op_touched: None,
10251        spot_mask: None,
10252    };
10253
10254    let elements = list.elements();
10255    let vp_x_f = vp_x as f32;
10256    let vp_y_f = vp_y as f32;
10257    let sx = scale_x as f32;
10258    let sy = scale_y as f32;
10259    let vp_x_max = vp_x + vp_w;
10260    let vp_y_max = vp_y + vp_h;
10261
10262    for epoch in &prepared.epochs {
10263        if !epoch.has_erase_page {
10264            match epoch.paint_bbox {
10265                Some(ref pb)
10266                    if pb.x_max <= vp_x
10267                        || pb.x_min >= vp_x_max
10268                        || pb.y_max <= vp_y
10269                        || pb.y_min >= vp_y_max =>
10270                {
10271                    continue;
10272                }
10273                None => continue,
10274                _ => {}
10275            }
10276        }
10277
10278        #[allow(clippy::needless_range_loop)]
10279        for i in epoch.start_idx..epoch.end_idx {
10280            // OcgGroups with Clip/InitClip must always be processed — see
10281            // the banded renderer for the rationale.
10282            let force_process = matches!(
10283                &elements[i],
10284                DisplayElement::OcgGroup { elements: inner, .. }
10285                    if contains_clip_op(inner)
10286            );
10287            if !force_process
10288                && let Some(ref bbox) = prepared.bboxes[i]
10289                && (bbox.x_max <= vp_x
10290                    || bbox.x_min >= vp_x_max
10291                    || bbox.y_max <= vp_y
10292                    || bbox.y_min >= vp_y_max)
10293            {
10294                continue;
10295            }
10296            let ctx = RenderContext {
10297                vp_x: vp_x_f,
10298                vp_y: vp_y_f,
10299                scale_x: sx,
10300                scale_y: sy,
10301                out_w: pixel_w,
10302                out_h: render_h,
10303                effective_dpi,
10304                icc,
10305                image_cache,
10306                preprocessed: None,
10307                elem_idx: i,
10308                no_aa,
10309                opm_zero_transparent: false,
10310                knockout_painter_pass: KnockoutPainterPass::None,
10311                parent_group_isolated: false,
10312                alpha_extraction_pass: false,
10313                layer_set: &layer_set,
10314            };
10315            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10316        }
10317    }
10318
10319    // Composite onto white background
10320    composite_onto_white(pixmap.data_mut());
10321    // Extract only the requested pixel_h rows (skip the OVERLAP padding at the bottom).
10322    let row_bytes = pixel_w as usize * 4;
10323    let end = pixel_h as usize * row_bytes;
10324    pixmap.data()[..end].to_vec()
10325}
10326
10327/// Compute the number of bands and band height for viewport banding.
10328///
10329/// Returns `(num_bands, band_height)` using the same L2-cache-budget logic
10330/// as the full-page banded renderer.
10331pub fn viewport_band_count(pixel_w: u32, pixel_h: u32) -> (u32, u32) {
10332    let band_h = select_band_height(pixel_w, pixel_h);
10333    let num_bands = if band_h >= pixel_h {
10334        1
10335    } else {
10336        pixel_h.div_ceil(band_h)
10337    };
10338    (num_bands, band_h)
10339}
10340
10341/// Render a single horizontal band of a viewport region.
10342///
10343/// This is the per-band counterpart to [`render_region_prepared()`]. The caller
10344/// loops over `band_idx` in `0..num_bands`, collecting RGBA strips that tile
10345/// vertically to form the full viewport image.
10346///
10347/// Returns RGBA pixel data for `actual_h` rows (may be less than `band_h` for
10348/// the last band).
10349#[allow(clippy::too_many_arguments)]
10350pub fn render_region_single_band(
10351    list: &DisplayList,
10352    prepared: &PreparedDisplayList,
10353    vp_x: f64,
10354    vp_y: f64,
10355    vp_w: f64,
10356    vp_h: f64,
10357    pixel_w: u32,
10358    pixel_h: u32,
10359    band_idx: u32,
10360    band_h: u32,
10361    num_bands: u32,
10362    dpi: f64,
10363    icc: Option<&IccCache>,
10364    image_cache: Option<&ImageCache>,
10365    no_aa: bool,
10366) -> Vec<u8> {
10367    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
10368        let actual_h = if band_idx < num_bands - 1 {
10369            band_h
10370        } else {
10371            pixel_h - band_idx * band_h
10372        };
10373        return vec![0xFF; pixel_w as usize * actual_h as usize * 4];
10374    }
10375
10376    let layer_set = LayerSet::new();
10377    let scale_x = pixel_w as f64 / vp_w;
10378    let scale_y = pixel_h as f64 / vp_h;
10379    let effective_dpi = dpi * scale_x;
10380
10381    // Output Y range for this band
10382    let out_y_start = band_idx * band_h;
10383    let actual_h = if band_idx < num_bands - 1 {
10384        band_h
10385    } else {
10386        pixel_h - out_y_start
10387    };
10388
10389    // Add overlap above/below for anti-aliasing at seams.
10390    //
10391    // The pixmap is always `band_h + 2*OVERLAP` rows — matching the page
10392    // renderer (`render_banded_to_sink`) — even at the bottom band, where
10393    // content rendering stops at `pixel_h`. Without this, the bottom band's
10394    // pixmap is shorter than the page renderer's, and tiny-skia's
10395    // `Mask::fill_path` rasterizes clip paths into a tighter mask, producing
10396    // edge-pixel divergences from the banded baseline (39 pixels on
10397    // 1915_1.pdf, etc.). The extra rows below `pixel_h` are unused for output
10398    // but ensure mask-size-independent rasterization.
10399    const OVERLAP: u32 = 6;
10400    let render_y_start = out_y_start.saturating_sub(OVERLAP);
10401    let render_y_end = (out_y_start + actual_h + OVERLAP).min(pixel_h);
10402    let render_h = band_h + 2 * OVERLAP;
10403    let overlap_top = out_y_start - render_y_start;
10404
10405    // Source-space Y range for culling
10406    let src_y_min = vp_y + render_y_start as f64 / scale_y;
10407    let src_y_max = vp_y + render_y_end as f64 / scale_y;
10408
10409    // Adjusted viewport offset for this band's pixmap
10410    let band_vp_y = vp_y + render_y_start as f64 / scale_y;
10411
10412    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create band pixmap");
10413    pixmap.fill(Color::TRANSPARENT);
10414
10415    let cmyk_buf = if has_overprint_elements(list)
10416        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
10417        || has_cmyk_group(list)
10418    {
10419        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
10420    } else {
10421        None
10422    };
10423
10424    let mut state = BandState {
10425        clip_region: None,
10426        spare_mask: None,
10427        clip_mask_cache: HashMap::new(),
10428        clip_mask_seen: prepared.clip_seen.clone(),
10429        mask_pool: Vec::new(),
10430        cmyk_buffer: cmyk_buf,
10431        op_bg_snapshot: None,
10432        op_touched: None,
10433        spot_mask: None,
10434    };
10435
10436    let elements = list.elements();
10437    let vp_x_f = vp_x as f32;
10438    let band_vp_y_f = band_vp_y as f32;
10439    let sx = scale_x as f32;
10440    let sy = scale_y as f32;
10441    let vp_x_max = vp_x + vp_w;
10442
10443    for epoch in &prepared.epochs {
10444        if !epoch.has_erase_page {
10445            match epoch.paint_bbox {
10446                Some(ref pb)
10447                    if pb.x_max <= vp_x
10448                        || pb.x_min >= vp_x_max
10449                        || pb.y_max <= src_y_min
10450                        || pb.y_min >= src_y_max =>
10451                {
10452                    continue;
10453                }
10454                None => continue,
10455                _ => {}
10456            }
10457        }
10458
10459        #[allow(clippy::needless_range_loop)]
10460        for i in epoch.start_idx..epoch.end_idx {
10461            // OcgGroups containing Clip/InitClip must always be processed
10462            // regardless of this band's bbox — see the full-page banded
10463            // renderer for the rationale.
10464            let force_process = matches!(
10465                &elements[i],
10466                DisplayElement::OcgGroup { elements: inner, .. }
10467                    if contains_clip_op(inner)
10468            );
10469            if !force_process
10470                && let Some(ref bbox) = prepared.bboxes[i]
10471                && (bbox.x_max <= vp_x
10472                    || bbox.x_min >= vp_x_max
10473                    || bbox.y_max <= src_y_min
10474                    || bbox.y_min >= src_y_max)
10475            {
10476                continue;
10477            }
10478            let ctx = RenderContext {
10479                vp_x: vp_x_f,
10480                vp_y: band_vp_y_f,
10481                scale_x: sx,
10482                scale_y: sy,
10483                out_w: pixel_w,
10484                out_h: render_h,
10485                effective_dpi,
10486                icc,
10487                image_cache,
10488                preprocessed: None,
10489                elem_idx: i,
10490                no_aa,
10491                opm_zero_transparent: false,
10492                knockout_painter_pass: KnockoutPainterPass::None,
10493                parent_group_isolated: false,
10494                alpha_extraction_pass: false,
10495                layer_set: &layer_set,
10496            };
10497            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
10498        }
10499    }
10500
10501    // Composite onto white background
10502    composite_onto_white(pixmap.data_mut());
10503
10504    // Extract only the non-overlap rows
10505    let row_bytes = pixel_w as usize * 4;
10506    let start = overlap_top as usize * row_bytes;
10507    let end = start + actual_h as usize * row_bytes;
10508    pixmap.data()[start..end].to_vec()
10509}
10510
10511/// Render a viewport region using parallel banded rendering via rayon.
10512///
10513/// This is the WASM counterpart to the parallel path in `render_banded_to_sink`.
10514/// All bands are rendered in parallel using `par_iter`, then assembled into the
10515/// final RGBA buffer in order.
10516///
10517/// Requires the `parallel` feature (rayon). Falls back to sequential rendering
10518/// if `parallel` is not enabled.
10519#[allow(clippy::too_many_arguments)]
10520pub fn render_region_prepared_parallel(
10521    list: &DisplayList,
10522    prepared: &PreparedDisplayList,
10523    vp_x: f64,
10524    vp_y: f64,
10525    vp_w: f64,
10526    vp_h: f64,
10527    pixel_w: u32,
10528    pixel_h: u32,
10529    dpi: f64,
10530    icc: Option<&IccCache>,
10531    image_cache: Option<&ImageCache>,
10532    no_aa: bool,
10533) -> Vec<u8> {
10534    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10535
10536    if num_bands <= 1 {
10537        // Single band — no parallelism needed
10538        return render_region_prepared(
10539            list,
10540            prepared,
10541            vp_x,
10542            vp_y,
10543            vp_w,
10544            vp_h,
10545            pixel_w,
10546            pixel_h,
10547            dpi,
10548            icc,
10549            image_cache,
10550            no_aa,
10551        );
10552    }
10553
10554    let render_band = |band_idx: u32| -> Vec<u8> {
10555        render_region_single_band(
10556            list,
10557            prepared,
10558            vp_x,
10559            vp_y,
10560            vp_w,
10561            vp_h,
10562            pixel_w,
10563            pixel_h,
10564            band_idx,
10565            band_h,
10566            num_bands,
10567            dpi,
10568            icc,
10569            image_cache,
10570            no_aa,
10571        )
10572    };
10573
10574    let row_bytes = pixel_w as usize * 4;
10575    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10576
10577    #[cfg(feature = "parallel")]
10578    {
10579        let chunk_size = rayon::current_num_threads().max(1);
10580
10581        for chunk_start in (0..num_bands).step_by(chunk_size) {
10582            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10583
10584            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10585                .into_par_iter()
10586                .map(&render_band)
10587                .collect();
10588
10589            for (i, band_data) in rendered.iter().enumerate() {
10590                let band_idx = chunk_start + i as u32;
10591                let y_start = (band_idx * band_h) as usize;
10592                let dest_start = y_start * row_bytes;
10593                let len = band_data.len();
10594                result[dest_start..dest_start + len].copy_from_slice(band_data);
10595            }
10596        }
10597    }
10598    #[cfg(not(feature = "parallel"))]
10599    {
10600        for band_idx in 0..num_bands {
10601            let band_data = render_band(band_idx);
10602            let y_start = (band_idx * band_h) as usize;
10603            let dest_start = y_start * row_bytes;
10604            let len = band_data.len();
10605            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10606        }
10607    }
10608
10609    result
10610}
10611
10612/// Like [`render_region_prepared_parallel()`] but with an atomic progress counter.
10613///
10614/// The counter is incremented after each chunk of bands completes. The total
10615/// number of bands is returned alongside the counter via [`viewport_band_count()`].
10616#[allow(clippy::too_many_arguments)]
10617pub fn render_region_prepared_parallel_with_progress(
10618    list: &DisplayList,
10619    prepared: &PreparedDisplayList,
10620    vp_x: f64,
10621    vp_y: f64,
10622    vp_w: f64,
10623    vp_h: f64,
10624    pixel_w: u32,
10625    pixel_h: u32,
10626    dpi: f64,
10627    icc: Option<&IccCache>,
10628    image_cache: Option<&ImageCache>,
10629    no_aa: bool,
10630    progress: &std::sync::atomic::AtomicU32,
10631) -> Vec<u8> {
10632    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10633
10634    if num_bands <= 1 {
10635        let result = render_region_prepared(
10636            list,
10637            prepared,
10638            vp_x,
10639            vp_y,
10640            vp_w,
10641            vp_h,
10642            pixel_w,
10643            pixel_h,
10644            dpi,
10645            icc,
10646            image_cache,
10647            no_aa,
10648        );
10649        progress.store(1, std::sync::atomic::Ordering::Relaxed);
10650        return result;
10651    }
10652
10653    let render_band = |band_idx: u32| -> Vec<u8> {
10654        render_region_single_band(
10655            list,
10656            prepared,
10657            vp_x,
10658            vp_y,
10659            vp_w,
10660            vp_h,
10661            pixel_w,
10662            pixel_h,
10663            band_idx,
10664            band_h,
10665            num_bands,
10666            dpi,
10667            icc,
10668            image_cache,
10669            no_aa,
10670        )
10671    };
10672
10673    let row_bytes = pixel_w as usize * 4;
10674    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10675
10676    #[cfg(feature = "parallel")]
10677    {
10678        let chunk_size = rayon::current_num_threads().max(1);
10679
10680        for chunk_start in (0..num_bands).step_by(chunk_size) {
10681            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10682
10683            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10684                .into_par_iter()
10685                .map(&render_band)
10686                .collect();
10687
10688            for (i, band_data) in rendered.iter().enumerate() {
10689                let band_idx = chunk_start + i as u32;
10690                let y_start = (band_idx * band_h) as usize;
10691                let dest_start = y_start * row_bytes;
10692                let len = band_data.len();
10693                result[dest_start..dest_start + len].copy_from_slice(band_data);
10694            }
10695            progress.store(chunk_end, std::sync::atomic::Ordering::Relaxed);
10696        }
10697    }
10698    #[cfg(not(feature = "parallel"))]
10699    {
10700        for band_idx in 0..num_bands {
10701            let band_data = render_band(band_idx);
10702            let y_start = (band_idx * band_h) as usize;
10703            let dest_start = y_start * row_bytes;
10704            let len = band_data.len();
10705            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10706            progress.store(band_idx + 1, std::sync::atomic::Ordering::Relaxed);
10707        }
10708    }
10709
10710    result
10711}
10712
10713/// Like [`render_region_prepared_parallel()`] but checks a cancellation flag
10714/// between band chunks. Returns `None` if cancelled.
10715#[allow(clippy::too_many_arguments)]
10716pub fn render_region_prepared_parallel_cancellable(
10717    list: &DisplayList,
10718    prepared: &PreparedDisplayList,
10719    vp_x: f64,
10720    vp_y: f64,
10721    vp_w: f64,
10722    vp_h: f64,
10723    pixel_w: u32,
10724    pixel_h: u32,
10725    dpi: f64,
10726    icc: Option<&IccCache>,
10727    image_cache: Option<&ImageCache>,
10728    no_aa: bool,
10729    cancelled: &std::sync::atomic::AtomicBool,
10730) -> Option<Vec<u8>> {
10731    if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10732        return None;
10733    }
10734
10735    let (num_bands, band_h) = viewport_band_count(pixel_w, pixel_h);
10736
10737    if num_bands <= 1 {
10738        return Some(render_region_prepared(
10739            list,
10740            prepared,
10741            vp_x,
10742            vp_y,
10743            vp_w,
10744            vp_h,
10745            pixel_w,
10746            pixel_h,
10747            dpi,
10748            icc,
10749            image_cache,
10750            no_aa,
10751        ));
10752    }
10753
10754    let render_band = |band_idx: u32| -> Vec<u8> {
10755        render_region_single_band(
10756            list,
10757            prepared,
10758            vp_x,
10759            vp_y,
10760            vp_w,
10761            vp_h,
10762            pixel_w,
10763            pixel_h,
10764            band_idx,
10765            band_h,
10766            num_bands,
10767            dpi,
10768            icc,
10769            image_cache,
10770            no_aa,
10771        )
10772    };
10773
10774    let row_bytes = pixel_w as usize * 4;
10775    let mut result = vec![0u8; pixel_w as usize * pixel_h as usize * 4];
10776
10777    #[cfg(feature = "parallel")]
10778    {
10779        let chunk_size = rayon::current_num_threads().max(1);
10780
10781        for chunk_start in (0..num_bands).step_by(chunk_size) {
10782            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10783                return None;
10784            }
10785            let chunk_end = (chunk_start + chunk_size as u32).min(num_bands);
10786
10787            let rendered: Vec<Vec<u8>> = (chunk_start..chunk_end)
10788                .into_par_iter()
10789                .map(&render_band)
10790                .collect();
10791
10792            for (i, band_data) in rendered.iter().enumerate() {
10793                let band_idx = chunk_start + i as u32;
10794                let y_start = (band_idx * band_h) as usize;
10795                let dest_start = y_start * row_bytes;
10796                let len = band_data.len();
10797                result[dest_start..dest_start + len].copy_from_slice(band_data);
10798            }
10799        }
10800    }
10801    #[cfg(not(feature = "parallel"))]
10802    {
10803        for band_idx in 0..num_bands {
10804            if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
10805                return None;
10806            }
10807            let band_data = render_band(band_idx);
10808            let y_start = (band_idx * band_h) as usize;
10809            let dest_start = y_start * row_bytes;
10810            let len = band_data.len();
10811            result[dest_start..dest_start + len].copy_from_slice(&band_data);
10812        }
10813    }
10814
10815    Some(result)
10816}
10817
10818/// Render a full-page display list to RGBA pixels using the banded parallel renderer.
10819///
10820/// This is the preferred way to render a complete page — it uses rayon parallelism
10821/// (when the `parallel` feature is enabled) and L2-cache-friendly band sizing.
10822/// For sub-region / zoomed viewport rendering, use `render_region` instead.
10823///
10824/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`, composited onto white.
10825pub fn render_to_rgba(
10826    list: &DisplayList,
10827    pixel_w: u32,
10828    pixel_h: u32,
10829    dpi: f64,
10830    icc: Option<&IccCache>,
10831    no_aa: bool,
10832) -> Vec<u8> {
10833    render_to_rgba_with_layers(list, pixel_w, pixel_h, dpi, icc, no_aa, &LayerSet::new())
10834}
10835
10836/// Like [`render_to_rgba`] but consults the supplied [`LayerSet`] when
10837/// evaluating each `OcgGroup`'s visibility.
10838///
10839/// Pass `&LayerSet::new()` (or use [`render_to_rgba`]) to fall back to
10840/// each OCG's `default_visible` baked from the document's default
10841/// configuration.
10842#[allow(clippy::too_many_arguments)]
10843pub fn render_to_rgba_with_layers(
10844    list: &DisplayList,
10845    pixel_w: u32,
10846    pixel_h: u32,
10847    dpi: f64,
10848    icc: Option<&IccCache>,
10849    no_aa: bool,
10850    layer_set: &LayerSet,
10851) -> Vec<u8> {
10852    if pixel_w == 0 || pixel_h == 0 {
10853        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10854    }
10855
10856    let mut icc_cache = match icc {
10857        Some(c) => c.clone(),
10858        None => IccCache::new(),
10859    };
10860    // Register any ICC profiles from shadings in the display list
10861    // (the caller's cache only has image profiles)
10862    register_shading_icc_profiles(list, &mut icc_cache);
10863
10864    let mut sink = MemorySink {
10865        data: Vec::new(),
10866        width: 0,
10867    };
10868
10869    let band_h = select_band_height(pixel_w, pixel_h);
10870    if let Err(e) = render_banded_to_sink(
10871        pixel_w, pixel_h, band_h, dpi, list, &mut sink, &icc_cache, no_aa, layer_set,
10872    ) {
10873        eprintln!("render_to_rgba: banded render failed: {e}");
10874        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10875    }
10876
10877    sink.data
10878}
10879
10880/// Render a display list to RGBA using the **viewport** code path, with
10881/// the viewport set to the full page at 1:1 scale.
10882///
10883/// This exists to audit the viewport pipeline (`render_region_prepared_*`)
10884/// against the same baselines the banded PNG path uses. The two paths share
10885/// `render_element` and the same display list, so their output should be
10886/// pixel-identical on a correctly implemented display list. Differences
10887/// indicate a bug in one of the two culling / epoch / bbox pipelines.
10888///
10889/// The CLI exposes this as `--device viewport-png`; the visual test runner
10890/// uses it to double-cover each sample without maintaining a second
10891/// baseline.
10892pub fn render_to_rgba_viewport(
10893    list: &DisplayList,
10894    pixel_w: u32,
10895    pixel_h: u32,
10896    dpi: f64,
10897    icc: Option<&IccCache>,
10898    no_aa: bool,
10899) -> Vec<u8> {
10900    if pixel_w == 0 || pixel_h == 0 {
10901        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
10902    }
10903
10904    let mut icc_cache = match icc {
10905        Some(c) => c.clone(),
10906        None => IccCache::new(),
10907    };
10908    register_shading_icc_profiles(list, &mut icc_cache);
10909
10910    let prepared = prepare_display_list(list);
10911    render_region_prepared_parallel(
10912        list,
10913        &prepared,
10914        0.0,
10915        0.0,
10916        pixel_w as f64,
10917        pixel_h as f64,
10918        pixel_w,
10919        pixel_h,
10920        dpi,
10921        Some(&icc_cache),
10922        None,
10923        no_aa,
10924    )
10925}
10926
10927/// Debug helper: format both bbox precomputations side-by-side.
10928///
10929/// Returns one line per element describing its Y-only bbox (used by the
10930/// banded page pipeline) and its 2D bbox (used by the viewport pipeline).
10931/// Elements that disagree on presence, or whose 2D bbox's Y extent differs
10932/// from the Y-only bbox, are marked with `DIFF`.
10933fn debug_bbox_lines(list: &DisplayList, dpi: f64, depth: usize, out: &mut Vec<String>) {
10934    let y_bboxes = precompute_bboxes(list, dpi);
10935    let full_bboxes = precompute_full_bboxes(list, dpi);
10936    let elements = list.elements();
10937    let indent = "  ".repeat(depth);
10938    for (i, elem) in elements.iter().enumerate() {
10939        let kind = match elem {
10940            DisplayElement::Fill { .. } => "Fill",
10941            DisplayElement::Stroke { .. } => "Stroke",
10942            DisplayElement::Image { .. } => "Image",
10943            DisplayElement::AxialShading { .. } => "AxialShading",
10944            DisplayElement::RadialShading { .. } => "RadialShading",
10945            DisplayElement::MeshShading { .. } => "MeshShading",
10946            DisplayElement::PatchShading { .. } => "PatchShading",
10947            DisplayElement::PatternFill { .. } => "PatternFill",
10948            DisplayElement::Group { .. } => "Group",
10949            DisplayElement::SoftMasked { .. } => "SoftMasked",
10950            DisplayElement::OcgGroup { .. } => "OcgGroup",
10951            DisplayElement::Clip { .. } => "Clip",
10952            DisplayElement::InitClip => "InitClip",
10953            DisplayElement::ErasePage => "ErasePage",
10954            DisplayElement::Text { .. } => "Text",
10955            _ => "Unknown",
10956        };
10957        let yb = &y_bboxes[i];
10958        let fb = &full_bboxes[i];
10959        let mut diff = false;
10960        if yb.is_some() != fb.is_some() {
10961            diff = true;
10962        }
10963        if let (Some(yb), Some(fb)) = (yb, fb)
10964            && ((yb.y_min - fb.y_min).abs() > 1e-9 || (yb.y_max - fb.y_max).abs() > 1e-9)
10965        {
10966            diff = true;
10967        }
10968        let yb_s = match yb {
10969            Some(b) => format!("Y[{:8.3}..{:8.3}]", b.y_min, b.y_max),
10970            None => "Y[None]".to_string(),
10971        };
10972        let fb_s = match fb {
10973            Some(b) => format!(
10974                "2D[x {:8.3}..{:8.3} y {:8.3}..{:8.3}]",
10975                b.x_min, b.x_max, b.y_min, b.y_max
10976            ),
10977            None => "2D[None]".to_string(),
10978        };
10979        out.push(format!(
10980            "{}{:4} {:15} {:30} {:55} {}",
10981            indent,
10982            i,
10983            kind,
10984            yb_s,
10985            fb_s,
10986            if diff { "DIFF" } else { "" }
10987        ));
10988        if let DisplayElement::Stroke { path, params } = elem {
10989            let rp = path_full_bbox(path);
10990            let m = &params.ctm;
10991            out.push(format!(
10992                "{}        ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] lw={:.4} miter={:.4} raw={}",
10993                indent,
10994                m.a,
10995                m.b,
10996                m.c,
10997                m.d,
10998                m.tx,
10999                m.ty,
11000                params.line_width,
11001                params.miter_limit,
11002                match rp {
11003                    Some(b) => format!(
11004                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11005                        b.x_min, b.x_max, b.y_min, b.y_max
11006                    ),
11007                    None => "None".to_string(),
11008                }
11009            ));
11010        }
11011        if let DisplayElement::Clip { path, params } = elem {
11012            let rp = path_full_bbox(path);
11013            let m = &params.ctm;
11014            out.push(format!(
11015                "{}        clip ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] rule={:?} raw={}",
11016                indent,
11017                m.a,
11018                m.b,
11019                m.c,
11020                m.d,
11021                m.tx,
11022                m.ty,
11023                params.fill_rule,
11024                match rp {
11025                    Some(b) => format!(
11026                        "x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11027                        b.x_min, b.x_max, b.y_min, b.y_max
11028                    ),
11029                    None => "None".to_string(),
11030                }
11031            ));
11032        }
11033        if let DisplayElement::PatchShading { params } = elem {
11034            out.push(format!(
11035                "{}        patch ctm=[{:.4} {:.4} {:.4} {:.4} {:.4} {:.4}] bbox={:?} patches={}",
11036                indent,
11037                params.ctm.a,
11038                params.ctm.b,
11039                params.ctm.c,
11040                params.ctm.d,
11041                params.ctm.tx,
11042                params.ctm.ty,
11043                params.bbox,
11044                params.patches.len()
11045            ));
11046            if !params.patches.is_empty() {
11047                let patch = &params.patches[0];
11048                // Compute device-space bbox of patch points
11049                let mut x_min = f64::INFINITY;
11050                let mut y_min = f64::INFINITY;
11051                let mut x_max = f64::NEG_INFINITY;
11052                let mut y_max = f64::NEG_INFINITY;
11053                for &(px, py) in &patch.points {
11054                    let (dx, dy) = params.ctm.transform_point(px, py);
11055                    x_min = x_min.min(dx);
11056                    y_min = y_min.min(dy);
11057                    x_max = x_max.max(dx);
11058                    y_max = y_max.max(dy);
11059                }
11060                out.push(format!(
11061                    "{}        patch[0] pts={} dev x[{:.3}..{:.3}] y[{:.3}..{:.3}]",
11062                    indent,
11063                    patch.points.len(),
11064                    x_min,
11065                    x_max,
11066                    y_min,
11067                    y_max
11068                ));
11069            }
11070        }
11071        if let DisplayElement::Group {
11072            elements: inner,
11073            params,
11074        } = elem
11075        {
11076            out.push(format!(
11077                "{}        group bbox={:?} iso={} ko={} alpha={} bm={} cs={:?}",
11078                indent,
11079                params.bbox,
11080                params.isolated,
11081                params.knockout,
11082                params.alpha,
11083                params.blend_mode,
11084                params.color_space
11085            ));
11086            debug_bbox_lines(inner, dpi, depth + 1, out);
11087        }
11088        if let DisplayElement::SoftMasked {
11089            content, params, ..
11090        } = elem
11091        {
11092            out.push(format!(
11093                "{}        softmasked bbox={:?}",
11094                indent, params.bbox
11095            ));
11096            debug_bbox_lines(content, dpi, depth + 1, out);
11097        }
11098        if let DisplayElement::OcgGroup {
11099            elements: inner,
11100            visibility,
11101        } = elem
11102        {
11103            out.push(format!(
11104                "{}        ocg default_visible={}",
11105                indent,
11106                visibility.default_visible()
11107            ));
11108            debug_bbox_lines(inner, dpi, depth + 1, out);
11109        }
11110    }
11111}
11112
11113pub fn debug_bbox_comparison(list: &DisplayList, dpi: f64) -> Vec<String> {
11114    let mut out = Vec::new();
11115    debug_bbox_lines(list, dpi, 0, &mut out);
11116    out
11117}
11118
11119/// In-memory page sink that collects RGBA rows into a Vec.
11120struct MemorySink {
11121    data: Vec<u8>,
11122    width: u32,
11123}
11124
11125impl stet_graphics::device::PageSink for MemorySink {
11126    fn begin_page(&mut self, width: u32, height: u32) -> Result<(), String> {
11127        self.width = width;
11128        self.data.reserve(width as usize * height as usize * 4);
11129        Ok(())
11130    }
11131
11132    fn write_rows(&mut self, rgba_rows: &[u8], _num_rows: u32) -> Result<(), String> {
11133        self.data.extend_from_slice(rgba_rows);
11134        Ok(())
11135    }
11136
11137    fn end_page(&mut self) -> Result<(), String> {
11138        Ok(())
11139    }
11140}
11141
11142/// Render a rectangular viewport region of a display list to RGBA pixels.
11143///
11144/// - `list`: The display list to render (in device-space coordinates at the reference DPI)
11145/// - `vp_x, vp_y, vp_w, vp_h`: Viewport rectangle in device-space pixels
11146/// - `pixel_w, pixel_h`: Output pixel dimensions
11147/// - `dpi`: Reference DPI (for hairline width decisions)
11148///
11149/// Returns RGBA pixel data of size `pixel_w × pixel_h × 4`.
11150#[allow(clippy::too_many_arguments)]
11151pub fn render_region(
11152    list: &DisplayList,
11153    vp_x: f64,
11154    vp_y: f64,
11155    vp_w: f64,
11156    vp_h: f64,
11157    pixel_w: u32,
11158    pixel_h: u32,
11159    dpi: f64,
11160    icc: Option<&IccCache>,
11161    image_cache: Option<&ImageCache>,
11162    no_aa: bool,
11163) -> Vec<u8> {
11164    if pixel_w == 0 || pixel_h == 0 || vp_w <= 0.0 || vp_h <= 0.0 {
11165        return vec![0xFF; pixel_w as usize * pixel_h as usize * 4];
11166    }
11167
11168    let layer_set = LayerSet::new();
11169    let scale_x = pixel_w as f64 / vp_w;
11170    let scale_y = pixel_h as f64 / vp_h;
11171    // Effective DPI for hairline decisions — reference DPI scaled by zoom
11172    let effective_dpi = dpi * scale_x;
11173
11174    let bboxes = precompute_full_bboxes(list, effective_dpi);
11175    let epochs = build_viewport_epochs(list, &bboxes);
11176    let clip_seen = precompute_clip_seen(list);
11177
11178    // OVERLAP padding to match `render_banded_to_sink`. See the comment in
11179    // `render_region_prepared` for why this is required for tiny-skia
11180    // mask-rasterization parity with the page renderer.
11181    const OVERLAP: u32 = 6;
11182    let render_h = pixel_h + 2 * OVERLAP;
11183
11184    let mut pixmap = Pixmap::new(pixel_w, render_h).expect("Failed to create viewport pixmap");
11185    pixmap.fill(Color::TRANSPARENT);
11186
11187    let cmyk_buf = if has_overprint_elements(list)
11188        || list.page_group_color_space() == stet_graphics::display_list::GroupColorSpace::DeviceCMYK
11189        || has_cmyk_group(list)
11190    {
11191        Some(vec![0.0f32; pixel_w as usize * render_h as usize * 4])
11192    } else {
11193        None
11194    };
11195
11196    let mut state = BandState {
11197        clip_region: None,
11198        spare_mask: None,
11199        clip_mask_cache: HashMap::new(),
11200        clip_mask_seen: clip_seen,
11201        mask_pool: Vec::new(),
11202        cmyk_buffer: cmyk_buf,
11203        op_bg_snapshot: None,
11204        op_touched: None,
11205        spot_mask: None,
11206    };
11207
11208    let elements = list.elements();
11209    let vp_x_f = vp_x as f32;
11210    let vp_y_f = vp_y as f32;
11211    let sx = scale_x as f32;
11212    let sy = scale_y as f32;
11213    let vp_x_max = vp_x + vp_w;
11214    let vp_y_max = vp_y + vp_h;
11215
11216    for epoch in &epochs {
11217        // Epoch-level culling
11218        if !epoch.has_erase_page {
11219            match epoch.paint_bbox {
11220                Some(ref pb)
11221                    if pb.x_max <= vp_x
11222                        || pb.x_min >= vp_x_max
11223                        || pb.y_max <= vp_y
11224                        || pb.y_min >= vp_y_max =>
11225                {
11226                    continue;
11227                }
11228                None => continue,
11229                _ => {}
11230            }
11231        }
11232
11233        for i in epoch.start_idx..epoch.end_idx {
11234            // OcgGroups with Clip/InitClip must always be processed — see
11235            // render_region_prepared for the rationale.
11236            let force_process = matches!(
11237                &elements[i],
11238                DisplayElement::OcgGroup { elements: inner, .. }
11239                    if contains_clip_op(inner)
11240            );
11241            // Element-level culling
11242            if !force_process
11243                && let Some(ref bbox) = bboxes[i]
11244                && (bbox.x_max <= vp_x
11245                    || bbox.x_min >= vp_x_max
11246                    || bbox.y_max <= vp_y
11247                    || bbox.y_min >= vp_y_max)
11248            {
11249                continue;
11250            }
11251            let ctx = RenderContext {
11252                vp_x: vp_x_f,
11253                vp_y: vp_y_f,
11254                scale_x: sx,
11255                scale_y: sy,
11256                out_w: pixel_w,
11257                out_h: render_h,
11258                effective_dpi,
11259                icc,
11260                image_cache,
11261                preprocessed: None,
11262                elem_idx: i,
11263                no_aa,
11264                opm_zero_transparent: false,
11265                knockout_painter_pass: KnockoutPainterPass::None,
11266                parent_group_isolated: false,
11267                alpha_extraction_pass: false,
11268                layer_set: &layer_set,
11269            };
11270            render_element(&mut pixmap, &mut state, &elements[i], &ctx);
11271        }
11272    }
11273
11274    composite_onto_white(pixmap.data_mut());
11275    // Extract only the requested pixel_h rows (skip OVERLAP padding).
11276    let row_bytes = pixel_w as usize * 4;
11277    let end = pixel_h as usize * row_bytes;
11278    pixmap.data()[..end].to_vec()
11279}
11280/// Copy a rectangular region from parent pixmap into a smaller crop pixmap.
11281fn copy_backdrop_crop(
11282    parent: &Pixmap,
11283    crop_x: i32,
11284    crop_y: i32,
11285    crop_w: u32,
11286    crop_h: u32,
11287) -> Vec<u8> {
11288    let pw = parent.width() as usize;
11289    let src = parent.data();
11290    let cw = crop_w as usize;
11291    let ch = crop_h as usize;
11292    let cx = crop_x as usize;
11293    let cy = crop_y as usize;
11294    let mut backdrop = vec![0u8; cw * ch * 4];
11295    for row in 0..ch {
11296        let src_off = ((cy + row) * pw + cx) * 4;
11297        let dst_off = row * cw * 4;
11298        backdrop[dst_off..dst_off + cw * 4].copy_from_slice(&src[src_off..src_off + cw * 4]);
11299    }
11300    backdrop
11301}
11302// ---- Shading rendering ----
11303
11304/// Sutherland-Hodgman polygon clipping against a half-plane.
11305/// Keeps the side where `nx*(x-px) + ny*(y-py) >= 0`.
11306fn clip_polygon_halfplane(
11307    poly: &[(f32, f32)],
11308    nx: f32,
11309    ny: f32,
11310    px: f32,
11311    py: f32,
11312) -> Vec<(f32, f32)> {
11313    if poly.is_empty() {
11314        return vec![];
11315    }
11316    let dot = |x: f32, y: f32| nx * (x - px) + ny * (y - py);
11317    let mut out = Vec::with_capacity(poly.len() + 1);
11318    let n = poly.len();
11319    for i in 0..n {
11320        let (ax, ay) = poly[i];
11321        let (bx, by) = poly[(i + 1) % n];
11322        let da = dot(ax, ay);
11323        let db = dot(bx, by);
11324        if da >= 0.0 {
11325            out.push((ax, ay));
11326        }
11327        if (da >= 0.0) != (db >= 0.0) {
11328            // Edge crosses the clipping line — compute intersection
11329            let t = da / (da - db);
11330            out.push((ax + t * (bx - ax), ay + t * (by - ay)));
11331        }
11332    }
11333    out
11334}
11335
11336/// Render an axial (linear) gradient shading.
11337#[allow(clippy::too_many_arguments)]
11338fn render_axial_shading(
11339    pixmap: &mut Pixmap,
11340    params: &AxialShadingParams,
11341    vp_x: f32,
11342    vp_y: f32,
11343    scale_x: f32,
11344    scale_y: f32,
11345    clip_mask: Option<&Mask>,
11346    no_aa: bool,
11347    cmyk_buf: Option<&mut [f32]>,
11348    icc: Option<&IccCache>,
11349) {
11350    let pw = pixmap.width();
11351    let ph = pixmap.height();
11352    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
11353        return;
11354    }
11355
11356    let (mut rx_min, mut ry_min, mut rx_max, mut ry_max) = if let Some(bbox) = &params.bbox {
11357        let corners = [
11358            params.ctm.transform_point(bbox[0], bbox[1]),
11359            params.ctm.transform_point(bbox[2], bbox[1]),
11360            params.ctm.transform_point(bbox[0], bbox[3]),
11361            params.ctm.transform_point(bbox[2], bbox[3]),
11362        ];
11363        let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
11364        let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
11365        let x_max = corners
11366            .iter()
11367            .map(|c| c.0)
11368            .fold(f64::NEG_INFINITY, f64::max);
11369        let y_max = corners
11370            .iter()
11371            .map(|c| c.1)
11372            .fold(f64::NEG_INFINITY, f64::max);
11373        (
11374            ((x_min as f32 - vp_x) * scale_x).max(0.0),
11375            ((y_min as f32 - vp_y) * scale_y).max(0.0),
11376            ((x_max as f32 - vp_x) * scale_x).min(pw as f32),
11377            ((y_max as f32 - vp_y) * scale_y).min(ph as f32),
11378        )
11379    } else {
11380        (0.0, 0.0, pw as f32, ph as f32)
11381    };
11382
11383    if rx_max <= rx_min || ry_max <= ry_min {
11384        return;
11385    }
11386
11387    // Transform endpoints to device space for perpendicular clipping
11388    let (dx0, dy0) = params.ctm.transform_point(params.x0, params.y0);
11389    let (dx1, dy1) = params.ctm.transform_point(params.x1, params.y1);
11390
11391    // When extend is false on a side, clip the fill area along a line
11392    // perpendicular to the gradient axis through that endpoint. For diagonal
11393    // gradients this produces a diagonal cutoff (not axis-aligned).
11394    let needs_perpendicular_clip = (!params.extend_start || !params.extend_end) && {
11395        let axis_x = dx1 - dx0;
11396        let axis_y = dy1 - dy0;
11397        axis_x.abs() > 1e-6 && axis_y.abs() > 1e-6
11398    };
11399
11400    // Detect rotated BBox: if CTM has rotation components (b or c non-zero),
11401    // the BBox is not axis-aligned in device space and needs proper polygon clipping.
11402    let bbox_is_rotated =
11403        params.bbox.is_some() && (params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10);
11404
11405    if needs_perpendicular_clip {
11406        // Diagonal gradient with non-extended side — fall back to tiny-skia
11407        // for Sutherland-Hodgman polygon clipping.
11408        let stops = build_gradient_stops(&params.color_stops);
11409        if stops.is_empty() {
11410            return;
11411        }
11412        let start = stet_tiny_skia::Point::from_xy(params.x0 as f32, params.y0 as f32);
11413        let end = stet_tiny_skia::Point::from_xy(params.x1 as f32, params.y1 as f32);
11414        let gradient_transform =
11415            viewport_transform(to_transform(&params.ctm), vp_x, vp_y, scale_x, scale_y);
11416        let Some(gradient) = stet_tiny_skia::LinearGradient::new(
11417            start,
11418            end,
11419            stops,
11420            stet_tiny_skia::SpreadMode::Pad,
11421            gradient_transform,
11422        ) else {
11423            return;
11424        };
11425        let paint = Paint {
11426            shader: gradient,
11427            anti_alias: !no_aa,
11428            ..Paint::default()
11429        };
11430
11431        // Use rotated BBox polygon when CTM has rotation, otherwise axis-aligned rect
11432        let mut poly: Vec<(f32, f32)> = if bbox_is_rotated {
11433            let bbox = params.bbox.as_ref().unwrap();
11434            let corners = [
11435                params.ctm.transform_point(bbox[0], bbox[1]),
11436                params.ctm.transform_point(bbox[2], bbox[1]),
11437                params.ctm.transform_point(bbox[2], bbox[3]),
11438                params.ctm.transform_point(bbox[0], bbox[3]),
11439            ];
11440            corners
11441                .iter()
11442                .map(|(x, y)| ((*x as f32 - vp_x) * scale_x, (*y as f32 - vp_y) * scale_y))
11443                .collect()
11444        } else {
11445            vec![
11446                (rx_min, ry_min),
11447                (rx_max, ry_min),
11448                (rx_max, ry_max),
11449                (rx_min, ry_max),
11450            ]
11451        };
11452        let ax = (dx1 - dx0) as f32 * scale_x;
11453        let ay = (dy1 - dy0) as f32 * scale_y;
11454        if !params.extend_start {
11455            let px = (dx0 as f32 - vp_x) * scale_x;
11456            let py = (dy0 as f32 - vp_y) * scale_y;
11457            poly = clip_polygon_halfplane(&poly, ax, ay, px, py);
11458        }
11459        if !params.extend_end {
11460            let px = (dx1 as f32 - vp_x) * scale_x;
11461            let py = (dy1 as f32 - vp_y) * scale_y;
11462            poly = clip_polygon_halfplane(&poly, -ax, -ay, px, py);
11463        }
11464        if poly.len() >= 3 {
11465            let mut pb = PathBuilder::new();
11466            pb.move_to(poly[0].0, poly[0].1);
11467            for &(x, y) in &poly[1..] {
11468                pb.line_to(x, y);
11469            }
11470            pb.close();
11471            if let Some(path) = pb.finish() {
11472                pixmap.fill_path(
11473                    &path,
11474                    &paint,
11475                    SkiaFillRule::Winding,
11476                    Transform::identity(),
11477                    clip_mask,
11478                );
11479            }
11480        }
11481    } else {
11482        // Common case: axis-aligned or both sides extended — direct rasterization.
11483        // Clip fill rect to gradient extent when sides aren't extended.
11484        if !params.extend_start || !params.extend_end {
11485            let axis_x = dx1 - dx0;
11486            let axis_y = dy1 - dy0;
11487            let gx0 = (dx0 as f32 - vp_x) * scale_x;
11488            let gy0 = (dy0 as f32 - vp_y) * scale_y;
11489            let gx1 = (dx1 as f32 - vp_x) * scale_x;
11490            let gy1 = (dy1 as f32 - vp_y) * scale_y;
11491
11492            if axis_x.abs() >= axis_y.abs() {
11493                if !params.extend_start {
11494                    if axis_x >= 0.0 {
11495                        rx_min = rx_min.max(gx0);
11496                    } else {
11497                        rx_max = rx_max.min(gx0);
11498                    }
11499                }
11500                if !params.extend_end {
11501                    if axis_x >= 0.0 {
11502                        rx_max = rx_max.min(gx1);
11503                    } else {
11504                        rx_min = rx_min.max(gx1);
11505                    }
11506                }
11507            } else {
11508                if !params.extend_start {
11509                    if axis_y >= 0.0 {
11510                        ry_min = ry_min.max(gy0);
11511                    } else {
11512                        ry_max = ry_max.min(gy0);
11513                    }
11514                }
11515                if !params.extend_end {
11516                    if axis_y >= 0.0 {
11517                        ry_max = ry_max.min(gy1);
11518                    } else {
11519                        ry_min = ry_min.max(gy1);
11520                    }
11521                }
11522            }
11523            if rx_max <= rx_min || ry_max <= ry_min {
11524                return;
11525            }
11526        }
11527
11528        // Compute gradient axis in shading space.
11529        let ax = params.x1 - params.x0;
11530        let ay = params.y1 - params.y0;
11531        let axis_sq = ax * ax + ay * ay;
11532        if axis_sq < 1e-20 {
11533            return;
11534        }
11535
11536        // Size the LUT to the gradient's pixel span so each entry covers ≤1 pixel.
11537        // This ensures nearest-neighbor lookup produces pixel-perfect sharp edges
11538        // at stitching function discontinuities without banding in smooth gradients.
11539        let pixel_dx = (dx1 - dx0) * scale_x as f64;
11540        let pixel_dy = (dy1 - dy0) * scale_y as f64;
11541        let pixel_axis_len = (pixel_dx * pixel_dx + pixel_dy * pixel_dy).sqrt();
11542        let lut_size = (pixel_axis_len as usize)
11543            .max(params.color_stops.len())
11544            .max(256)
11545            .min(16384);
11546        let lut = build_gradient_lut(&params.color_stops, lut_size);
11547
11548        let Some(inv) = params.ctm.invert() else {
11549            return;
11550        };
11551        let inv_sx = 1.0 / scale_x as f64;
11552        let inv_sy = 1.0 / scale_y as f64;
11553        let dev_origin_x = vp_x as f64;
11554        let dev_origin_y = vp_y as f64;
11555
11556        // Shading-space coords as linear function of pixel coords:
11557        //   sx = sx_base + dsx_dx * px + dsx_dy * py
11558        //   sy = sy_base + dsy_dx * px + dsy_dy * py
11559        let sx_base = inv.a * dev_origin_x + inv.c * dev_origin_y + inv.tx;
11560        let sy_base = inv.b * dev_origin_x + inv.d * dev_origin_y + inv.ty;
11561        let dsx_dx = inv.a * inv_sx;
11562        let dsx_dy = inv.c * inv_sy;
11563        let dsy_dx = inv.b * inv_sx;
11564        let dsy_dy = inv.d * inv_sy;
11565
11566        // t = dot(P_shading - P0, axis) / dot(axis, axis)
11567        let inv_axis_sq = 1.0 / axis_sq;
11568        let t_origin = ((sx_base - params.x0) * ax + (sy_base - params.y0) * ay) * inv_axis_sq;
11569        let dt_dx = (dsx_dx * ax + dsy_dx * ay) * inv_axis_sq;
11570        let dt_dy = (dsx_dy * ax + dsy_dy * ay) * inv_axis_sq;
11571
11572        // Per-pixel rotated BBox clipping: reuse inverse CTM to map each pixel
11573        // back to shading space and check against the original BBox.
11574        let bbox_pixel_clip = if bbox_is_rotated {
11575            let bbox = params.bbox.as_ref().unwrap();
11576            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11577            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11578            Some((
11579                dsx_dx, dsx_dy, sx_base, dsy_dx, dsy_dy, sy_base, bx0, by0, bx1, by1,
11580            ))
11581        } else {
11582            None
11583        };
11584
11585        let ix_min = rx_min.floor() as u32;
11586        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11587        let iy_min = ry_min.floor() as u32;
11588        let iy_max = ry_max.ceil().min(ph as f32) as u32;
11589
11590        let stride = pw as usize * 4;
11591        let data = pixmap.data_mut();
11592        let mask_data = clip_mask.map(|m| m.data());
11593        let alpha = (params.alpha.clamp(0.0, 1.0) * 255.0 + 0.5) as u16;
11594
11595        for py in iy_min..iy_max {
11596            let t_row = t_origin + dt_dy * py as f64;
11597            let row_offset = py as usize * stride;
11598
11599            // Precompute row-base values for rotated BBox check
11600            let (ux_row, uy_row) =
11601                if let Some((_, dux_dy, ux_base, _, duy_dy, uy_base, ..)) = &bbox_pixel_clip {
11602                    (ux_base + dux_dy * py as f64, uy_base + duy_dy * py as f64)
11603                } else {
11604                    (0.0, 0.0)
11605                };
11606
11607            for px in ix_min..ix_max {
11608                // Check clip mask
11609                if let Some(md) = mask_data {
11610                    if md[py as usize * pw as usize + px as usize] == 0 {
11611                        continue;
11612                    }
11613                }
11614
11615                // Per-pixel rotated BBox clip
11616                if let Some((dux_dx, _, _, duy_dx, _, _, bx0, by0, bx1, by1)) = &bbox_pixel_clip {
11617                    let ux = ux_row + dux_dx * px as f64;
11618                    let uy = uy_row + duy_dx * px as f64;
11619                    if ux < *bx0 || ux > *bx1 || uy < *by0 || uy > *by1 {
11620                        continue;
11621                    }
11622                }
11623
11624                let t = t_row + dt_dx * px as f64;
11625                let t_clamped = t.clamp(0.0, 1.0);
11626                let idx = (t_clamped * (lut_size - 1) as f64 + 0.5) as usize;
11627                let [r, g, b, _] = lut[idx.min(lut_size - 1)];
11628
11629                let offset = row_offset + px as usize * 4;
11630                if alpha >= 255 {
11631                    data[offset] = r;
11632                    data[offset + 1] = g;
11633                    data[offset + 2] = b;
11634                    data[offset + 3] = 255;
11635                } else {
11636                    // Alpha blend: premultiply and composite over existing pixel
11637                    let a = alpha as u16;
11638                    let inv_a = 255 - a;
11639                    data[offset] = ((r as u16 * a + data[offset] as u16 * inv_a + 127) / 255) as u8;
11640                    data[offset + 1] =
11641                        ((g as u16 * a + data[offset + 1] as u16 * inv_a + 127) / 255) as u8;
11642                    data[offset + 2] =
11643                        ((b as u16 * a + data[offset + 2] as u16 * inv_a + 127) / 255) as u8;
11644                    data[offset + 3] = ((a + data[offset + 3] as u16 * inv_a / 255).min(255)) as u8;
11645                }
11646            }
11647        }
11648    }
11649
11650    // Update CMYK tracking buffer for axial shading
11651    if let Some(buf) = cmyk_buf {
11652        let pw = pixmap.width();
11653        let inv_sx = 1.0 / scale_x as f64;
11654        let inv_sy = 1.0 / scale_y as f64;
11655        let axis_x = params.x1 - params.x0;
11656        let axis_y = params.y1 - params.y0;
11657        let axis_len_sq = axis_x * axis_x + axis_y * axis_y;
11658        let Some(inv_ctm) = params.ctm.invert() else {
11659            return;
11660        };
11661
11662        let iy_min = ry_min.floor() as u32;
11663        let iy_max = ry_max.ceil().min(pixmap.height() as f32) as u32;
11664        let ix_min = rx_min.floor() as u32;
11665        let ix_max = rx_max.ceil().min(pw as f32) as u32;
11666
11667        for py in iy_min..iy_max {
11668            let dev_y = py as f64 * inv_sy + vp_y as f64;
11669            for px in ix_min..ix_max {
11670                let dev_x = px as f64 * inv_sx + vp_x as f64;
11671                let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11672                let t = if axis_len_sq > 1e-10 {
11673                    ((ux - params.x0) * axis_x + (uy - params.y0) * axis_y) / axis_len_sq
11674                } else {
11675                    0.0
11676                };
11677                if t < 0.0 && !params.extend_start {
11678                    continue;
11679                }
11680                if t > 1.0 && !params.extend_end {
11681                    continue;
11682                }
11683                let clamped = t.clamp(0.0, 1.0);
11684
11685                if let Some(mask) = clip_mask {
11686                    let mi = py as usize * pw as usize + px as usize;
11687                    if mask.data()[mi] == 0 {
11688                        continue;
11689                    }
11690                }
11691
11692                let color = interpolate_color_stops(&params.color_stops, clamped);
11693                let cmyk = interpolate_cmyk_from_stops(
11694                    &params.color_stops,
11695                    &params.color_space,
11696                    clamped,
11697                    &color,
11698                    icc,
11699                );
11700                let ci = (py as usize * pw as usize + px as usize) * 4;
11701                if ci + 3 < buf.len() {
11702                    if params.spot_tint_blend && params.overprint {
11703                        // Per PDF spec 11.7.4.5 a Separation/DeviceN gradient
11704                        // only affects the device colorants identified by its
11705                        // color space: plates for NAMED PROCESS colorants are
11706                        // REPLACED with the gradient's CMYK value at this
11707                        // pixel, plates not tied to a named process colorant
11708                        // are PRESERVED.  The LUT-painted pixmap already
11709                        // carries the spot's full ICC-converted color, so:
11710                        //
11711                        // Gated on `overprint` because the LUT pass for
11712                        // non-overprint shadings carries the author-intended
11713                        // blend mode (e.g. 2265.pdf draws each circle wedge
11714                        // twice — Normal then Multiply — and the multiplied
11715                        // pixmap is the wedge's final color).  Recomposing
11716                        // here would overwrite the multiply-darkened result
11717                        // with a single ICC sample of the source CMYK.
11718                        //   * Where the CMYK buffer is empty (fresh paper),
11719                        //     leave the pixmap alone — re-running CMYK→RGB
11720                        //     here would round-trip through the system
11721                        //     profile and produce a perceptibly different
11722                        //     gradient curve (the snowman shading regression
11723                        //     guarded against in the original recompose
11724                        //     branch).  Just record the named-process
11725                        //     contribution to the buffer for later overprint
11726                        //     tracking.
11727                        //   * Where the CMYK buffer has prior values (a
11728                        //     CMYK fill underneath, e.g. a `1 0 1 0.5 k`
11729                        //     checkmark under the strip), the LUT-paint had
11730                        //     wiped that underlying paint from the pixmap.
11731                        //     Recompose the pixmap from the merged CMYK
11732                        //     (REPLACE named, preserve non-named) to restore
11733                        //     the checkmark with the gradient's named-plate
11734                        //     contribution layered on top.
11735                        let cur_c = buf[ci] as f64;
11736                        let cur_m = buf[ci + 1] as f64;
11737                        let cur_y = buf[ci + 2] as f64;
11738                        let cur_k = buf[ci + 3] as f64;
11739                        let cur_is_zero =
11740                            cur_c == 0.0 && cur_m == 0.0 && cur_y == 0.0 && cur_k == 0.0;
11741                        let named = params.painted_channels;
11742                        if cur_is_zero {
11743                            if named & stet_graphics::device::CMYK_C != 0 {
11744                                buf[ci] = cmyk.0 as f32;
11745                            }
11746                            if named & stet_graphics::device::CMYK_M != 0 {
11747                                buf[ci + 1] = cmyk.1 as f32;
11748                            }
11749                            if named & stet_graphics::device::CMYK_Y != 0 {
11750                                buf[ci + 2] = cmyk.2 as f32;
11751                            }
11752                            if named & stet_graphics::device::CMYK_K != 0 {
11753                                buf[ci + 3] = cmyk.3 as f32;
11754                            }
11755                        } else {
11756                            let new_c = if named & stet_graphics::device::CMYK_C != 0 {
11757                                cmyk.0
11758                            } else {
11759                                cur_c
11760                            };
11761                            let new_m = if named & stet_graphics::device::CMYK_M != 0 {
11762                                cmyk.1
11763                            } else {
11764                                cur_m
11765                            };
11766                            let new_y = if named & stet_graphics::device::CMYK_Y != 0 {
11767                                cmyk.2
11768                            } else {
11769                                cur_y
11770                            };
11771                            let new_k = if named & stet_graphics::device::CMYK_K != 0 {
11772                                cmyk.3
11773                            } else {
11774                                cur_k
11775                            };
11776                            buf[ci] = new_c as f32;
11777                            buf[ci + 1] = new_m as f32;
11778                            buf[ci + 2] = new_y as f32;
11779                            buf[ci + 3] = new_k as f32;
11780                            let (rv, gv, bv) = if let Some(icc_cache) = icc {
11781                                icc_cache
11782                                    .convert_cmyk_readonly(new_c, new_m, new_y, new_k)
11783                                    .unwrap_or_else(|| cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k))
11784                            } else {
11785                                cmyk_to_rgb_plrm(new_c, new_m, new_y, new_k)
11786                            };
11787                            let stride = pixmap.data().len() / pixmap.height() as usize;
11788                            let offset = py as usize * stride + px as usize * 4;
11789                            let data = pixmap.data_mut();
11790                            data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11791                            data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11792                            data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11793                        }
11794                    } else if params.overprint
11795                        && params.painted_channels != stet_graphics::device::CMYK_ALL
11796                    {
11797                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
11798                            buf[ci] = cmyk.0 as f32;
11799                        }
11800                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
11801                            buf[ci + 1] = cmyk.1 as f32;
11802                        }
11803                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
11804                            buf[ci + 2] = cmyk.2 as f32;
11805                        }
11806                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
11807                            buf[ci + 3] = cmyk.3 as f32;
11808                        }
11809                        // Recomposite RGB from merged CMYK via ICC
11810                        let c = buf[ci] as f64;
11811                        let m = buf[ci + 1] as f64;
11812                        let y = buf[ci + 2] as f64;
11813                        let k = buf[ci + 3] as f64;
11814                        let (rv, gv, bv) = if let Some(icc_cache) = icc {
11815                            icc_cache
11816                                .convert_cmyk_readonly(c, m, y, k)
11817                                .unwrap_or_else(|| cmyk_to_rgb_plrm(c, m, y, k))
11818                        } else {
11819                            cmyk_to_rgb_plrm(c, m, y, k)
11820                        };
11821                        let stride = pixmap.data().len() / pixmap.height() as usize;
11822                        let offset = py as usize * stride + px as usize * 4;
11823                        let data = pixmap.data_mut();
11824                        data[offset] = (rv * 255.0).round().clamp(0.0, 255.0) as u8;
11825                        data[offset + 1] = (gv * 255.0).round().clamp(0.0, 255.0) as u8;
11826                        data[offset + 2] = (bv * 255.0).round().clamp(0.0, 255.0) as u8;
11827                    } else {
11828                        // Non-overprint axial shading: write the source CMYK
11829                        // to the buffer for any consumer that needs it (e.g.
11830                        // overprint sibling tracking) but leave the pixmap
11831                        // alone — `build_gradient_lut` already painted the
11832                        // pixel with linearly-interpolated source RGB, and
11833                        // round-tripping CMYK→RGB through the ICC profile
11834                        // produces a different gradient curve (linear in
11835                        // CMYK rather than linear in RGB) that diverges
11836                        // visibly from the LUT result. The CMYK buffer is
11837                        // only consumed by `composite_non_isolated_cmyk`,
11838                        // which excludes shading-containing groups via
11839                        // `group_content_is_native_cmyk`, so the
11840                        // buffer/pixmap mismatch never reaches a consumer
11841                        // that would notice. Reintroducing the round-trip
11842                        // here was the 3000_9 / 3000_10 snowman shading
11843                        // regression in the silly-weaving-bird plan.
11844                        buf[ci] = cmyk.0 as f32;
11845                        buf[ci + 1] = cmyk.1 as f32;
11846                        buf[ci + 2] = cmyk.2 as f32;
11847                        buf[ci + 3] = cmyk.3 as f32;
11848                    }
11849                }
11850            }
11851        }
11852    }
11853}
11854
11855/// Render a radial gradient shading.
11856#[allow(clippy::too_many_arguments)]
11857fn render_radial_shading(
11858    pixmap: &mut Pixmap,
11859    params: &RadialShadingParams,
11860    vp_x: f32,
11861    vp_y: f32,
11862    scale_x: f32,
11863    scale_y: f32,
11864    clip_mask: Option<&Mask>,
11865    _no_aa: bool,
11866    mut cmyk_buf: Option<&mut [f32]>,
11867    icc: Option<&IccCache>,
11868) {
11869    let pw = pixmap.width();
11870    let ph = pixmap.height();
11871    if params.color_stops.is_empty() || pw == 0 || ph == 0 {
11872        return;
11873    }
11874
11875    let Some(inv_ctm) = params.ctm.invert() else {
11876        return;
11877    };
11878
11879    let (px_min, py_min, px_max, py_max) = if let Some(bbox) = &params.bbox {
11880        let corners = [
11881            params.ctm.transform_point(bbox[0], bbox[1]),
11882            params.ctm.transform_point(bbox[2], bbox[1]),
11883            params.ctm.transform_point(bbox[0], bbox[3]),
11884            params.ctm.transform_point(bbox[2], bbox[3]),
11885        ];
11886        let x_min = corners
11887            .iter()
11888            .map(|c| c.0 as f32)
11889            .fold(f32::INFINITY, f32::min);
11890        let y_min = corners
11891            .iter()
11892            .map(|c| c.1 as f32)
11893            .fold(f32::INFINITY, f32::min);
11894        let x_max = corners
11895            .iter()
11896            .map(|c| c.0 as f32)
11897            .fold(f32::NEG_INFINITY, f32::max);
11898        let y_max = corners
11899            .iter()
11900            .map(|c| c.1 as f32)
11901            .fold(f32::NEG_INFINITY, f32::max);
11902        (
11903            ((x_min - vp_x) * scale_x).max(0.0) as u32,
11904            ((y_min - vp_y) * scale_y).max(0.0) as u32,
11905            (((x_max - vp_x) * scale_x).ceil() as u32).min(pw),
11906            (((y_max - vp_y) * scale_y).ceil() as u32).min(ph),
11907        )
11908    } else {
11909        (0, 0, pw, ph)
11910    };
11911
11912    let inv_sx = 1.0 / scale_x as f64;
11913    let inv_sy = 1.0 / scale_y as f64;
11914
11915    // Rotated BBox: check per-pixel user-space containment
11916    let rotated_bbox = if let Some(bbox) = &params.bbox {
11917        if params.ctm.b.abs() > 1e-10 || params.ctm.c.abs() > 1e-10 {
11918            let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
11919            let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
11920            Some((bx0, by0, bx1, by1))
11921        } else {
11922            None
11923        }
11924    } else {
11925        None
11926    };
11927
11928    let data = pixmap.data_mut();
11929    let stride = pw as usize * 4;
11930
11931    for py in py_min..py_max {
11932        let dev_y = py as f64 * inv_sy + vp_y as f64;
11933        for px in px_min..px_max {
11934            let dev_x = px as f64 * inv_sx + vp_x as f64;
11935            let (ux, uy) = inv_ctm.transform_point(dev_x, dev_y);
11936
11937            // Per-pixel rotated BBox clip
11938            if let Some((bx0, by0, bx1, by1)) = rotated_bbox {
11939                if ux < bx0 || ux > bx1 || uy < by0 || uy > by1 {
11940                    continue;
11941                }
11942            }
11943
11944            let t = solve_radial_t(
11945                ux,
11946                uy,
11947                params.x0,
11948                params.y0,
11949                params.r0,
11950                params.x1,
11951                params.y1,
11952                params.r1,
11953                params.extend_start,
11954                params.extend_end,
11955            );
11956            if let Some(t) = t {
11957                let clamped = t.clamp(0.0, 1.0);
11958                let color = interpolate_color_stops(&params.color_stops, clamped);
11959
11960                let clipped = clip_mask
11961                    .is_some_and(|mask| mask.data()[py as usize * pw as usize + px as usize] == 0);
11962
11963                if clipped {
11964                    continue;
11965                }
11966
11967                // Decide whether this pixel should use the multiplicative
11968                // ink-stacking blend to preserve a spot backdrop. We mirror
11969                // the rule in `render_overprint_fill`: overprint + subset
11970                // painted channels + buffer effectively empty at this pixel
11971                // means the pixmap carries a non-CMYK contribution (or the
11972                // pixel is fresh), so per-channel ink-stacking gives the
11973                // correct result whether the backdrop was spot-painted or
11974                // plain.
11975                let cmyk = interpolate_cmyk_from_stops(
11976                    &params.color_stops,
11977                    &params.color_space,
11978                    clamped,
11979                    &color,
11980                    icc,
11981                );
11982                let ci = (py as usize * pw as usize + px as usize) * 4;
11983                let buffer_clean = if let Some(ref buf) = cmyk_buf {
11984                    if ci + 3 < buf.len() {
11985                        buf[ci] == 0.0
11986                            && buf[ci + 1] == 0.0
11987                            && buf[ci + 2] == 0.0
11988                            && buf[ci + 3] == 0.0
11989                    } else {
11990                        false
11991                    }
11992                } else {
11993                    false
11994                };
11995                let offset_for_check = py as usize * stride + px as usize * 4;
11996                let pixmap_has_colour = data[offset_for_check + 3] > 0
11997                    && (data[offset_for_check] < 250
11998                        || data[offset_for_check + 1] < 250
11999                        || data[offset_for_check + 2] < 250);
12000                let use_multiplicative = params.overprint
12001                    && params.painted_channels != stet_graphics::device::CMYK_ALL
12002                    && buffer_clean
12003                    && pixmap_has_colour;
12004
12005                // Write CMYK buffer at non-clipped pixels
12006                if let Some(ref mut buf) = cmyk_buf
12007                    && ci + 3 < buf.len()
12008                {
12009                    if params.overprint
12010                        && params.painted_channels != stet_graphics::device::CMYK_ALL
12011                    {
12012                        if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12013                            buf[ci] = cmyk.0 as f32;
12014                        }
12015                        if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12016                            buf[ci + 1] = cmyk.1 as f32;
12017                        }
12018                        if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12019                            buf[ci + 2] = cmyk.2 as f32;
12020                        }
12021                        if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12022                            buf[ci + 3] = cmyk.3 as f32;
12023                        }
12024                    } else {
12025                        buf[ci] = cmyk.0 as f32;
12026                        buf[ci + 1] = cmyk.1 as f32;
12027                        buf[ci + 2] = cmyk.2 as f32;
12028                        buf[ci + 3] = cmyk.3 as f32;
12029                    }
12030                }
12031
12032                let offset = py as usize * stride + px as usize * 4;
12033                if use_multiplicative {
12034                    // Ink-stack the per-stop CMYK onto the pixmap RGB. Only
12035                    // channels named by painted_channels contribute; others
12036                    // leave the pixmap untouched, so a spot-painted backdrop
12037                    // survives with just the named inks darkening it.
12038                    let bg_r = data[offset] as f64 / 255.0;
12039                    let bg_g = data[offset + 1] as f64 / 255.0;
12040                    let bg_b = data[offset + 2] as f64 / 255.0;
12041                    let over_r = if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12042                        1.0 - cmyk.0
12043                    } else {
12044                        1.0
12045                    };
12046                    let over_g = if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12047                        1.0 - cmyk.1
12048                    } else {
12049                        1.0
12050                    };
12051                    let over_b = if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12052                        1.0 - cmyk.2
12053                    } else {
12054                        1.0
12055                    };
12056                    let k_fac = if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12057                        1.0 - cmyk.3
12058                    } else {
12059                        1.0
12060                    };
12061                    data[offset] = ((bg_r * over_r * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12062                    data[offset + 1] =
12063                        ((bg_g * over_g * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12064                    data[offset + 2] =
12065                        ((bg_b * over_b * k_fac).clamp(0.0, 1.0) * 255.0).round() as u8;
12066                    data[offset + 3] = 255;
12067                } else {
12068                    data[offset] = (color.r * 255.0).round().clamp(0.0, 255.0) as u8;
12069                    data[offset + 1] = (color.g * 255.0).round().clamp(0.0, 255.0) as u8;
12070                    data[offset + 2] = (color.b * 255.0).round().clamp(0.0, 255.0) as u8;
12071                    data[offset + 3] = 255;
12072
12073                    // Recomposite RGB from the CMYK buffer via ICC only for
12074                    // overprint DeviceCMYK shadings on a CMYK-only backdrop,
12075                    // where the per-channel merge in the buffer means the
12076                    // displayed pixel must reflect the merged CMYK rather
12077                    // than the source's RGB. For non-overprint shadings the
12078                    // LUT-rendered pixmap (above) is already correct, and
12079                    // round-tripping CMYK→RGB through the ICC profile
12080                    // produces a different gradient curve (linear in CMYK
12081                    // rather than linear in RGB) — that drift was the
12082                    // 3000_9 / 3000_10 snowman shading regression. The CMYK
12083                    // buffer is only consumed by `composite_non_isolated_cmyk`,
12084                    // which excludes shading-containing groups via
12085                    // `group_content_is_native_cmyk`, so the buffer/pixmap
12086                    // mismatch never reaches a consumer that would notice.
12087                    if params.overprint
12088                        && params.painted_channels != stet_graphics::device::CMYK_ALL
12089                        && matches!(params.color_space, ShadingColorSpace::DeviceCMYK)
12090                        && let Some(ref mut buf) = cmyk_buf
12091                        && ci + 3 < buf.len()
12092                        && let Some(icc_cache) = icc
12093                    {
12094                        let c = buf[ci] as f64;
12095                        let m = buf[ci + 1] as f64;
12096                        let y = buf[ci + 2] as f64;
12097                        let k = buf[ci + 3] as f64;
12098                        if let Some((r, g, b)) = icc_cache.convert_cmyk_readonly(c, m, y, k) {
12099                            data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
12100                            data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
12101                            data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
12102                        }
12103                    }
12104                }
12105            }
12106        }
12107    }
12108}
12109/// Solve for the parameter t of a two-circle radial gradient at point (px, py).
12110///
12111/// Returns the largest root of the circle equation that falls within the valid
12112/// domain and has R(t) >= 0. The valid domain is [0,1], extended by extend flags.
12113#[allow(clippy::too_many_arguments)]
12114fn solve_radial_t(
12115    px: f64,
12116    py: f64,
12117    x0: f64,
12118    y0: f64,
12119    r0: f64,
12120    x1: f64,
12121    y1: f64,
12122    r1: f64,
12123    extend_start: bool,
12124    extend_end: bool,
12125) -> Option<f64> {
12126    // Parametric: C(t) = (1-t)*C0 + t*C1, R(t) = (1-t)*r0 + t*r1
12127    // Solve: (px - Cx(t))^2 + (py - Cy(t))^2 = R(t)^2
12128    let cdx = x1 - x0;
12129    let cdy = y1 - y0;
12130    let dr = r1 - r0;
12131
12132    let a = cdx * cdx + cdy * cdy - dr * dr;
12133    let dpx = px - x0;
12134    let dpy = py - y0;
12135    let b = -2.0 * (dpx * cdx + dpy * cdy + r0 * dr);
12136    let c = dpx * dpx + dpy * dpy - r0 * r0;
12137
12138    // Helper: check if a root is in the valid domain
12139    let in_domain = |t: f64| -> bool {
12140        (0.0..=1.0).contains(&t) || (t < 0.0 && extend_start) || (t > 1.0 && extend_end)
12141    };
12142
12143    if a.abs() < 1e-10 {
12144        // Linear case
12145        if b.abs() < 1e-10 {
12146            return None;
12147        }
12148        let t = -c / b;
12149        let radius = r0 + t * dr;
12150        if radius >= 0.0 && in_domain(t) {
12151            return Some(t);
12152        }
12153        return None;
12154    }
12155
12156    let discriminant = b * b - 4.0 * a * c;
12157    if discriminant < 0.0 {
12158        return None;
12159    }
12160    let sqrt_d = discriminant.sqrt();
12161    let t1 = (-b + sqrt_d) / (2.0 * a);
12162    let t2 = (-b - sqrt_d) / (2.0 * a);
12163
12164    // Pick the largest root that is in the valid domain and has R(t) >= 0
12165    let mut best: Option<f64> = None;
12166    for t in [t1, t2] {
12167        let radius = r0 + t * dr;
12168        if radius >= 0.0 && in_domain(t) {
12169            best = Some(match best {
12170                Some(prev) => prev.max(t),
12171                None => t,
12172            });
12173        }
12174    }
12175    best
12176}
12177
12178/// Render a Gouraud-shaded triangle mesh.
12179#[allow(clippy::too_many_arguments)]
12180fn render_mesh_shading(
12181    pixmap: &mut Pixmap,
12182    params: &MeshShadingParams,
12183    vp_x: f32,
12184    vp_y: f32,
12185    scale_x: f32,
12186    scale_y: f32,
12187    clip_mask: Option<&Mask>,
12188    mut cmyk_buf: Option<&mut [f32]>,
12189    icc: Option<&IccCache>,
12190) {
12191    let pw = pixmap.width() as usize;
12192    let ph = pixmap.height() as usize;
12193    if pw == 0 || ph == 0 {
12194        return;
12195    }
12196    let data = pixmap.data_mut();
12197    let stride = pw * 4;
12198
12199    let lut = params.color_lut.as_deref();
12200
12201    for tri in &params.triangles {
12202        let (dx0, dy0) = params.ctm.transform_point(tri.v0.x, tri.v0.y);
12203        let (dx1, dy1) = params.ctm.transform_point(tri.v1.x, tri.v1.y);
12204        let (dx2, dy2) = params.ctm.transform_point(tri.v2.x, tri.v2.y);
12205
12206        let x0 = (dx0 as f32 - vp_x) * scale_x;
12207        let y0 = (dy0 as f32 - vp_y) * scale_y;
12208        let x1 = (dx1 as f32 - vp_x) * scale_x;
12209        let y1 = (dy1 as f32 - vp_y) * scale_y;
12210        let x2 = (dx2 as f32 - vp_x) * scale_x;
12211        let y2 = (dy2 as f32 - vp_y) * scale_y;
12212
12213        let min_x = (x0.min(x1).min(x2).floor().max(0.0)) as usize;
12214        let max_x = (x0.max(x1).max(x2).ceil() as usize).min(pw);
12215        let min_y = (y0.min(y1).min(y2).floor().max(0.0)) as usize;
12216        let max_y = (y0.max(y1).max(y2).ceil() as usize).min(ph);
12217
12218        if min_x >= max_x || min_y >= max_y {
12219            continue;
12220        }
12221
12222        let x0 = x0 as f64;
12223        let y0 = y0 as f64;
12224        let x1 = x1 as f64;
12225        let y1 = y1 as f64;
12226        let x2 = x2 as f64;
12227        let y2 = y2 as f64;
12228        // Swap vertices 1 and 2 when the triangle has reversed winding
12229        // (from a CTM with negative determinant, e.g. X- or Y-flip).
12230        // This ensures barycentric coordinates stay positive for interior
12231        // points regardless of the CTM orientation.
12232        let denom = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2);
12233        if denom.abs() < 1e-10 {
12234            continue;
12235        }
12236        let (x1, y1, x2, y2) = if denom < 0.0 {
12237            (x2, y2, x1, y1)
12238        } else {
12239            (x1, y1, x2, y2)
12240        };
12241        let (v1_ref, v2_ref) = if denom < 0.0 {
12242            (&tri.v2, &tri.v1)
12243        } else {
12244            (&tri.v1, &tri.v2)
12245        };
12246        let denom = denom.abs();
12247        let inv_denom = 1.0 / denom;
12248
12249        for py in min_y..max_y {
12250            for px in min_x..max_x {
12251                let pxf = px as f64 + 0.5;
12252                let pyf = py as f64 + 0.5;
12253
12254                let w0 = ((y1 - y2) * (pxf - x2) + (x2 - x1) * (pyf - y2)) * inv_denom;
12255                let w1 = ((y2 - y0) * (pxf - x2) + (x0 - x2) * (pyf - y2)) * inv_denom;
12256                let w2 = 1.0 - w0 - w1;
12257
12258                if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
12259                    continue;
12260                }
12261
12262                let clipped = clip_mask.is_some_and(|mask| mask.data()[py * pw + px] == 0);
12263
12264                let w0c = w0.max(0.0);
12265                let w1c = w1.max(0.0);
12266                let w2c = w2.max(0.0);
12267                let wsum = w0c + w1c + w2c;
12268                let w0n = w0c / wsum;
12269                let w1n = w1c / wsum;
12270                let w2n = w2c / wsum;
12271
12272                // Per-pixel color: either LUT lookup (for function-based meshes)
12273                // or direct Gouraud interpolation of vertex DeviceColors.
12274                let (r, g, b) = if let Some(lut) = lut {
12275                    // Interpolate raw function input values per-pixel
12276                    let raw = w0n * tri.v0.raw_components[0]
12277                        + w1n * v1_ref.raw_components[0]
12278                        + w2n * v2_ref.raw_components[0];
12279                    let raw = raw.clamp(0.0, 1.0);
12280                    // Linear interpolation in the LUT
12281                    let fi = raw * (lut.len() - 1) as f64;
12282                    let i0 = (fi as usize).min(lut.len().saturating_sub(2));
12283                    let frac = fi - i0 as f64;
12284                    let c0 = &lut[i0];
12285                    let c1 = &lut[i0 + 1];
12286                    (
12287                        c0.r + frac * (c1.r - c0.r),
12288                        c0.g + frac * (c1.g - c0.g),
12289                        c0.b + frac * (c1.b - c0.b),
12290                    )
12291                } else {
12292                    (
12293                        w0n * tri.v0.color.r + w1n * v1_ref.color.r + w2n * v2_ref.color.r,
12294                        w0n * tri.v0.color.g + w1n * v1_ref.color.g + w2n * v2_ref.color.g,
12295                        w0n * tri.v0.color.b + w1n * v1_ref.color.b + w2n * v2_ref.color.b,
12296                    )
12297                };
12298
12299                // Write CMYK buffer
12300                if let Some(ref mut buf) = cmyk_buf {
12301                    let ci = (py * pw + px) * 4;
12302                    if ci + 3 < buf.len() {
12303                        let cmyk = interpolate_cmyk_from_vertices(
12304                            &tri.v0,
12305                            v1_ref,
12306                            v2_ref,
12307                            w0n,
12308                            w1n,
12309                            w2n,
12310                            &params.color_space,
12311                            r,
12312                            g,
12313                            b,
12314                            icc,
12315                        );
12316                        if params.overprint
12317                            && params.painted_channels != stet_graphics::device::CMYK_ALL
12318                        {
12319                            if !clipped {
12320                                if params.painted_channels & stet_graphics::device::CMYK_C != 0 {
12321                                    buf[ci] = cmyk.0 as f32;
12322                                }
12323                                if params.painted_channels & stet_graphics::device::CMYK_M != 0 {
12324                                    buf[ci + 1] = cmyk.1 as f32;
12325                                }
12326                                if params.painted_channels & stet_graphics::device::CMYK_Y != 0 {
12327                                    buf[ci + 2] = cmyk.2 as f32;
12328                                }
12329                                if params.painted_channels & stet_graphics::device::CMYK_K != 0 {
12330                                    buf[ci + 3] = cmyk.3 as f32;
12331                                }
12332                            }
12333                        } else {
12334                            buf[ci] = cmyk.0 as f32;
12335                            buf[ci + 1] = cmyk.1 as f32;
12336                            buf[ci + 2] = cmyk.2 as f32;
12337                            buf[ci + 3] = cmyk.3 as f32;
12338                        }
12339                    }
12340                }
12341
12342                if clipped {
12343                    continue;
12344                }
12345
12346                let offset = py * stride + px * 4;
12347                data[offset] = (r * 255.0).round().clamp(0.0, 255.0) as u8;
12348                data[offset + 1] = (g * 255.0).round().clamp(0.0, 255.0) as u8;
12349                data[offset + 2] = (b * 255.0).round().clamp(0.0, 255.0) as u8;
12350                data[offset + 3] = 255;
12351            }
12352        }
12353    }
12354}
12355
12356/// Render a Coons/tensor-product patch mesh by subdividing into triangles.
12357#[allow(clippy::too_many_arguments)]
12358fn render_patch_shading(
12359    pixmap: &mut Pixmap,
12360    params: &PatchShadingParams,
12361    vp_x: f32,
12362    vp_y: f32,
12363    scale_x: f32,
12364    scale_y: f32,
12365    clip_mask: Option<&Mask>,
12366    cmyk_buf: Option<&mut [f32]>,
12367    icc: Option<&IccCache>,
12368) {
12369    let mut triangles = Vec::new();
12370    let scale = scale_x.max(scale_y) as f64;
12371    for patch in &params.patches {
12372        if patch.points.len() >= 12 {
12373            // Compute device-space extent to choose subdivision level
12374            let mut x_min = f64::INFINITY;
12375            let mut y_min = f64::INFINITY;
12376            let mut x_max = f64::NEG_INFINITY;
12377            let mut y_max = f64::NEG_INFINITY;
12378            for &(px, py) in &patch.points {
12379                let (dx, dy) = params.ctm.transform_point(px, py);
12380                x_min = x_min.min(dx);
12381                y_min = y_min.min(dy);
12382                x_max = x_max.max(dx);
12383                y_max = y_max.max(dy);
12384            }
12385            let extent = (x_max - x_min).max(y_max - y_min).abs() * scale;
12386            // Target ~2 device pixels per boundary segment
12387            let n = (extent / 2.0).ceil().clamp(8.0, 64.0) as usize;
12388            // Extract ICC profile hash for per-grid-point color conversion
12389            let icc_profile_hash = match &params.color_space {
12390                stet_graphics::device::ShadingColorSpace::ICCBased { profile_hash, .. } => {
12391                    Some(profile_hash)
12392                }
12393                _ => None,
12394            };
12395            subdivide_patch_to_triangles(patch, &mut triangles, n, icc_profile_hash, icc);
12396        }
12397    }
12398    if !triangles.is_empty() {
12399        let mesh_params = MeshShadingParams {
12400            triangles,
12401            ctm: params.ctm,
12402            bbox: params.bbox,
12403            color_space: params.color_space.clone(),
12404            overprint: params.overprint,
12405            painted_channels: params.painted_channels,
12406            color_lut: params.color_lut.clone(),
12407            alpha: params.alpha,
12408            blend_mode: params.blend_mode,
12409            alpha_is_shape: params.alpha_is_shape,
12410        };
12411        render_mesh_shading(
12412            pixmap,
12413            &mesh_params,
12414            vp_x,
12415            vp_y,
12416            scale_x,
12417            scale_y,
12418            clip_mask,
12419            cmyk_buf,
12420            icc,
12421        );
12422    }
12423}
12424/// Subdivide a Coons/tensor patch into triangles via grid subdivision.
12425/// Evaluates the patch at NxN points and triangulates the resulting grid.
12426/// When an ICC profile hash and cache are provided, interpolates colors in the
12427/// source ICC color space and converts per-grid-point for accurate rendering.
12428fn subdivide_patch_to_triangles(
12429    patch: &stet_graphics::device::ShadingPatch,
12430    triangles: &mut Vec<stet_graphics::device::ShadingTriangle>,
12431    n: usize,
12432    icc_profile_hash: Option<&stet_graphics::icc::ProfileHash>,
12433    icc_cache: Option<&IccCache>,
12434) {
12435    // Evaluate patch at grid points.
12436    // Use tensor-product evaluation when 16 control points are available (Type 7),
12437    // otherwise fall back to Coons blending (Type 6, 12 points).
12438    let mut grid: Vec<(f64, f64, DeviceColor, Vec<f64>)> = Vec::with_capacity((n + 1) * (n + 1));
12439    let use_tensor = patch.points.len() >= 16;
12440    let has_raw = !patch.raw_colors[0].is_empty();
12441    // Use per-grid-point ICC conversion when profile info is available
12442    let use_icc_interp = has_raw && icc_profile_hash.is_some() && icc_cache.is_some();
12443
12444    for row in 0..=n {
12445        let v = row as f64 / n as f64;
12446        for col in 0..=n {
12447            let u = col as f64 / n as f64;
12448            let (x, y) = if use_tensor {
12449                eval_tensor_patch(patch, u, v)
12450            } else {
12451                eval_coons_patch(patch, u, v)
12452            };
12453            let raw = if has_raw {
12454                bilinear_raw(&patch.raw_colors, u, v)
12455            } else {
12456                vec![]
12457            };
12458            // When ICC profile is available, convert the interpolated raw
12459            // components at each grid point for accurate color rendering.
12460            // This interpolates in the source color space (e.g. ProPhoto RGB)
12461            // and converts per-grid-point, rather than interpolating pre-converted
12462            // sRGB values from only the 4 corners.
12463            let color = if use_icc_interp {
12464                if let Some((r, g, b)) = icc_cache
12465                    .unwrap()
12466                    .convert_color_readonly(icc_profile_hash.unwrap(), &raw)
12467                {
12468                    DeviceColor::from_rgb(r, g, b)
12469                } else {
12470                    bilinear_color(&patch.colors, u, v)
12471                }
12472            } else {
12473                bilinear_color(&patch.colors, u, v)
12474            };
12475            grid.push((x, y, color, raw));
12476        }
12477    }
12478
12479    // Triangulate grid
12480    let cols = n + 1;
12481    for row in 0..n {
12482        for col in 0..n {
12483            let i00 = row * cols + col;
12484            let i10 = i00 + 1;
12485            let i01 = i00 + cols;
12486            let i11 = i01 + 1;
12487
12488            let (x00, y00, c00, r00) = &grid[i00];
12489            let (x10, y10, c10, r10) = &grid[i10];
12490            let (x01, y01, c01, r01) = &grid[i01];
12491            let (x11, y11, c11, r11) = &grid[i11];
12492
12493            use stet_graphics::device::ShadingVertex;
12494            triangles.push(stet_graphics::device::ShadingTriangle {
12495                v0: ShadingVertex {
12496                    x: *x00,
12497                    y: *y00,
12498                    color: c00.clone(),
12499                    raw_components: r00.clone(),
12500                },
12501                v1: ShadingVertex {
12502                    x: *x10,
12503                    y: *y10,
12504                    color: c10.clone(),
12505                    raw_components: r10.clone(),
12506                },
12507                v2: ShadingVertex {
12508                    x: *x01,
12509                    y: *y01,
12510                    color: c01.clone(),
12511                    raw_components: r01.clone(),
12512                },
12513            });
12514            triangles.push(stet_graphics::device::ShadingTriangle {
12515                v0: ShadingVertex {
12516                    x: *x10,
12517                    y: *y10,
12518                    color: c10.clone(),
12519                    raw_components: r10.clone(),
12520                },
12521                v1: ShadingVertex {
12522                    x: *x11,
12523                    y: *y11,
12524                    color: c11.clone(),
12525                    raw_components: r11.clone(),
12526                },
12527                v2: ShadingVertex {
12528                    x: *x01,
12529                    y: *y01,
12530                    color: c01.clone(),
12531                    raw_components: r01.clone(),
12532                },
12533            });
12534        }
12535    }
12536}
12537
12538/// Evaluate a Coons patch at parameter (u, v).
12539/// The 12 control points define 4 cubic Bezier boundary curves.
12540fn eval_coons_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12541    let pts = &patch.points;
12542    if pts.len() < 12 {
12543        return (0.0, 0.0);
12544    }
12545
12546    // Side 0 (bottom): pts[0..4], u goes 0→1
12547    // Side 1 (right): pts[3..7], v goes 0→1
12548    // Side 2 (top): pts[6..10], u goes 1→0 (reversed)
12549    // Side 3 (left): pts[9..12] + pts[0], v goes 1→0 (reversed)
12550    let c0 = eval_cubic_bezier(pts[0], pts[1], pts[2], pts[3], u);
12551    let c2 = eval_cubic_bezier(pts[6], pts[7], pts[8], pts[9], 1.0 - u);
12552    let d0 = eval_cubic_bezier(pts[0], pts[11], pts[10], pts[9], v);
12553    let d1 = eval_cubic_bezier(pts[3], pts[4], pts[5], pts[6], v);
12554
12555    // Bilinear blending of corners
12556    let p00 = pts[0];
12557    let p10 = pts[3];
12558    let p01 = pts[9];
12559    let p11 = pts[6];
12560    let bx = (1.0 - u) * (1.0 - v) * p00.0
12561        + u * (1.0 - v) * p10.0
12562        + (1.0 - u) * v * p01.0
12563        + u * v * p11.0;
12564    let by = (1.0 - u) * (1.0 - v) * p00.1
12565        + u * (1.0 - v) * p10.1
12566        + (1.0 - u) * v * p01.1
12567        + u * v * p11.1;
12568
12569    // Coons blending: S(u,v) = c(u,v) + d(u,v) - B(u,v)
12570    let x = (1.0 - v) * c0.0 + v * c2.0 + (1.0 - u) * d0.0 + u * d1.0 - bx;
12571    let y = (1.0 - v) * c0.1 + v * c2.1 + (1.0 - u) * d0.1 + u * d1.1 - by;
12572
12573    (x, y)
12574}
12575
12576/// Evaluate a Type 7 tensor-product patch at parameter (u, v).
12577///
12578/// Uses 16 control points arranged in a 4×4 grid, evaluated as a bicubic
12579/// Bernstein surface: S(u,v) = ΣΣ B_i(u) * B_j(v) * P_ij
12580///
12581/// PDF spec (ISO 32000, Table 85) data ordering for flag=0:
12582///   p₁₁ p₁₂ p₁₃ p₁₄  p₂₁ p₂₂ p₂₃ p₂₄  p₃₁ p₃₂ p₃₃ p₃₄  p₄₁ p₄₂ p₄₃ p₄₄
12583///
12584/// In the grid (Figure 86), column index = u direction, row index = v direction:
12585///   grid[v=0][u] = p₁₁, p₂₁, p₃₁, p₄₁  = pts[0], pts[4], pts[8],  pts[12]
12586///   grid[v=⅓][u] = p₁₂, p₂₂, p₃₂, p₄₂  = pts[1], pts[5], pts[9],  pts[13]
12587///   grid[v=⅔][u] = p₁₃, p₂₃, p₃₃, p₄₃  = pts[2], pts[6], pts[10], pts[14]
12588///   grid[v=1][u] = p₁₄, p₂₄, p₃₄, p₄₄  = pts[3], pts[7], pts[11], pts[15]
12589fn eval_tensor_patch(patch: &stet_graphics::device::ShadingPatch, u: f64, v: f64) -> (f64, f64) {
12590    let pts = &patch.points;
12591
12592    // Map data indices to 4×4 grid [row][col].
12593    // pts[0..12] are boundary points around the perimeter (same as Type 6).
12594    // pts[12..16] are the 4 interior control points.
12595    let grid: [[usize; 4]; 4] = [[0, 1, 2, 3], [11, 12, 13, 4], [10, 15, 14, 5], [9, 8, 7, 6]];
12596
12597    // Cubic Bernstein basis values
12598    let su = 1.0 - u;
12599    let bu = [su * su * su, 3.0 * su * su * u, 3.0 * su * u * u, u * u * u];
12600    let sv = 1.0 - v;
12601    let bv = [sv * sv * sv, 3.0 * sv * sv * v, 3.0 * sv * v * v, v * v * v];
12602
12603    let mut x = 0.0;
12604    let mut y = 0.0;
12605    for j in 0..4 {
12606        for i in 0..4 {
12607            let w = bu[i] * bv[j];
12608            let p = pts[grid[j][i]];
12609            x += w * p.0;
12610            y += w * p.1;
12611        }
12612    }
12613    (x, y)
12614}
12615
12616/// Evaluate a cubic Bezier curve at parameter t.
12617fn eval_cubic_bezier(
12618    p0: (f64, f64),
12619    p1: (f64, f64),
12620    p2: (f64, f64),
12621    p3: (f64, f64),
12622    t: f64,
12623) -> (f64, f64) {
12624    let s = 1.0 - t;
12625    let s2 = s * s;
12626    let t2 = t * t;
12627    let b0 = s2 * s;
12628    let b1 = 3.0 * s2 * t;
12629    let b2 = 3.0 * s * t2;
12630    let b3 = t2 * t;
12631    (
12632        b0 * p0.0 + b1 * p1.0 + b2 * p2.0 + b3 * p3.0,
12633        b0 * p0.1 + b1 * p1.1 + b2 * p2.1 + b3 * p3.1,
12634    )
12635}
12636
12637/// Bilinear color interpolation across patch corners.
12638fn bilinear_color(colors: &[DeviceColor; 4], u: f64, v: f64) -> DeviceColor {
12639    let r = (1.0 - u) * (1.0 - v) * colors[0].r
12640        + u * (1.0 - v) * colors[1].r
12641        + (1.0 - u) * v * colors[3].r
12642        + u * v * colors[2].r;
12643    let g = (1.0 - u) * (1.0 - v) * colors[0].g
12644        + u * (1.0 - v) * colors[1].g
12645        + (1.0 - u) * v * colors[3].g
12646        + u * v * colors[2].g;
12647    let b = (1.0 - u) * (1.0 - v) * colors[0].b
12648        + u * (1.0 - v) * colors[1].b
12649        + (1.0 - u) * v * colors[3].b
12650        + u * v * colors[2].b;
12651    DeviceColor::from_rgb(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
12652}
12653
12654/// Bilinear interpolation of raw color components across patch corners.
12655fn bilinear_raw(raw_colors: &[Vec<f64>; 4], u: f64, v: f64) -> Vec<f64> {
12656    let n = raw_colors[0].len();
12657    let mut result = vec![0.0; n];
12658    for i in 0..n {
12659        result[i] = (1.0 - u) * (1.0 - v) * raw_colors[0][i]
12660            + u * (1.0 - v) * raw_colors[1][i]
12661            + (1.0 - u) * v * raw_colors[3][i]
12662            + u * v * raw_colors[2][i];
12663    }
12664    result
12665}
12666
12667/// Pre-rasterize color stops into a 256-entry RGBA lookup table.
12668///
12669/// Each entry is linearly interpolated from the color stops. Used by the
12670/// direct-rasterization axial shading path to replace per-pixel stop search
12671/// with a single array lookup.
12672fn build_gradient_lut(stops: &[stet_graphics::device::ColorStop], size: usize) -> Vec<[u8; 4]> {
12673    let size = size.max(2);
12674    let mut lut = vec![[0u8; 4]; size];
12675    if stops.is_empty() {
12676        return lut;
12677    }
12678    let mut si = 0usize; // current stop index
12679    let last = (size - 1) as f64;
12680    for i in 0..size {
12681        let t = i as f64 / last;
12682        // Advance stop index
12683        while si + 1 < stops.len() && stops[si + 1].position < t {
12684            si += 1;
12685        }
12686        let (r, g, b) = if si + 1 >= stops.len() {
12687            let c = &stops[stops.len() - 1].color;
12688            (c.r, c.g, c.b)
12689        } else if t <= stops[si].position {
12690            let c = &stops[si].color;
12691            (c.r, c.g, c.b)
12692        } else {
12693            let t0 = stops[si].position;
12694            let t1 = stops[si + 1].position;
12695            let frac = if (t1 - t0).abs() < 1e-10 {
12696                0.0
12697            } else {
12698                (t - t0) / (t1 - t0)
12699            };
12700            let c0 = &stops[si].color;
12701            let c1 = &stops[si + 1].color;
12702            (
12703                c0.r + frac * (c1.r - c0.r),
12704                c0.g + frac * (c1.g - c0.g),
12705                c0.b + frac * (c1.b - c0.b),
12706            )
12707        };
12708        lut[i] = [
12709            (r * 255.0).round().clamp(0.0, 255.0) as u8,
12710            (g * 255.0).round().clamp(0.0, 255.0) as u8,
12711            (b * 255.0).round().clamp(0.0, 255.0) as u8,
12712            255,
12713        ];
12714    }
12715    lut
12716}
12717
12718/// Build tiny-skia gradient stops from color stops.
12719fn build_gradient_stops(
12720    stops: &[stet_graphics::device::ColorStop],
12721) -> Vec<stet_tiny_skia::GradientStop> {
12722    let mut result = Vec::with_capacity(stops.len());
12723    for stop in stops {
12724        let r = (stop.color.r * 255.0).round().clamp(0.0, 255.0) as u8;
12725        let g = (stop.color.g * 255.0).round().clamp(0.0, 255.0) as u8;
12726        let b = (stop.color.b * 255.0).round().clamp(0.0, 255.0) as u8;
12727        result.push(stet_tiny_skia::GradientStop::new(
12728            stop.position as f32,
12729            Color::from_rgba8(r, g, b, 255),
12730        ));
12731    }
12732    result
12733}
12734
12735/// Interpolate between color stops at a given position (0.0..=1.0).
12736fn interpolate_color_stops(
12737    stops: &[stet_graphics::device::ColorStop],
12738    position: f64,
12739) -> DeviceColor {
12740    if stops.is_empty() {
12741        return DeviceColor::from_gray(0.0);
12742    }
12743    if stops.len() == 1 || position <= stops[0].position {
12744        return stops[0].color.clone();
12745    }
12746    if position >= stops.last().unwrap().position {
12747        return stops.last().unwrap().color.clone();
12748    }
12749
12750    // Find the two stops bracketing this position
12751    for i in 1..stops.len() {
12752        if position <= stops[i].position {
12753            let t0 = stops[i - 1].position;
12754            let t1 = stops[i].position;
12755            let frac = if (t1 - t0).abs() < 1e-10 {
12756                0.0
12757            } else {
12758                (position - t0) / (t1 - t0)
12759            };
12760            let c0 = &stops[i - 1].color;
12761            let c1 = &stops[i].color;
12762            return DeviceColor::from_rgb(
12763                (c0.r + frac * (c1.r - c0.r)).clamp(0.0, 1.0),
12764                (c0.g + frac * (c1.g - c0.g)).clamp(0.0, 1.0),
12765                (c0.b + frac * (c1.b - c0.b)).clamp(0.0, 1.0),
12766            );
12767        }
12768    }
12769
12770    stops.last().unwrap().color.clone()
12771}
12772
12773/// Derive CMYK values from color stops at parameter t.
12774///
12775/// For DeviceCMYK shading color spaces the per-stop `raw_components` carry the
12776/// authoritative 4-channel CMYK values (already tint-transformed for
12777/// Separation/DeviceN with a CMYK alt) — those are interpolated directly.
12778///
12779/// For non-CMYK source color spaces (DeviceRGB, DeviceGray, CalRGB, CalGray,
12780/// ICCBased non-4) the interpolated sRGB color is round-tripped to CMYK via
12781/// the system CMYK ICC profile so the parallel CMYK buffer holds an accurate
12782/// representation. Falls back to PLRM `(1−r, 1−g, 1−b, 0)` when no system
12783/// profile is registered (e.g. `--no-icc`).
12784fn interpolate_cmyk_from_stops(
12785    stops: &[stet_graphics::device::ColorStop],
12786    cs: &ShadingColorSpace,
12787    t: f64,
12788    color: &DeviceColor,
12789    icc: Option<&IccCache>,
12790) -> (f64, f64, f64, f64) {
12791    let rgb_to_cmyk = |c: &DeviceColor| -> (f64, f64, f64, f64) {
12792        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(c.r, c.g, c.b)) {
12793            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
12794        } else {
12795            (
12796                (1.0 - c.r).clamp(0.0, 1.0),
12797                (1.0 - c.g).clamp(0.0, 1.0),
12798                (1.0 - c.b).clamp(0.0, 1.0),
12799                0.0,
12800            )
12801        }
12802    };
12803
12804    match cs {
12805        ShadingColorSpace::DeviceCMYK => {
12806            // Interpolate raw CMYK components from stops
12807            if stops.len() == 1 {
12808                let rc = &stops[0].raw_components;
12809                if rc.len() >= 4 {
12810                    return (rc[0], rc[1], rc[2], rc[3]);
12811                }
12812            }
12813            // Find surrounding stops and interpolate
12814            let mut lo = &stops[0];
12815            let mut hi = stops.last().unwrap();
12816            for i in 0..stops.len() - 1 {
12817                if stops[i + 1].position >= t {
12818                    lo = &stops[i];
12819                    hi = &stops[i + 1];
12820                    break;
12821                }
12822            }
12823            let span = hi.position - lo.position;
12824            let frac = if span > 1e-10 {
12825                (t - lo.position) / span
12826            } else {
12827                0.0
12828            };
12829            let frac = frac.clamp(0.0, 1.0);
12830            if lo.raw_components.len() >= 4 && hi.raw_components.len() >= 4 {
12831                (
12832                    lo.raw_components[0] + frac * (hi.raw_components[0] - lo.raw_components[0]),
12833                    lo.raw_components[1] + frac * (hi.raw_components[1] - lo.raw_components[1]),
12834                    lo.raw_components[2] + frac * (hi.raw_components[2] - lo.raw_components[2]),
12835                    lo.raw_components[3] + frac * (hi.raw_components[3] - lo.raw_components[3]),
12836                )
12837            } else {
12838                rgb_to_cmyk(color)
12839            }
12840        }
12841        _ => rgb_to_cmyk(color),
12842    }
12843}
12844
12845/// Derive CMYK values from triangle mesh vertices using barycentric weights.
12846///
12847/// Mirrors [`interpolate_cmyk_from_stops`]: DeviceCMYK source spaces use the
12848/// per-vertex `raw_components`, non-CMYK spaces ICC-reverse the interpolated
12849/// sRGB color, and PLRM is the last-resort fallback.
12850#[allow(clippy::too_many_arguments)]
12851fn interpolate_cmyk_from_vertices(
12852    v0: &ShadingVertex,
12853    v1: &ShadingVertex,
12854    v2: &ShadingVertex,
12855    w0: f64,
12856    w1: f64,
12857    w2: f64,
12858    cs: &ShadingColorSpace,
12859    r: f64,
12860    g: f64,
12861    b: f64,
12862    icc: Option<&IccCache>,
12863) -> (f64, f64, f64, f64) {
12864    let rgb_to_cmyk = |r: f64, g: f64, b: f64| -> (f64, f64, f64, f64) {
12865        if let Some(cmyk) = icc.and_then(|i| i.convert_rgb_to_cmyk_readonly(r, g, b)) {
12866            (cmyk[0], cmyk[1], cmyk[2], cmyk[3])
12867        } else {
12868            (
12869                (1.0 - r).clamp(0.0, 1.0),
12870                (1.0 - g).clamp(0.0, 1.0),
12871                (1.0 - b).clamp(0.0, 1.0),
12872                0.0,
12873            )
12874        }
12875    };
12876
12877    match cs {
12878        ShadingColorSpace::DeviceCMYK => {
12879            if v0.raw_components.len() >= 4
12880                && v1.raw_components.len() >= 4
12881                && v2.raw_components.len() >= 4
12882            {
12883                (
12884                    w0 * v0.raw_components[0]
12885                        + w1 * v1.raw_components[0]
12886                        + w2 * v2.raw_components[0],
12887                    w0 * v0.raw_components[1]
12888                        + w1 * v1.raw_components[1]
12889                        + w2 * v2.raw_components[1],
12890                    w0 * v0.raw_components[2]
12891                        + w1 * v1.raw_components[2]
12892                        + w2 * v2.raw_components[2],
12893                    w0 * v0.raw_components[3]
12894                        + w1 * v1.raw_components[3]
12895                        + w2 * v2.raw_components[3],
12896                )
12897            } else {
12898                rgb_to_cmyk(r, g, b)
12899            }
12900        }
12901        _ => rgb_to_cmyk(r, g, b),
12902    }
12903}
12904
12905#[cfg(test)]
12906mod tests {
12907    use super::*;
12908    use stet_graphics::color::DashPattern;
12909    use stet_graphics::device::{BgUcrState, HalftoneState, TransferState};
12910
12911    #[test]
12912    fn test_create_device() {
12913        let dev = SkiaDevice::new(100, 100);
12914        assert_eq!(dev.page_size(), (100, 100));
12915    }
12916
12917    #[test]
12918    fn test_fill_rect() {
12919        let mut dev = SkiaDevice::new(100, 100);
12920        let mut path = PsPath::new();
12921        path.segments.push(PathSegment::MoveTo(10.0, 10.0));
12922        path.segments.push(PathSegment::LineTo(90.0, 10.0));
12923        path.segments.push(PathSegment::LineTo(90.0, 90.0));
12924        path.segments.push(PathSegment::LineTo(10.0, 90.0));
12925        path.segments.push(PathSegment::ClosePath);
12926
12927        let params = FillParams {
12928            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
12929            fill_rule: FillRule::NonZeroWinding,
12930            ctm: Matrix::identity(),
12931            is_text_glyph: false,
12932            overprint: false,
12933            overprint_mode: 0,
12934            opm_paired: false,
12935            painted_channels: 0,
12936            is_device_cmyk: false,
12937            spot_color: None,
12938            rendering_intent: 0,
12939            transfer: TransferState::default(),
12940            halftone: HalftoneState::default(),
12941            bg_ucr: BgUcrState::default(),
12942            alpha: 1.0,
12943            blend_mode: 0,
12944            alpha_is_shape: false,
12945        };
12946        dev.fill_path(&path, &params);
12947
12948        // Check that pixel at center is red
12949        let pixel = dev.pixmap().pixel(50, 50).unwrap();
12950        assert_eq!(pixel.red(), 255);
12951        assert_eq!(pixel.green(), 0);
12952        assert_eq!(pixel.blue(), 0);
12953    }
12954
12955    #[test]
12956    fn test_stroke_line() {
12957        let mut dev = SkiaDevice::new(100, 100);
12958        let mut path = PsPath::new();
12959        path.segments.push(PathSegment::MoveTo(10.0, 50.0));
12960        path.segments.push(PathSegment::LineTo(90.0, 50.0));
12961
12962        let params = StrokeParams {
12963            color: DeviceColor::from_rgb(0.0, 0.0, 1.0),
12964            line_width: 4.0,
12965            line_cap: LineCap::Butt,
12966            line_join: LineJoin::Miter,
12967            miter_limit: 10.0,
12968            dash_pattern: DashPattern::solid(),
12969            ctm: Matrix::identity(),
12970            stroke_adjust: false,
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: 1.0,
12983            blend_mode: 0,
12984            alpha_is_shape: false,
12985        };
12986        dev.stroke_path(&path, &params);
12987
12988        // Check that pixel on the line is blue
12989        let pixel = dev.pixmap().pixel(50, 50).unwrap();
12990        assert_eq!(pixel.blue(), 255);
12991    }
12992
12993    #[test]
12994    fn test_clip() {
12995        let mut dev = SkiaDevice::new(100, 100);
12996
12997        // Set clip to left half
12998        let mut clip_path = PsPath::new();
12999        clip_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13000        clip_path.segments.push(PathSegment::LineTo(50.0, 0.0));
13001        clip_path.segments.push(PathSegment::LineTo(50.0, 100.0));
13002        clip_path.segments.push(PathSegment::LineTo(0.0, 100.0));
13003        clip_path.segments.push(PathSegment::ClosePath);
13004
13005        let clip_params = ClipParams {
13006            fill_rule: FillRule::NonZeroWinding,
13007            ctm: Matrix::identity(),
13008            stroke_params: None,
13009        };
13010        dev.clip_path(&clip_path, &clip_params);
13011
13012        // Fill entire page with red
13013        let mut fill_path = PsPath::new();
13014        fill_path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13015        fill_path.segments.push(PathSegment::LineTo(100.0, 0.0));
13016        fill_path.segments.push(PathSegment::LineTo(100.0, 100.0));
13017        fill_path.segments.push(PathSegment::LineTo(0.0, 100.0));
13018        fill_path.segments.push(PathSegment::ClosePath);
13019
13020        let fill_params = FillParams {
13021            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
13022            fill_rule: FillRule::NonZeroWinding,
13023            ctm: Matrix::identity(),
13024            is_text_glyph: false,
13025            overprint: false,
13026            overprint_mode: 0,
13027            opm_paired: false,
13028            painted_channels: 0,
13029            is_device_cmyk: false,
13030            spot_color: None,
13031            rendering_intent: 0,
13032            transfer: TransferState::default(),
13033            halftone: HalftoneState::default(),
13034            bg_ucr: BgUcrState::default(),
13035            alpha: 1.0,
13036            blend_mode: 0,
13037            alpha_is_shape: false,
13038        };
13039        dev.fill_path(&fill_path, &fill_params);
13040
13041        // Left half should be red
13042        let left_pixel = dev.pixmap().pixel(25, 50).unwrap();
13043        assert_eq!(left_pixel.red(), 255);
13044
13045        // Right half should still be white
13046        let right_pixel = dev.pixmap().pixel(75, 50).unwrap();
13047        assert_eq!(right_pixel.red(), 255);
13048        assert_eq!(right_pixel.green(), 255); // white
13049    }
13050
13051    #[test]
13052    fn test_erase_page() {
13053        let mut dev = SkiaDevice::new(100, 100);
13054        // Fill with red
13055        let mut path = PsPath::new();
13056        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13057        path.segments.push(PathSegment::LineTo(100.0, 0.0));
13058        path.segments.push(PathSegment::LineTo(100.0, 100.0));
13059        path.segments.push(PathSegment::LineTo(0.0, 100.0));
13060        path.segments.push(PathSegment::ClosePath);
13061        let params = FillParams {
13062            color: DeviceColor::from_rgb(1.0, 0.0, 0.0),
13063            fill_rule: FillRule::NonZeroWinding,
13064            ctm: Matrix::identity(),
13065            is_text_glyph: false,
13066            overprint: false,
13067            overprint_mode: 0,
13068            opm_paired: false,
13069            painted_channels: 0,
13070            is_device_cmyk: false,
13071            spot_color: None,
13072            rendering_intent: 0,
13073            transfer: TransferState::default(),
13074            halftone: HalftoneState::default(),
13075            bg_ucr: BgUcrState::default(),
13076            alpha: 1.0,
13077            blend_mode: 0,
13078            alpha_is_shape: false,
13079        };
13080        dev.fill_path(&path, &params);
13081
13082        dev.erase_page();
13083
13084        // Should be white again
13085        let pixel = dev.pixmap().pixel(50, 50).unwrap();
13086        assert_eq!(pixel.red(), 255);
13087        assert_eq!(pixel.green(), 255);
13088        assert_eq!(pixel.blue(), 255);
13089    }
13090
13091    #[test]
13092    fn test_show_page() {
13093        let mut dev = SkiaDevice::new(10, 10);
13094        let path = std::env::temp_dir().join("stet_test_output.png");
13095        let path_str = path.to_string_lossy();
13096        let result = dev.show_page(&path_str);
13097        assert!(result.is_ok());
13098        assert!(path.exists());
13099        std::fs::remove_file(&path).ok();
13100    }
13101
13102    #[test]
13103    fn test_transform() {
13104        let mut dev = SkiaDevice::new(200, 200);
13105        // Draw at origin with a translate transform
13106        let mut path = PsPath::new();
13107        path.segments.push(PathSegment::MoveTo(0.0, 0.0));
13108        path.segments.push(PathSegment::LineTo(10.0, 0.0));
13109        path.segments.push(PathSegment::LineTo(10.0, 10.0));
13110        path.segments.push(PathSegment::LineTo(0.0, 10.0));
13111        path.segments.push(PathSegment::ClosePath);
13112
13113        let params = FillParams {
13114            color: DeviceColor::from_rgb(0.0, 1.0, 0.0),
13115            fill_rule: FillRule::NonZeroWinding,
13116            ctm: Matrix::translate(100.0, 100.0),
13117            is_text_glyph: false,
13118            overprint: false,
13119            overprint_mode: 0,
13120            opm_paired: false,
13121            painted_channels: 0,
13122            is_device_cmyk: false,
13123            spot_color: None,
13124            rendering_intent: 0,
13125            transfer: TransferState::default(),
13126            halftone: HalftoneState::default(),
13127            bg_ucr: BgUcrState::default(),
13128            alpha: 1.0,
13129            blend_mode: 0,
13130            alpha_is_shape: false,
13131        };
13132        dev.fill_path(&path, &params);
13133
13134        // Pixel at translated location should be green
13135        let pixel = dev.pixmap().pixel(105, 105).unwrap();
13136        assert_eq!(pixel.green(), 255);
13137        assert_eq!(pixel.red(), 0);
13138    }
13139
13140    fn make_test_fill_at(x: f64, y: f64, w: f64, h: f64) -> DisplayElement {
13141        let mut path = PsPath::new();
13142        path.segments.push(PathSegment::MoveTo(x, y));
13143        path.segments.push(PathSegment::LineTo(x + w, y));
13144        path.segments.push(PathSegment::LineTo(x + w, y + h));
13145        path.segments.push(PathSegment::LineTo(x, y + h));
13146        path.segments.push(PathSegment::ClosePath);
13147        DisplayElement::Fill {
13148            path,
13149            params: FillParams {
13150                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
13151                fill_rule: FillRule::NonZeroWinding,
13152                ctm: Matrix::identity(),
13153                is_text_glyph: false,
13154                overprint: false,
13155                overprint_mode: 0,
13156                opm_paired: false,
13157                painted_channels: 0,
13158                is_device_cmyk: false,
13159                spot_color: None,
13160                rendering_intent: 0,
13161                transfer: TransferState::default(),
13162                halftone: HalftoneState::default(),
13163                bg_ucr: BgUcrState::default(),
13164                alpha: 1.0,
13165                blend_mode: 0,
13166                alpha_is_shape: false,
13167            },
13168        }
13169    }
13170
13171    #[test]
13172    fn test_compute_paint_bounds_two_fills() {
13173        let mut list = DisplayList::new();
13174        list.push(make_test_fill_at(10.0, 20.0, 30.0, 40.0)); // [10..40, 20..60]
13175        list.push(make_test_fill_at(100.0, 50.0, 50.0, 25.0)); // [100..150, 50..75]
13176
13177        let bounds = compute_paint_bounds(&list, 72.0).expect("expected union bounds");
13178        assert!(
13179            (bounds.x_min - 10.0).abs() < 1e-9,
13180            "x_min was {}",
13181            bounds.x_min
13182        );
13183        assert!(
13184            (bounds.y_min - 20.0).abs() < 1e-9,
13185            "y_min was {}",
13186            bounds.y_min
13187        );
13188        assert!(
13189            (bounds.x_max - 150.0).abs() < 1e-9,
13190            "x_max was {}",
13191            bounds.x_max
13192        );
13193        assert!(
13194            (bounds.y_max - 75.0).abs() < 1e-9,
13195            "y_max was {}",
13196            bounds.y_max
13197        );
13198    }
13199
13200    #[test]
13201    fn test_compute_paint_bounds_empty_list() {
13202        let list = DisplayList::new();
13203        assert!(compute_paint_bounds(&list, 72.0).is_none());
13204    }
13205
13206    #[test]
13207    fn test_compute_paint_bounds_only_clip_returns_none() {
13208        let mut list = DisplayList::new();
13209        list.push(DisplayElement::InitClip);
13210        // Clip / InitClip / ErasePage are skipped (return None from
13211        // precompute_full_bboxes), so a list of only clip ops yields no bounds.
13212        assert!(compute_paint_bounds(&list, 72.0).is_none());
13213    }
13214
13215    #[test]
13216    fn test_rasterize_mask_anchors_to_paint_bounds() {
13217        use stet_graphics::display_list::{SoftMaskParams, SoftMaskSubtype};
13218
13219        // A 50×40 white fill at page coords (200, 300)..(250, 340).
13220        // Mask paint bounds in device units: x [200..250], y [300..340].
13221        let mut mask = DisplayList::new();
13222        let mut path = PsPath::new();
13223        path.segments.push(PathSegment::MoveTo(200.0, 300.0));
13224        path.segments.push(PathSegment::LineTo(250.0, 300.0));
13225        path.segments.push(PathSegment::LineTo(250.0, 340.0));
13226        path.segments.push(PathSegment::LineTo(200.0, 340.0));
13227        path.segments.push(PathSegment::ClosePath);
13228        mask.push(DisplayElement::Fill {
13229            path,
13230            params: FillParams {
13231                color: DeviceColor::from_rgb(1.0, 1.0, 1.0),
13232                fill_rule: FillRule::NonZeroWinding,
13233                ctm: Matrix::identity(),
13234                is_text_glyph: false,
13235                overprint: false,
13236                overprint_mode: 0,
13237                opm_paired: false,
13238                painted_channels: 0,
13239                is_device_cmyk: false,
13240                spot_color: None,
13241                rendering_intent: 0,
13242                transfer: TransferState::default(),
13243                halftone: HalftoneState::default(),
13244                bg_ucr: BgUcrState::default(),
13245                alpha: 1.0,
13246                blend_mode: 0,
13247                alpha_is_shape: false,
13248            },
13249        });
13250
13251        let params = SoftMaskParams {
13252            subtype: SoftMaskSubtype::Luminosity,
13253            // Form bbox; intentionally tighter than paint bounds — the
13254            // raster should follow paint bounds, not this.
13255            bbox: [0.0, 0.0, 100.0, 100.0],
13256            backdrop_color: None, // black backdrop → out-of-bounds value = 0
13257            transfer_invert: false,
13258            has_nested_mask_scope: false,
13259            parent_clip_bbox: None,
13260        };
13261
13262        let raster = rasterize_mask(
13263            &mask,
13264            &params,
13265            None,
13266            false,
13267            72.0,
13268            1.0,
13269            1.0,
13270            &LayerSet::new(),
13271        )
13272        .expect("expected raster");
13273
13274        // Origin must be at (or just before) the paint bounds, with the
13275        // 1-pixel AA pad.
13276        assert_eq!(raster.origin_x, 199);
13277        assert_eq!(raster.origin_y, 299);
13278        // Width / height = paint bounds + 2 pixels of pad (1 each side).
13279        assert_eq!(raster.width, 52);
13280        assert_eq!(raster.height, 42);
13281        assert_eq!(raster.scale_x, 1.0);
13282        assert_eq!(raster.scale_y, 1.0);
13283
13284        // The raster should be non-zero somewhere inside the painted region.
13285        // Sample the center of the painted area: page (225, 320) → mask
13286        // index (225 - 199, 320 - 299) = (26, 21).
13287        let mx = 225 - raster.origin_x;
13288        let my = 320 - raster.origin_y;
13289        assert!(mx >= 0 && (mx as u32) < raster.width);
13290        assert!(my >= 0 && (my as u32) < raster.height);
13291        let center_value = raster.data[(my as usize) * raster.width as usize + mx as usize];
13292        assert_eq!(
13293            center_value, 255,
13294            "center of painted mask should be opaque white (lum=255)"
13295        );
13296
13297        // A point outside the paint bounds (page (300, 320)) maps to mask
13298        // index (101, 21) which is outside the raster width — sampling
13299        // there should fall back to out_of_bounds_mask_value(params) = 0.
13300        let mx_out = 300 - raster.origin_x;
13301        let in_bounds = mx_out >= 0 && (mx_out as u32) < raster.width;
13302        assert!(!in_bounds, "page x=300 should be outside the mask raster");
13303        assert_eq!(
13304            out_of_bounds_mask_value(&params),
13305            0,
13306            "black backdrop → out-of-bounds = 0"
13307        );
13308    }
13309
13310    #[test]
13311    fn test_band_local_to_mask_formula() {
13312        // Verify the band-local → page-pixel → mask-index arithmetic for
13313        // several band offsets. This is the highest-risk part of Step 4
13314        // because it bridges three coordinate systems:
13315        //
13316        //   band-local pixel (x, y)
13317        //     + (crop_x, crop_y)            → soft-mask offset within band
13318        //     + (vp_x_pixels, vp_y_pixels)  → page-pixel position
13319        //     - (origin_x, origin_y)        → mask raster index
13320
13321        // Mask raster anchored at page-pixel (200, 300).
13322        let raster_origin_x = 200i32;
13323        let raster_origin_y = 300i32;
13324
13325        // Helper that runs the formula from render_soft_masked.
13326        let sample = |vp_x_dev: f32,
13327                      vp_y_dev: f32,
13328                      scale: f32,
13329                      crop_x: i32,
13330                      crop_y: i32,
13331                      x: i32,
13332                      y: i32|
13333         -> (i32, i32) {
13334            let vp_x_pixels = (vp_x_dev * scale).round() as i32;
13335            let vp_y_pixels = (vp_y_dev * scale).round() as i32;
13336            let page_x = vp_x_pixels + crop_x + x;
13337            let page_y = vp_y_pixels + crop_y + y;
13338            let mx = page_x - raster_origin_x;
13339            let my = page_y - raster_origin_y;
13340            (mx, my)
13341        };
13342
13343        // Case 1: band starts at page Y=0 (top band of page).
13344        // vp_y=0, scale=1. The soft-mask top-left page (220, 310) must
13345        // map to mask index (20, 10).
13346        // crop_x = floor((220 - 0) * 1) = 220, crop_y = floor((310 - 0) * 1) = 310
13347        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 0, 0);
13348        assert_eq!((mx, my), (20, 10), "top band: smask top-left");
13349
13350        // 5 pixels into the smask region (band-local): page (225, 315)
13351        let (mx, my) = sample(0.0, 0.0, 1.0, 220, 310, 5, 5);
13352        assert_eq!((mx, my), (25, 15), "top band: 5px into smask");
13353
13354        // Case 2: band starts at page Y=400. The smask region [310..340]
13355        // doesn't intersect this band — covered by the early-return path.
13356        // But test a band that DOES intersect the smask, e.g. starting at
13357        // Y=305. Then page-Y 310 is band-local Y=5.
13358        // vp_y_pixels = round(305 * 1) = 305
13359        // crop_y = floor((310 - 305) * 1) = 5  (band-local)
13360        // For content y=0 (band-local), page_y = 305 + 5 + 0 = 310 ✓
13361        let (mx, my) = sample(0.0, 305.0, 1.0, 220, 5, 0, 0);
13362        assert_eq!((mx, my), (20, 10), "mid band: smask top-left");
13363
13364        // Case 3: viewport rendering at scale 2. vp_x=100.0, vp_y=150.0,
13365        // scale=2. Page pixel offset = (200, 300). The smask region
13366        // [220..270] in device units = [440..540] in page-pixels at scale 2.
13367        // But the mask raster was built at scale 1, so this is a
13368        // SCALE-MISMATCH case — the cache would invalidate and rebuild.
13369        // We're not testing the rebuild, just that the formula computes
13370        // the right page-pixel coords:
13371        //   vp_x_pixels = round(100 * 2) = 200
13372        //   smask in band: page (440..540), band-local (240..340)
13373        //   crop_x = max(0, floor((220 - 100) * 2)) = 240
13374        //   For x=0 (band-local), page_x = 200 + 240 + 0 = 440 ✓
13375        let vp_x_pixels = (100.0_f32 * 2.0).round() as i32;
13376        let crop_x = ((220.0_f32 - 100.0) * 2.0).floor() as i32;
13377        let page_x_for_x_zero = vp_x_pixels + crop_x;
13378        assert_eq!(page_x_for_x_zero, 440, "viewport scale-2: page-x at x=0");
13379    }
13380
13381    // --- obscured-fill skip (§ GWG reference-under-test pattern) ---
13382
13383    fn x_path() -> PsPath {
13384        let mut p = PsPath::new();
13385        p.segments.push(PathSegment::MoveTo(10.0, 10.0));
13386        p.segments.push(PathSegment::LineTo(20.0, 20.0));
13387        p.segments.push(PathSegment::LineTo(30.0, 10.0));
13388        p.segments.push(PathSegment::LineTo(20.0, 0.0));
13389        p.segments.push(PathSegment::ClosePath);
13390        p
13391    }
13392
13393    fn x_path_perturbed() -> PsPath {
13394        // Same shape, sub-unit rounding — stand-in for GWG's 0.001-unit
13395        // coordinate drift between duplicated path emissions.
13396        let mut p = PsPath::new();
13397        p.segments.push(PathSegment::MoveTo(10.001, 10.0));
13398        p.segments.push(PathSegment::LineTo(20.0, 19.999));
13399        p.segments.push(PathSegment::LineTo(30.002, 10.001));
13400        p.segments.push(PathSegment::LineTo(19.999, 0.0));
13401        p.segments.push(PathSegment::ClosePath);
13402        p
13403    }
13404
13405    fn fill(path: PsPath, alpha: f64, blend: u8) -> DisplayElement {
13406        DisplayElement::Fill {
13407            path,
13408            params: FillParams {
13409                color: DeviceColor::from_rgb(0.0, 0.0, 0.0),
13410                fill_rule: FillRule::NonZeroWinding,
13411                ctm: Matrix::identity(),
13412                is_text_glyph: false,
13413                overprint: false,
13414                overprint_mode: 0,
13415                opm_paired: false,
13416                painted_channels: 0,
13417                is_device_cmyk: false,
13418                spot_color: None,
13419                rendering_intent: 0,
13420                transfer: TransferState::default(),
13421                halftone: HalftoneState::default(),
13422                bg_ucr: BgUcrState::default(),
13423                alpha,
13424                blend_mode: blend,
13425                alpha_is_shape: false,
13426            },
13427        }
13428    }
13429
13430    fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> PsPath {
13431        let mut p = PsPath::new();
13432        p.segments.push(PathSegment::MoveTo(x0, y0));
13433        p.segments.push(PathSegment::LineTo(x1, y0));
13434        p.segments.push(PathSegment::LineTo(x1, y1));
13435        p.segments.push(PathSegment::LineTo(x0, y1));
13436        p.segments.push(PathSegment::ClosePath);
13437        p
13438    }
13439
13440    fn clip_elem(path: PsPath) -> DisplayElement {
13441        DisplayElement::Clip {
13442            path,
13443            params: ClipParams {
13444                fill_rule: FillRule::NonZeroWinding,
13445                ctm: Matrix::identity(),
13446                stroke_params: None,
13447            },
13448        }
13449    }
13450
13451    fn group_elem(
13452        inner: Vec<DisplayElement>,
13453        bbox: [f64; 4],
13454        isolated: bool,
13455        alpha: f64,
13456        blend: u8,
13457    ) -> DisplayElement {
13458        let mut dl = DisplayList::new();
13459        for e in inner {
13460            dl.push(e);
13461        }
13462        DisplayElement::Group {
13463            elements: dl,
13464            params: stet_graphics::display_list::GroupParams {
13465                bbox,
13466                isolated,
13467                knockout: false,
13468                blend_mode: blend,
13469                alpha,
13470                color_space: stet_graphics::display_list::GroupColorSpace::Inherited,
13471            },
13472        }
13473    }
13474
13475    fn dl(elements: Vec<DisplayElement>) -> DisplayList {
13476        let mut d = DisplayList::new();
13477        for e in elements {
13478            d.push(e);
13479        }
13480        d
13481    }
13482
13483    #[test]
13484    fn obscured_skip_fires_on_matching_fill_plus_iso_group() {
13485        // Classic GWG pattern: parent Fill, then a clip, then an isolated
13486        // alpha-1 Group whose first paint is a matching Fill.
13487        let parent = fill(x_path(), 1.0, 0);
13488        let inner = vec![fill(x_path_perturbed(), 1.0, 0)];
13489        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13490        let d = dl(vec![
13491            parent,
13492            clip_elem(rect_path(0.0, -5.0, 40.0, 30.0)),
13493            grp,
13494        ]);
13495        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
13496    }
13497
13498    #[test]
13499    fn obscured_skip_does_not_fire_on_non_isolated_group() {
13500        let parent = fill(x_path(), 1.0, 0);
13501        let inner = vec![fill(x_path(), 1.0, 0)];
13502        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], false, 1.0, 0);
13503        let d = dl(vec![parent, grp]);
13504        assert!(compute_obscured_fill_skips(&d).is_empty());
13505    }
13506
13507    #[test]
13508    fn obscured_skip_does_not_fire_on_partial_alpha_group() {
13509        let parent = fill(x_path(), 1.0, 0);
13510        let inner = vec![fill(x_path(), 1.0, 0)];
13511        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 0.5, 0);
13512        let d = dl(vec![parent, grp]);
13513        assert!(compute_obscured_fill_skips(&d).is_empty());
13514    }
13515
13516    #[test]
13517    fn obscured_skip_does_not_fire_on_non_normal_blend() {
13518        let parent = fill(x_path(), 1.0, 0);
13519        let inner = vec![fill(x_path(), 1.0, 0)];
13520        // blend_mode = 10 (Difference) on the group — composite-back
13521        // semantics differ from Normal, so skipping parent is unsafe.
13522        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 10);
13523        let d = dl(vec![parent, grp]);
13524        assert!(compute_obscured_fill_skips(&d).is_empty());
13525    }
13526
13527    #[test]
13528    fn obscured_skip_does_not_fire_when_paths_differ() {
13529        let parent = fill(rect_path(0.0, 0.0, 5.0, 5.0), 1.0, 0);
13530        let inner = vec![fill(x_path(), 1.0, 0)];
13531        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13532        let d = dl(vec![parent, grp]);
13533        assert!(compute_obscured_fill_skips(&d).is_empty());
13534    }
13535
13536    #[test]
13537    fn obscured_skip_does_not_fire_when_group_bbox_too_small() {
13538        // Parent fills a rectangle larger than the group's declared
13539        // bbox — the form's BBox would clip the inner fill to a subset
13540        // of the parent's extent, so the parent cannot be dropped.
13541        let big = rect_path(0.0, 0.0, 100.0, 100.0);
13542        let parent = fill(big.clone(), 1.0, 0);
13543        let inner = vec![fill(big, 1.0, 0)];
13544        // Group bbox only covers [0..10, 0..10], much smaller than parent.
13545        let grp = group_elem(inner, [0.0, 0.0, 10.0, 10.0], true, 1.0, 0);
13546        let d = dl(vec![parent, grp]);
13547        assert!(compute_obscured_fill_skips(&d).is_empty());
13548    }
13549
13550    #[test]
13551    fn obscured_skip_does_not_fire_when_intervening_clip_too_small() {
13552        // A clip between the parent fill and the group is narrower than
13553        // the parent's extent — dropping the parent's fill would reveal
13554        // backdrop where the group couldn't paint.
13555        let parent = fill(x_path(), 1.0, 0);
13556        let narrow_clip = clip_elem(rect_path(12.0, 5.0, 18.0, 15.0));
13557        let inner = vec![fill(x_path(), 1.0, 0)];
13558        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13559        let d = dl(vec![parent, narrow_clip, grp]);
13560        assert!(compute_obscured_fill_skips(&d).is_empty());
13561    }
13562
13563    #[test]
13564    fn obscured_skip_does_not_fire_when_inner_clip_too_small() {
13565        // Clip *inside* the group is narrower than the parent's extent.
13566        let parent = fill(x_path(), 1.0, 0);
13567        let inner = vec![
13568            clip_elem(rect_path(12.0, 5.0, 18.0, 15.0)),
13569            fill(x_path(), 1.0, 0),
13570        ];
13571        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13572        let d = dl(vec![parent, grp]);
13573        assert!(compute_obscured_fill_skips(&d).is_empty());
13574    }
13575
13576    #[test]
13577    fn obscured_skip_fires_when_inner_clip_is_wider_than_parent_path() {
13578        // A clip inside the group that's larger than the parent's fill
13579        // doesn't threaten coverage; still safe to skip the parent.
13580        let parent = fill(x_path(), 1.0, 0);
13581        let inner = vec![
13582            clip_elem(rect_path(-10.0, -10.0, 40.0, 30.0)),
13583            fill(x_path_perturbed(), 1.0, 0),
13584        ];
13585        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13586        let d = dl(vec![parent, grp]);
13587        assert_eq!(compute_obscured_fill_skips(&d), vec![0]);
13588    }
13589
13590    #[test]
13591    fn obscured_skip_does_not_fire_on_partial_alpha_parent() {
13592        // A parent fill at alpha < 1 might blend with backdrop; dropping
13593        // it changes the visual even when the group overpaints.
13594        let parent = fill(x_path(), 0.5, 0);
13595        let inner = vec![fill(x_path(), 1.0, 0)];
13596        let grp = group_elem(inner, [0.0, -5.0, 40.0, 30.0], true, 1.0, 0);
13597        let d = dl(vec![parent, grp]);
13598        assert!(compute_obscured_fill_skips(&d).is_empty());
13599    }
13600}