Skip to main content

rpdfium_render/
cfx_renderdevice.rs

1// Derived from PDFium's fpdfsdk/fpdf_view.cpp render entry point
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//! Public rendering API.
7
8use rpdfium_core::Matrix;
9use rpdfium_graphics::Bitmap;
10use rpdfium_page::display::{DisplayTree, walk};
11
12use crate::cfx_defaultrenderdevice::TinySkiaBackend;
13use crate::error::RenderError;
14use crate::image::ImageDecoder;
15use crate::page_transform::compute_page_transform;
16use crate::render_defines::RenderConfig;
17use crate::renderdevicedriver_iface::RenderBackend;
18use crate::renderer::DisplayRenderer;
19
20/// Render a display tree to a bitmap using the default backend.
21pub fn render(tree: &DisplayTree, config: &RenderConfig) -> Result<Bitmap, RenderError> {
22    render_internal(tree, config, None)
23}
24
25/// Render a display tree with image decoding support.
26pub fn render_with_images(
27    tree: &DisplayTree,
28    config: &RenderConfig,
29    decoder: &dyn ImageDecoder,
30) -> Result<Bitmap, RenderError> {
31    render_internal(tree, config, Some(decoder))
32}
33
34fn render_internal(
35    tree: &DisplayTree,
36    config: &RenderConfig,
37    decoder: Option<&dyn ImageDecoder>,
38) -> Result<Bitmap, RenderError> {
39    let mut backend = TinySkiaBackend::new();
40    if !config.antialiasing {
41        backend.set_antialiasing(false);
42    }
43
44    // Use forced color scheme background when set.
45    let effective_bg = config
46        .forced_color_scheme
47        .as_ref()
48        .map_or(&config.background, |s| &s.background_color);
49
50    let mut surface = backend.create_surface(config.width, config.height, effective_bg);
51
52    let page_transform = if let Some(m) = config.custom_transform {
53        m
54    } else {
55        match config.media_box {
56            Some(ref mb) => {
57                compute_page_transform(mb, config.width, config.height, config.rotation)
58            }
59            None => Matrix::identity(),
60        }
61    };
62
63    {
64        let mut renderer =
65            DisplayRenderer::new(&mut backend, &mut surface, page_transform, decoder)
66                .with_per_feature_aa(
67                    config.text_antialiasing,
68                    config.path_antialiasing,
69                    config.image_antialiasing,
70                );
71        if let Some(ref scheme) = config.forced_color_scheme {
72            renderer = renderer.with_forced_color_scheme(scheme.clone());
73        }
74        walk(tree, &mut renderer);
75    }
76
77    let mut bitmap = backend.finish(surface);
78
79    if let Some(ref clip) = config.clip_rect {
80        apply_clip_rect(&mut bitmap, clip, effective_bg);
81    }
82
83    if config.grayscale {
84        apply_grayscale(&mut bitmap);
85    }
86
87    Ok(bitmap)
88}
89
90/// Mask out pixels outside the given device-space clip rectangle.
91///
92/// `clip.left/right` are x pixel bounds; `clip.bottom/top` are y pixel bounds
93/// measured from the top of the bitmap (y=0 is the top row).  Pixels outside
94/// the rectangle are replaced with `bg`.
95fn apply_clip_rect(
96    bitmap: &mut Bitmap,
97    clip: &rpdfium_core::Rect,
98    bg: &crate::color_convert::RgbaColor,
99) {
100    let bw = bitmap.width as usize;
101    let bh = bitmap.height as usize;
102    let x0 = (clip.left.max(0.0) as usize).min(bw);
103    let x1 = (clip.right.max(0.0) as usize).min(bw);
104    let y0 = (clip.bottom.max(0.0) as usize).min(bh);
105    let y1 = (clip.top.max(0.0) as usize).min(bh);
106    let stride = bw * 4;
107    for y in 0..bh {
108        let in_y = y >= y0 && y < y1;
109        for x in 0..bw {
110            if !in_y || x < x0 || x >= x1 {
111                let off = y * stride + x * 4;
112                bitmap.data[off] = bg.r;
113                bitmap.data[off + 1] = bg.g;
114                bitmap.data[off + 2] = bg.b;
115                bitmap.data[off + 3] = bg.a;
116            }
117        }
118    }
119}
120
121/// Convert all pixels in a bitmap to grayscale using the luminance formula:
122/// `gray = (r*299 + g*587 + b*114) / 1000`
123fn apply_grayscale(bitmap: &mut Bitmap) {
124    for chunk in bitmap.data.chunks_exact_mut(4) {
125        let r = chunk[0] as u32;
126        let g = chunk[1] as u32;
127        let b = chunk[2] as u32;
128        let gray = ((r * 299 + g * 587 + b * 114) / 1000) as u8;
129        chunk[0] = gray;
130        chunk[1] = gray;
131        chunk[2] = gray;
132        // Alpha unchanged
133    }
134}
135
136/// Render a display tree using tile-based progressive rendering.
137///
138/// If `config.tile_size` is `Some`, the page is rendered in tiles.
139/// The progress callback is called after each tile completes; returning
140/// `false` from the callback aborts rendering early (the returned bitmap
141/// will contain only the tiles rendered so far).
142///
143/// If `config.tile_size` is `None`, this falls back to the normal
144/// full-page render path.
145pub fn render_tiled(tree: &DisplayTree, config: &RenderConfig) -> Result<Bitmap, RenderError> {
146    render_tiled_internal(tree, config, None)
147}
148
149/// Render a display tree using tile-based progressive rendering with
150/// image decoding support.
151pub fn render_tiled_with_images(
152    tree: &DisplayTree,
153    config: &RenderConfig,
154    decoder: &dyn ImageDecoder,
155) -> Result<Bitmap, RenderError> {
156    render_tiled_internal(tree, config, Some(decoder))
157}
158
159fn render_tiled_internal(
160    tree: &DisplayTree,
161    config: &RenderConfig,
162    decoder: Option<&dyn ImageDecoder>,
163) -> Result<Bitmap, RenderError> {
164    let tile_size = match config.tile_size {
165        Some(ts) if ts > 0 => ts,
166        _ => return render_internal(tree, config, decoder),
167    };
168
169    let width = config.width;
170    let height = config.height;
171
172    let cols = width.div_ceil(tile_size);
173    let rows = height.div_ceil(tile_size);
174    let total_tiles = cols * rows;
175
176    // Use forced color scheme background when set.
177    let effective_bg = config
178        .forced_color_scheme
179        .as_ref()
180        .map_or(&config.background, |s| &s.background_color);
181
182    // Create the final bitmap filled with the background color
183    let mut final_bitmap = Bitmap::new(width, height, rpdfium_graphics::BitmapFormat::Rgba32);
184    // Fill with background
185    for pixel in final_bitmap.data.chunks_exact_mut(4) {
186        pixel[0] = effective_bg.r;
187        pixel[1] = effective_bg.g;
188        pixel[2] = effective_bg.b;
189        pixel[3] = effective_bg.a;
190    }
191
192    let page_transform = match config.media_box {
193        Some(ref mb) => compute_page_transform(mb, width, height, config.rotation),
194        None => Matrix::identity(),
195    };
196
197    for row in 0..rows {
198        for col in 0..cols {
199            let tile_x = col * tile_size;
200            let tile_y = row * tile_size;
201            let tile_w = tile_size.min(width - tile_x);
202            let tile_h = tile_size.min(height - tile_y);
203
204            // Create a backend for this tile, using a clip-and-offset approach:
205            // We render the full scene but offset so only this tile region is drawn.
206            let mut backend = TinySkiaBackend::new();
207            if !config.antialiasing {
208                backend.set_antialiasing(false);
209            }
210            let mut surface = backend.create_surface(tile_w, tile_h, effective_bg);
211
212            // Translate the page transform so the tile origin maps to (0,0)
213            let tile_offset = Matrix::from_translation(-(tile_x as f64), -(tile_y as f64));
214            let tile_transform = tile_offset.pre_concat(&page_transform);
215
216            {
217                let mut renderer =
218                    DisplayRenderer::new(&mut backend, &mut surface, tile_transform, decoder)
219                        .with_per_feature_aa(
220                            config.text_antialiasing,
221                            config.path_antialiasing,
222                            config.image_antialiasing,
223                        );
224                if let Some(ref scheme) = config.forced_color_scheme {
225                    renderer = renderer.with_forced_color_scheme(scheme.clone());
226                }
227                walk(tree, &mut renderer);
228            }
229
230            let tile_bitmap = backend.finish(surface);
231
232            // Copy tile pixels into the final bitmap
233            for ty in 0..tile_h {
234                let dst_y = tile_y + ty;
235                if dst_y >= height {
236                    break;
237                }
238                let src_row = tile_bitmap.scanline(ty);
239                let dst_start = (dst_y * final_bitmap.stride + tile_x * 4) as usize;
240                let copy_len = (tile_w * 4) as usize;
241                final_bitmap.data[dst_start..dst_start + copy_len]
242                    .copy_from_slice(&src_row[..copy_len]);
243            }
244
245            // Call progress callback
246            if let Some(ref progress) = config.progress {
247                if !progress.on_tile_complete(col, row, total_tiles) {
248                    return Ok(final_bitmap);
249                }
250            }
251        }
252    }
253
254    if config.grayscale {
255        apply_grayscale(&mut final_bitmap);
256    }
257
258    Ok(final_bitmap)
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use rpdfium_core::Rect;
265    use rpdfium_graphics::{BlendMode, Color, FillRule, PathOp, PathStyle};
266    use rpdfium_page::display::DisplayNode;
267
268    use crate::color_convert::RgbaColor;
269
270    #[test]
271    fn test_render_empty_tree() {
272        let tree = DisplayTree {
273            root: DisplayNode::Group {
274                blend_mode: BlendMode::Normal,
275                clip: None,
276                opacity: 1.0,
277                isolated: false,
278                knockout: false,
279                soft_mask: None,
280                children: Vec::new(),
281            },
282        };
283        let config = RenderConfig {
284            width: 50,
285            height: 50,
286            background: RgbaColor::WHITE,
287            ..RenderConfig::default()
288        };
289        let bitmap = render(&tree, &config).unwrap();
290        assert_eq!(bitmap.width, 50);
291        assert_eq!(bitmap.height, 50);
292    }
293
294    #[test]
295    fn test_render_simple_fill() {
296        let tree = DisplayTree {
297            root: DisplayNode::Group {
298                blend_mode: BlendMode::Normal,
299                clip: None,
300                opacity: 1.0,
301                isolated: false,
302                knockout: false,
303                soft_mask: None,
304                children: vec![DisplayNode::Path {
305                    ops: vec![
306                        PathOp::MoveTo { x: 0.0, y: 0.0 },
307                        PathOp::LineTo { x: 100.0, y: 0.0 },
308                        PathOp::LineTo { x: 100.0, y: 100.0 },
309                        PathOp::LineTo { x: 0.0, y: 100.0 },
310                        PathOp::Close,
311                    ],
312                    style: PathStyle {
313                        fill: Some(FillRule::NonZero),
314                        ..PathStyle::default()
315                    },
316                    matrix: Matrix::identity(),
317                    fill_color: Some(Color::rgb(1.0, 0.0, 0.0)),
318                    stroke_color: None,
319                    fill_color_space: None,
320                    stroke_color_space: None,
321                    transfer_function: None,
322                    overprint: false,
323                    overprint_mode: 0,
324                }],
325            },
326        };
327        let config = RenderConfig {
328            width: 100,
329            height: 100,
330            background: RgbaColor::WHITE,
331            ..RenderConfig::default()
332        };
333        let bitmap = render(&tree, &config).unwrap();
334        // The rectangle covers the entire surface, so center pixel should be red
335        let idx = (50 * bitmap.stride + 50 * 4) as usize;
336        assert_eq!(bitmap.data[idx], 255); // R
337        assert_eq!(bitmap.data[idx + 1], 0); // G
338        assert_eq!(bitmap.data[idx + 2], 0); // B
339    }
340
341    #[test]
342    fn test_render_with_page_transform() {
343        // A red rectangle at PDF coordinates (0,0)-(100,100) on a 200x200pt page.
344        // With page transform, PDF Y is flipped: (0,0) in PDF → (0,200) in device.
345        let tree = DisplayTree {
346            root: DisplayNode::Group {
347                blend_mode: BlendMode::Normal,
348                clip: None,
349                opacity: 1.0,
350                isolated: false,
351                knockout: false,
352                soft_mask: None,
353                children: vec![DisplayNode::Path {
354                    ops: vec![
355                        PathOp::MoveTo { x: 0.0, y: 0.0 },
356                        PathOp::LineTo { x: 200.0, y: 0.0 },
357                        PathOp::LineTo { x: 200.0, y: 200.0 },
358                        PathOp::LineTo { x: 0.0, y: 200.0 },
359                        PathOp::Close,
360                    ],
361                    style: PathStyle {
362                        fill: Some(FillRule::NonZero),
363                        ..PathStyle::default()
364                    },
365                    matrix: Matrix::identity(),
366                    fill_color: Some(Color::rgb(1.0, 0.0, 0.0)),
367                    stroke_color: None,
368                    fill_color_space: None,
369                    stroke_color_space: None,
370                    transfer_function: None,
371                    overprint: false,
372                    overprint_mode: 0,
373                }],
374            },
375        };
376        let config = RenderConfig {
377            width: 200,
378            height: 200,
379            background: RgbaColor::WHITE,
380            media_box: Some(Rect::new(0.0, 0.0, 200.0, 200.0)),
381            rotation: 0,
382            ..RenderConfig::default()
383        };
384        let bitmap = render(&tree, &config).unwrap();
385        // With page transform, the full-page rectangle should still cover everything
386        let idx = (100 * bitmap.stride + 100 * 4) as usize;
387        assert_eq!(bitmap.data[idx], 255); // R
388        assert_eq!(bitmap.data[idx + 1], 0); // G
389        assert_eq!(bitmap.data[idx + 2], 0); // B
390    }
391
392    // ---- Tiled / Progressive Rendering Tests ----
393
394    fn make_red_rect_tree() -> DisplayTree {
395        DisplayTree {
396            root: DisplayNode::Group {
397                blend_mode: BlendMode::Normal,
398                clip: None,
399                opacity: 1.0,
400                isolated: false,
401                knockout: false,
402                soft_mask: None,
403                children: vec![DisplayNode::Path {
404                    ops: vec![
405                        PathOp::MoveTo { x: 0.0, y: 0.0 },
406                        PathOp::LineTo { x: 100.0, y: 0.0 },
407                        PathOp::LineTo { x: 100.0, y: 100.0 },
408                        PathOp::LineTo { x: 0.0, y: 100.0 },
409                        PathOp::Close,
410                    ],
411                    style: PathStyle {
412                        fill: Some(FillRule::NonZero),
413                        ..PathStyle::default()
414                    },
415                    matrix: Matrix::identity(),
416                    fill_color: Some(Color::rgb(1.0, 0.0, 0.0)),
417                    stroke_color: None,
418                    fill_color_space: None,
419                    stroke_color_space: None,
420                    transfer_function: None,
421                    overprint: false,
422                    overprint_mode: 0,
423                }],
424            },
425        }
426    }
427
428    #[test]
429    fn test_tiled_render_matches_full_render() {
430        use super::render_tiled;
431
432        let tree = make_red_rect_tree();
433        let config = RenderConfig {
434            width: 100,
435            height: 100,
436            background: RgbaColor::WHITE,
437            ..RenderConfig::default()
438        };
439        let full = render(&tree, &config).unwrap();
440
441        let tiled_config = RenderConfig {
442            width: 100,
443            height: 100,
444            background: RgbaColor::WHITE,
445            tile_size: Some(50),
446            ..RenderConfig::default()
447        };
448        let tiled = render_tiled(&tree, &tiled_config).unwrap();
449
450        assert_eq!(full.width, tiled.width);
451        assert_eq!(full.height, tiled.height);
452        // Check center pixel matches
453        let idx = (50 * full.stride + 50 * 4) as usize;
454        assert_eq!(full.data[idx], tiled.data[idx]); // R
455        assert_eq!(full.data[idx + 1], tiled.data[idx + 1]); // G
456        assert_eq!(full.data[idx + 2], tiled.data[idx + 2]); // B
457    }
458
459    #[test]
460    fn test_tiled_render_progress_callback_count() {
461        use super::render_tiled;
462        use std::sync::Arc;
463        use std::sync::atomic::{AtomicU32, Ordering};
464
465        use crate::render_defines::RenderProgress;
466
467        struct CountProgress {
468            count: AtomicU32,
469        }
470        impl RenderProgress for CountProgress {
471            fn on_tile_complete(&self, _tx: u32, _ty: u32, _total: u32) -> bool {
472                self.count.fetch_add(1, Ordering::Relaxed);
473                true
474            }
475        }
476
477        let tree = make_red_rect_tree();
478        let progress = Arc::new(CountProgress {
479            count: AtomicU32::new(0),
480        });
481        let config = RenderConfig {
482            width: 100,
483            height: 100,
484            background: RgbaColor::WHITE,
485            tile_size: Some(50),
486            progress: Some(progress.clone()),
487            ..RenderConfig::default()
488        };
489        let _ = render_tiled(&tree, &config).unwrap();
490
491        // 100x100 with 50px tiles = 2x2 = 4 tiles
492        assert_eq!(progress.count.load(Ordering::Relaxed), 4);
493    }
494
495    #[test]
496    fn test_tiled_render_cancellation_stops_early() {
497        use super::render_tiled;
498        use std::sync::Arc;
499        use std::sync::atomic::{AtomicU32, Ordering};
500
501        use crate::render_defines::RenderProgress;
502
503        struct CancelAfterN {
504            count: AtomicU32,
505            cancel_after: u32,
506        }
507        impl RenderProgress for CancelAfterN {
508            fn on_tile_complete(&self, _tx: u32, _ty: u32, _total: u32) -> bool {
509                let c = self.count.fetch_add(1, Ordering::Relaxed) + 1;
510                c < self.cancel_after
511            }
512        }
513
514        let tree = make_red_rect_tree();
515        let progress = Arc::new(CancelAfterN {
516            count: AtomicU32::new(0),
517            cancel_after: 2,
518        });
519        let config = RenderConfig {
520            width: 100,
521            height: 100,
522            background: RgbaColor::WHITE,
523            tile_size: Some(50),
524            progress: Some(progress.clone()),
525            ..RenderConfig::default()
526        };
527        let bitmap = render_tiled(&tree, &config).unwrap();
528        // Should have stopped after 2 tiles
529        assert_eq!(progress.count.load(Ordering::Relaxed), 2);
530        // Bitmap was still returned (partially rendered)
531        assert_eq!(bitmap.width, 100);
532        assert_eq!(bitmap.height, 100);
533    }
534
535    #[test]
536    fn test_tiled_render_none_tile_size_falls_back() {
537        use super::render_tiled;
538
539        let tree = make_red_rect_tree();
540        let config = RenderConfig {
541            width: 100,
542            height: 100,
543            background: RgbaColor::WHITE,
544            tile_size: None,
545            ..RenderConfig::default()
546        };
547        // Should work like normal render
548        let bitmap = render_tiled(&tree, &config).unwrap();
549        assert_eq!(bitmap.width, 100);
550        assert_eq!(bitmap.height, 100);
551        // Center pixel should be red
552        let idx = (50 * bitmap.stride + 50 * 4) as usize;
553        assert_eq!(bitmap.data[idx], 255);
554    }
555
556    #[test]
557    fn test_tiled_render_config_builder() {
558        use std::sync::Arc;
559
560        use crate::render_defines::RenderProgress;
561
562        struct NoopProgress;
563        impl RenderProgress for NoopProgress {
564            fn on_tile_complete(&self, _tx: u32, _ty: u32, _total: u32) -> bool {
565                true
566            }
567        }
568
569        let config = RenderConfig::default()
570            .with_tile_size(128)
571            .with_progress(Arc::new(NoopProgress));
572        assert_eq!(config.tile_size, Some(128));
573        assert!(config.progress.is_some());
574    }
575
576    fn make_white_rect_50() -> DisplayNode {
577        DisplayNode::Path {
578            ops: vec![
579                PathOp::MoveTo { x: 0.0, y: 0.0 },
580                PathOp::LineTo { x: 50.0, y: 0.0 },
581                PathOp::LineTo { x: 50.0, y: 50.0 },
582                PathOp::LineTo { x: 0.0, y: 50.0 },
583                PathOp::Close,
584            ],
585            style: PathStyle {
586                fill: Some(FillRule::NonZero),
587                ..PathStyle::default()
588            },
589            matrix: Matrix::identity(),
590            fill_color: Some(Color::rgb(1.0, 1.0, 1.0)),
591            stroke_color: None,
592            fill_color_space: None,
593            stroke_color_space: None,
594            transfer_function: None,
595            overprint: false,
596            overprint_mode: 0,
597        }
598    }
599
600    #[test]
601    fn test_render_group_opacity_transparent_bg_simple() {
602        // Simple: Screen/0.6 group with white fill on transparent background.
603        let tree = DisplayTree {
604            root: DisplayNode::Group {
605                blend_mode: BlendMode::Normal,
606                clip: None,
607                opacity: 1.0,
608                isolated: true,
609                knockout: false,
610                soft_mask: None,
611                children: vec![DisplayNode::Group {
612                    blend_mode: BlendMode::Screen,
613                    clip: None,
614                    opacity: 0.6,
615                    isolated: true,
616                    knockout: false,
617                    soft_mask: None,
618                    children: vec![make_white_rect_50()],
619                }],
620            },
621        };
622        let config = RenderConfig {
623            width: 50,
624            height: 50,
625            background: RgbaColor::new(0, 0, 0, 0),
626            ..RenderConfig::default()
627        };
628        let bitmap = render(&tree, &config).unwrap();
629        let idx = (25 * bitmap.stride + 25 * 4) as usize;
630        let a = bitmap.data[idx + 3];
631        eprintln!(
632            "simple: RGBA=[{},{},{},{}]",
633            bitmap.data[idx],
634            bitmap.data[idx + 1],
635            bitmap.data[idx + 2],
636            a
637        );
638        assert!(a > 140 && a < 166, "Expected alpha ~153 but got {a}");
639    }
640
641    #[test]
642    fn test_render_group_opacity_transparent_bg_with_smask() {
643        // Full smask_blend structure: outer group with Alpha SMask,
644        // inner Screen/0.6 group, on transparent background.
645        use rpdfium_page::display::{SoftMask, SoftMaskSubtype};
646
647        // SMask group: fully opaque white fill
648        let mask_tree = DisplayTree {
649            root: DisplayNode::Group {
650                blend_mode: BlendMode::Normal,
651                clip: None,
652                opacity: 1.0,
653                isolated: true,
654                knockout: false,
655                soft_mask: None,
656                children: vec![make_white_rect_50()],
657            },
658        };
659
660        let smask = SoftMask {
661            subtype: SoftMaskSubtype::Alpha,
662            group: mask_tree,
663            backdrop_color: None,
664            transfer_function: None,
665        };
666
667        let tree = DisplayTree {
668            root: DisplayNode::Group {
669                blend_mode: BlendMode::Normal,
670                clip: None,
671                opacity: 1.0,
672                isolated: true,
673                knockout: false,
674                soft_mask: None,
675                children: vec![DisplayNode::Group {
676                    blend_mode: BlendMode::Normal,
677                    clip: None,
678                    opacity: 1.0,
679                    isolated: true,
680                    knockout: false,
681                    soft_mask: Some(Box::new(smask)),
682                    children: vec![DisplayNode::Group {
683                        blend_mode: BlendMode::Normal,
684                        clip: None,
685                        opacity: 1.0,
686                        isolated: true,
687                        knockout: false,
688                        soft_mask: None,
689                        children: vec![DisplayNode::Group {
690                            blend_mode: BlendMode::Screen,
691                            clip: None,
692                            opacity: 0.6,
693                            isolated: true,
694                            knockout: false,
695                            soft_mask: None,
696                            children: vec![make_white_rect_50()],
697                        }],
698                    }],
699                }],
700            },
701        };
702        let config = RenderConfig {
703            width: 50,
704            height: 50,
705            background: RgbaColor::new(0, 0, 0, 0),
706            ..RenderConfig::default()
707        };
708        let bitmap = render(&tree, &config).unwrap();
709        let idx = (25 * bitmap.stride + 25 * 4) as usize;
710        let a = bitmap.data[idx + 3];
711        eprintln!(
712            "with_smask: RGBA=[{},{},{},{}]",
713            bitmap.data[idx],
714            bitmap.data[idx + 1],
715            bitmap.data[idx + 2],
716            a
717        );
718        assert!(a > 140 && a < 166, "Expected alpha ~153 but got {a}");
719    }
720
721    // ---- T5: Transparency group + opacity (additional pixel-level tests) ----
722
723    #[test]
724    fn test_render_group_half_opacity_on_opaque_bg() {
725        // Normal blend mode, opacity=0.5, white fill on solid red background.
726        // Expected centre pixel: ~(255, 128, 128, 255) — halfway between red and white.
727        let tree = DisplayTree {
728            root: DisplayNode::Group {
729                blend_mode: BlendMode::Normal,
730                clip: None,
731                opacity: 1.0,
732                isolated: true,
733                knockout: false,
734                soft_mask: None,
735                children: vec![DisplayNode::Group {
736                    blend_mode: BlendMode::Normal,
737                    clip: None,
738                    opacity: 0.5,
739                    isolated: true,
740                    knockout: false,
741                    soft_mask: None,
742                    children: vec![make_white_rect_50()],
743                }],
744            },
745        };
746        let config = RenderConfig {
747            width: 50,
748            height: 50,
749            background: RgbaColor::new(255, 0, 0, 255), // solid red
750            ..RenderConfig::default()
751        };
752        let bitmap = render(&tree, &config).unwrap();
753        let idx = (25 * bitmap.stride + 25 * 4) as usize;
754        let r = bitmap.data[idx];
755        let g = bitmap.data[idx + 1];
756        let b = bitmap.data[idx + 2];
757        let a = bitmap.data[idx + 3];
758        // Full opacity output on opaque bg
759        assert_eq!(a, 255, "alpha should be 255 on opaque bg");
760        // R stays near 255; G and B rise to ~128 from 0
761        assert!(r > 200, "R={r}: red component should stay high");
762        assert!(g > 100 && g < 160, "G={g}: green should be ~128");
763        assert!(b > 100 && b < 160, "B={b}: blue should be ~128");
764    }
765
766    #[test]
767    fn test_render_group_multiply_blend_mode() {
768        // Multiply blend: grey (0.5) fill on white background → should darken to ~grey.
769        // Multiply(src=0.5, dst=1.0) = 0.5 → RGB ~128.
770        let grey_rect = DisplayNode::Path {
771            ops: vec![
772                PathOp::MoveTo { x: 0.0, y: 0.0 },
773                PathOp::LineTo { x: 50.0, y: 0.0 },
774                PathOp::LineTo { x: 50.0, y: 50.0 },
775                PathOp::LineTo { x: 0.0, y: 50.0 },
776                PathOp::Close,
777            ],
778            style: PathStyle {
779                fill: Some(FillRule::NonZero),
780                ..PathStyle::default()
781            },
782            matrix: rpdfium_core::Matrix::identity(),
783            fill_color: Some(Color::rgb(0.5, 0.5, 0.5)),
784            stroke_color: None,
785            fill_color_space: None,
786            stroke_color_space: None,
787            transfer_function: None,
788            overprint: false,
789            overprint_mode: 0,
790        };
791        let tree = DisplayTree {
792            root: DisplayNode::Group {
793                blend_mode: BlendMode::Normal,
794                clip: None,
795                opacity: 1.0,
796                isolated: true,
797                knockout: false,
798                soft_mask: None,
799                children: vec![DisplayNode::Group {
800                    blend_mode: BlendMode::Multiply,
801                    clip: None,
802                    opacity: 1.0,
803                    isolated: true,
804                    knockout: false,
805                    soft_mask: None,
806                    children: vec![grey_rect],
807                }],
808            },
809        };
810        let config = RenderConfig {
811            width: 50,
812            height: 50,
813            background: RgbaColor::new(255, 255, 255, 255), // white
814            ..RenderConfig::default()
815        };
816        let bitmap = render(&tree, &config).unwrap();
817        let idx = (25 * bitmap.stride + 25 * 4) as usize;
818        let r = bitmap.data[idx];
819        let g = bitmap.data[idx + 1];
820        let b = bitmap.data[idx + 2];
821        // Multiply(0.5 * 1.0) = 0.5 → ~128.  Tolerance ±15.
822        assert!(r > 113 && r < 143, "R={r}: expected ~128");
823        assert!(g > 113 && g < 143, "G={g}: expected ~128");
824        assert!(b > 113 && b < 143, "B={b}: expected ~128");
825    }
826
827    #[test]
828    fn test_render_group_isolated_contains_content_within_boundary() {
829        // An isolated group painted at half-opacity over transparent background.
830        // The area outside the 50×50 fill should stay transparent.
831        let tree = DisplayTree {
832            root: DisplayNode::Group {
833                blend_mode: BlendMode::Normal,
834                clip: None,
835                opacity: 1.0,
836                isolated: true,
837                knockout: false,
838                soft_mask: None,
839                children: vec![DisplayNode::Group {
840                    blend_mode: BlendMode::Normal,
841                    clip: None,
842                    opacity: 0.8,
843                    isolated: true,
844                    knockout: false,
845                    soft_mask: None,
846                    children: vec![make_white_rect_50()],
847                }],
848            },
849        };
850        let config = RenderConfig {
851            width: 100,
852            height: 100,
853            background: RgbaColor::new(0, 0, 0, 0), // transparent
854            ..RenderConfig::default()
855        };
856        let bitmap = render(&tree, &config).unwrap();
857        // Inside 50×50 area: alpha > 0
858        let inside_idx = (25 * bitmap.stride + 25 * 4) as usize;
859        let inside_a = bitmap.data[inside_idx + 3];
860        assert!(
861            inside_a > 0,
862            "inside area should have alpha > 0, got {inside_a}"
863        );
864        // Outside 50×50 area (bottom-right corner): should be transparent
865        let outside_idx = (80 * bitmap.stride + 80 * 4) as usize;
866        let outside_a = bitmap.data[outside_idx + 3];
867        assert_eq!(
868            outside_a, 0,
869            "outside area should be transparent, got {outside_a}"
870        );
871    }
872
873    #[test]
874    fn test_forced_color_overrides_fill() {
875        // A red rectangle rendered with forced green text color on blue background.
876        let tree = make_red_rect_tree();
877        let config = RenderConfig {
878            width: 100,
879            height: 100,
880            ..RenderConfig::default()
881        }
882        .with_forced_colors(
883            RgbaColor::new(0, 255, 0, 255), // green foreground
884            RgbaColor::new(0, 0, 255, 255), // blue background
885        );
886        let bitmap = render(&tree, &config).unwrap();
887
888        // The rectangle covers 0..100 x 0..100, so center pixel should be green (forced).
889        let idx = (50 * bitmap.stride + 50 * 4) as usize;
890        assert_eq!(bitmap.data[idx], 0, "R should be 0 (forced green)");
891        assert_eq!(bitmap.data[idx + 1], 255, "G should be 255 (forced green)");
892        assert_eq!(bitmap.data[idx + 2], 0, "B should be 0 (forced green)");
893    }
894
895    #[test]
896    fn test_forced_color_background_applied() {
897        // Empty tree with forced color scheme — background should be blue.
898        let tree = DisplayTree {
899            root: DisplayNode::Group {
900                blend_mode: BlendMode::Normal,
901                clip: None,
902                opacity: 1.0,
903                isolated: false,
904                knockout: false,
905                soft_mask: None,
906                children: Vec::new(),
907            },
908        };
909        let config = RenderConfig {
910            width: 10,
911            height: 10,
912            ..RenderConfig::default()
913        }
914        .with_forced_colors(
915            RgbaColor::new(0, 0, 0, 255),
916            RgbaColor::new(0, 0, 255, 255), // blue background
917        );
918        let bitmap = render(&tree, &config).unwrap();
919        // Every pixel should be blue
920        let idx = (5 * bitmap.stride + 5 * 4) as usize;
921        assert_eq!(bitmap.data[idx], 0, "R");
922        assert_eq!(bitmap.data[idx + 1], 0, "G");
923        assert_eq!(bitmap.data[idx + 2], 255, "B");
924        assert_eq!(bitmap.data[idx + 3], 255, "A");
925    }
926}