Skip to main content

rpdfium_render/
cfx_defaultrenderdevice.rs

1// Derived from PDFium's core/fxge/skia/ rendering backend
2// Original: Copyright 2014 The PDFium Authors
3// Licensed under BSD-3-Clause / Apache-2.0
4// See pdfium-upstream/LICENSE for the original license.
5
6//! tiny-skia implementation of the [`RenderBackend`] trait.
7
8use rpdfium_core::Matrix;
9use rpdfium_graphics::{Bitmap, BitmapFormat, BlendMode, ClipPath, FillRule, PathOp};
10
11use crate::color_convert::RgbaColor;
12use crate::image::{DecodedImage, DecodedImageFormat};
13use crate::renderdevicedriver_iface::RenderBackend;
14use crate::stroke::StrokeStyle;
15
16/// A rendering surface backed by a `tiny_skia::Pixmap`.
17pub struct SkiaSurface {
18    pixmap: tiny_skia::Pixmap,
19}
20
21impl SkiaSurface {
22    /// Access a pixel at the given coordinates (premultiplied RGBA).
23    pub fn pixel(&self, x: u32, y: u32) -> Option<tiny_skia::PremultipliedColorU8> {
24        self.pixmap.pixel(x, y)
25    }
26}
27
28/// Entry for the transparency group stack.
29struct GroupEntry {
30    pixmap: tiny_skia::Pixmap,
31    blend_mode: tiny_skia::BlendMode,
32    opacity: f32,
33    #[allow(dead_code)]
34    isolated: bool,
35    knockout: bool,
36    /// Soft mask (alpha channel) to apply during compositing.
37    mask: Option<tiny_skia::Mask>,
38}
39
40/// tiny-skia based implementation of [`RenderBackend`].
41pub struct TinySkiaBackend {
42    clip_stack: Vec<tiny_skia::Mask>,
43    group_stack: Vec<GroupEntry>,
44    antialiasing: bool,
45}
46
47impl TinySkiaBackend {
48    /// Create a new `TinySkiaBackend`.
49    pub fn new() -> Self {
50        Self {
51            clip_stack: Vec::new(),
52            group_stack: Vec::new(),
53            antialiasing: true,
54        }
55    }
56
57    /// Set whether anti-aliasing is enabled for path rendering.
58    pub fn set_antialiasing(&mut self, enabled: bool) {
59        self.antialiasing = enabled;
60    }
61
62    /// Returns true if the current topmost group is a knockout group.
63    fn is_knockout(&self) -> bool {
64        self.group_stack.last().is_some_and(|e| e.knockout)
65    }
66}
67
68impl Default for TinySkiaBackend {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74// ---------------------------------------------------------------------------
75// Conversion helpers
76// ---------------------------------------------------------------------------
77
78fn build_path(ops: &[PathOp]) -> Option<tiny_skia::Path> {
79    let mut pb = tiny_skia::PathBuilder::new();
80    for op in ops {
81        match *op {
82            PathOp::MoveTo { x, y } => pb.move_to(x, y),
83            PathOp::LineTo { x, y } => pb.line_to(x, y),
84            PathOp::CurveTo {
85                x1,
86                y1,
87                x2,
88                y2,
89                x3,
90                y3,
91            } => pb.cubic_to(x1, y1, x2, y2, x3, y3),
92            PathOp::Close => pb.close(),
93        }
94    }
95    pb.finish()
96}
97
98fn to_transform(m: &Matrix) -> tiny_skia::Transform {
99    tiny_skia::Transform::from_row(
100        m.a as f32, m.b as f32, m.c as f32, m.d as f32, m.e as f32, m.f as f32,
101    )
102}
103
104fn to_color(c: &RgbaColor) -> tiny_skia::Color {
105    tiny_skia::Color::from_rgba8(c.r, c.g, c.b, c.a)
106}
107
108fn to_fill_rule(rule: FillRule) -> tiny_skia::FillRule {
109    match rule {
110        FillRule::NonZero => tiny_skia::FillRule::Winding,
111        FillRule::EvenOdd => tiny_skia::FillRule::EvenOdd,
112    }
113}
114
115fn to_line_cap(cap: rpdfium_graphics::LineCapStyle) -> tiny_skia::LineCap {
116    match cap {
117        rpdfium_graphics::LineCapStyle::Butt => tiny_skia::LineCap::Butt,
118        rpdfium_graphics::LineCapStyle::Round => tiny_skia::LineCap::Round,
119        rpdfium_graphics::LineCapStyle::Square => tiny_skia::LineCap::Square,
120    }
121}
122
123fn to_line_join(join: rpdfium_graphics::LineJoinStyle) -> tiny_skia::LineJoin {
124    match join {
125        rpdfium_graphics::LineJoinStyle::Miter => tiny_skia::LineJoin::Miter,
126        rpdfium_graphics::LineJoinStyle::Round => tiny_skia::LineJoin::Round,
127        rpdfium_graphics::LineJoinStyle::Bevel => tiny_skia::LineJoin::Bevel,
128    }
129}
130
131fn to_blend_mode(mode: BlendMode) -> tiny_skia::BlendMode {
132    match mode {
133        BlendMode::Normal => tiny_skia::BlendMode::SourceOver,
134        BlendMode::Multiply => tiny_skia::BlendMode::Multiply,
135        BlendMode::Screen => tiny_skia::BlendMode::Screen,
136        BlendMode::Overlay => tiny_skia::BlendMode::Overlay,
137        BlendMode::Darken => tiny_skia::BlendMode::Darken,
138        BlendMode::Lighten => tiny_skia::BlendMode::Lighten,
139        BlendMode::ColorDodge => tiny_skia::BlendMode::ColorDodge,
140        BlendMode::ColorBurn => tiny_skia::BlendMode::ColorBurn,
141        BlendMode::HardLight => tiny_skia::BlendMode::HardLight,
142        BlendMode::SoftLight => tiny_skia::BlendMode::SoftLight,
143        BlendMode::Difference => tiny_skia::BlendMode::Difference,
144        BlendMode::Exclusion => tiny_skia::BlendMode::Exclusion,
145        BlendMode::Hue => tiny_skia::BlendMode::Hue,
146        BlendMode::Saturation => tiny_skia::BlendMode::Saturation,
147        BlendMode::Color => tiny_skia::BlendMode::Color,
148        BlendMode::Luminosity => tiny_skia::BlendMode::Luminosity,
149    }
150}
151
152fn build_stroke(style: &StrokeStyle) -> tiny_skia::Stroke {
153    let mut stroke = tiny_skia::Stroke {
154        width: style.width,
155        miter_limit: style.miter_limit,
156        line_cap: to_line_cap(style.line_cap),
157        line_join: to_line_join(style.line_join),
158        dash: None,
159    };
160    if let Some(ref dash) = style.dash {
161        // Clamp very small dash values to 0.1 to avoid rendering artifacts.
162        // Matches upstream PDFium fx_skia_device.cpp L583-586: values <= 0.000001
163        // are replaced with 0.1, but negative values are passed as-is (upstream
164        // does NOT take abs()).
165        let mut arr: Vec<f32> = dash
166            .array
167            .iter()
168            .map(|&v| if v <= 0.000001 { 0.1 } else { v })
169            .collect();
170        // StrokeDash::new requires an even-length array; duplicate if odd.
171        if arr.len() % 2 != 0 {
172            arr.extend_from_within(..);
173        }
174        stroke.dash = tiny_skia::StrokeDash::new(arr, dash.phase);
175    }
176    stroke
177}
178
179fn make_paint(color: &RgbaColor, anti_alias: bool) -> tiny_skia::Paint<'static> {
180    let mut paint = tiny_skia::Paint::default();
181    paint.set_color(to_color(color));
182    paint.anti_alias = anti_alias;
183    paint
184}
185
186/// Convert image pixels to premultiplied RGBA suitable for `PixmapRef`.
187fn image_to_premul_rgba(image: &DecodedImage) -> Vec<u8> {
188    let pixel_count = (image.width * image.height) as usize;
189    let straight = match image.format {
190        DecodedImageFormat::Rgba32 => image.data.clone(),
191        DecodedImageFormat::Rgb24 => {
192            let mut out = Vec::with_capacity(pixel_count * 4);
193            for i in 0..pixel_count {
194                let base = i * 3;
195                out.push(image.data[base]);
196                out.push(image.data[base + 1]);
197                out.push(image.data[base + 2]);
198                out.push(255);
199            }
200            out
201        }
202        DecodedImageFormat::Gray8 => {
203            let mut out = Vec::with_capacity(pixel_count * 4);
204            for i in 0..pixel_count {
205                let v = image.data[i];
206                out.push(v);
207                out.push(v);
208                out.push(v);
209                out.push(255);
210            }
211            out
212        }
213    };
214    // Premultiply alpha.
215    let mut premul = straight;
216    for chunk in premul.chunks_exact_mut(4) {
217        let a = chunk[3] as u16;
218        if a < 255 {
219            chunk[0] = ((chunk[0] as u16 * a + 128) / 255) as u8;
220            chunk[1] = ((chunk[1] as u16 * a + 128) / 255) as u8;
221            chunk[2] = ((chunk[2] as u16 * a + 128) / 255) as u8;
222        }
223    }
224    premul
225}
226
227// ---------------------------------------------------------------------------
228// RenderBackend implementation
229// ---------------------------------------------------------------------------
230
231impl RenderBackend for TinySkiaBackend {
232    type Surface = SkiaSurface;
233
234    fn create_surface(&self, width: u32, height: u32, bg: &RgbaColor) -> Self::Surface {
235        // Clamp to reasonable limits to avoid allocation failures.
236        // tiny_skia requires width/height to fit in i32 and width*height*4 in usize.
237        let w = width.clamp(1, 65535);
238        let h = height.clamp(1, 65535);
239        let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("failed to create rendering surface");
240        pixmap.fill(to_color(bg));
241        SkiaSurface { pixmap }
242    }
243
244    fn fill_path(
245        &mut self,
246        surface: &mut Self::Surface,
247        ops: &[PathOp],
248        fill_rule: FillRule,
249        color: &RgbaColor,
250        transform: &Matrix,
251    ) {
252        let Some(path) = build_path(ops) else {
253            return;
254        };
255        let mut paint = make_paint(color, self.antialiasing);
256        // In knockout groups, use Source blend mode so each element overwrites
257        // rather than blends with previous elements (PDF spec section 11.4.6).
258        if self.is_knockout() {
259            paint.blend_mode = tiny_skia::BlendMode::Source;
260        }
261        let mask = self.clip_stack.last();
262        surface.pixmap.fill_path(
263            &path,
264            &paint,
265            to_fill_rule(fill_rule),
266            to_transform(transform),
267            mask,
268        );
269    }
270
271    fn fill_path_no_aa(
272        &mut self,
273        surface: &mut Self::Surface,
274        ops: &[PathOp],
275        fill_rule: FillRule,
276        color: &RgbaColor,
277        transform: &Matrix,
278    ) {
279        let Some(path) = build_path(ops) else {
280            return;
281        };
282        let mut paint = make_paint(color, false);
283        if self.is_knockout() {
284            paint.blend_mode = tiny_skia::BlendMode::Source;
285        }
286        let mask = self.clip_stack.last();
287        surface.pixmap.fill_path(
288            &path,
289            &paint,
290            to_fill_rule(fill_rule),
291            to_transform(transform),
292            mask,
293        );
294    }
295
296    fn stroke_path(
297        &mut self,
298        surface: &mut Self::Surface,
299        ops: &[PathOp],
300        style: &StrokeStyle,
301        color: &RgbaColor,
302        transform: &Matrix,
303    ) {
304        let Some(path) = build_path(ops) else {
305            return;
306        };
307        let mut paint = make_paint(color, self.antialiasing);
308        if self.is_knockout() {
309            paint.blend_mode = tiny_skia::BlendMode::Source;
310        }
311        let stroke = build_stroke(style);
312        let mask = self.clip_stack.last();
313        surface
314            .pixmap
315            .stroke_path(&path, &paint, &stroke, to_transform(transform), mask);
316    }
317
318    fn draw_image(
319        &mut self,
320        surface: &mut Self::Surface,
321        image: &DecodedImage,
322        transform: &Matrix,
323        interpolate: bool,
324    ) {
325        let premul = image_to_premul_rgba(image);
326        let Some(src) = tiny_skia::PixmapRef::from_bytes(&premul, image.width, image.height) else {
327            return;
328        };
329        let mut paint = tiny_skia::PixmapPaint::default();
330        if self.is_knockout() {
331            paint.blend_mode = tiny_skia::BlendMode::Source;
332        }
333        if interpolate {
334            paint.quality = tiny_skia::FilterQuality::Bilinear;
335        }
336        let mask = self.clip_stack.last();
337        surface
338            .pixmap
339            .draw_pixmap(0, 0, src, &paint, to_transform(transform), mask);
340    }
341
342    fn push_clip(&mut self, surface: &mut Self::Surface, clip: &ClipPath, transform: &Matrix) {
343        let w = surface.pixmap.width();
344        let h = surface.pixmap.height();
345        let Some(mut mask) = tiny_skia::Mask::new(w, h) else {
346            return;
347        };
348        let ts = to_transform(transform);
349        for (i, entry) in clip.paths.iter().enumerate() {
350            let Some(path) = build_path(&entry.ops) else {
351                continue;
352            };
353            let rule = to_fill_rule(entry.fill_rule);
354            if i == 0 {
355                mask.fill_path(&path, rule, true, ts);
356            } else {
357                mask.intersect_path(&path, rule, true, ts);
358            }
359        }
360        self.clip_stack.push(mask);
361    }
362
363    fn pop_clip(&mut self, _surface: &mut Self::Surface) {
364        self.clip_stack.pop();
365    }
366
367    fn push_group(
368        &mut self,
369        surface: &mut Self::Surface,
370        blend_mode: BlendMode,
371        opacity: f32,
372        isolated: bool,
373        knockout: bool,
374    ) {
375        let w = surface.pixmap.width();
376        let h = surface.pixmap.height();
377        // For isolated groups, start with a blank pixmap (existing behavior).
378        // For non-isolated groups, clone the parent as initial content.
379        let group_pixmap = if isolated {
380            tiny_skia::Pixmap::new(w, h).expect("failed to create group surface")
381        } else {
382            surface.pixmap.clone()
383        };
384        let prev = std::mem::replace(&mut surface.pixmap, group_pixmap);
385        self.group_stack.push(GroupEntry {
386            pixmap: prev,
387            blend_mode: to_blend_mode(blend_mode),
388            opacity,
389            isolated,
390            knockout,
391            mask: None,
392        });
393    }
394
395    fn pop_group(&mut self, surface: &mut Self::Surface) {
396        let Some(entry) = self.group_stack.pop() else {
397            return;
398        };
399        let group = std::mem::replace(&mut surface.pixmap, entry.pixmap);
400        let paint = tiny_skia::PixmapPaint {
401            opacity: entry.opacity,
402            blend_mode: entry.blend_mode,
403            ..tiny_skia::PixmapPaint::default()
404        };
405        // Use soft mask if available, otherwise fall back to clip mask.
406        let mask = entry.mask.as_ref().or(self.clip_stack.last());
407        surface.pixmap.draw_pixmap(
408            0,
409            0,
410            group.as_ref(),
411            &paint,
412            tiny_skia::Transform::identity(),
413            mask,
414        );
415    }
416
417    fn set_group_mask(&mut self, alpha_data: Vec<u8>, width: u32, height: u32) {
418        let Some(entry) = self.group_stack.last_mut() else {
419            return;
420        };
421        let Some(mut mask) = tiny_skia::Mask::new(width, height) else {
422            return;
423        };
424        // Copy alpha values into the mask's data buffer.
425        let mask_data = mask.data_mut();
426        let len = mask_data.len().min(alpha_data.len());
427        mask_data[..len].copy_from_slice(&alpha_data[..len]);
428        entry.mask = Some(mask);
429    }
430
431    fn draw_alpha_bitmap(
432        &mut self,
433        surface: &mut Self::Surface,
434        alpha: &[u8],
435        width: u32,
436        height: u32,
437        bearing_x: i32,
438        bearing_y: i32,
439        color: &RgbaColor,
440        transform: &Matrix,
441    ) {
442        if width == 0 || height == 0 || alpha.is_empty() {
443            return;
444        }
445
446        // Create a tiny pixmap for the glyph
447        let Some(mut glyph_pixmap) = tiny_skia::Pixmap::new(width, height) else {
448            return;
449        };
450
451        // Fill each pixel with color * alpha (premultiplied)
452        let pixels = glyph_pixmap.pixels_mut();
453        for (i, &a) in alpha.iter().enumerate() {
454            if i >= pixels.len() {
455                break;
456            }
457            if a == 0 {
458                continue;
459            }
460            let alpha_f = a as f32 / 255.0;
461            let r = (color.r as f32 * alpha_f) as u8;
462            let g = (color.g as f32 * alpha_f) as u8;
463            let b = (color.b as f32 * alpha_f) as u8;
464            let a_out = (color.a as f32 * alpha_f) as u8;
465            if let Some(c) = tiny_skia::PremultipliedColorU8::from_rgba(r, g, b, a_out) {
466                pixels[i] = c;
467            }
468        }
469
470        // Build transform incorporating bearing offset
471        let ts = tiny_skia::Transform {
472            sx: transform.a as f32,
473            kx: transform.b as f32,
474            ky: transform.c as f32,
475            sy: transform.d as f32,
476            tx: (transform.e + bearing_x as f64) as f32,
477            ty: (transform.f - bearing_y as f64) as f32,
478        };
479
480        let paint = tiny_skia::PixmapPaint {
481            opacity: 1.0,
482            blend_mode: tiny_skia::BlendMode::SourceOver,
483            quality: tiny_skia::FilterQuality::Bilinear,
484        };
485
486        let mask = self.clip_stack.last();
487        surface
488            .pixmap
489            .draw_pixmap(0, 0, glyph_pixmap.as_ref(), &paint, ts, mask);
490    }
491
492    fn composite_over(&mut self, dst: &mut Self::Surface, src: &Self::Surface) {
493        dst.pixmap.draw_pixmap(
494            0,
495            0,
496            src.pixmap.as_ref(),
497            &tiny_skia::PixmapPaint::default(),
498            tiny_skia::Transform::identity(),
499            None,
500        );
501    }
502
503    fn set_antialiasing(&mut self, enabled: bool) {
504        self.antialiasing = enabled;
505    }
506
507    fn surface_dimensions(&self, surface: &Self::Surface) -> (u32, u32) {
508        (surface.pixmap.width(), surface.pixmap.height())
509    }
510
511    fn surface_pixels(&self, surface: &Self::Surface) -> Vec<u8> {
512        surface.pixmap.data().to_vec()
513    }
514
515    fn finish(self, surface: Self::Surface) -> Bitmap {
516        let w = surface.pixmap.width();
517        let h = surface.pixmap.height();
518        let data = surface.pixmap.take();
519        Bitmap {
520            width: w,
521            height: h,
522            format: BitmapFormat::Rgba32,
523            stride: w * 4,
524            data,
525        }
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn test_build_path_triangle() {
535        let ops = vec![
536            PathOp::MoveTo { x: 0.0, y: 0.0 },
537            PathOp::LineTo { x: 100.0, y: 0.0 },
538            PathOp::LineTo { x: 50.0, y: 100.0 },
539            PathOp::Close,
540        ];
541        assert!(build_path(&ops).is_some());
542    }
543
544    #[test]
545    fn test_build_path_curve() {
546        let ops = vec![
547            PathOp::MoveTo { x: 0.0, y: 0.0 },
548            PathOp::CurveTo {
549                x1: 10.0,
550                y1: 20.0,
551                x2: 30.0,
552                y2: 40.0,
553                x3: 50.0,
554                y3: 0.0,
555            },
556        ];
557        assert!(build_path(&ops).is_some());
558    }
559
560    #[test]
561    fn test_build_path_empty() {
562        assert!(build_path(&[]).is_none());
563    }
564
565    #[test]
566    fn test_transform_identity() {
567        let t = to_transform(&Matrix::identity());
568        assert!(t.is_identity());
569    }
570
571    #[test]
572    fn test_transform_scale() {
573        let m = Matrix::from_scale(2.0, 3.0);
574        let t = to_transform(&m);
575        assert_eq!(t.sx, 2.0);
576        assert_eq!(t.sy, 3.0);
577    }
578
579    #[test]
580    fn test_fill_produces_colored_pixels() {
581        let mut backend = TinySkiaBackend::new();
582        let mut surface = backend.create_surface(100, 100, &RgbaColor::WHITE);
583        let ops = vec![
584            PathOp::MoveTo { x: 10.0, y: 10.0 },
585            PathOp::LineTo { x: 90.0, y: 10.0 },
586            PathOp::LineTo { x: 90.0, y: 90.0 },
587            PathOp::LineTo { x: 10.0, y: 90.0 },
588            PathOp::Close,
589        ];
590        let red = RgbaColor::new(255, 0, 0, 255);
591        backend.fill_path(
592            &mut surface,
593            &ops,
594            FillRule::NonZero,
595            &red,
596            &Matrix::identity(),
597        );
598        // Check center pixel is red (premultiplied)
599        let px = surface.pixel(50, 50).unwrap();
600        assert_eq!(px.red(), 255);
601        assert_eq!(px.green(), 0);
602        assert_eq!(px.blue(), 0);
603    }
604
605    #[test]
606    fn test_stroke_produces_pixels() {
607        let mut backend = TinySkiaBackend::new();
608        let mut surface = backend.create_surface(100, 100, &RgbaColor::WHITE);
609        let ops = vec![
610            PathOp::MoveTo { x: 10.0, y: 50.0 },
611            PathOp::LineTo { x: 90.0, y: 50.0 },
612        ];
613        let black = RgbaColor::BLACK;
614        let style = StrokeStyle {
615            width: 2.0,
616            line_cap: rpdfium_graphics::LineCapStyle::Butt,
617            line_join: rpdfium_graphics::LineJoinStyle::Miter,
618            miter_limit: 10.0,
619            dash: None,
620        };
621        backend.stroke_path(&mut surface, &ops, &style, &black, &Matrix::identity());
622        // Center of the line should have non-white pixels
623        let px = surface.pixel(50, 50).unwrap();
624        assert!(px.red() < 255 || px.green() < 255 || px.blue() < 255);
625    }
626
627    #[test]
628    fn test_finish_returns_bitmap() {
629        let backend = TinySkiaBackend::new();
630        let surface = backend.create_surface(50, 30, &RgbaColor::WHITE);
631        let bitmap = backend.finish(surface);
632        assert_eq!(bitmap.width, 50);
633        assert_eq!(bitmap.height, 30);
634        assert_eq!(bitmap.format, BitmapFormat::Rgba32);
635        assert_eq!(bitmap.stride, 200);
636        assert_eq!(bitmap.data.len(), 200 * 30);
637    }
638
639    #[test]
640    fn test_blend_mode_mapping() {
641        assert_eq!(
642            to_blend_mode(BlendMode::Normal),
643            tiny_skia::BlendMode::SourceOver
644        );
645        assert_eq!(
646            to_blend_mode(BlendMode::Multiply),
647            tiny_skia::BlendMode::Multiply
648        );
649        assert_eq!(to_blend_mode(BlendMode::Hue), tiny_skia::BlendMode::Hue);
650        assert_eq!(
651            to_blend_mode(BlendMode::Luminosity),
652            tiny_skia::BlendMode::Luminosity
653        );
654    }
655
656    #[test]
657    fn test_group_compositing() {
658        let mut backend = TinySkiaBackend::new();
659        let mut surface = backend.create_surface(50, 50, &RgbaColor::WHITE);
660
661        backend.push_group(&mut surface, BlendMode::Normal, 0.5, true, false);
662        let ops = vec![
663            PathOp::MoveTo { x: 0.0, y: 0.0 },
664            PathOp::LineTo { x: 50.0, y: 0.0 },
665            PathOp::LineTo { x: 50.0, y: 50.0 },
666            PathOp::LineTo { x: 0.0, y: 50.0 },
667            PathOp::Close,
668        ];
669        let red = RgbaColor::new(255, 0, 0, 255);
670        backend.fill_path(
671            &mut surface,
672            &ops,
673            FillRule::NonZero,
674            &red,
675            &Matrix::identity(),
676        );
677        backend.pop_group(&mut surface);
678
679        // At 50% opacity over white, red should be blended
680        let px = surface.pixel(25, 25).unwrap();
681        // Premultiplied: expect ~128 red, ~128 green+blue (from white below)
682        assert!(px.red() > 100);
683        assert!(px.green() > 50);
684    }
685
686    #[test]
687    fn test_default_backend() {
688        let _backend = TinySkiaBackend::default();
689    }
690
691    #[test]
692    fn test_image_to_premul_gray() {
693        let img = DecodedImage {
694            width: 2,
695            height: 1,
696            data: vec![128, 255],
697            format: DecodedImageFormat::Gray8,
698        };
699        let premul = image_to_premul_rgba(&img);
700        assert_eq!(premul.len(), 8);
701        // Gray 128, alpha 255 → unchanged
702        assert_eq!(&premul[0..4], &[128, 128, 128, 255]);
703        // Gray 255, alpha 255 → unchanged
704        assert_eq!(&premul[4..8], &[255, 255, 255, 255]);
705    }
706
707    #[test]
708    fn test_image_to_premul_rgb() {
709        let img = DecodedImage {
710            width: 1,
711            height: 1,
712            data: vec![255, 0, 128],
713            format: DecodedImageFormat::Rgb24,
714        };
715        let premul = image_to_premul_rgba(&img);
716        assert_eq!(premul.len(), 4);
717        // RGB with alpha 255 → unchanged
718        assert_eq!(&premul[..], &[255, 0, 128, 255]);
719    }
720
721    #[test]
722    fn test_image_to_premul_rgba_with_alpha() {
723        let img = DecodedImage {
724            width: 1,
725            height: 1,
726            data: vec![200, 100, 50, 128],
727            format: DecodedImageFormat::Rgba32,
728        };
729        let premul = image_to_premul_rgba(&img);
730        assert_eq!(premul.len(), 4);
731        // 200 * 128 / 255 ≈ 100
732        assert!(premul[0] > 90 && premul[0] < 110);
733        assert_eq!(premul[3], 128);
734    }
735
736    #[test]
737    fn test_clip_path_fill() {
738        let mut backend = TinySkiaBackend::new();
739        let mut surface = backend.create_surface(100, 100, &RgbaColor::WHITE);
740
741        // Clip to a small rectangle in the center
742        let mut clip = ClipPath::new();
743        clip.push(
744            vec![
745                PathOp::MoveTo { x: 40.0, y: 40.0 },
746                PathOp::LineTo { x: 60.0, y: 40.0 },
747                PathOp::LineTo { x: 60.0, y: 60.0 },
748                PathOp::LineTo { x: 40.0, y: 60.0 },
749                PathOp::Close,
750            ],
751            FillRule::NonZero,
752        );
753        backend.push_clip(&mut surface, &clip, &Matrix::identity());
754
755        // Fill entire surface with red
756        let ops = vec![
757            PathOp::MoveTo { x: 0.0, y: 0.0 },
758            PathOp::LineTo { x: 100.0, y: 0.0 },
759            PathOp::LineTo { x: 100.0, y: 100.0 },
760            PathOp::LineTo { x: 0.0, y: 100.0 },
761            PathOp::Close,
762        ];
763        let red = RgbaColor::new(255, 0, 0, 255);
764        backend.fill_path(
765            &mut surface,
766            &ops,
767            FillRule::NonZero,
768            &red,
769            &Matrix::identity(),
770        );
771
772        // Center should be red
773        let px = surface.pixel(50, 50).unwrap();
774        assert_eq!(px.red(), 255);
775        assert_eq!(px.green(), 0);
776
777        // Corner should still be white (clipped out)
778        let corner = surface.pixel(5, 5).unwrap();
779        assert_eq!(corner.red(), 255);
780        assert_eq!(corner.green(), 255);
781
782        backend.pop_clip(&mut surface);
783    }
784
785    #[test]
786    fn test_stroke_with_dash() {
787        let mut backend = TinySkiaBackend::new();
788        let mut surface = backend.create_surface(100, 100, &RgbaColor::WHITE);
789        let ops = vec![
790            PathOp::MoveTo { x: 10.0, y: 50.0 },
791            PathOp::LineTo { x: 90.0, y: 50.0 },
792        ];
793        let style = StrokeStyle {
794            width: 4.0,
795            line_cap: rpdfium_graphics::LineCapStyle::Butt,
796            line_join: rpdfium_graphics::LineJoinStyle::Miter,
797            miter_limit: 10.0,
798            dash: Some(rpdfium_graphics::DashPattern {
799                array: vec![10.0, 5.0, 10.0, 5.0],
800                phase: 0.0,
801            }),
802        };
803        let black = RgbaColor::BLACK;
804        backend.stroke_path(&mut surface, &ops, &style, &black, &Matrix::identity());
805        // Some pixel along the line should be drawn
806        let bitmap = backend.finish(surface);
807        // At least some pixels should be non-white
808        let has_dark = bitmap.data.chunks_exact(4).any(|px| px[0] < 200);
809        assert!(has_dark);
810    }
811
812    #[test]
813    fn test_group_with_alpha_mask() {
814        let mut backend = TinySkiaBackend::new();
815        let mut surface = backend.create_surface(50, 50, &RgbaColor::WHITE);
816
817        backend.push_group(&mut surface, BlendMode::Normal, 1.0, true, false);
818
819        // Build a mask: top half opaque, bottom half transparent.
820        let mut alpha_data = vec![0u8; 50 * 50];
821        for y in 0..25u32 {
822            for x in 0..50u32 {
823                alpha_data[(y * 50 + x) as usize] = 255;
824            }
825        }
826        // Bottom half stays 0 (transparent).
827        backend.set_group_mask(alpha_data, 50, 50);
828
829        // Fill the entire group pixmap with red.
830        let ops = vec![
831            PathOp::MoveTo { x: 0.0, y: 0.0 },
832            PathOp::LineTo { x: 50.0, y: 0.0 },
833            PathOp::LineTo { x: 50.0, y: 50.0 },
834            PathOp::LineTo { x: 0.0, y: 50.0 },
835            PathOp::Close,
836        ];
837        let red = RgbaColor::new(255, 0, 0, 255);
838        backend.fill_path(
839            &mut surface,
840            &ops,
841            FillRule::NonZero,
842            &red,
843            &Matrix::identity(),
844        );
845        backend.pop_group(&mut surface);
846
847        // Top half (y=12): should be red (mask was opaque).
848        let top = surface.pixel(25, 12).unwrap();
849        assert_eq!(top.red(), 255);
850        assert_eq!(top.green(), 0);
851
852        // Bottom half (y=37): should be white (mask was transparent).
853        let bottom = surface.pixel(25, 37).unwrap();
854        assert_eq!(bottom.red(), 255);
855        assert_eq!(bottom.green(), 255);
856        assert_eq!(bottom.blue(), 255);
857    }
858
859    #[test]
860    fn test_group_without_mask_unchanged() {
861        // Regression: groups without mask should composite normally.
862        let mut backend = TinySkiaBackend::new();
863        let mut surface = backend.create_surface(50, 50, &RgbaColor::WHITE);
864
865        backend.push_group(&mut surface, BlendMode::Normal, 1.0, true, false);
866        let ops = vec![
867            PathOp::MoveTo { x: 0.0, y: 0.0 },
868            PathOp::LineTo { x: 50.0, y: 0.0 },
869            PathOp::LineTo { x: 50.0, y: 50.0 },
870            PathOp::LineTo { x: 0.0, y: 50.0 },
871            PathOp::Close,
872        ];
873        let red = RgbaColor::new(255, 0, 0, 255);
874        backend.fill_path(
875            &mut surface,
876            &ops,
877            FillRule::NonZero,
878            &red,
879            &Matrix::identity(),
880        );
881        backend.pop_group(&mut surface);
882
883        let px = surface.pixel(25, 25).unwrap();
884        assert_eq!(px.red(), 255);
885        assert_eq!(px.green(), 0);
886        assert_eq!(px.blue(), 0);
887    }
888
889    #[test]
890    fn test_surface_dimensions_and_pixels() {
891        let backend = TinySkiaBackend::new();
892        let surface = backend.create_surface(40, 30, &RgbaColor::WHITE);
893        let (w, h) = backend.surface_dimensions(&surface);
894        assert_eq!((w, h), (40, 30));
895        let pixels = backend.surface_pixels(&surface);
896        assert_eq!(pixels.len(), (40 * 30 * 4) as usize);
897    }
898
899    #[test]
900    fn test_nested_group_opacity_on_transparent() {
901        // Test nested groups: outer Normal/1.0 with mask, inner Screen/0.6
902        let mut backend = TinySkiaBackend::new();
903        let transparent = RgbaColor::new(0, 0, 0, 0);
904        let mut surface = backend.create_surface(50, 50, &transparent);
905
906        // Outer group: Normal, 1.0, isolated
907        backend.push_group(&mut surface, BlendMode::Normal, 1.0, true, false);
908
909        // Set a mask that's fully opaque
910        let mask_data = vec![255u8; 50 * 50];
911        backend.set_group_mask(mask_data, 50, 50);
912
913        // Inner group: Screen, 0.6, isolated
914        backend.push_group(&mut surface, BlendMode::Screen, 0.6, true, false);
915        let ops = vec![
916            PathOp::MoveTo { x: 0.0, y: 0.0 },
917            PathOp::LineTo { x: 50.0, y: 0.0 },
918            PathOp::LineTo { x: 50.0, y: 50.0 },
919            PathOp::LineTo { x: 0.0, y: 50.0 },
920            PathOp::Close,
921        ];
922        let white = RgbaColor::new(255, 255, 255, 255);
923        backend.fill_path(
924            &mut surface,
925            &ops,
926            FillRule::NonZero,
927            &white,
928            &Matrix::identity(),
929        );
930        backend.pop_group(&mut surface); // pop inner
931
932        backend.pop_group(&mut surface); // pop outer
933
934        let px = surface.pixel(25, 25).unwrap();
935        eprintln!(
936            "Nested group: R={}, G={}, B={}, A={}",
937            px.red(),
938            px.green(),
939            px.blue(),
940            px.alpha()
941        );
942        // Should be same as single group: alpha ~153
943        assert!(
944            px.alpha() > 140 && px.alpha() < 166,
945            "Expected alpha ~153 but got {}",
946            px.alpha()
947        );
948    }
949
950    // -----------------------------------------------------------------------
951    // Blend mode pixel-level tests
952    // -----------------------------------------------------------------------
953
954    /// Create a rectangle path covering a specific area.
955    fn rect_path(x: f32, y: f32, w: f32, h: f32) -> Vec<PathOp> {
956        vec![
957            PathOp::MoveTo { x, y },
958            PathOp::LineTo { x: x + w, y },
959            PathOp::LineTo { x: x + w, y: y + h },
960            PathOp::LineTo { x, y: y + h },
961            PathOp::Close,
962        ]
963    }
964
965    /// Render two overlapping rectangles with a specific blend mode and return
966    /// the pixel at the overlap. Background rect is drawn first (full coverage),
967    /// then foreground rect with blend mode via an isolated group.
968    fn blend_test(
969        bg_r: u8,
970        bg_g: u8,
971        bg_b: u8,
972        fg_r: u8,
973        fg_g: u8,
974        fg_b: u8,
975        mode: BlendMode,
976    ) -> (u8, u8, u8) {
977        let mut backend = TinySkiaBackend::new();
978        // White background surface
979        let white = RgbaColor {
980            r: 255,
981            g: 255,
982            b: 255,
983            a: 255,
984        };
985        let mut surface = backend.create_surface(4, 4, &white);
986
987        // Draw background color rectangle (full surface)
988        let bg_color = RgbaColor {
989            r: bg_r,
990            g: bg_g,
991            b: bg_b,
992            a: 255,
993        };
994        let full_rect = rect_path(0.0, 0.0, 4.0, 4.0);
995        backend.fill_path(
996            &mut surface,
997            &full_rect,
998            FillRule::NonZero,
999            &bg_color,
1000            &Matrix::identity(),
1001        );
1002
1003        // Push group with blend mode, draw foreground, pop
1004        backend.push_group(&mut surface, mode, 1.0, true, false);
1005        let fg_color = RgbaColor {
1006            r: fg_r,
1007            g: fg_g,
1008            b: fg_b,
1009            a: 255,
1010        };
1011        backend.fill_path(
1012            &mut surface,
1013            &full_rect,
1014            FillRule::NonZero,
1015            &fg_color,
1016            &Matrix::identity(),
1017        );
1018        backend.pop_group(&mut surface);
1019
1020        // Read pixels (premultiplied RGBA)
1021        let pixels = backend.surface_pixels(&surface);
1022        // First pixel at (0,0): offset 0
1023        (pixels[0], pixels[1], pixels[2])
1024    }
1025
1026    /// Assert pixel values are within tolerance.
1027    fn assert_pixel_near(actual: (u8, u8, u8), expected: (u8, u8, u8), tolerance: u8, msg: &str) {
1028        let dr = (actual.0 as i16 - expected.0 as i16).unsigned_abs() as u8;
1029        let dg = (actual.1 as i16 - expected.1 as i16).unsigned_abs() as u8;
1030        let db = (actual.2 as i16 - expected.2 as i16).unsigned_abs() as u8;
1031        assert!(
1032            dr <= tolerance && dg <= tolerance && db <= tolerance,
1033            "{msg}: expected ({}, {}, {}), got ({}, {}, {}), diff ({dr}, {dg}, {db})",
1034            expected.0,
1035            expected.1,
1036            expected.2,
1037            actual.0,
1038            actual.1,
1039            actual.2
1040        );
1041    }
1042
1043    #[test]
1044    fn test_blend_mode_normal_pixel() {
1045        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Normal);
1046        assert_pixel_near(result, (153, 102, 51), 2, "Normal");
1047    }
1048
1049    #[test]
1050    fn test_blend_mode_multiply_pixel() {
1051        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Multiply);
1052        assert_pixel_near(result, (122, 61, 20), 2, "Multiply");
1053    }
1054
1055    #[test]
1056    fn test_blend_mode_screen_pixel() {
1057        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Screen);
1058        assert_pixel_near(result, (235, 194, 133), 2, "Screen");
1059    }
1060
1061    #[test]
1062    fn test_blend_mode_overlay_pixel() {
1063        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Overlay);
1064        assert_pixel_near(result, (214, 133, 41), 3, "Overlay");
1065    }
1066
1067    #[test]
1068    fn test_blend_mode_darken_pixel() {
1069        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Darken);
1070        assert_pixel_near(result, (153, 102, 51), 2, "Darken");
1071    }
1072
1073    #[test]
1074    fn test_blend_mode_lighten_pixel() {
1075        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Lighten);
1076        assert_pixel_near(result, (204, 153, 102), 2, "Lighten");
1077    }
1078
1079    #[test]
1080    fn test_blend_mode_color_dodge_pixel() {
1081        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::ColorDodge);
1082        assert_pixel_near(result, (255, 255, 128), 3, "ColorDodge");
1083    }
1084
1085    #[test]
1086    fn test_blend_mode_color_burn_pixel() {
1087        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::ColorBurn);
1088        assert_pixel_near(result, (170, 0, 0), 3, "ColorBurn");
1089    }
1090
1091    #[test]
1092    fn test_blend_mode_hard_light_pixel() {
1093        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::HardLight);
1094        assert_pixel_near(result, (214, 122, 41), 3, "HardLight");
1095    }
1096
1097    #[test]
1098    fn test_blend_mode_difference_pixel() {
1099        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Difference);
1100        assert_pixel_near(result, (51, 51, 51), 2, "Difference");
1101    }
1102
1103    #[test]
1104    fn test_blend_mode_exclusion_pixel() {
1105        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Exclusion);
1106        assert_pixel_near(result, (112, 133, 112), 3, "Exclusion");
1107    }
1108
1109    // SoftLight uses a complex formula — just verify reasonable output range.
1110    #[test]
1111    fn test_blend_mode_soft_light_pixel() {
1112        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::SoftLight);
1113        // SoftLight should produce values between darken and lighten
1114        assert!(
1115            result.0 >= 140 && result.0 <= 220,
1116            "SoftLight R out of range: {}",
1117            result.0
1118        );
1119        assert!(
1120            result.1 >= 100 && result.1 <= 170,
1121            "SoftLight G out of range: {}",
1122            result.1
1123        );
1124        assert!(
1125            result.2 >= 30 && result.2 <= 120,
1126            "SoftLight B out of range: {}",
1127            result.2
1128        );
1129    }
1130
1131    // Non-separable modes — verify they produce non-zero output and satisfy
1132    // structural properties from PDF spec §11.3.5.
1133
1134    #[test]
1135    fn test_blend_mode_hue_pixel() {
1136        // Hue: SetLum(SetSat(Cs, Sat(Cb)), Lum(Cb))
1137        // Backdrop (204, 153, 102), Source (153, 102, 51)
1138        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Hue);
1139        assert!(
1140            result.0 > 0 || result.1 > 0 || result.2 > 0,
1141            "Hue produced all-black pixels: {:?}",
1142            result
1143        );
1144        // Hue takes source hue but backdrop luminosity; since both have the same
1145        // hue ratio (warm tone, R > G > B) and same saturation (range=102), the
1146        // result should be close to the backdrop.
1147        assert_pixel_near(result, (204, 153, 102), 5, "Hue (same-hue inputs)");
1148    }
1149
1150    #[test]
1151    fn test_blend_mode_hue_pixel_distinct_hue() {
1152        // Red backdrop (255, 0, 0) + Blue source (0, 0, 255)
1153        // Hue mode adopts source hue (blue) with backdrop luminosity + saturation
1154        let result = blend_test(255, 0, 0, 0, 0, 255, BlendMode::Hue);
1155        // The result should shift hue toward blue while preserving backdrop brightness.
1156        // Blue channel should dominate (higher than red channel).
1157        assert!(
1158            result.2 > result.0,
1159            "Hue should adopt source blue hue: got {:?}",
1160            result
1161        );
1162    }
1163
1164    #[test]
1165    fn test_blend_mode_saturation_pixel() {
1166        // Saturation: SetLum(SetSat(Cb, Sat(Cs)), Lum(Cb))
1167        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Saturation);
1168        assert!(
1169            result.0 > 0 || result.1 > 0 || result.2 > 0,
1170            "Saturation produced all-black pixels: {:?}",
1171            result
1172        );
1173        // Both inputs have same saturation range (102), so result should be
1174        // close to the backdrop.
1175        assert_pixel_near(result, (204, 153, 102), 5, "Saturation (equal-sat inputs)");
1176    }
1177
1178    #[test]
1179    fn test_blend_mode_saturation_pixel_desaturate() {
1180        // Saturated backdrop (255, 0, 0) + Gray source (128, 128, 128) → desaturated
1181        let result = blend_test(255, 0, 0, 128, 128, 128, BlendMode::Saturation);
1182        // Source saturation is 0 (gray), so result should be grayscale at backdrop lum.
1183        // All channels should be approximately equal.
1184        let spread = result.0.max(result.1).max(result.2) - result.0.min(result.1).min(result.2);
1185        assert!(
1186            spread <= 3,
1187            "Saturation of gray source should produce near-gray: {:?} (spread {spread})",
1188            result
1189        );
1190    }
1191
1192    #[test]
1193    fn test_blend_mode_color_mode_pixel() {
1194        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Color);
1195        assert!(
1196            result.0 > 0 || result.1 > 0 || result.2 > 0,
1197            "Color produced all-black pixels: {:?}",
1198            result
1199        );
1200        // Color = SetLum(Cs, Lum(Cb)): takes source hue+sat with backdrop luminosity.
1201        // Since these inputs have same hue and sat, should be close to backdrop.
1202        assert_pixel_near(result, (204, 153, 102), 5, "Color (same-hue-sat inputs)");
1203    }
1204
1205    #[test]
1206    fn test_blend_mode_luminosity_pixel() {
1207        // Luminosity: SetLum(Cb, Lum(Cs))
1208        let result = blend_test(204, 153, 102, 153, 102, 51, BlendMode::Luminosity);
1209        assert!(
1210            result.0 > 0 || result.1 > 0 || result.2 > 0,
1211            "Luminosity produced all-black pixels: {:?}",
1212            result
1213        );
1214        // Luminosity takes source luminosity with backdrop hue+sat.
1215        // Lum(Cs) = 0.299*153 + 0.587*102 + 0.114*51 ≈ 105.6
1216        // Lum(Cb) = 0.299*204 + 0.587*153 + 0.114*102 ≈ 162.4
1217        // Result should be darker than backdrop (lower luminosity).
1218        let result_lum =
1219            0.299 * result.0 as f64 + 0.587 * result.1 as f64 + 0.114 * result.2 as f64;
1220        let backdrop_lum = 0.299 * 204.0 + 0.587 * 153.0 + 0.114 * 102.0;
1221        assert!(
1222            result_lum < backdrop_lum - 10.0,
1223            "Luminosity result should be darker: result_lum={result_lum:.1}, backdrop_lum={backdrop_lum:.1}"
1224        );
1225    }
1226
1227    #[test]
1228    fn test_blend_mode_luminosity_pixel_preserves_hue() {
1229        // Red backdrop (200, 50, 50) + bright source (200, 200, 200)
1230        // Luminosity should brighten while keeping hue.
1231        let result = blend_test(200, 50, 50, 200, 200, 200, BlendMode::Luminosity);
1232        // Red should still be the dominant channel (hue preserved).
1233        assert!(
1234            result.0 > result.1 && result.0 > result.2,
1235            "Luminosity should preserve red hue: got {:?}",
1236            result
1237        );
1238    }
1239
1240    #[test]
1241    fn test_group_opacity_on_transparent() {
1242        // Test that group opacity works correctly on a transparent surface.
1243        let mut backend = TinySkiaBackend::new();
1244        let transparent = RgbaColor::new(0, 0, 0, 0);
1245        let mut surface = backend.create_surface(50, 50, &transparent);
1246
1247        // Push a group with 0.6 opacity (Screen blend, isolated)
1248        backend.push_group(&mut surface, BlendMode::Screen, 0.6, true, false);
1249        let ops = vec![
1250            PathOp::MoveTo { x: 0.0, y: 0.0 },
1251            PathOp::LineTo { x: 50.0, y: 0.0 },
1252            PathOp::LineTo { x: 50.0, y: 50.0 },
1253            PathOp::LineTo { x: 0.0, y: 50.0 },
1254            PathOp::Close,
1255        ];
1256        let white = RgbaColor::new(255, 255, 255, 255);
1257        backend.fill_path(
1258            &mut surface,
1259            &ops,
1260            FillRule::NonZero,
1261            &white,
1262            &Matrix::identity(),
1263        );
1264        backend.pop_group(&mut surface);
1265
1266        // After pop_group at 0.6 opacity, on transparent backdrop:
1267        // Expected premultiplied: (153, 153, 153, 153) = (255*0.6, 255*0.6, 255*0.6, 255*0.6)
1268        let px = surface.pixel(25, 25).unwrap();
1269        eprintln!(
1270            "Group 0.6 opacity on transparent: R={}, G={}, B={}, A={}",
1271            px.red(),
1272            px.green(),
1273            px.blue(),
1274            px.alpha()
1275        );
1276        // Alpha should be ~153 (0.6 * 255), not 255
1277        assert!(
1278            px.alpha() > 140 && px.alpha() < 166,
1279            "Expected alpha ~153 but got {}",
1280            px.alpha()
1281        );
1282    }
1283
1284    // -----------------------------------------------------------------------
1285    // draw_alpha_bitmap tests
1286    // -----------------------------------------------------------------------
1287
1288    #[test]
1289    fn test_draw_alpha_bitmap_produces_colored_pixel() {
1290        let mut backend = TinySkiaBackend::new();
1291        let mut surface = backend.create_surface(10, 10, &RgbaColor::WHITE);
1292        // 2x2 alpha bitmap: fully opaque
1293        let alpha = vec![255, 255, 255, 255];
1294        let red = RgbaColor::new(255, 0, 0, 255);
1295        backend.draw_alpha_bitmap(
1296            &mut surface,
1297            &alpha,
1298            2,
1299            2,
1300            0,
1301            2, // bearing_y=2 so glyph starts at y=0 in device coords
1302            &red,
1303            &Matrix::identity(),
1304        );
1305        // With bearing_y=2 and identity transform, ty = 0 - 2 = -2,
1306        // so the glyph is drawn above the surface. Re-test with bearing at origin:
1307        let mut surface2 = backend.create_surface(10, 10, &RgbaColor::WHITE);
1308        backend.draw_alpha_bitmap(&mut surface2, &alpha, 2, 2, 0, 0, &red, &Matrix::identity());
1309        let px2 = surface2.pixel(0, 0).unwrap();
1310        assert_eq!(px2.red(), 255);
1311        assert_eq!(px2.green(), 0);
1312        assert_eq!(px2.blue(), 0);
1313    }
1314
1315    #[test]
1316    fn test_draw_alpha_bitmap_zero_alpha_leaves_bg() {
1317        let mut backend = TinySkiaBackend::new();
1318        let mut surface = backend.create_surface(10, 10, &RgbaColor::WHITE);
1319        // 2x2 alpha bitmap: fully transparent
1320        let alpha = vec![0, 0, 0, 0];
1321        let red = RgbaColor::new(255, 0, 0, 255);
1322        backend.draw_alpha_bitmap(&mut surface, &alpha, 2, 2, 0, 0, &red, &Matrix::identity());
1323        // Background should remain white
1324        let px = surface.pixel(0, 0).unwrap();
1325        assert_eq!(px.red(), 255);
1326        assert_eq!(px.green(), 255);
1327        assert_eq!(px.blue(), 255);
1328    }
1329
1330    #[test]
1331    fn test_draw_alpha_bitmap_empty_is_noop() {
1332        let mut backend = TinySkiaBackend::new();
1333        let mut surface = backend.create_surface(10, 10, &RgbaColor::WHITE);
1334        // Empty alpha data should not crash
1335        backend.draw_alpha_bitmap(
1336            &mut surface,
1337            &[],
1338            0,
1339            0,
1340            0,
1341            0,
1342            &RgbaColor::BLACK,
1343            &Matrix::identity(),
1344        );
1345        // Surface should remain white
1346        let px = surface.pixel(5, 5).unwrap();
1347        assert_eq!(px.red(), 255);
1348    }
1349
1350    #[test]
1351    fn test_draw_alpha_bitmap_half_alpha() {
1352        let mut backend = TinySkiaBackend::new();
1353        let mut surface = backend.create_surface(10, 10, &RgbaColor::WHITE);
1354        // 1x1 alpha bitmap: half transparent
1355        let alpha = vec![128];
1356        let red = RgbaColor::new(255, 0, 0, 255);
1357        backend.draw_alpha_bitmap(&mut surface, &alpha, 1, 1, 0, 0, &red, &Matrix::identity());
1358        // Should be blended: some red over white
1359        let px = surface.pixel(0, 0).unwrap();
1360        // Red channel should be 255 (red + white background)
1361        assert_eq!(px.red(), 255);
1362        // Green should be reduced from 255 (some red blended over white)
1363        assert!(px.green() > 100 && px.green() < 200, "green={}", px.green());
1364    }
1365
1366    // --- Upstream ports: GetClipBox tests ---
1367    //
1368    // The upstream C++ tests use CFX_DefaultRenderDevice::GetClipBox() to query
1369    // the current clip region. rpdfium's TinySkiaBackend does not expose a
1370    // clip-box query API (clipping is managed internally via tiny_skia::Mask).
1371    // These tests verify clip behavior indirectly through pixel rendering.
1372
1373    /// Upstream: TEST(CFXDefaultRenderDeviceTest, GetClipBoxDefault)
1374    ///
1375    /// Verifies that without any clip, the entire surface is paintable.
1376    #[test]
1377    fn test_clip_box_default_full_surface() {
1378        let mut backend = TinySkiaBackend::new();
1379        let mut surface = backend.create_surface(16, 16, &RgbaColor::WHITE);
1380
1381        // Fill entire surface with red (no clip applied)
1382        let ops = vec![
1383            PathOp::MoveTo { x: 0.0, y: 0.0 },
1384            PathOp::LineTo { x: 16.0, y: 0.0 },
1385            PathOp::LineTo { x: 16.0, y: 16.0 },
1386            PathOp::LineTo { x: 0.0, y: 16.0 },
1387            PathOp::Close,
1388        ];
1389        let red = RgbaColor::new(255, 0, 0, 255);
1390        backend.fill_path(
1391            &mut surface,
1392            &ops,
1393            FillRule::NonZero,
1394            &red,
1395            &Matrix::identity(),
1396        );
1397
1398        // All corners should be red (fully painted, no clip)
1399        for (x, y) in [(0, 0), (15, 0), (0, 15), (15, 15)] {
1400            let px = surface.pixel(x, y).unwrap();
1401            assert_eq!(px.red(), 255, "pixel ({x},{y}) should be red");
1402            assert_eq!(px.green(), 0, "pixel ({x},{y}) should have no green");
1403        }
1404    }
1405
1406    /// Upstream: TEST(CFXDefaultRenderDeviceTest, GetClipBoxPathFill)
1407    ///
1408    /// Verifies that a path-fill clip restricts painting.
1409    #[test]
1410    fn test_clip_box_path_fill_restricts_painting() {
1411        let mut backend = TinySkiaBackend::new();
1412        let mut surface = backend.create_surface(16, 16, &RgbaColor::WHITE);
1413
1414        // Clip to a small rect in the center
1415        let mut clip = ClipPath::new();
1416        clip.push(
1417            vec![
1418                PathOp::MoveTo { x: 4.0, y: 4.0 },
1419                PathOp::LineTo { x: 12.0, y: 4.0 },
1420                PathOp::LineTo { x: 12.0, y: 12.0 },
1421                PathOp::LineTo { x: 4.0, y: 12.0 },
1422                PathOp::Close,
1423            ],
1424            FillRule::EvenOdd,
1425        );
1426        backend.push_clip(&mut surface, &clip, &Matrix::identity());
1427
1428        // Fill entire surface with red
1429        let full_rect = vec![
1430            PathOp::MoveTo { x: 0.0, y: 0.0 },
1431            PathOp::LineTo { x: 16.0, y: 0.0 },
1432            PathOp::LineTo { x: 16.0, y: 16.0 },
1433            PathOp::LineTo { x: 0.0, y: 16.0 },
1434            PathOp::Close,
1435        ];
1436        let red = RgbaColor::new(255, 0, 0, 255);
1437        backend.fill_path(
1438            &mut surface,
1439            &full_rect,
1440            FillRule::NonZero,
1441            &red,
1442            &Matrix::identity(),
1443        );
1444
1445        // Center should be red (inside clip)
1446        let center = surface.pixel(8, 8).unwrap();
1447        assert_eq!(center.red(), 255);
1448        assert_eq!(center.green(), 0);
1449
1450        // Corner should be white (outside clip)
1451        let corner = surface.pixel(1, 1).unwrap();
1452        assert_eq!(corner.red(), 255);
1453        assert_eq!(corner.green(), 255);
1454
1455        backend.pop_clip(&mut surface);
1456    }
1457
1458    /// Upstream: TEST(CFXDefaultRenderDeviceTest, GetClipBoxRect)
1459    ///
1460    /// Verifies rect clipping via path clip (rpdfium doesn't have
1461    /// a separate SetClip_Rect; it uses push_clip with a rect path).
1462    #[test]
1463    fn test_clip_box_rect_restricts_painting() {
1464        let mut backend = TinySkiaBackend::new();
1465        let mut surface = backend.create_surface(16, 16, &RgbaColor::WHITE);
1466
1467        // Clip to rect (2,4,14,12)
1468        let mut clip = ClipPath::new();
1469        clip.push(
1470            vec![
1471                PathOp::MoveTo { x: 2.0, y: 4.0 },
1472                PathOp::LineTo { x: 14.0, y: 4.0 },
1473                PathOp::LineTo { x: 14.0, y: 12.0 },
1474                PathOp::LineTo { x: 2.0, y: 12.0 },
1475                PathOp::Close,
1476            ],
1477            FillRule::NonZero,
1478        );
1479        backend.push_clip(&mut surface, &clip, &Matrix::identity());
1480
1481        let full_rect = vec![
1482            PathOp::MoveTo { x: 0.0, y: 0.0 },
1483            PathOp::LineTo { x: 16.0, y: 0.0 },
1484            PathOp::LineTo { x: 16.0, y: 16.0 },
1485            PathOp::LineTo { x: 0.0, y: 16.0 },
1486            PathOp::Close,
1487        ];
1488        let blue = RgbaColor::new(0, 0, 255, 255);
1489        backend.fill_path(
1490            &mut surface,
1491            &full_rect,
1492            FillRule::NonZero,
1493            &blue,
1494            &Matrix::identity(),
1495        );
1496
1497        // Inside clip: should be blue
1498        let inside = surface.pixel(8, 8).unwrap();
1499        assert_eq!(inside.blue(), 255);
1500        assert_eq!(inside.red(), 0);
1501
1502        // Outside clip: should be white
1503        let outside = surface.pixel(0, 0).unwrap();
1504        assert_eq!(outside.red(), 255);
1505        assert_eq!(outside.green(), 255);
1506
1507        backend.pop_clip(&mut surface);
1508    }
1509
1510    /// Upstream: TEST(CFXDefaultRenderDeviceTest, GetClipBoxEmpty)
1511    ///
1512    /// Verifies that an empty clip rect prevents all painting.
1513    #[test]
1514    fn test_clip_box_empty_prevents_painting() {
1515        let mut backend = TinySkiaBackend::new();
1516        let mut surface = backend.create_surface(16, 16, &RgbaColor::WHITE);
1517
1518        // Clip to an empty rect (top == bottom: y=8 to y=8)
1519        let mut clip = ClipPath::new();
1520        clip.push(
1521            vec![
1522                PathOp::MoveTo { x: 2.0, y: 8.0 },
1523                PathOp::LineTo { x: 14.0, y: 8.0 },
1524                PathOp::LineTo { x: 14.0, y: 8.0 },
1525                PathOp::LineTo { x: 2.0, y: 8.0 },
1526                PathOp::Close,
1527            ],
1528            FillRule::NonZero,
1529        );
1530        backend.push_clip(&mut surface, &clip, &Matrix::identity());
1531
1532        let full_rect = vec![
1533            PathOp::MoveTo { x: 0.0, y: 0.0 },
1534            PathOp::LineTo { x: 16.0, y: 0.0 },
1535            PathOp::LineTo { x: 16.0, y: 16.0 },
1536            PathOp::LineTo { x: 0.0, y: 16.0 },
1537            PathOp::Close,
1538        ];
1539        let red = RgbaColor::new(255, 0, 0, 255);
1540        backend.fill_path(
1541            &mut surface,
1542            &full_rect,
1543            FillRule::NonZero,
1544            &red,
1545            &Matrix::identity(),
1546        );
1547
1548        // Everything should remain white (empty clip blocks all painting)
1549        let px = surface.pixel(8, 8).unwrap();
1550        assert_eq!(px.red(), 255);
1551        assert_eq!(px.green(), 255);
1552        assert_eq!(px.blue(), 255);
1553
1554        backend.pop_clip(&mut surface);
1555    }
1556
1557    /// Upstream: TEST(CFXDefaultRenderDeviceTest, GetClipBoxPathStroke)
1558    ///
1559    /// rpdfium doesn't have a SetClip_PathStroke equivalent (stroke-based
1560    /// clipping). This test is marked as ignored.
1561    #[test]
1562    #[ignore = "rpdfium does not expose stroke-based clip path API"]
1563    fn test_clip_box_path_stroke() {
1564        // Upstream tests stroke-based clipping which is not available in rpdfium.
1565    }
1566}