Skip to main content

rpdfium_render/
renderer.rs

1// Derived from PDFium's core/fpdfapi/render/cpdf_renderstatus.cpp
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//! Display tree renderer that bridges [`DisplayVisitor`] to [`RenderBackend`].
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use rpdfium_core::{Matrix, Name};
12use rpdfium_font::ResolvedFont;
13use rpdfium_font::font_type::PdfFontType;
14#[cfg(test)]
15use rpdfium_graphics::TextRenderingMode;
16use rpdfium_graphics::{
17    BlendMode, ClipPath, Color, ColorSpaceFamily, FillRule, ImageRef, PathOp, PathStyle,
18};
19use rpdfium_page::ShadingDict;
20use rpdfium_page::display::{DisplayTree, DisplayVisitor, SoftMask, SoftMaskSubtype, TextRun};
21use rpdfium_page::pattern::TilingPattern;
22use rpdfium_parser::Operand;
23
24use crate::cfx_glyphcache::{MAX_CACHED_SIZE, RasterGlyph, RasterGlyphKey, RasterizedGlyphCache};
25use crate::color_convert::RgbaColor;
26use crate::image::ImageDecoder;
27use crate::render_defines::ColorScheme;
28use crate::renderdevicedriver_iface::RenderBackend;
29use crate::stroke::StrokeStyle;
30
31/// Tracks what was pushed so `leave_group` can pop in the right order.
32#[derive(Debug, Clone, Copy)]
33enum GroupAction {
34    Nothing,
35    ClipOnly,
36    GroupOnly,
37    ClipAndGroup,
38}
39
40/// Maximum number of entries in the renderer-level glyph outline cache.
41const MAX_GLYPH_CACHE_SIZE: usize = 4096;
42
43/// Renders a [`DisplayTree`] by implementing
44/// [`DisplayVisitor`] and forwarding drawing commands to a [`RenderBackend`].
45pub struct DisplayRenderer<'a, B: RenderBackend> {
46    backend: &'a mut B,
47    surface: &'a mut B::Surface,
48    page_transform: Matrix,
49    image_decoder: Option<&'a dyn ImageDecoder>,
50    group_stack: Vec<GroupAction>,
51    /// Optional fallback fonts to try when the primary font is missing a glyph.
52    fallback_fonts: Vec<Arc<ResolvedFont>>,
53    /// Accumulated glyph outlines for text clipping (Tr modes 4-7).
54    /// Collected during `render_text_run` when the rendering mode includes clipping.
55    text_clip_ops: Vec<PathOp>,
56    /// Number of text clip regions currently pushed on the clip stack.
57    /// These get popped at `leave_group`.
58    text_clip_depth: usize,
59    /// Per-render glyph outline cache, keyed by (font pointer identity, glyph_id).
60    /// Avoids repeated DashMap lookups into ResolvedFont's internal cache.
61    glyph_cache: HashMap<(usize, u16), Option<Vec<PathOp>>>,
62    /// Rasterized glyph cache for fast text rendering at common sizes.
63    raster_cache: RasterizedGlyphCache,
64    /// Optional forced color scheme for accessibility (high-contrast) rendering.
65    forced_color_scheme: Option<ColorScheme>,
66    /// Per-feature anti-aliasing flags from `RenderConfig`.
67    text_antialiasing: bool,
68    path_antialiasing: bool,
69    image_antialiasing: bool,
70}
71
72impl<'a, B: RenderBackend> DisplayRenderer<'a, B> {
73    /// Create a new renderer targeting the given backend and surface.
74    pub fn new(
75        backend: &'a mut B,
76        surface: &'a mut B::Surface,
77        page_transform: Matrix,
78        image_decoder: Option<&'a dyn ImageDecoder>,
79    ) -> Self {
80        Self {
81            backend,
82            surface,
83            page_transform,
84            image_decoder,
85            group_stack: Vec::new(),
86            fallback_fonts: Vec::new(),
87            text_clip_ops: Vec::new(),
88            text_clip_depth: 0,
89            glyph_cache: HashMap::new(),
90            raster_cache: RasterizedGlyphCache::new(),
91            forced_color_scheme: None,
92            text_antialiasing: true,
93            path_antialiasing: true,
94            image_antialiasing: true,
95        }
96    }
97
98    /// Set fallback fonts to use when the primary font is missing a glyph.
99    pub fn with_fallback_fonts(mut self, fonts: Vec<Arc<ResolvedFont>>) -> Self {
100        self.fallback_fonts = fonts;
101        self
102    }
103
104    /// Set a forced color scheme for accessibility rendering.
105    pub fn with_forced_color_scheme(mut self, scheme: ColorScheme) -> Self {
106        self.forced_color_scheme = Some(scheme);
107        self
108    }
109
110    /// Set per-feature anti-aliasing flags.
111    pub fn with_per_feature_aa(mut self, text_aa: bool, path_aa: bool, image_aa: bool) -> Self {
112        self.text_antialiasing = text_aa;
113        self.path_antialiasing = path_aa;
114        self.image_antialiasing = image_aa;
115        self
116    }
117}
118
119impl<B: RenderBackend> DisplayRenderer<'_, B> {
120    /// Render a single text run by extracting glyph outlines and drawing them.
121    fn render_text_run(&mut self, run: &TextRun) {
122        // Per-feature AA: use text_antialiasing for text rendering.
123        self.backend.set_antialiasing(self.text_antialiasing);
124
125        let resolved_font = match run.resolved_font.as_ref() {
126            Some(font) => font,
127            None => return, // No font data — skip this run
128        };
129
130        // Dispatch to Type 3 rendering if this is a Type 3 font
131        if resolved_font.font_type == PdfFontType::Type3 {
132            self.render_type3_text_run(run, resolved_font);
133            return;
134        }
135
136        let units_per_em = resolved_font.units_per_em().unwrap_or(1000) as f32;
137        if units_per_em == 0.0 {
138            return;
139        }
140
141        let rendering_mode = run.rendering_mode;
142        // Invisible mode: no fill, no stroke — skip rendering
143        if rendering_mode.is_invisible() {
144            return;
145        }
146
147        let should_fill = rendering_mode.is_fill();
148        let should_stroke = rendering_mode.is_stroke();
149        let should_clip = rendering_mode.is_clip();
150
151        let fill_rgba = if let Some(ref scheme) = self.forced_color_scheme {
152            scheme.text_color
153        } else if should_fill {
154            run.fill_color
155                .as_ref()
156                .map(|c| RgbaColor::from_pdf_color(c, 1.0))
157                .unwrap_or(RgbaColor::BLACK)
158        } else {
159            RgbaColor::BLACK
160        };
161
162        let stroke_rgba = if let Some(ref scheme) = self.forced_color_scheme {
163            scheme.text_color
164        } else if should_stroke {
165            run.stroke_color
166                .as_ref()
167                .map(|c| RgbaColor::from_pdf_color(c, 1.0))
168                .unwrap_or(RgbaColor::BLACK)
169        } else {
170            RgbaColor::BLACK
171        };
172
173        let font_size = run.font_size;
174        let scale = font_size / units_per_em;
175        let rise = run.rise;
176        let is_vertical = run.is_vertical;
177
178        // Track position through the run.
179        // For horizontal text, x_pos advances rightward.
180        // For vertical text, y_pos advances downward (negative in PDF y-up space).
181        let mut x_pos: f32 = 0.0;
182        let mut y_pos: f32 = 0.0;
183
184        // Whether spacing heuristic can apply (P1-b: charposlist.cpp ApplyGlyphSpacingHeuristic).
185        // Only for non-embedded TrueType/CIDFontType2 fonts with explicit widths.
186        let can_apply_spacing_heuristic = !is_vertical
187            && !resolved_font.is_embedded
188            && matches!(
189                resolved_font.font_type,
190                PdfFontType::TrueType | PdfFontType::CIDFontType2
191            )
192            && !resolved_font.widths.is_empty();
193
194        for (i, &byte) in run.text.iter().enumerate() {
195            // Try the primary font first, then fallback fonts.
196            let (glyph_id, active_font, active_scale) =
197                match resolved_font.glyph_from_char_code(byte as u16) {
198                    Some(gid) => (gid, resolved_font.as_ref(), scale),
199                    None => {
200                        // Try each fallback font in order
201                        match self.find_fallback_glyph(byte as u16, font_size) {
202                            Some(info) => info,
203                            None => {
204                                // No fallback found — skip glyph
205                                if i < run.positions.len() {
206                                    if is_vertical {
207                                        y_pos -= run.positions[i];
208                                    } else {
209                                        x_pos += run.positions[i];
210                                    }
211                                }
212                                continue;
213                            }
214                        }
215                    }
216                };
217
218            // Determine if a fallback font is being used (different from the primary).
219            let using_fallback = !std::ptr::eq(active_font, resolved_font.as_ref());
220
221            // P1-b: Glyph spacing heuristic for substituted non-embedded TrueType fonts.
222            // Matches charposlist.cpp ApplyGlyphSpacingHeuristic() / position adjust logic.
223            // Shift the glyph right by half the excess when the PDF width > font width,
224            // or apply a horizontal scaling matrix when PDF width < font width.
225            let mut x_shift: f32 = 0.0;
226            let mut h_scale: f32 = 1.0;
227            if can_apply_spacing_heuristic && using_fallback {
228                let pdf_glyph_width = resolved_font.char_width(byte as u16) as f32;
229                if let Some(outline) = active_font.glyph_outline(glyph_id) {
230                    let font_upem = active_font.units_per_em().unwrap_or(1000) as f32;
231                    if font_upem > 0.0 {
232                        // Normalize font glyph advance to 1/1000 em units
233                        let font_glyph_width = outline.advance_width / font_upem * 1000.0;
234                        if pdf_glyph_width > font_glyph_width + 1.0 {
235                            // Shift right by half the excess (text space coordinates)
236                            x_shift = (pdf_glyph_width - font_glyph_width) * font_size / 2000.0;
237                        } else if pdf_glyph_width > 0.0
238                            && font_glyph_width > 0.0
239                            && pdf_glyph_width < font_glyph_width
240                        {
241                            // Scale glyph horizontally to fit PDF-specified width
242                            h_scale = pdf_glyph_width / font_glyph_width;
243                        }
244                    }
245                }
246            }
247
248            // Look up glyph outline through the renderer-level cache.
249            let font_key = active_font as *const ResolvedFont as usize;
250            let cache_key = (font_key, glyph_id);
251            if !self.glyph_cache.contains_key(&cache_key) {
252                let ops = active_font.glyph_outline(glyph_id).map(|o| o.ops.clone());
253                // Evict all entries if cache exceeds limit.
254                if self.glyph_cache.len() >= MAX_GLYPH_CACHE_SIZE {
255                    self.glyph_cache.clear();
256                }
257                self.glyph_cache.insert(cache_key, ops);
258            }
259
260            let outline_ops = match self.glyph_cache.get(&cache_key) {
261                Some(Some(ops)) => ops,
262                _ => {
263                    if i < run.positions.len() {
264                        if is_vertical {
265                            y_pos -= run.positions[i];
266                        } else {
267                            x_pos += run.positions[i];
268                        }
269                    }
270                    continue;
271                }
272            };
273
274            // Build the glyph transform.
275            //
276            // Horizontal: text_matrix * translate(x_pos + x_shift, rise) * scale(h_scale * active_scale, active_scale)
277            // Vertical:   text_matrix * translate(-vx * fs/1000, y_pos - vy * fs/1000) * scale(active_scale, active_scale)
278            //   where (vx, vy) are the vertical origin offsets from /W2 or /DW2.
279            let glyph_transform = if is_vertical {
280                let (vx, vy) = run.vert_origins.get(i).copied().unwrap_or((0, 880));
281                let gx = -(vx as f32) * font_size / 1000.0;
282                let gy = y_pos - vy as f32 * font_size / 1000.0;
283                let glyph_translate = Matrix::from_translation(gx as f64, gy as f64);
284                let glyph_scale =
285                    Matrix::new(active_scale as f64, 0.0, 0.0, active_scale as f64, 0.0, 0.0);
286                run.matrix
287                    .pre_concat(&glyph_translate)
288                    .pre_concat(&glyph_scale)
289            } else {
290                let glyph_translate =
291                    Matrix::from_translation((x_pos + x_shift) as f64, rise as f64);
292                let glyph_scale = Matrix::new(
293                    (active_scale * h_scale) as f64,
294                    0.0,
295                    0.0,
296                    active_scale as f64,
297                    0.0,
298                    0.0,
299                );
300                run.matrix
301                    .pre_concat(&glyph_translate)
302                    .pre_concat(&glyph_scale)
303            };
304
305            // Apply page transform
306            let final_transform = self.page_transform.pre_concat(&glyph_transform);
307
308            // For common fill-only rendering at small sizes, try raster cache.
309            // Only for horizontal text (raster cache doesn't account for vertical origin offsets).
310            if !is_vertical
311                && should_fill
312                && !should_stroke
313                && !should_clip
314                && font_size < MAX_CACHED_SIZE
315            {
316                let raster_key = RasterGlyphKey {
317                    font_id: font_key,
318                    glyph_id,
319                    size_q: RasterizedGlyphCache::quantize_size(font_size),
320                };
321
322                if let Some(cached) = self.raster_cache.get(&raster_key) {
323                    match cached {
324                        Some(glyph) => {
325                            self.backend.draw_alpha_bitmap(
326                                self.surface,
327                                &glyph.alpha,
328                                glyph.width,
329                                glyph.height,
330                                glyph.bearing_x,
331                                glyph.bearing_y,
332                                &fill_rgba,
333                                &final_transform,
334                            );
335                            if i < run.positions.len() {
336                                x_pos += run.positions[i];
337                            }
338                            continue;
339                        }
340                        None => {
341                            // Negative cache — no outline, skip
342                            if i < run.positions.len() {
343                                x_pos += run.positions[i];
344                            }
345                            continue;
346                        }
347                    }
348                }
349
350                // Cache miss — rasterize the glyph outline to an alpha bitmap.
351                // Use a pixel size based on font_size and rasterize at 1:1 scale.
352                let pixel_size = font_size.ceil() as u32;
353                let glyph_dim = (pixel_size * 2).min(256);
354                if glyph_dim > 0 {
355                    let raster =
356                        rasterize_glyph_alpha(outline_ops, active_scale, pixel_size, glyph_dim);
357                    if let Some(ref rg) = raster {
358                        self.backend.draw_alpha_bitmap(
359                            self.surface,
360                            &rg.alpha,
361                            rg.width,
362                            rg.height,
363                            rg.bearing_x,
364                            rg.bearing_y,
365                            &fill_rgba,
366                            &final_transform,
367                        );
368                    }
369                    self.raster_cache.insert(raster_key, raster);
370                    if i < run.positions.len() {
371                        x_pos += run.positions[i];
372                    }
373                    continue;
374                }
375            }
376
377            // Draw the glyph outline
378            if should_fill {
379                self.backend.fill_path(
380                    self.surface,
381                    outline_ops,
382                    FillRule::NonZero,
383                    &fill_rgba,
384                    &final_transform,
385                );
386            }
387            if should_stroke {
388                let stroke_style = StrokeStyle {
389                    width: 1.0, // Glyph stroke width in font units
390                    line_cap: rpdfium_graphics::LineCapStyle::default(),
391                    line_join: rpdfium_graphics::LineJoinStyle::default(),
392                    miter_limit: 10.0,
393                    dash: None,
394                };
395                self.backend.stroke_path(
396                    self.surface,
397                    outline_ops,
398                    &stroke_style,
399                    &stroke_rgba,
400                    &final_transform,
401                );
402            }
403
404            // Accumulate glyph outline for text clipping (Tr modes 4-7).
405            if should_clip {
406                // Transform each PathOp by the final_transform and accumulate.
407                for op in outline_ops {
408                    self.text_clip_ops
409                        .push(transform_path_op(op, &final_transform));
410                }
411            }
412
413            // Advance position
414            if i < run.positions.len() {
415                if is_vertical {
416                    y_pos -= run.positions[i];
417                } else {
418                    x_pos += run.positions[i];
419                }
420            }
421        }
422    }
423
424    /// Try fallback fonts for a missing glyph.
425    ///
426    /// Returns `(glyph_id, font_ref, scale)` for the first fallback font that
427    /// has the glyph, or `None` if no fallback font has it.
428    fn find_fallback_glyph(
429        &self,
430        char_code: u16,
431        font_size: f32,
432    ) -> Option<(u16, &ResolvedFont, f32)> {
433        for fb_font in &self.fallback_fonts {
434            if let Some(gid) = fb_font.glyph_from_char_code(char_code) {
435                let fb_upem = fb_font.units_per_em().unwrap_or(1000) as f32;
436                if fb_upem == 0.0 {
437                    continue;
438                }
439                let fb_scale = font_size / fb_upem;
440                return Some((gid, fb_font.as_ref(), fb_scale));
441            }
442        }
443        None
444    }
445
446    /// Render a Type 3 font text run.
447    ///
448    /// Type 3 fonts define glyphs via content streams. When pre-interpreted
449    /// glyph ops are available (from `TextRun.type3_glyph_ops`), they are
450    /// rendered directly. Otherwise, a placeholder rectangle is drawn.
451    fn render_type3_text_run(&mut self, run: &TextRun, resolved_font: &rpdfium_font::ResolvedFont) {
452        let type3 = match resolved_font.type3.as_ref() {
453            Some(t3) => t3,
454            None => return,
455        };
456
457        let rendering_mode = run.rendering_mode;
458        if rendering_mode.is_invisible() {
459            return;
460        }
461
462        let should_fill = rendering_mode.is_fill();
463        let should_stroke = rendering_mode.is_stroke();
464        let should_clip = rendering_mode.is_clip();
465
466        let fill_rgba = if let Some(ref scheme) = self.forced_color_scheme {
467            scheme.text_color
468        } else if should_fill {
469            run.fill_color
470                .as_ref()
471                .map(|c| RgbaColor::from_pdf_color(c, 1.0))
472                .unwrap_or(RgbaColor::BLACK)
473        } else {
474            RgbaColor::BLACK
475        };
476
477        let stroke_rgba = if let Some(ref scheme) = self.forced_color_scheme {
478            scheme.text_color
479        } else if should_stroke {
480            run.stroke_color
481                .as_ref()
482                .map(|c| RgbaColor::from_pdf_color(c, 1.0))
483                .unwrap_or(RgbaColor::BLACK)
484        } else {
485            RgbaColor::BLACK
486        };
487
488        // Build the font matrix as a Matrix
489        let fm = &type3.font_matrix;
490        let font_matrix = Matrix::new(
491            fm[0] as f64,
492            fm[1] as f64,
493            fm[2] as f64,
494            fm[3] as f64,
495            fm[4] as f64,
496            fm[5] as f64,
497        );
498
499        let font_size = run.font_size;
500        let rise = run.rise;
501        let mut x_pos: f32 = 0.0;
502
503        for (i, &byte) in run.text.iter().enumerate() {
504            // Build transform: text_matrix * translate(x_pos, rise) * scale(fontSize) * font_matrix
505            let glyph_translate = Matrix::from_translation(x_pos as f64, rise as f64);
506            let size_scale = Matrix::new(font_size as f64, 0.0, 0.0, font_size as f64, 0.0, 0.0);
507            let glyph_transform = run
508                .matrix
509                .pre_concat(&glyph_translate)
510                .pre_concat(&size_scale)
511                .pre_concat(&font_matrix);
512
513            let final_transform = self.page_transform.pre_concat(&glyph_transform);
514
515            // Try pre-interpreted glyph ops first, then fallback to placeholder
516            let glyph_ops = run.type3_glyph_ops.as_ref().and_then(|map| map.get(&byte));
517
518            let ops_to_render: &[PathOp];
519            let placeholder_ops;
520
521            if let Some(ops) = glyph_ops {
522                if !ops.is_empty() {
523                    ops_to_render = ops;
524                } else {
525                    // Empty glyph ops (e.g., space character) — skip rendering
526                    if i < run.positions.len() {
527                        x_pos += run.positions[i];
528                    }
529                    continue;
530                }
531            } else if type3.char_proc_for_code(byte).is_some() {
532                // Fallback: render a placeholder rectangle
533                let width = type3.char_width_f(byte);
534                placeholder_ops = vec![
535                    PathOp::MoveTo { x: 0.0, y: 0.0 },
536                    PathOp::LineTo { x: width, y: 0.0 },
537                    PathOp::LineTo {
538                        x: width,
539                        y: 1000.0,
540                    },
541                    PathOp::LineTo { x: 0.0, y: 1000.0 },
542                    PathOp::Close,
543                ];
544                ops_to_render = &placeholder_ops;
545            } else {
546                // No char proc — skip
547                if i < run.positions.len() {
548                    x_pos += run.positions[i];
549                }
550                continue;
551            }
552
553            if should_fill {
554                self.backend.fill_path(
555                    self.surface,
556                    ops_to_render,
557                    FillRule::NonZero,
558                    &fill_rgba,
559                    &final_transform,
560                );
561            }
562            if should_stroke {
563                let stroke_style = StrokeStyle {
564                    width: 1.0,
565                    line_cap: rpdfium_graphics::LineCapStyle::default(),
566                    line_join: rpdfium_graphics::LineJoinStyle::default(),
567                    miter_limit: 10.0,
568                    dash: None,
569                };
570                self.backend.stroke_path(
571                    self.surface,
572                    ops_to_render,
573                    &stroke_style,
574                    &stroke_rgba,
575                    &final_transform,
576                );
577            }
578
579            // Accumulate for text clipping (Tr modes 4-7).
580            if should_clip {
581                for op in ops_to_render {
582                    self.text_clip_ops
583                        .push(transform_path_op(op, &final_transform));
584                }
585            }
586
587            // Advance position
588            if i < run.positions.len() {
589                x_pos += run.positions[i];
590            }
591        }
592    }
593}
594
595/// Transform a `PathOp` by a matrix, producing pre-transformed coordinates.
596///
597/// This is used for text clipping where glyph outlines need to be accumulated
598/// in device coordinates (already transformed) rather than passing a separate
599/// transform to the clip path.
600fn transform_path_op(op: &PathOp, m: &Matrix) -> PathOp {
601    use rpdfium_core::Point;
602    match *op {
603        PathOp::MoveTo { x, y } => {
604            let p = m.transform_point(Point {
605                x: x as f64,
606                y: y as f64,
607            });
608            PathOp::MoveTo {
609                x: p.x as f32,
610                y: p.y as f32,
611            }
612        }
613        PathOp::LineTo { x, y } => {
614            let p = m.transform_point(Point {
615                x: x as f64,
616                y: y as f64,
617            });
618            PathOp::LineTo {
619                x: p.x as f32,
620                y: p.y as f32,
621            }
622        }
623        PathOp::CurveTo {
624            x1,
625            y1,
626            x2,
627            y2,
628            x3,
629            y3,
630        } => {
631            let p1 = m.transform_point(Point {
632                x: x1 as f64,
633                y: y1 as f64,
634            });
635            let p2 = m.transform_point(Point {
636                x: x2 as f64,
637                y: y2 as f64,
638            });
639            let p3 = m.transform_point(Point {
640                x: x3 as f64,
641                y: y3 as f64,
642            });
643            PathOp::CurveTo {
644                x1: p1.x as f32,
645                y1: p1.y as f32,
646                x2: p2.x as f32,
647                y2: p2.y as f32,
648                x3: p3.x as f32,
649                y3: p3.y as f32,
650            }
651        }
652        PathOp::Close => PathOp::Close,
653    }
654}
655
656/// Rasterize a glyph outline to an alpha bitmap for caching.
657///
658/// Creates a tiny-skia Pixmap, fills the outline at the given scale,
659/// and extracts the alpha channel. Returns `None` if the outline cannot
660/// be rasterized (e.g., empty path or zero dimensions).
661fn rasterize_glyph_alpha(
662    outline_ops: &[PathOp],
663    scale: f32,
664    _pixel_size: u32,
665    dim: u32,
666) -> Option<RasterGlyph> {
667    if outline_ops.is_empty() || dim == 0 {
668        return None;
669    }
670
671    // Compute bounding box of the outline (in font units)
672    let mut min_x = f32::MAX;
673    let mut min_y = f32::MAX;
674    let mut max_x = f32::MIN;
675    let mut max_y = f32::MIN;
676    for op in outline_ops {
677        match *op {
678            PathOp::MoveTo { x, y } | PathOp::LineTo { x, y } => {
679                min_x = min_x.min(x);
680                min_y = min_y.min(y);
681                max_x = max_x.max(x);
682                max_y = max_y.max(y);
683            }
684            PathOp::CurveTo {
685                x1,
686                y1,
687                x2,
688                y2,
689                x3,
690                y3,
691            } => {
692                min_x = min_x.min(x1).min(x2).min(x3);
693                min_y = min_y.min(y1).min(y2).min(y3);
694                max_x = max_x.max(x1).max(x2).max(x3);
695                max_y = max_y.max(y1).max(y2).max(y3);
696            }
697            PathOp::Close => {}
698        }
699    }
700
701    if min_x >= max_x || min_y >= max_y {
702        return None;
703    }
704
705    // Scale to pixel coordinates
706    let width = ((max_x - min_x) * scale).ceil() as u32 + 2; // +2 for padding
707    let height = ((max_y - min_y) * scale).ceil() as u32 + 2;
708    let width = width.min(dim).max(1);
709    let height = height.min(dim).max(1);
710
711    let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
712
713    // Build a path from the outline
714    let mut pb = tiny_skia::PathBuilder::new();
715    for op in outline_ops {
716        match *op {
717            PathOp::MoveTo { x, y } => pb.move_to(x, y),
718            PathOp::LineTo { x, y } => pb.line_to(x, y),
719            PathOp::CurveTo {
720                x1,
721                y1,
722                x2,
723                y2,
724                x3,
725                y3,
726            } => pb.cubic_to(x1, y1, x2, y2, x3, y3),
727            PathOp::Close => pb.close(),
728        }
729    }
730    let path = pb.finish()?;
731
732    // Transform: scale and translate so the glyph fits in the pixmap
733    let ts = tiny_skia::Transform::from_row(
734        scale,
735        0.0,
736        0.0,
737        -scale,               // flip Y (font coords are Y-up)
738        -min_x * scale + 1.0, // center with padding
739        max_y * scale + 1.0,
740    );
741
742    let mut paint = tiny_skia::Paint::default();
743    paint.set_color(tiny_skia::Color::WHITE);
744    paint.anti_alias = true;
745
746    pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, ts, None);
747
748    // Extract alpha channel
749    let pixels = pixmap.data();
750    let alpha: Vec<u8> = pixels.chunks_exact(4).map(|px| px[3]).collect();
751
752    let bearing_x = (min_x * scale).floor() as i32 - 1;
753    let bearing_y = (max_y * scale).ceil() as i32 + 1;
754
755    Some(RasterGlyph {
756        width,
757        height,
758        bearing_x,
759        bearing_y,
760        alpha,
761    })
762}
763
764/// Check whether a color space family is a CMYK variant.
765///
766/// Used for overprint simulation: when overprint is active and the color space
767/// is CMYK-based, the blend mode is forced to Darken.
768fn is_cmyk_family(cs: Option<&ColorSpaceFamily>) -> bool {
769    matches!(cs, Some(ColorSpaceFamily::DeviceCMYK))
770}
771
772/// Pre-scale an image transform from pixel coordinates to the PDF unit square.
773///
774/// PDF image XObjects occupy a (0,0)-(1,1) unit square in user space. The CTM
775/// maps from this unit square to device space. Since decoded images are in
776/// pixel coordinates (0,0)-(W,H), we pre-scale to map pixels → unit square.
777///
778/// Image pixel data is stored top-to-bottom: pixel row 0 is the top of the
779/// image, which corresponds to y=1 in the PDF unit square (since PDF Y-axis
780/// points upward). The pre-scale therefore includes a Y-flip:
781///   pixel (px, py) → unit (px/W, 1 - py/H)
782///
783/// As a matrix: `[1/W, 0, 0, -1/H, 0, 1]`
784fn image_unit_transform(img: &crate::image::DecodedImage, transform: &Matrix) -> Matrix {
785    let w = img.width.max(1) as f64;
786    let h = img.height.max(1) as f64;
787    let pre_scale = Matrix::new(1.0 / w, 0.0, 0.0, -1.0 / h, 0.0, 1.0);
788    transform.pre_concat(&pre_scale)
789}
790
791/// Check whether bilinear interpolation should be used for an image.
792///
793/// Matches PDFium's auto-bilinear heuristic in `CStretchEngine::UseInterpolateBilinear`:
794/// when the image is being upscaled, use bilinear filtering to smooth the result.
795///
796/// The heuristic triggers when:
797///   `|dest_height| / 8 < src_width * src_height / |dest_width|`
798///
799/// which roughly means the destination area is large relative to the source.
800fn should_interpolate_image(img: &crate::image::DecodedImage, transform: &Matrix) -> bool {
801    // The transform maps pixel coords to device coords.
802    // For an axis-aligned image, a and d are the scale factors.
803    // For a general affine transform, compute effective width/height from the matrix.
804    let src_w = img.width as f64;
805    let src_h = img.height as f64;
806
807    // Effective destination dimensions from the image transform:
808    // The image occupies pixel coords (0,0)-(W,H). After transform:
809    //   dest_width  ≈ |(a*W, b*W)| ≈ W * sqrt(a² + b²)
810    //   dest_height ≈ |(c*H, d*H)| ≈ H * sqrt(c² + d²)
811    let dest_w = src_w * (transform.a * transform.a + transform.b * transform.b).sqrt();
812    let dest_h = src_h * (transform.c * transform.c + transform.d * transform.d).sqrt();
813
814    if dest_w < 1.0 || dest_h < 1.0 {
815        return false;
816    }
817
818    // PDFium heuristic: abs(dest_height) / 8 < src_width * src_height / abs(dest_width)
819    dest_h / 8.0 < src_w * src_h / dest_w
820}
821
822/// Apply a transfer function to an RGBA color.
823///
824/// The transfer function maps each component (in the [0,1] range) through
825/// the given PDF function. Alpha is left unchanged.
826fn apply_transfer(color: &mut RgbaColor, tf: &rpdfium_page::function::TransferFunction) {
827    use rpdfium_page::function::{TransferFunction, evaluate};
828    match tf {
829        TransferFunction::Identity => {}
830        TransferFunction::Single(f) => {
831            let rv = evaluate(f, &[color.r as f32 / 255.0]);
832            let gv = evaluate(f, &[color.g as f32 / 255.0]);
833            let bv = evaluate(f, &[color.b as f32 / 255.0]);
834            color.r = (rv.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0) as u8;
835            color.g = (gv.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0) as u8;
836            color.b = (bv.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0) as u8;
837        }
838        TransferFunction::PerComponent { r, g, b, k: _ } => {
839            let rv = evaluate(r.as_ref(), &[color.r as f32 / 255.0]);
840            let gv = evaluate(g.as_ref(), &[color.g as f32 / 255.0]);
841            let bv = evaluate(b.as_ref(), &[color.b as f32 / 255.0]);
842            color.r = (rv.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0) as u8;
843            color.g = (gv.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0) as u8;
844            color.b = (bv.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0) as u8;
845        }
846    }
847}
848
849/// Maximum number of tile repetitions in each axis to prevent runaway rendering.
850const MAX_PATTERN_TILES: i32 = 512;
851
852/// Render a tiling pattern fill by tiling the pattern cell across the path's bounding box.
853///
854/// Steps:
855/// 1. Compute the path bounding box in user space.
856/// 2. Clip the backend surface to the fill path.
857/// 3. Render the pattern cell to a temporary surface at the cell size.
858/// 4. Blit the cell at each tile position within the bounding box.
859/// 5. Pop the clip.
860#[allow(clippy::too_many_arguments)]
861fn render_pattern_fill<B: RenderBackend>(
862    backend: &mut B,
863    surface: &mut B::Surface,
864    path_ops: &[PathOp],
865    fill_rule: FillRule,
866    pattern: &TilingPattern,
867    pattern_tree: &DisplayTree,
868    page_transform: &Matrix,
869    ctm: &Matrix,
870    image_decoder: Option<&dyn ImageDecoder>,
871) {
872    use rpdfium_page::display::walk as walk_tree;
873
874    let x_step = pattern.x_step.abs();
875    let y_step = pattern.y_step.abs();
876    if x_step < 0.001 || y_step < 0.001 {
877        return; // Degenerate pattern — avoid division by zero
878    }
879
880    // Combined transform: page_transform * CTM * pattern_matrix
881    let device_ctm = page_transform.pre_concat(ctm);
882    let combined = device_ctm.pre_concat(&pattern.matrix);
883    let cell_w = (x_step as f64 * combined.a.abs().max(combined.c.abs())).ceil() as u32;
884    let cell_h = (y_step as f64 * combined.d.abs().max(combined.b.abs())).ceil() as u32;
885    if cell_w == 0 || cell_h == 0 || cell_w > 4096 || cell_h > 4096 {
886        return; // Cell too small or too large
887    }
888
889    // Render the pattern cell to a temporary surface
890    let cell_transform =
891        Matrix::from_scale(cell_w as f64 / x_step as f64, cell_h as f64 / y_step as f64);
892    let mut cell_surface = backend.create_surface(cell_w, cell_h, &RgbaColor::TRANSPARENT);
893    {
894        let mut sub_renderer =
895            DisplayRenderer::new(backend, &mut cell_surface, cell_transform, image_decoder);
896        walk_tree(pattern_tree, &mut sub_renderer);
897    }
898    let cell_pixels = backend.surface_pixels(&cell_surface);
899
900    // Clip to the fill path
901    let mut clip = ClipPath::new();
902    clip.push(path_ops.to_vec(), fill_rule);
903    backend.push_clip(surface, &clip, &device_ctm);
904
905    // Compute the path's bounding box in user space
906    let bbox = path_bounding_box(path_ops);
907
908    // Transform bbox corners by combined matrix to get pixel-space bounds
909    let p1 = combined.transform_point(rpdfium_core::Point {
910        x: bbox[0] as f64,
911        y: bbox[1] as f64,
912    });
913    let p2 = combined.transform_point(rpdfium_core::Point {
914        x: bbox[2] as f64,
915        y: bbox[3] as f64,
916    });
917    let px_min_x = p1.x.min(p2.x) as i32;
918    let px_min_y = p1.y.min(p2.y) as i32;
919    let px_max_x = p1.x.max(p2.x).ceil() as i32;
920    let px_max_y = p1.y.max(p2.y).ceil() as i32;
921
922    // Determine tile grid indices
923    let cell_w_i = cell_w as i32;
924    let cell_h_i = cell_h as i32;
925    if cell_w_i == 0 || cell_h_i == 0 {
926        backend.pop_clip(surface);
927        return;
928    }
929
930    // Use floor division for negative coordinates
931    let start_col = px_min_x.div_euclid(cell_w_i) - 1;
932    let end_col = px_max_x.div_euclid(cell_w_i) + 1;
933    let start_row = px_min_y.div_euclid(cell_h_i) - 1;
934    let end_row = px_max_y.div_euclid(cell_h_i) + 1;
935
936    // Clamp to max tiles
937    let n_cols = (end_col - start_col).min(MAX_PATTERN_TILES);
938    let n_rows = (end_row - start_row).min(MAX_PATTERN_TILES);
939
940    // Blit the cell at each tile position using draw_image
941    let decoded = crate::image::DecodedImage {
942        width: cell_w,
943        height: cell_h,
944        data: cell_pixels,
945        format: crate::image::DecodedImageFormat::Rgba32,
946    };
947
948    for row in 0..n_rows {
949        for col in 0..n_cols {
950            let tile_x = (start_col + col) * cell_w_i;
951            let tile_y = (start_row + row) * cell_h_i;
952            // Build a transform that places the tile at the correct pixel position
953            // The image is drawn in a unit square [0,1]x[0,1] scaled to cell_w x cell_h
954            let tile_transform = Matrix::new(
955                cell_w as f64,
956                0.0,
957                0.0,
958                cell_h as f64,
959                tile_x as f64,
960                tile_y as f64,
961            );
962            backend.draw_image(surface, &decoded, &tile_transform, false);
963        }
964    }
965
966    backend.pop_clip(surface);
967}
968
969/// Compute the axis-aligned bounding box of a set of path operations.
970/// Returns `[min_x, min_y, max_x, max_y]`.
971fn path_bounding_box(ops: &[PathOp]) -> [f32; 4] {
972    let mut min_x = f32::MAX;
973    let mut min_y = f32::MAX;
974    let mut max_x = f32::MIN;
975    let mut max_y = f32::MIN;
976
977    for op in ops {
978        match *op {
979            PathOp::MoveTo { x, y } | PathOp::LineTo { x, y } => {
980                min_x = min_x.min(x);
981                min_y = min_y.min(y);
982                max_x = max_x.max(x);
983                max_y = max_y.max(y);
984            }
985            PathOp::CurveTo {
986                x1,
987                y1,
988                x2,
989                y2,
990                x3,
991                y3,
992            } => {
993                min_x = min_x.min(x1).min(x2).min(x3);
994                min_y = min_y.min(y1).min(y2).min(y3);
995                max_x = max_x.max(x1).max(x2).max(x3);
996                max_y = max_y.max(y1).max(y2).max(y3);
997            }
998            PathOp::Close => {}
999        }
1000    }
1001
1002    if min_x > max_x {
1003        [0.0, 0.0, 0.0, 0.0]
1004    } else {
1005        [min_x, min_y, max_x, max_y]
1006    }
1007}
1008
1009/// Render a soft mask's display tree and return the alpha buffer.
1010///
1011/// Returns `(alpha_data, width, height)` or `None` if the surface cannot be
1012/// created.  The function creates a fresh sub-renderer to avoid borrow
1013/// conflicts with the main renderer.
1014fn render_soft_mask_alpha<B: RenderBackend>(
1015    backend: &mut B,
1016    surface: &B::Surface,
1017    sm: &SoftMask,
1018    page_transform: Matrix,
1019    image_decoder: Option<&dyn ImageDecoder>,
1020) -> Option<(Vec<u8>, u32, u32)> {
1021    use rpdfium_page::display::walk as walk_tree;
1022
1023    let (w, h) = backend.surface_dimensions(surface);
1024    if w == 0 || h == 0 {
1025        return None;
1026    }
1027
1028    // Determine the backdrop color for the mask surface.
1029    // If a backdrop color is specified (from /BC in the soft mask dict),
1030    // fill the surface with that color before rendering the mask content.
1031    // This matches upstream PDFium's CPDF_RenderStatus::LoadSMask() behavior.
1032    let bg = match sm.backdrop_color {
1033        Some(ref bc) => {
1034            let r = bc.first().copied().unwrap_or(0.0);
1035            let g = bc.get(1).copied().unwrap_or(r);
1036            let b = bc.get(2).copied().unwrap_or(r);
1037            RgbaColor {
1038                r: (r.clamp(0.0, 1.0) * 255.0).round() as u8,
1039                g: (g.clamp(0.0, 1.0) * 255.0).round() as u8,
1040                b: (b.clamp(0.0, 1.0) * 255.0).round() as u8,
1041                a: 255,
1042            }
1043        }
1044        None => RgbaColor::TRANSPARENT,
1045    };
1046
1047    // Render the mask group to a temporary surface.
1048    let mut mask_surface = backend.create_surface(w, h, &bg);
1049    {
1050        let mut sub_renderer =
1051            DisplayRenderer::new(backend, &mut mask_surface, page_transform, image_decoder);
1052        walk_tree(&sm.group, &mut sub_renderer);
1053    }
1054
1055    // Extract pixel data from the rendered mask surface.
1056    let pixels = backend.surface_pixels(&mask_surface);
1057
1058    // Convert to single-channel alpha buffer.
1059    let pixel_count = (w * h) as usize;
1060    let mut alpha = vec![0u8; pixel_count];
1061
1062    match sm.subtype {
1063        SoftMaskSubtype::Alpha => {
1064            // Use the alpha channel directly (every 4th byte in RGBA).
1065            for (i, a) in alpha.iter_mut().enumerate() {
1066                let idx = i * 4 + 3;
1067                if idx < pixels.len() {
1068                    *a = pixels[idx];
1069                }
1070            }
1071        }
1072        SoftMaskSubtype::Luminosity => {
1073            // Convert RGB to luminosity: 0.2126R + 0.7152G + 0.0722B
1074            // Pixels are premultiplied RGBA; un-premultiply first.
1075            for (i, a) in alpha.iter_mut().enumerate() {
1076                let base = i * 4;
1077                if base + 3 >= pixels.len() {
1078                    break;
1079                }
1080                let pa = pixels[base + 3] as f32;
1081                if pa == 0.0 {
1082                    *a = 0;
1083                    continue;
1084                }
1085                let factor = 255.0 / pa;
1086                let r = (pixels[base] as f32 * factor).min(255.0);
1087                let g = (pixels[base + 1] as f32 * factor).min(255.0);
1088                let b = (pixels[base + 2] as f32 * factor).min(255.0);
1089                let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
1090                *a = (lum.round() as u32).min(255) as u8;
1091            }
1092        }
1093    }
1094
1095    // Apply transfer function to alpha values if present.
1096    if let Some(ref tf) = sm.transfer_function {
1097        use rpdfium_page::function::{TransferFunction, evaluate};
1098        match tf.as_ref() {
1099            TransferFunction::Identity => {}
1100            TransferFunction::Single(f) => {
1101                for a in alpha.iter_mut() {
1102                    let input = *a as f32 / 255.0;
1103                    let output = evaluate(f, &[input])
1104                        .first()
1105                        .copied()
1106                        .unwrap_or(input)
1107                        .clamp(0.0, 1.0);
1108                    *a = (output * 255.0).round() as u8;
1109                }
1110            }
1111            TransferFunction::PerComponent { r, .. } => {
1112                // For SMask, use the first (R) function for all alpha values
1113                for a in alpha.iter_mut() {
1114                    let input = *a as f32 / 255.0;
1115                    let output = evaluate(r.as_ref(), &[input])
1116                        .first()
1117                        .copied()
1118                        .unwrap_or(input)
1119                        .clamp(0.0, 1.0);
1120                    *a = (output * 255.0).round() as u8;
1121                }
1122            }
1123        }
1124    }
1125
1126    Some((alpha, w, h))
1127}
1128
1129/// Apply an image mask to a decoded image.
1130///
1131/// - **Stencil**: The image data is a 1-bit mask. For each pixel, if the sample
1132///   is nonzero (opaque in the mask), the pixel is painted with `fill_color`;
1133///   otherwise it is fully transparent.
1134/// - **ColorKey**: Pixels whose sample values fall within the specified ranges
1135///   are made fully transparent (alpha = 0).
1136/// - **ExplicitMask**: Decode the mask stream as a grayscale image and apply
1137///   as the alpha channel.
1138/// - **SoftMask**: Decode the mask stream as a grayscale image and apply
1139///   as the alpha channel, with optional matte color removal.
1140fn apply_image_mask(
1141    mut img: crate::image::DecodedImage,
1142    mask: Option<&rpdfium_page::display::ImageMask>,
1143    fill_color: Option<&Color>,
1144    decoder: Option<&dyn ImageDecoder>,
1145) -> crate::image::DecodedImage {
1146    use crate::image::DecodedImageFormat;
1147    use rpdfium_page::display::ImageMask;
1148
1149    let mask = match mask {
1150        Some(m) => m,
1151        None => return img,
1152    };
1153
1154    match mask {
1155        ImageMask::Stencil => {
1156            let rgba = fill_color
1157                .map(|c| RgbaColor::from_pdf_color(c, 1.0))
1158                .unwrap_or(RgbaColor::BLACK);
1159
1160            let w = img.width as usize;
1161            let h = img.height as usize;
1162            let pixel_count = w * h;
1163            let mut out = vec![0u8; pixel_count * 4];
1164
1165            match img.format {
1166                DecodedImageFormat::Gray8 => {
1167                    // Each byte is a grayscale sample; nonzero → fill color
1168                    for i in 0..pixel_count.min(img.data.len()) {
1169                        let dst = i * 4;
1170                        if img.data[i] != 0 {
1171                            out[dst] = rgba.r;
1172                            out[dst + 1] = rgba.g;
1173                            out[dst + 2] = rgba.b;
1174                            out[dst + 3] = rgba.a;
1175                        }
1176                        // else: stays [0,0,0,0] (transparent)
1177                    }
1178                }
1179                DecodedImageFormat::Rgb24 => {
1180                    // Treat as grayscale via luminance of each pixel
1181                    for i in 0..pixel_count {
1182                        let src = i * 3;
1183                        let dst = i * 4;
1184                        if src + 2 < img.data.len() {
1185                            let lum = img.data[src] as u16
1186                                + img.data[src + 1] as u16
1187                                + img.data[src + 2] as u16;
1188                            if lum > 0 {
1189                                out[dst] = rgba.r;
1190                                out[dst + 1] = rgba.g;
1191                                out[dst + 2] = rgba.b;
1192                                out[dst + 3] = rgba.a;
1193                            }
1194                        }
1195                    }
1196                }
1197                DecodedImageFormat::Rgba32 => {
1198                    // Use alpha channel as mask signal
1199                    for i in 0..pixel_count {
1200                        let src = i * 4;
1201                        let dst = i * 4;
1202                        if src + 3 < img.data.len() && img.data[src + 3] != 0 {
1203                            out[dst] = rgba.r;
1204                            out[dst + 1] = rgba.g;
1205                            out[dst + 2] = rgba.b;
1206                            out[dst + 3] = rgba.a;
1207                        }
1208                    }
1209                }
1210            }
1211
1212            img.data = out;
1213            img.format = DecodedImageFormat::Rgba32;
1214            img
1215        }
1216        ImageMask::ColorKey { ranges } => {
1217            // Convert to RGBA if needed, then set alpha=0 for matching pixels
1218            let w = img.width as usize;
1219            let h = img.height as usize;
1220            let pixel_count = w * h;
1221
1222            match img.format {
1223                DecodedImageFormat::Gray8 => {
1224                    // 1 component: check ranges[0]
1225                    let mut out = vec![0u8; pixel_count * 4];
1226                    for i in 0..pixel_count.min(img.data.len()) {
1227                        let g = img.data[i];
1228                        let dst = i * 4;
1229                        out[dst] = g;
1230                        out[dst + 1] = g;
1231                        out[dst + 2] = g;
1232                        let masked = ranges
1233                            .first()
1234                            .is_some_and(|r| (g as u32) >= r[0] && (g as u32) <= r[1]);
1235                        out[dst + 3] = if masked { 0 } else { 255 };
1236                    }
1237                    img.data = out;
1238                    img.format = DecodedImageFormat::Rgba32;
1239                }
1240                DecodedImageFormat::Rgb24 => {
1241                    // 3 components: check ranges[0..3]
1242                    let mut out = vec![0u8; pixel_count * 4];
1243                    for i in 0..pixel_count {
1244                        let src = i * 3;
1245                        let dst = i * 4;
1246                        if src + 2 >= img.data.len() {
1247                            break;
1248                        }
1249                        let r = img.data[src];
1250                        let g = img.data[src + 1];
1251                        let b = img.data[src + 2];
1252                        out[dst] = r;
1253                        out[dst + 1] = g;
1254                        out[dst + 2] = b;
1255
1256                        let components = [r, g, b];
1257                        let masked = components.iter().enumerate().all(|(ci, &val)| {
1258                            ranges
1259                                .get(ci)
1260                                .is_some_and(|rng| (val as u32) >= rng[0] && (val as u32) <= rng[1])
1261                        }) && !ranges.is_empty();
1262                        out[dst + 3] = if masked { 0 } else { 255 };
1263                    }
1264                    img.data = out;
1265                    img.format = DecodedImageFormat::Rgba32;
1266                }
1267                DecodedImageFormat::Rgba32 => {
1268                    // 3 visible components (R, G, B), alpha already present
1269                    for i in 0..pixel_count {
1270                        let base = i * 4;
1271                        if base + 3 >= img.data.len() {
1272                            break;
1273                        }
1274                        let components = [img.data[base], img.data[base + 1], img.data[base + 2]];
1275                        let masked = components.iter().enumerate().all(|(ci, &val)| {
1276                            ranges
1277                                .get(ci)
1278                                .is_some_and(|rng| (val as u32) >= rng[0] && (val as u32) <= rng[1])
1279                        }) && !ranges.is_empty();
1280                        if masked {
1281                            img.data[base + 3] = 0;
1282                        }
1283                    }
1284                }
1285            }
1286            img
1287        }
1288        ImageMask::ExplicitMask { mask_object_id } => {
1289            // Decode the mask stream as a grayscale image and apply as alpha
1290            let Some(dec) = decoder else {
1291                return img;
1292            };
1293            let mask_ref = ImageRef {
1294                object_id: *mask_object_id,
1295            };
1296            let mask_img = match dec.decode_image(&mask_ref, &rpdfium_core::Matrix::identity()) {
1297                Ok(m) => m,
1298                Err(_) => return img,
1299            };
1300
1301            apply_grayscale_mask_alpha(&mut img, &mask_img, None);
1302            img
1303        }
1304        ImageMask::SoftMask {
1305            smask_object_id,
1306            matte,
1307        } => {
1308            // Decode the soft mask stream as a grayscale image and apply as alpha
1309            let Some(dec) = decoder else {
1310                return img;
1311            };
1312            let mask_ref = ImageRef {
1313                object_id: *smask_object_id,
1314            };
1315            let mask_img = match dec.decode_image(&mask_ref, &rpdfium_core::Matrix::identity()) {
1316                Ok(m) => m,
1317                Err(_) => return img,
1318            };
1319
1320            apply_grayscale_mask_alpha(&mut img, &mask_img, matte.as_deref());
1321            img
1322        }
1323    }
1324}
1325
1326/// Apply a grayscale mask image as the alpha channel of the target image.
1327///
1328/// If `matte` is provided, performs matte color removal (un-pre-multiplication):
1329/// `result[c] = clamp((pixel[c] - matte_int) * 255 / mask + matte_int, 0, 255)`
1330///
1331/// Matte is only applied when the number of matte components matches the
1332/// image's original color component count (matching upstream PDFium behavior
1333/// from cpdf_dib.cpp: `pMatte->size() == components_`).
1334fn apply_grayscale_mask_alpha(
1335    img: &mut crate::image::DecodedImage,
1336    mask: &crate::image::DecodedImage,
1337    matte: Option<&[f32]>,
1338) {
1339    use crate::image::DecodedImageFormat;
1340
1341    let w = img.width as usize;
1342    let h = img.height as usize;
1343    let pixel_count = w * h;
1344
1345    // Determine the original color component count for matte validation.
1346    // Matte is only applied when matte.len() == components (upstream check).
1347    let original_components: usize = match img.format {
1348        DecodedImageFormat::Gray8 => 1,
1349        DecodedImageFormat::Rgb24 | DecodedImageFormat::Rgba32 => 3,
1350    };
1351    let validated_matte: Option<&[f32]> = matte.filter(|m| m.len() == original_components);
1352
1353    // Extract grayscale values from the mask
1354    let mask_values: Vec<u8> = match mask.format {
1355        DecodedImageFormat::Gray8 => mask.data.clone(),
1356        DecodedImageFormat::Rgb24 => mask
1357            .data
1358            .chunks_exact(3)
1359            .map(|p| ((p[0] as u16 + p[1] as u16 + p[2] as u16) / 3) as u8)
1360            .collect(),
1361        DecodedImageFormat::Rgba32 => mask.data.chunks_exact(4).map(|p| p[0]).collect(),
1362    };
1363
1364    // Scale mask to image dimensions if they differ (simple nearest-neighbor)
1365    let scaled_mask: Vec<u8> = if mask.width == img.width && mask.height == img.height {
1366        mask_values
1367    } else {
1368        let mw = mask.width as usize;
1369        let mh = mask.height as usize;
1370        if mw == 0 || mh == 0 {
1371            return;
1372        }
1373        let mut scaled = vec![0u8; pixel_count];
1374        for y in 0..h {
1375            for x in 0..w {
1376                let mx = x * mw / w;
1377                let my = y * mh / h;
1378                let mi = my * mw + mx;
1379                scaled[y * w + x] = mask_values.get(mi).copied().unwrap_or(0);
1380            }
1381        }
1382        scaled
1383    };
1384
1385    // Ensure image is RGBA
1386    match img.format {
1387        DecodedImageFormat::Gray8 => {
1388            let mut rgba = vec![0u8; pixel_count * 4];
1389            for i in 0..pixel_count.min(img.data.len()) {
1390                let g = img.data[i];
1391                let base = i * 4;
1392                rgba[base] = g;
1393                rgba[base + 1] = g;
1394                rgba[base + 2] = g;
1395                rgba[base + 3] = 255;
1396            }
1397            img.data = rgba;
1398            img.format = DecodedImageFormat::Rgba32;
1399        }
1400        DecodedImageFormat::Rgb24 => {
1401            let mut rgba = vec![0u8; pixel_count * 4];
1402            for i in 0..pixel_count {
1403                let src = i * 3;
1404                let dst = i * 4;
1405                if src + 2 < img.data.len() {
1406                    rgba[dst] = img.data[src];
1407                    rgba[dst + 1] = img.data[src + 1];
1408                    rgba[dst + 2] = img.data[src + 2];
1409                    rgba[dst + 3] = 255;
1410                }
1411            }
1412            img.data = rgba;
1413            img.format = DecodedImageFormat::Rgba32;
1414        }
1415        DecodedImageFormat::Rgba32 => {}
1416    }
1417
1418    // Apply the mask as alpha channel
1419    for i in 0..pixel_count {
1420        let base = i * 4;
1421        if base + 3 >= img.data.len() {
1422            break;
1423        }
1424        let alpha = scaled_mask.get(i).copied().unwrap_or(0);
1425
1426        // Apply matte color removal if validated (component count matches).
1427        // Uses the upstream PDFium integer formula from cpdf_imagerenderer.cpp:
1428        //   orig[c] = (pixel[c] - matte_int) * 255 / mask + matte_int
1429        if let Some(m) = validated_matte {
1430            if alpha > 0 {
1431                let mask_i = alpha as i32;
1432                for c in 0..3 {
1433                    let mc = m.get(c).copied().unwrap_or(0.0);
1434                    let matte_int = (mc * 255.0).round() as i32;
1435                    let pixel_val = img.data[base + c] as i32;
1436                    let orig = (pixel_val - matte_int) * 255 / mask_i + matte_int;
1437                    img.data[base + c] = orig.clamp(0, 255) as u8;
1438                }
1439            }
1440        }
1441
1442        img.data[base + 3] = alpha;
1443    }
1444}
1445
1446/// Apply a transfer function to each pixel of a decoded image (R, G, B channels).
1447fn apply_transfer_to_image(
1448    img: &mut crate::image::DecodedImage,
1449    tf: &rpdfium_page::function::TransferFunction,
1450) {
1451    use crate::image::DecodedImageFormat;
1452    use rpdfium_page::function::{TransferFunction, evaluate};
1453
1454    if matches!(tf, TransferFunction::Identity) {
1455        return;
1456    }
1457
1458    match img.format {
1459        DecodedImageFormat::Gray8 => {
1460            for pixel in img.data.iter_mut() {
1461                let v = *pixel as f32 / 255.0;
1462                let result = match tf {
1463                    TransferFunction::Identity => v,
1464                    TransferFunction::Single(f) => evaluate(f, &[v]).first().copied().unwrap_or(v),
1465                    TransferFunction::PerComponent { r, .. } => {
1466                        evaluate(r.as_ref(), &[v]).first().copied().unwrap_or(v)
1467                    }
1468                };
1469                *pixel = (result.clamp(0.0, 1.0) * 255.0) as u8;
1470            }
1471        }
1472        DecodedImageFormat::Rgb24 => {
1473            for chunk in img.data.chunks_exact_mut(3) {
1474                for (c, byte) in chunk.iter_mut().enumerate() {
1475                    let v = *byte as f32 / 255.0;
1476                    let result = match tf {
1477                        TransferFunction::Identity => v,
1478                        TransferFunction::Single(f) => {
1479                            evaluate(f, &[v]).first().copied().unwrap_or(v)
1480                        }
1481                        TransferFunction::PerComponent { r, g, b, .. } => {
1482                            let f = match c {
1483                                0 => r.as_ref(),
1484                                1 => g.as_ref(),
1485                                _ => b.as_ref(),
1486                            };
1487                            evaluate(f, &[v]).first().copied().unwrap_or(v)
1488                        }
1489                    };
1490                    *byte = (result.clamp(0.0, 1.0) * 255.0) as u8;
1491                }
1492            }
1493        }
1494        DecodedImageFormat::Rgba32 => {
1495            for chunk in img.data.chunks_exact_mut(4) {
1496                #[allow(clippy::needless_range_loop)]
1497                for c in 0..3 {
1498                    let v = chunk[c] as f32 / 255.0;
1499                    let result = match tf {
1500                        TransferFunction::Identity => v,
1501                        TransferFunction::Single(f) => {
1502                            evaluate(f, &[v]).first().copied().unwrap_or(v)
1503                        }
1504                        TransferFunction::PerComponent { r, g, b, .. } => {
1505                            let f = match c {
1506                                0 => r.as_ref(),
1507                                1 => g.as_ref(),
1508                                _ => b.as_ref(),
1509                            };
1510                            evaluate(f, &[v]).first().copied().unwrap_or(v)
1511                        }
1512                    };
1513                    chunk[c] = (result.clamp(0.0, 1.0) * 255.0) as u8;
1514                }
1515                // Alpha channel unchanged
1516            }
1517        }
1518    }
1519}
1520
1521impl<B: RenderBackend> DisplayVisitor for DisplayRenderer<'_, B> {
1522    fn enter_group(
1523        &mut self,
1524        blend_mode: BlendMode,
1525        clip: Option<&ClipPath>,
1526        opacity: f32,
1527        isolated: bool,
1528        knockout: bool,
1529        soft_mask: &Option<Box<SoftMask>>,
1530    ) -> bool {
1531        let has_clip = clip.is_some_and(|c| !c.is_empty());
1532        let has_smask = soft_mask.is_some();
1533        let has_group = blend_mode != BlendMode::Normal || opacity < 1.0 || knockout || has_smask;
1534
1535        if has_clip {
1536            self.backend
1537                .push_clip(self.surface, clip.unwrap(), &self.page_transform);
1538        }
1539        if has_group {
1540            self.backend
1541                .push_group(self.surface, blend_mode, opacity, isolated, knockout);
1542        }
1543
1544        // Render soft mask into an alpha buffer and attach to the group.
1545        if let Some(sm) = soft_mask {
1546            let alpha = render_soft_mask_alpha(
1547                self.backend,
1548                self.surface,
1549                sm,
1550                self.page_transform,
1551                self.image_decoder,
1552            );
1553            if let Some((data, w, h)) = alpha {
1554                self.backend.set_group_mask(data, w, h);
1555            }
1556        }
1557
1558        let action = match (has_clip, has_group) {
1559            (false, false) => GroupAction::Nothing,
1560            (true, false) => GroupAction::ClipOnly,
1561            (false, true) => GroupAction::GroupOnly,
1562            (true, true) => GroupAction::ClipAndGroup,
1563        };
1564        self.group_stack.push(action);
1565        true // always descend
1566    }
1567
1568    fn leave_group(&mut self) {
1569        // Pop any text clips accumulated during this group's text objects.
1570        while self.text_clip_depth > 0 {
1571            self.backend.pop_clip(self.surface);
1572            self.text_clip_depth -= 1;
1573        }
1574
1575        if let Some(action) = self.group_stack.pop() {
1576            match action {
1577                GroupAction::Nothing => {}
1578                GroupAction::ClipOnly => {
1579                    self.backend.pop_clip(self.surface);
1580                }
1581                GroupAction::GroupOnly => {
1582                    self.backend.pop_group(self.surface);
1583                }
1584                GroupAction::ClipAndGroup => {
1585                    self.backend.pop_group(self.surface);
1586                    self.backend.pop_clip(self.surface);
1587                }
1588            }
1589        }
1590    }
1591
1592    fn visit_path(
1593        &mut self,
1594        ops: &[PathOp],
1595        style: &PathStyle,
1596        matrix: &Matrix,
1597        fill_color: Option<&Color>,
1598        stroke_color: Option<&Color>,
1599        fill_color_space: Option<&ColorSpaceFamily>,
1600        stroke_color_space: Option<&ColorSpaceFamily>,
1601        transfer_function: Option<&rpdfium_page::function::TransferFunction>,
1602        overprint: bool,
1603        _overprint_mode: u32,
1604    ) {
1605        // Combine CTM with page transform: user space → page space → device space
1606        let transform = self.page_transform.pre_concat(matrix);
1607
1608        // Per-feature AA: set backend to path_antialiasing for path rendering.
1609        self.backend.set_antialiasing(self.path_antialiasing);
1610
1611        // Overprint simulation: when overprint is active and the color is CMYK,
1612        // force Darken blend mode for the operation (simplified upstream approach).
1613        let fill_overprint_darken = overprint && is_cmyk_family(fill_color_space);
1614        let stroke_overprint_darken = overprint && is_cmyk_family(stroke_color_space);
1615
1616        // Fill
1617        if let (Some(fill_rule), Some(color)) = (style.fill, fill_color) {
1618            if fill_overprint_darken {
1619                self.backend
1620                    .push_group(self.surface, BlendMode::Darken, 1.0, false, false);
1621            }
1622            let mut rgba = if let Some(ref scheme) = self.forced_color_scheme {
1623                scheme.text_color
1624            } else {
1625                RgbaColor::from_pdf_color(color, 1.0)
1626            };
1627            if let Some(tf) = transfer_function {
1628                apply_transfer(&mut rgba, tf);
1629            }
1630            self.backend
1631                .fill_path(self.surface, ops, fill_rule, &rgba, &transform);
1632            if fill_overprint_darken {
1633                self.backend.pop_group(self.surface);
1634            }
1635        }
1636        // Stroke
1637        if style.stroke {
1638            if let Some(color) = stroke_color {
1639                if stroke_overprint_darken {
1640                    self.backend
1641                        .push_group(self.surface, BlendMode::Darken, 1.0, false, false);
1642                }
1643                let mut rgba = if let Some(ref scheme) = self.forced_color_scheme {
1644                    scheme.text_color
1645                } else {
1646                    RgbaColor::from_pdf_color(color, 1.0)
1647                };
1648                if let Some(tf) = transfer_function {
1649                    apply_transfer(&mut rgba, tf);
1650                }
1651                let stroke_style = StrokeStyle::from_path_style(style);
1652                self.backend
1653                    .stroke_path(self.surface, ops, &stroke_style, &rgba, &transform);
1654                if stroke_overprint_darken {
1655                    self.backend.pop_group(self.surface);
1656                }
1657            }
1658        }
1659    }
1660
1661    fn visit_image(
1662        &mut self,
1663        image_ref: &ImageRef,
1664        matrix: &Matrix,
1665        mask: Option<&rpdfium_page::display::ImageMask>,
1666        fill_color: Option<&Color>,
1667        transfer_function: Option<&rpdfium_page::function::TransferFunction>,
1668    ) {
1669        if let Some(decoder) = self.image_decoder {
1670            let transform = self.page_transform.pre_concat(matrix);
1671            match decoder.decode_image(image_ref, &transform) {
1672                Ok(img) => {
1673                    let mut masked = apply_image_mask(img, mask, fill_color, self.image_decoder);
1674                    if let Some(tf) = transfer_function {
1675                        apply_transfer_to_image(&mut masked, tf);
1676                    }
1677                    // PDF images occupy a unit square (0,0)-(1,1) in user space.
1678                    // The CTM maps from unit square to device space. Since the
1679                    // decoded image is in pixel coords (0,0)-(W,H), pre-scale
1680                    // to map pixel coords → unit square before applying the CTM.
1681                    let img_transform = image_unit_transform(&masked, &transform);
1682                    let interpolate = self.image_antialiasing
1683                        && should_interpolate_image(&masked, &img_transform);
1684                    self.backend
1685                        .draw_image(self.surface, &masked, &img_transform, interpolate);
1686                }
1687                Err(e) => tracing::warn!(
1688                    object_id = %image_ref.object_id,
1689                    error = %e,
1690                    "failed to decode image XObject"
1691                ),
1692            }
1693        }
1694    }
1695
1696    fn visit_inline_image(
1697        &mut self,
1698        properties: &HashMap<Name, Operand>,
1699        data: &[u8],
1700        matrix: &Matrix,
1701    ) {
1702        if let Some(decoder) = self.image_decoder {
1703            let transform = self.page_transform.pre_concat(matrix);
1704            match decoder.decode_inline_image(properties, data, &transform) {
1705                Ok(img) => {
1706                    let img_transform = image_unit_transform(&img, &transform);
1707                    let interpolate = should_interpolate_image(&img, &img_transform);
1708                    self.backend
1709                        .draw_image(self.surface, &img, &img_transform, interpolate);
1710                }
1711                Err(e) => tracing::warn!(
1712                    data_len = data.len(),
1713                    error = %e,
1714                    "failed to decode inline image"
1715                ),
1716            }
1717        }
1718    }
1719
1720    fn visit_pattern_fill(
1721        &mut self,
1722        path_ops: &[PathOp],
1723        fill_rule: FillRule,
1724        pattern: &TilingPattern,
1725        pattern_tree: &DisplayTree,
1726        _fill_color: Option<&Color>,
1727        matrix: &Matrix,
1728    ) {
1729        render_pattern_fill(
1730            self.backend,
1731            self.surface,
1732            path_ops,
1733            fill_rule,
1734            pattern,
1735            pattern_tree,
1736            &self.page_transform,
1737            matrix,
1738            self.image_decoder,
1739        );
1740    }
1741
1742    fn visit_shading_fill(&mut self, shading: &ShadingDict, matrix: &Matrix) {
1743        let transform = self.page_transform.pre_concat(matrix);
1744        // `sh` operator = direct shading object, so is_shading_object=true
1745        // (background not rendered per upstream L1019).
1746        crate::render_shading::render_shading(
1747            self.backend,
1748            self.surface,
1749            shading,
1750            &transform,
1751            true,
1752        );
1753    }
1754
1755    fn visit_text(&mut self, runs: &[TextRun]) {
1756        for run in runs {
1757            self.render_text_run(run);
1758        }
1759
1760        // Apply accumulated text clip (Tr modes 4-7).
1761        // The clip was accumulated during render_text_run for runs with is_clip().
1762        if !self.text_clip_ops.is_empty() {
1763            let ops = std::mem::take(&mut self.text_clip_ops);
1764            let mut clip = ClipPath::new();
1765            // The ops are already in device coordinates (pre-transformed),
1766            // so use identity transform when pushing.
1767            clip.push(ops, FillRule::NonZero);
1768            self.backend
1769                .push_clip(self.surface, &clip, &Matrix::identity());
1770            self.text_clip_depth += 1;
1771        }
1772    }
1773}
1774
1775#[cfg(test)]
1776mod tests {
1777    use super::*;
1778    use rpdfium_graphics::{Bitmap, BitmapFormat, FillRule};
1779    use rpdfium_page::display::{DisplayNode, DisplayTree, walk};
1780    use std::sync::Mutex;
1781
1782    use crate::image::DecodedImage;
1783
1784    /// Trivial surface type for mock backend (avoids clippy::let_unit_value).
1785    struct MockSurface;
1786
1787    /// A mock backend that records operations for testing.
1788    struct MockBackend {
1789        log: Mutex<Vec<String>>,
1790    }
1791
1792    impl MockBackend {
1793        fn new() -> Self {
1794            Self {
1795                log: Mutex::new(Vec::new()),
1796            }
1797        }
1798
1799        fn log(&self) -> Vec<String> {
1800            self.log.lock().unwrap().clone()
1801        }
1802    }
1803
1804    impl RenderBackend for MockBackend {
1805        type Surface = MockSurface;
1806
1807        fn create_surface(&self, _w: u32, _h: u32, _bg: &RgbaColor) -> Self::Surface {
1808            MockSurface
1809        }
1810
1811        fn fill_path(
1812            &mut self,
1813            _surface: &mut Self::Surface,
1814            _ops: &[PathOp],
1815            _fill_rule: FillRule,
1816            _color: &RgbaColor,
1817            _transform: &Matrix,
1818        ) {
1819            self.log.lock().unwrap().push("fill_path".to_string());
1820        }
1821
1822        fn stroke_path(
1823            &mut self,
1824            _surface: &mut Self::Surface,
1825            _ops: &[PathOp],
1826            _style: &StrokeStyle,
1827            _color: &RgbaColor,
1828            _transform: &Matrix,
1829        ) {
1830            self.log.lock().unwrap().push("stroke_path".to_string());
1831        }
1832
1833        fn draw_image(
1834            &mut self,
1835            _surface: &mut Self::Surface,
1836            _image: &DecodedImage,
1837            _transform: &Matrix,
1838            _interpolate: bool,
1839        ) {
1840            self.log.lock().unwrap().push("draw_image".to_string());
1841        }
1842
1843        fn push_clip(
1844            &mut self,
1845            _surface: &mut Self::Surface,
1846            _clip: &ClipPath,
1847            _transform: &Matrix,
1848        ) {
1849            self.log.lock().unwrap().push("push_clip".to_string());
1850        }
1851
1852        fn pop_clip(&mut self, _surface: &mut Self::Surface) {
1853            self.log.lock().unwrap().push("pop_clip".to_string());
1854        }
1855
1856        fn push_group(
1857            &mut self,
1858            _surface: &mut Self::Surface,
1859            _blend_mode: BlendMode,
1860            _opacity: f32,
1861            _isolated: bool,
1862            _knockout: bool,
1863        ) {
1864            self.log.lock().unwrap().push("push_group".to_string());
1865        }
1866
1867        fn pop_group(&mut self, _surface: &mut Self::Surface) {
1868            self.log.lock().unwrap().push("pop_group".to_string());
1869        }
1870
1871        fn surface_dimensions(&self, _surface: &Self::Surface) -> (u32, u32) {
1872            (100, 100)
1873        }
1874
1875        fn composite_over(&mut self, _dst: &mut Self::Surface, _src: &Self::Surface) {}
1876
1877        fn surface_pixels(&self, _surface: &Self::Surface) -> Vec<u8> {
1878            vec![0u8; 100 * 100 * 4]
1879        }
1880
1881        fn finish(self, _surface: Self::Surface) -> Bitmap {
1882            Bitmap::new(1, 1, BitmapFormat::Rgba32)
1883        }
1884    }
1885
1886    /// Helper to create a MockBackend + MockSurface and run the renderer on a tree.
1887    fn run_mock_renderer(tree: &DisplayTree) -> Vec<String> {
1888        let mut backend = MockBackend::new();
1889        let mut surface = backend.create_surface(100, 100, &RgbaColor::WHITE);
1890        {
1891            let mut renderer =
1892                DisplayRenderer::new(&mut backend, &mut surface, Matrix::identity(), None);
1893            walk(tree, &mut renderer);
1894        }
1895        backend.log()
1896    }
1897
1898    #[test]
1899    fn test_renderer_empty_tree() {
1900        let tree = DisplayTree {
1901            root: DisplayNode::Group {
1902                blend_mode: BlendMode::Normal,
1903                clip: None,
1904                opacity: 1.0,
1905                isolated: false,
1906                knockout: false,
1907                soft_mask: None,
1908                children: Vec::new(),
1909            },
1910        };
1911        // Normal blend + opacity 1.0 + no clip = GroupAction::Nothing
1912        assert!(run_mock_renderer(&tree).is_empty());
1913    }
1914
1915    #[test]
1916    fn test_renderer_fill_path() {
1917        let tree = DisplayTree {
1918            root: DisplayNode::Group {
1919                blend_mode: BlendMode::Normal,
1920                clip: None,
1921                opacity: 1.0,
1922                isolated: false,
1923                knockout: false,
1924                soft_mask: None,
1925                children: vec![DisplayNode::Path {
1926                    ops: vec![
1927                        PathOp::MoveTo { x: 0.0, y: 0.0 },
1928                        PathOp::LineTo { x: 100.0, y: 0.0 },
1929                        PathOp::LineTo { x: 100.0, y: 100.0 },
1930                        PathOp::Close,
1931                    ],
1932                    style: PathStyle {
1933                        fill: Some(FillRule::NonZero),
1934                        ..PathStyle::default()
1935                    },
1936                    matrix: Matrix::identity(),
1937                    fill_color: Some(Color::rgb(1.0, 0.0, 0.0)),
1938                    stroke_color: None,
1939                    fill_color_space: None,
1940                    stroke_color_space: None,
1941                    transfer_function: None,
1942                    overprint: false,
1943                    overprint_mode: 0,
1944                }],
1945            },
1946        };
1947        assert_eq!(run_mock_renderer(&tree), vec!["fill_path"]);
1948    }
1949
1950    #[test]
1951    fn test_renderer_stroke_path() {
1952        let tree = DisplayTree {
1953            root: DisplayNode::Group {
1954                blend_mode: BlendMode::Normal,
1955                clip: None,
1956                opacity: 1.0,
1957                isolated: false,
1958                knockout: false,
1959                soft_mask: None,
1960                children: vec![DisplayNode::Path {
1961                    ops: vec![
1962                        PathOp::MoveTo { x: 0.0, y: 0.0 },
1963                        PathOp::LineTo { x: 100.0, y: 0.0 },
1964                    ],
1965                    style: PathStyle {
1966                        stroke: true,
1967                        ..PathStyle::default()
1968                    },
1969                    matrix: Matrix::identity(),
1970                    fill_color: None,
1971                    stroke_color: Some(Color::gray(0.0)),
1972                    fill_color_space: None,
1973                    stroke_color_space: None,
1974                    transfer_function: None,
1975                    overprint: false,
1976                    overprint_mode: 0,
1977                }],
1978            },
1979        };
1980        assert_eq!(run_mock_renderer(&tree), vec!["stroke_path"]);
1981    }
1982
1983    #[test]
1984    fn test_renderer_fill_and_stroke() {
1985        let tree = DisplayTree {
1986            root: DisplayNode::Group {
1987                blend_mode: BlendMode::Normal,
1988                clip: None,
1989                opacity: 1.0,
1990                isolated: false,
1991                knockout: false,
1992                soft_mask: None,
1993                children: vec![DisplayNode::Path {
1994                    ops: vec![
1995                        PathOp::MoveTo { x: 0.0, y: 0.0 },
1996                        PathOp::LineTo { x: 50.0, y: 0.0 },
1997                        PathOp::LineTo { x: 50.0, y: 50.0 },
1998                        PathOp::Close,
1999                    ],
2000                    style: PathStyle {
2001                        fill: Some(FillRule::EvenOdd),
2002                        stroke: true,
2003                        ..PathStyle::default()
2004                    },
2005                    matrix: Matrix::identity(),
2006                    fill_color: Some(Color::rgb(0.0, 1.0, 0.0)),
2007                    stroke_color: Some(Color::gray(0.0)),
2008                    fill_color_space: None,
2009                    stroke_color_space: None,
2010                    transfer_function: None,
2011                    overprint: false,
2012                    overprint_mode: 0,
2013                }],
2014            },
2015        };
2016        assert_eq!(run_mock_renderer(&tree), vec!["fill_path", "stroke_path"]);
2017    }
2018
2019    #[test]
2020    fn test_renderer_group_with_clip() {
2021        let mut clip = ClipPath::new();
2022        clip.push(
2023            vec![
2024                PathOp::MoveTo { x: 0.0, y: 0.0 },
2025                PathOp::LineTo { x: 50.0, y: 50.0 },
2026                PathOp::Close,
2027            ],
2028            FillRule::NonZero,
2029        );
2030        let tree = DisplayTree {
2031            root: DisplayNode::Group {
2032                blend_mode: BlendMode::Normal,
2033                clip: Some(clip),
2034                opacity: 1.0,
2035                isolated: false,
2036                knockout: false,
2037                soft_mask: None,
2038                children: Vec::new(),
2039            },
2040        };
2041        assert_eq!(run_mock_renderer(&tree), vec!["push_clip", "pop_clip"]);
2042    }
2043
2044    #[test]
2045    fn test_renderer_group_with_blend_mode() {
2046        let tree = DisplayTree {
2047            root: DisplayNode::Group {
2048                blend_mode: BlendMode::Multiply,
2049                clip: None,
2050                opacity: 0.5,
2051                isolated: false,
2052                knockout: false,
2053                soft_mask: None,
2054                children: Vec::new(),
2055            },
2056        };
2057        assert_eq!(run_mock_renderer(&tree), vec!["push_group", "pop_group"]);
2058    }
2059
2060    #[test]
2061    fn test_renderer_group_with_clip_and_blend() {
2062        let mut clip = ClipPath::new();
2063        clip.push(
2064            vec![PathOp::MoveTo { x: 0.0, y: 0.0 }, PathOp::Close],
2065            FillRule::NonZero,
2066        );
2067        let tree = DisplayTree {
2068            root: DisplayNode::Group {
2069                blend_mode: BlendMode::Screen,
2070                clip: Some(clip),
2071                opacity: 0.8,
2072                isolated: false,
2073                knockout: false,
2074                soft_mask: None,
2075                children: Vec::new(),
2076            },
2077        };
2078        // push_clip, push_group, then pop_group, pop_clip
2079        assert_eq!(
2080            run_mock_renderer(&tree),
2081            vec!["push_clip", "push_group", "pop_group", "pop_clip"]
2082        );
2083    }
2084
2085    #[test]
2086    fn test_renderer_text_no_font_is_noop() {
2087        let tree = DisplayTree {
2088            root: DisplayNode::Group {
2089                blend_mode: BlendMode::Normal,
2090                clip: None,
2091                opacity: 1.0,
2092                isolated: false,
2093                knockout: false,
2094                soft_mask: None,
2095                children: vec![DisplayNode::Text { runs: vec![] }],
2096            },
2097        };
2098        // Empty text runs — no backend calls
2099        assert!(run_mock_renderer(&tree).is_empty());
2100    }
2101
2102    #[test]
2103    fn test_renderer_type3_text_fills_glyphs() {
2104        use rpdfium_font::font_type::PdfFontType;
2105        use rpdfium_font::resolved::ResolvedFont;
2106        use rpdfium_font::type3_font::{Type3Encoding, Type3Font};
2107        use std::sync::Arc;
2108
2109        let mut char_procs = std::collections::HashMap::new();
2110        char_procs.insert(Name::from("a"), rpdfium_core::ObjectId::new(10, 0));
2111
2112        let type3 = Type3Font {
2113            font_matrix: [0.001, 0.0, 0.0, 0.001, 0.0, 0.0],
2114            char_procs,
2115            encoding: Type3Encoding::Differences(vec![(65, Name::from("a"))]),
2116            first_char: 65,
2117            last_char: 65,
2118            widths: vec![600.0],
2119        };
2120
2121        let font = Arc::new(ResolvedFont {
2122            font_type: PdfFontType::Type3,
2123            base_font_name: "TestType3".to_string(),
2124            widths: vec![600],
2125            default_width: 600,
2126            first_char: 65,
2127            ascent: 800,
2128            descent: -200,
2129            italic_angle: 0.0,
2130            is_embedded: false,
2131            to_unicode: None,
2132            encoding: rpdfium_font::fx_codepage::FontEncoding::Standard,
2133            font_data: None,
2134            cid_to_gid: None,
2135            wmode: 0,
2136            vertical_metrics: None,
2137            cid_cmap: None,
2138            type3: Some(type3),
2139            weight: None,
2140            flags: None,
2141            cid_coding: rpdfium_font::cid_font::CidCoding::Unknown,
2142            ttc_face_index: 0,
2143            glyph_outline_cache: std::sync::OnceLock::new(),
2144        });
2145
2146        let tree = DisplayTree {
2147            root: DisplayNode::Group {
2148                blend_mode: BlendMode::Normal,
2149                clip: None,
2150                opacity: 1.0,
2151                isolated: false,
2152                knockout: false,
2153                soft_mask: None,
2154                children: vec![DisplayNode::Text {
2155                    runs: vec![TextRun {
2156                        font_name: Name::from("F1"),
2157                        font_size: 12.0,
2158                        matrix: Matrix::identity(),
2159                        text: vec![65], // 'A' -> maps to glyph "a"
2160                        positions: vec![7.2],
2161                        resolved_font: Some(font),
2162                        rendering_mode: TextRenderingMode::Fill,
2163                        rise: 0.0,
2164                        fill_color: Some(Color::gray(0.0)),
2165                        stroke_color: None,
2166                        actual_text: None,
2167                        type3_glyph_ops: None,
2168                        actual_text_id: None,
2169                        is_vertical: false,
2170                        vert_origins: vec![],
2171                    }],
2172                }],
2173            },
2174        };
2175        // Type 3 glyph with a char proc should produce a fill_path call
2176        assert_eq!(run_mock_renderer(&tree), vec!["fill_path"]);
2177    }
2178
2179    #[test]
2180    fn test_renderer_type3_no_type3_data_is_noop() {
2181        use rpdfium_font::font_type::PdfFontType;
2182        use rpdfium_font::resolved::ResolvedFont;
2183        use std::sync::Arc;
2184
2185        // Type 3 font but with no type3 data
2186        let font = Arc::new(ResolvedFont {
2187            font_type: PdfFontType::Type3,
2188            base_font_name: "TestType3".to_string(),
2189            widths: vec![],
2190            default_width: 600,
2191            first_char: 0,
2192            ascent: 800,
2193            descent: -200,
2194            italic_angle: 0.0,
2195            is_embedded: false,
2196            to_unicode: None,
2197            encoding: rpdfium_font::fx_codepage::FontEncoding::Standard,
2198            font_data: None,
2199            cid_to_gid: None,
2200            wmode: 0,
2201            vertical_metrics: None,
2202            cid_cmap: None,
2203            type3: None, // No Type 3 data
2204            weight: None,
2205            flags: None,
2206            cid_coding: rpdfium_font::cid_font::CidCoding::Unknown,
2207            ttc_face_index: 0,
2208            glyph_outline_cache: std::sync::OnceLock::new(),
2209        });
2210
2211        let tree = DisplayTree {
2212            root: DisplayNode::Group {
2213                blend_mode: BlendMode::Normal,
2214                clip: None,
2215                opacity: 1.0,
2216                isolated: false,
2217                knockout: false,
2218                soft_mask: None,
2219                children: vec![DisplayNode::Text {
2220                    runs: vec![TextRun {
2221                        font_name: Name::from("F1"),
2222                        font_size: 12.0,
2223                        matrix: Matrix::identity(),
2224                        text: vec![65],
2225                        positions: vec![7.2],
2226                        resolved_font: Some(font),
2227                        rendering_mode: TextRenderingMode::Fill,
2228                        rise: 0.0,
2229                        fill_color: Some(Color::gray(0.0)),
2230                        stroke_color: None,
2231                        actual_text: None,
2232                        type3_glyph_ops: None,
2233                        actual_text_id: None,
2234                        is_vertical: false,
2235                        vert_origins: vec![],
2236                    }],
2237                }],
2238            },
2239        };
2240        // No type3 data — should not produce any backend calls
2241        assert!(run_mock_renderer(&tree).is_empty());
2242    }
2243
2244    #[test]
2245    fn test_renderer_image_without_decoder_is_noop() {
2246        let tree = DisplayTree {
2247            root: DisplayNode::Group {
2248                blend_mode: BlendMode::Normal,
2249                clip: None,
2250                opacity: 1.0,
2251                isolated: false,
2252                knockout: false,
2253                soft_mask: None,
2254                children: vec![DisplayNode::Image {
2255                    image_ref: ImageRef {
2256                        object_id: rpdfium_core::ObjectId::new(1, 0),
2257                    },
2258                    matrix: Matrix::identity(),
2259                    mask: None,
2260                    fill_color: None,
2261                    transfer_function: None,
2262                }],
2263            },
2264        };
2265        // No decoder provided — should not call draw_image
2266        assert!(run_mock_renderer(&tree).is_empty());
2267    }
2268
2269    #[test]
2270    fn test_renderer_group_with_smask_pushes_group() {
2271        use rpdfium_page::display::SoftMaskSubtype;
2272        // A group with an SMask should trigger push_group even with Normal blend + opacity 1.0.
2273        let mask_tree = DisplayTree {
2274            root: DisplayNode::Group {
2275                blend_mode: BlendMode::Normal,
2276                clip: None,
2277                opacity: 1.0,
2278                isolated: true,
2279                knockout: false,
2280                soft_mask: None,
2281                children: vec![DisplayNode::Path {
2282                    ops: vec![
2283                        PathOp::MoveTo { x: 0.0, y: 0.0 },
2284                        PathOp::LineTo { x: 50.0, y: 0.0 },
2285                        PathOp::LineTo { x: 50.0, y: 50.0 },
2286                        PathOp::Close,
2287                    ],
2288                    style: PathStyle {
2289                        fill: Some(FillRule::NonZero),
2290                        ..PathStyle::default()
2291                    },
2292                    matrix: Matrix::identity(),
2293                    fill_color: Some(Color::gray(0.5)),
2294                    stroke_color: None,
2295                    fill_color_space: None,
2296                    stroke_color_space: None,
2297                    transfer_function: None,
2298                    overprint: false,
2299                    overprint_mode: 0,
2300                }],
2301            },
2302        };
2303        let smask = SoftMask {
2304            subtype: SoftMaskSubtype::Alpha,
2305            group: mask_tree,
2306            backdrop_color: None,
2307            transfer_function: None,
2308        };
2309        let tree = DisplayTree {
2310            root: DisplayNode::Group {
2311                blend_mode: BlendMode::Normal,
2312                clip: None,
2313                opacity: 1.0,
2314                isolated: true,
2315                knockout: false,
2316                soft_mask: Some(Box::new(smask)),
2317                children: vec![DisplayNode::Path {
2318                    ops: vec![
2319                        PathOp::MoveTo { x: 0.0, y: 0.0 },
2320                        PathOp::LineTo { x: 100.0, y: 100.0 },
2321                        PathOp::Close,
2322                    ],
2323                    style: PathStyle {
2324                        fill: Some(FillRule::NonZero),
2325                        ..PathStyle::default()
2326                    },
2327                    matrix: Matrix::identity(),
2328                    fill_color: Some(Color::rgb(1.0, 0.0, 0.0)),
2329                    stroke_color: None,
2330                    fill_color_space: None,
2331                    stroke_color_space: None,
2332                    transfer_function: None,
2333                    overprint: false,
2334                    overprint_mode: 0,
2335                }],
2336            },
2337        };
2338        let log = run_mock_renderer(&tree);
2339        // SMask triggers push_group and pop_group even though blend is Normal + opacity 1.0
2340        assert!(log.contains(&"push_group".to_string()));
2341        assert!(log.contains(&"pop_group".to_string()));
2342        assert!(log.contains(&"fill_path".to_string()));
2343    }
2344
2345    #[test]
2346    fn test_renderer_group_without_smask_unchanged() {
2347        // Regression: Normal blend + opacity 1.0 + no clip + no SMask = Nothing
2348        let tree = DisplayTree {
2349            root: DisplayNode::Group {
2350                blend_mode: BlendMode::Normal,
2351                clip: None,
2352                opacity: 1.0,
2353                isolated: false,
2354                knockout: false,
2355                soft_mask: None,
2356                children: vec![DisplayNode::Path {
2357                    ops: vec![
2358                        PathOp::MoveTo { x: 0.0, y: 0.0 },
2359                        PathOp::LineTo { x: 50.0, y: 0.0 },
2360                        PathOp::Close,
2361                    ],
2362                    style: PathStyle {
2363                        fill: Some(FillRule::NonZero),
2364                        ..PathStyle::default()
2365                    },
2366                    matrix: Matrix::identity(),
2367                    fill_color: Some(Color::rgb(0.0, 1.0, 0.0)),
2368                    stroke_color: None,
2369                    fill_color_space: None,
2370                    stroke_color_space: None,
2371                    transfer_function: None,
2372                    overprint: false,
2373                    overprint_mode: 0,
2374                }],
2375            },
2376        };
2377        let log = run_mock_renderer(&tree);
2378        // No group actions — just the fill_path
2379        assert_eq!(log, vec!["fill_path"]);
2380    }
2381
2382    #[test]
2383    fn test_renderer_with_fallback_fonts_builder() {
2384        use rpdfium_font::resolved::ResolvedFont;
2385        use std::sync::Arc;
2386
2387        let fallback = Arc::new(ResolvedFont {
2388            font_type: rpdfium_font::font_type::PdfFontType::TrueType,
2389            base_font_name: "FallbackFont".to_string(),
2390            widths: vec![],
2391            default_width: 500,
2392            first_char: 0,
2393            ascent: 800,
2394            descent: -200,
2395            italic_angle: 0.0,
2396            is_embedded: false,
2397            to_unicode: None,
2398            encoding: rpdfium_font::fx_codepage::FontEncoding::Standard,
2399            font_data: None,
2400            cid_to_gid: None,
2401            wmode: 0,
2402            vertical_metrics: None,
2403            cid_cmap: None,
2404            type3: None,
2405            weight: None,
2406            flags: None,
2407            cid_coding: rpdfium_font::cid_font::CidCoding::Unknown,
2408            ttc_face_index: 0,
2409            glyph_outline_cache: std::sync::OnceLock::new(),
2410        });
2411
2412        let mut backend = MockBackend::new();
2413        let mut surface = backend.create_surface(100, 100, &RgbaColor::WHITE);
2414        let renderer = DisplayRenderer::new(&mut backend, &mut surface, Matrix::identity(), None)
2415            .with_fallback_fonts(vec![fallback]);
2416        assert_eq!(renderer.fallback_fonts.len(), 1);
2417    }
2418
2419    #[test]
2420    fn test_renderer_find_fallback_glyph_no_fallbacks() {
2421        let mut backend = MockBackend::new();
2422        let mut surface = backend.create_surface(100, 100, &RgbaColor::WHITE);
2423        let renderer = DisplayRenderer::new(&mut backend, &mut surface, Matrix::identity(), None);
2424        // No fallback fonts — should return None
2425        assert!(renderer.find_fallback_glyph(65, 12.0).is_none());
2426    }
2427
2428    #[test]
2429    fn test_renderer_find_fallback_glyph_no_font_data() {
2430        use rpdfium_font::resolved::ResolvedFont;
2431        use std::sync::Arc;
2432
2433        // Fallback font without font_data — char_to_glyph_id returns None
2434        let fallback = Arc::new(ResolvedFont {
2435            font_type: rpdfium_font::font_type::PdfFontType::TrueType,
2436            base_font_name: "NoData".to_string(),
2437            widths: vec![],
2438            default_width: 500,
2439            first_char: 0,
2440            ascent: 800,
2441            descent: -200,
2442            italic_angle: 0.0,
2443            is_embedded: false,
2444            to_unicode: None,
2445            encoding: rpdfium_font::fx_codepage::FontEncoding::Standard,
2446            font_data: None,
2447            cid_to_gid: None,
2448            wmode: 0,
2449            vertical_metrics: None,
2450            cid_cmap: None,
2451            type3: None,
2452            weight: None,
2453            flags: None,
2454            cid_coding: rpdfium_font::cid_font::CidCoding::Unknown,
2455            ttc_face_index: 0,
2456            glyph_outline_cache: std::sync::OnceLock::new(),
2457        });
2458
2459        let mut backend = MockBackend::new();
2460        let mut surface = backend.create_surface(100, 100, &RgbaColor::WHITE);
2461        let renderer = DisplayRenderer::new(&mut backend, &mut surface, Matrix::identity(), None)
2462            .with_fallback_fonts(vec![fallback]);
2463        // Fallback font has no font_data, so char_to_glyph_id returns None
2464        assert!(renderer.find_fallback_glyph(65, 12.0).is_none());
2465    }
2466
2467    #[test]
2468    fn test_renderer_text_missing_glyph_no_fallback_skips() {
2469        use rpdfium_font::resolved::ResolvedFont;
2470        use std::sync::Arc;
2471
2472        // Primary font with no font data — all glyphs are missing
2473        let font = Arc::new(ResolvedFont {
2474            font_type: rpdfium_font::font_type::PdfFontType::TrueType,
2475            base_font_name: "NoGlyphs".to_string(),
2476            widths: vec![500],
2477            default_width: 500,
2478            first_char: 65,
2479            ascent: 800,
2480            descent: -200,
2481            italic_angle: 0.0,
2482            is_embedded: false,
2483            to_unicode: None,
2484            encoding: rpdfium_font::fx_codepage::FontEncoding::Standard,
2485            font_data: None,
2486            cid_to_gid: None,
2487            wmode: 0,
2488            vertical_metrics: None,
2489            cid_cmap: None,
2490            type3: None,
2491            weight: None,
2492            flags: None,
2493            cid_coding: rpdfium_font::cid_font::CidCoding::Unknown,
2494            ttc_face_index: 0,
2495            glyph_outline_cache: std::sync::OnceLock::new(),
2496        });
2497
2498        let tree = DisplayTree {
2499            root: DisplayNode::Group {
2500                blend_mode: BlendMode::Normal,
2501                clip: None,
2502                opacity: 1.0,
2503                isolated: false,
2504                knockout: false,
2505                soft_mask: None,
2506                children: vec![DisplayNode::Text {
2507                    runs: vec![TextRun {
2508                        font_name: Name::from("F1"),
2509                        font_size: 12.0,
2510                        matrix: Matrix::identity(),
2511                        text: vec![65],
2512                        positions: vec![7.2],
2513                        resolved_font: Some(font),
2514                        rendering_mode: TextRenderingMode::Fill,
2515                        rise: 0.0,
2516                        fill_color: Some(Color::gray(0.0)),
2517                        stroke_color: None,
2518                        actual_text: None,
2519                        type3_glyph_ops: None,
2520                        actual_text_id: None,
2521                        is_vertical: false,
2522                        vert_origins: vec![],
2523                    }],
2524                }],
2525            },
2526        };
2527        // No font data, no fallback — should produce no backend calls
2528        assert!(run_mock_renderer(&tree).is_empty());
2529    }
2530
2531    #[test]
2532    fn test_renderer_luminosity_smask_pushes_group() {
2533        use rpdfium_page::display::SoftMaskSubtype;
2534        let mask_tree = DisplayTree {
2535            root: DisplayNode::Group {
2536                blend_mode: BlendMode::Normal,
2537                clip: None,
2538                opacity: 1.0,
2539                isolated: true,
2540                knockout: false,
2541                soft_mask: None,
2542                children: vec![DisplayNode::Path {
2543                    ops: vec![
2544                        PathOp::MoveTo { x: 0.0, y: 0.0 },
2545                        PathOp::LineTo { x: 100.0, y: 0.0 },
2546                        PathOp::LineTo { x: 100.0, y: 100.0 },
2547                        PathOp::LineTo { x: 0.0, y: 100.0 },
2548                        PathOp::Close,
2549                    ],
2550                    style: PathStyle {
2551                        fill: Some(FillRule::NonZero),
2552                        ..PathStyle::default()
2553                    },
2554                    matrix: Matrix::identity(),
2555                    fill_color: Some(Color::rgb(1.0, 1.0, 1.0)),
2556                    stroke_color: None,
2557                    fill_color_space: None,
2558                    stroke_color_space: None,
2559                    transfer_function: None,
2560                    overprint: false,
2561                    overprint_mode: 0,
2562                }],
2563            },
2564        };
2565        let smask = SoftMask {
2566            subtype: SoftMaskSubtype::Luminosity,
2567            group: mask_tree,
2568            backdrop_color: None,
2569            transfer_function: None,
2570        };
2571        let tree = DisplayTree {
2572            root: DisplayNode::Group {
2573                blend_mode: BlendMode::Normal,
2574                clip: None,
2575                opacity: 1.0,
2576                isolated: true,
2577                knockout: false,
2578                soft_mask: Some(Box::new(smask)),
2579                children: Vec::new(),
2580            },
2581        };
2582        let log = run_mock_renderer(&tree);
2583        assert!(log.contains(&"push_group".to_string()));
2584        assert!(log.contains(&"pop_group".to_string()));
2585    }
2586
2587    // ---- WS3: Text Clipping Tests ----
2588
2589    /// Helper to create a Type3 font that produces char procs (needed for clip tests).
2590    fn make_type3_font() -> Arc<rpdfium_font::resolved::ResolvedFont> {
2591        use rpdfium_font::font_type::PdfFontType;
2592        use rpdfium_font::resolved::ResolvedFont;
2593        use rpdfium_font::type3_font::{Type3Encoding, Type3Font};
2594
2595        let mut char_procs = std::collections::HashMap::new();
2596        char_procs.insert(Name::from("a"), rpdfium_core::ObjectId::new(10, 0));
2597
2598        let type3 = Type3Font {
2599            font_matrix: [0.001, 0.0, 0.0, 0.001, 0.0, 0.0],
2600            char_procs,
2601            encoding: Type3Encoding::Differences(vec![(65, Name::from("a"))]),
2602            first_char: 65,
2603            last_char: 65,
2604            widths: vec![600.0],
2605        };
2606
2607        Arc::new(ResolvedFont {
2608            font_type: PdfFontType::Type3,
2609            base_font_name: "TestType3".to_string(),
2610            widths: vec![600],
2611            default_width: 600,
2612            first_char: 65,
2613            ascent: 800,
2614            descent: -200,
2615            italic_angle: 0.0,
2616            is_embedded: false,
2617            to_unicode: None,
2618            encoding: rpdfium_font::fx_codepage::FontEncoding::Standard,
2619            font_data: None,
2620            cid_to_gid: None,
2621            wmode: 0,
2622            vertical_metrics: None,
2623            cid_cmap: None,
2624            type3: Some(type3),
2625            weight: None,
2626            flags: None,
2627            cid_coding: rpdfium_font::cid_font::CidCoding::Unknown,
2628            ttc_face_index: 0,
2629            glyph_outline_cache: std::sync::OnceLock::new(),
2630        })
2631    }
2632
2633    #[test]
2634    fn test_text_clip_mode4_fill_and_clip() {
2635        // Mode 4 (FillClip): should fill AND apply clip.
2636        let font = make_type3_font();
2637        let tree = DisplayTree {
2638            root: DisplayNode::Group {
2639                blend_mode: BlendMode::Normal,
2640                clip: None,
2641                opacity: 1.0,
2642                isolated: false,
2643                knockout: false,
2644                soft_mask: None,
2645                children: vec![DisplayNode::Text {
2646                    runs: vec![TextRun {
2647                        font_name: Name::from("F1"),
2648                        font_size: 12.0,
2649                        matrix: Matrix::identity(),
2650                        text: vec![65],
2651                        positions: vec![7.2],
2652                        resolved_font: Some(font),
2653                        rendering_mode: TextRenderingMode::FillClip,
2654                        rise: 0.0,
2655                        fill_color: Some(Color::gray(0.0)),
2656                        stroke_color: None,
2657                        actual_text: None,
2658                        type3_glyph_ops: None,
2659                        actual_text_id: None,
2660                        is_vertical: false,
2661                        vert_origins: vec![],
2662                    }],
2663                }],
2664            },
2665        };
2666        let log = run_mock_renderer(&tree);
2667        // Should contain fill_path (for the fill) AND push_clip (for the clip)
2668        assert!(
2669            log.contains(&"fill_path".to_string()),
2670            "expected fill_path in {:?}",
2671            log
2672        );
2673        assert!(
2674            log.contains(&"push_clip".to_string()),
2675            "expected push_clip in {:?}",
2676            log
2677        );
2678        // Clip should be popped at leave_group
2679        assert!(
2680            log.contains(&"pop_clip".to_string()),
2681            "expected pop_clip in {:?}",
2682            log
2683        );
2684    }
2685
2686    #[test]
2687    fn test_text_clip_mode7_clip_only() {
2688        // Mode 7 (Clip): no fill, no stroke, clip IS applied.
2689        let font = make_type3_font();
2690        let tree = DisplayTree {
2691            root: DisplayNode::Group {
2692                blend_mode: BlendMode::Normal,
2693                clip: None,
2694                opacity: 1.0,
2695                isolated: false,
2696                knockout: false,
2697                soft_mask: None,
2698                children: vec![DisplayNode::Text {
2699                    runs: vec![TextRun {
2700                        font_name: Name::from("F1"),
2701                        font_size: 12.0,
2702                        matrix: Matrix::identity(),
2703                        text: vec![65],
2704                        positions: vec![7.2],
2705                        resolved_font: Some(font),
2706                        rendering_mode: TextRenderingMode::Clip,
2707                        rise: 0.0,
2708                        fill_color: Some(Color::gray(0.0)),
2709                        stroke_color: None,
2710                        actual_text: None,
2711                        type3_glyph_ops: None,
2712                        actual_text_id: None,
2713                        is_vertical: false,
2714                        vert_origins: vec![],
2715                    }],
2716                }],
2717            },
2718        };
2719        let log = run_mock_renderer(&tree);
2720        // Should NOT contain fill_path or stroke_path
2721        assert!(
2722            !log.contains(&"fill_path".to_string()),
2723            "unexpected fill_path in {:?}",
2724            log
2725        );
2726        assert!(
2727            !log.contains(&"stroke_path".to_string()),
2728            "unexpected stroke_path in {:?}",
2729            log
2730        );
2731        // Should contain push_clip (for the clip)
2732        assert!(
2733            log.contains(&"push_clip".to_string()),
2734            "expected push_clip in {:?}",
2735            log
2736        );
2737        assert!(
2738            log.contains(&"pop_clip".to_string()),
2739            "expected pop_clip in {:?}",
2740            log
2741        );
2742    }
2743
2744    #[test]
2745    fn test_text_fill_mode0_no_clip() {
2746        // Regression: Mode 0 (Fill): fill happens, NO clip applied.
2747        let font = make_type3_font();
2748        let tree = DisplayTree {
2749            root: DisplayNode::Group {
2750                blend_mode: BlendMode::Normal,
2751                clip: None,
2752                opacity: 1.0,
2753                isolated: false,
2754                knockout: false,
2755                soft_mask: None,
2756                children: vec![DisplayNode::Text {
2757                    runs: vec![TextRun {
2758                        font_name: Name::from("F1"),
2759                        font_size: 12.0,
2760                        matrix: Matrix::identity(),
2761                        text: vec![65],
2762                        positions: vec![7.2],
2763                        resolved_font: Some(font),
2764                        rendering_mode: TextRenderingMode::Fill,
2765                        rise: 0.0,
2766                        fill_color: Some(Color::gray(0.0)),
2767                        stroke_color: None,
2768                        actual_text: None,
2769                        type3_glyph_ops: None,
2770                        actual_text_id: None,
2771                        is_vertical: false,
2772                        vert_origins: vec![],
2773                    }],
2774                }],
2775            },
2776        };
2777        let log = run_mock_renderer(&tree);
2778        // Should contain fill_path but NOT push_clip
2779        assert!(log.contains(&"fill_path".to_string()));
2780        assert!(
2781            !log.contains(&"push_clip".to_string()),
2782            "unexpected push_clip in {:?}",
2783            log
2784        );
2785    }
2786
2787    // ---- T4: Text rendering modes Tr 5 / Tr 6 / Tr 3 ----
2788
2789    #[test]
2790    fn test_text_clip_mode5_stroke_clip() {
2791        // Tr 5 (StrokeClip): stroke text AND add to clipping path — no fill.
2792        let font = make_type3_font();
2793        let tree = DisplayTree {
2794            root: DisplayNode::Group {
2795                blend_mode: BlendMode::Normal,
2796                clip: None,
2797                opacity: 1.0,
2798                isolated: false,
2799                knockout: false,
2800                soft_mask: None,
2801                children: vec![DisplayNode::Text {
2802                    runs: vec![TextRun {
2803                        font_name: Name::from("F1"),
2804                        font_size: 12.0,
2805                        matrix: Matrix::identity(),
2806                        text: vec![65],
2807                        positions: vec![7.2],
2808                        resolved_font: Some(font),
2809                        rendering_mode: TextRenderingMode::StrokeClip,
2810                        rise: 0.0,
2811                        fill_color: Some(Color::gray(0.0)),
2812                        stroke_color: Some(Color::gray(0.0)),
2813                        actual_text: None,
2814                        type3_glyph_ops: None,
2815                        actual_text_id: None,
2816                        is_vertical: false,
2817                        vert_origins: vec![],
2818                    }],
2819                }],
2820            },
2821        };
2822        let log = run_mock_renderer(&tree);
2823        // Stroke should be present, fill absent, clip applied and released.
2824        assert!(
2825            log.contains(&"stroke_path".to_string()),
2826            "expected stroke_path in {log:?}"
2827        );
2828        assert!(
2829            !log.contains(&"fill_path".to_string()),
2830            "unexpected fill_path in {log:?}"
2831        );
2832        assert!(
2833            log.contains(&"push_clip".to_string()),
2834            "expected push_clip in {log:?}"
2835        );
2836        assert!(
2837            log.contains(&"pop_clip".to_string()),
2838            "expected pop_clip in {log:?}"
2839        );
2840    }
2841
2842    #[test]
2843    fn test_text_clip_mode6_fill_stroke_clip() {
2844        // Tr 6 (FillStrokeClip): fill AND stroke AND add to clipping path.
2845        let font = make_type3_font();
2846        let tree = DisplayTree {
2847            root: DisplayNode::Group {
2848                blend_mode: BlendMode::Normal,
2849                clip: None,
2850                opacity: 1.0,
2851                isolated: false,
2852                knockout: false,
2853                soft_mask: None,
2854                children: vec![DisplayNode::Text {
2855                    runs: vec![TextRun {
2856                        font_name: Name::from("F1"),
2857                        font_size: 12.0,
2858                        matrix: Matrix::identity(),
2859                        text: vec![65],
2860                        positions: vec![7.2],
2861                        resolved_font: Some(font),
2862                        rendering_mode: TextRenderingMode::FillStrokeClip,
2863                        rise: 0.0,
2864                        fill_color: Some(Color::gray(0.0)),
2865                        stroke_color: Some(Color::gray(0.0)),
2866                        actual_text: None,
2867                        type3_glyph_ops: None,
2868                        actual_text_id: None,
2869                        is_vertical: false,
2870                        vert_origins: vec![],
2871                    }],
2872                }],
2873            },
2874        };
2875        let log = run_mock_renderer(&tree);
2876        // Both fill and stroke must be present, plus clip.
2877        assert!(
2878            log.contains(&"fill_path".to_string()),
2879            "expected fill_path in {log:?}"
2880        );
2881        assert!(
2882            log.contains(&"stroke_path".to_string()),
2883            "expected stroke_path in {log:?}"
2884        );
2885        assert!(
2886            log.contains(&"push_clip".to_string()),
2887            "expected push_clip in {log:?}"
2888        );
2889        assert!(
2890            log.contains(&"pop_clip".to_string()),
2891            "expected pop_clip in {log:?}"
2892        );
2893    }
2894
2895    #[test]
2896    fn test_text_clip_mode3_invisible_no_draw_no_clip() {
2897        // Tr 3 (Invisible): no fill, no stroke, no clip accumulation.
2898        let font = make_type3_font();
2899        let tree = DisplayTree {
2900            root: DisplayNode::Group {
2901                blend_mode: BlendMode::Normal,
2902                clip: None,
2903                opacity: 1.0,
2904                isolated: false,
2905                knockout: false,
2906                soft_mask: None,
2907                children: vec![DisplayNode::Text {
2908                    runs: vec![TextRun {
2909                        font_name: Name::from("F1"),
2910                        font_size: 12.0,
2911                        matrix: Matrix::identity(),
2912                        text: vec![65],
2913                        positions: vec![7.2],
2914                        resolved_font: Some(font),
2915                        rendering_mode: TextRenderingMode::Invisible,
2916                        rise: 0.0,
2917                        fill_color: Some(Color::gray(0.0)),
2918                        stroke_color: None,
2919                        actual_text: None,
2920                        type3_glyph_ops: None,
2921                        actual_text_id: None,
2922                        is_vertical: false,
2923                        vert_origins: vec![],
2924                    }],
2925                }],
2926            },
2927        };
2928        let log = run_mock_renderer(&tree);
2929        // Invisible text: no drawing operations, no clip operations.
2930        assert!(
2931            !log.contains(&"fill_path".to_string()),
2932            "unexpected fill_path in {log:?}"
2933        );
2934        assert!(
2935            !log.contains(&"stroke_path".to_string()),
2936            "unexpected stroke_path in {log:?}"
2937        );
2938        assert!(
2939            !log.contains(&"push_clip".to_string()),
2940            "unexpected push_clip in {log:?}"
2941        );
2942    }
2943
2944    #[test]
2945    fn test_transform_path_op_identity() {
2946        let m = Matrix::identity();
2947        let op = PathOp::MoveTo { x: 10.0, y: 20.0 };
2948        let result = transform_path_op(&op, &m);
2949        match result {
2950            PathOp::MoveTo { x, y } => {
2951                assert!((x - 10.0).abs() < 0.01);
2952                assert!((y - 20.0).abs() < 0.01);
2953            }
2954            _ => panic!("expected MoveTo"),
2955        }
2956    }
2957
2958    #[test]
2959    fn test_transform_path_op_scale() {
2960        let m = Matrix::from_scale(2.0, 3.0);
2961        let op = PathOp::LineTo { x: 5.0, y: 10.0 };
2962        let result = transform_path_op(&op, &m);
2963        match result {
2964            PathOp::LineTo { x, y } => {
2965                assert!((x - 10.0).abs() < 0.01);
2966                assert!((y - 30.0).abs() < 0.01);
2967            }
2968            _ => panic!("expected LineTo"),
2969        }
2970    }
2971
2972    #[test]
2973    fn test_transform_path_op_close() {
2974        let m = Matrix::from_scale(2.0, 3.0);
2975        let result = transform_path_op(&PathOp::Close, &m);
2976        assert!(matches!(result, PathOp::Close));
2977    }
2978
2979    // ---- WS4: Pattern Fill Tests ----
2980
2981    #[test]
2982    fn test_path_bounding_box_basic() {
2983        let ops = vec![
2984            PathOp::MoveTo { x: 10.0, y: 20.0 },
2985            PathOp::LineTo { x: 100.0, y: 20.0 },
2986            PathOp::LineTo { x: 100.0, y: 200.0 },
2987            PathOp::LineTo { x: 10.0, y: 200.0 },
2988            PathOp::Close,
2989        ];
2990        let bbox = path_bounding_box(&ops);
2991        assert_eq!(bbox, [10.0, 20.0, 100.0, 200.0]);
2992    }
2993
2994    #[test]
2995    fn test_path_bounding_box_empty() {
2996        let bbox = path_bounding_box(&[]);
2997        assert_eq!(bbox, [0.0, 0.0, 0.0, 0.0]);
2998    }
2999
3000    #[test]
3001    fn test_path_bounding_box_with_curves() {
3002        let ops = vec![
3003            PathOp::MoveTo { x: 0.0, y: 0.0 },
3004            PathOp::CurveTo {
3005                x1: 50.0,
3006                y1: 100.0,
3007                x2: 150.0,
3008                y2: 100.0,
3009                x3: 200.0,
3010                y3: 0.0,
3011            },
3012        ];
3013        let bbox = path_bounding_box(&ops);
3014        assert_eq!(bbox[0], 0.0);
3015        assert_eq!(bbox[1], 0.0);
3016        assert_eq!(bbox[2], 200.0);
3017        assert_eq!(bbox[3], 100.0);
3018    }
3019
3020    #[test]
3021    fn test_renderer_pattern_fill_clips_and_draws() {
3022        use rpdfium_page::pattern::{PaintType, TilingPattern, TilingType};
3023
3024        // Pattern tree: a single red rectangle
3025        let pattern_tree = DisplayTree {
3026            root: DisplayNode::Group {
3027                blend_mode: BlendMode::Normal,
3028                clip: None,
3029                opacity: 1.0,
3030                isolated: true,
3031                knockout: false,
3032                soft_mask: None,
3033                children: vec![DisplayNode::Path {
3034                    ops: vec![
3035                        PathOp::MoveTo { x: 0.0, y: 0.0 },
3036                        PathOp::LineTo { x: 10.0, y: 0.0 },
3037                        PathOp::LineTo { x: 10.0, y: 10.0 },
3038                        PathOp::LineTo { x: 0.0, y: 10.0 },
3039                        PathOp::Close,
3040                    ],
3041                    style: PathStyle {
3042                        fill: Some(FillRule::NonZero),
3043                        ..PathStyle::default()
3044                    },
3045                    matrix: Matrix::identity(),
3046                    fill_color: Some(Color::rgb(1.0, 0.0, 0.0)),
3047                    stroke_color: None,
3048                    fill_color_space: None,
3049                    stroke_color_space: None,
3050                    transfer_function: None,
3051                    overprint: false,
3052                    overprint_mode: 0,
3053                }],
3054            },
3055        };
3056
3057        let pattern = TilingPattern {
3058            paint_type: PaintType::Colored,
3059            tiling_type: TilingType::ConstantSpacing,
3060            bbox: [0.0, 0.0, 10.0, 10.0],
3061            x_step: 10.0,
3062            y_step: 10.0,
3063            matrix: Matrix::identity(),
3064            resources_id: None,
3065            stream_id: rpdfium_core::ObjectId::new(1, 0),
3066        };
3067
3068        let tree = DisplayTree {
3069            root: DisplayNode::Group {
3070                blend_mode: BlendMode::Normal,
3071                clip: None,
3072                opacity: 1.0,
3073                isolated: false,
3074                knockout: false,
3075                soft_mask: None,
3076                children: vec![DisplayNode::PatternFill {
3077                    path_ops: vec![
3078                        PathOp::MoveTo { x: 0.0, y: 0.0 },
3079                        PathOp::LineTo { x: 50.0, y: 0.0 },
3080                        PathOp::LineTo { x: 50.0, y: 50.0 },
3081                        PathOp::LineTo { x: 0.0, y: 50.0 },
3082                        PathOp::Close,
3083                    ],
3084                    fill_rule: FillRule::NonZero,
3085                    pattern,
3086                    pattern_tree: Box::new(pattern_tree),
3087                    fill_color: None,
3088                    matrix: Matrix::identity(),
3089                }],
3090            },
3091        };
3092        let log = run_mock_renderer(&tree);
3093        // Should clip, draw images (tiles), then pop clip
3094        assert!(
3095            log.contains(&"push_clip".to_string()),
3096            "expected push_clip in {:?}",
3097            log
3098        );
3099        assert!(
3100            log.contains(&"pop_clip".to_string()),
3101            "expected pop_clip in {:?}",
3102            log
3103        );
3104    }
3105
3106    // ---- Image Masking Tests ----
3107
3108    #[test]
3109    fn test_apply_image_mask_none_passes_through() {
3110        use crate::image::{DecodedImage, DecodedImageFormat};
3111
3112        let img = DecodedImage {
3113            width: 2,
3114            height: 2,
3115            data: vec![100, 200, 50, 255],
3116            format: DecodedImageFormat::Gray8,
3117        };
3118        let result = apply_image_mask(img, None, None, None);
3119        assert_eq!(result.format, DecodedImageFormat::Gray8);
3120        assert_eq!(result.data, vec![100, 200, 50, 255]);
3121    }
3122
3123    #[test]
3124    fn test_apply_stencil_mask_gray8() {
3125        use crate::image::{DecodedImage, DecodedImageFormat};
3126        use rpdfium_page::display::ImageMask;
3127
3128        // 2x1 gray image: pixel 0 is nonzero (opaque), pixel 1 is zero (transparent)
3129        let img = DecodedImage {
3130            width: 2,
3131            height: 1,
3132            data: vec![255, 0],
3133            format: DecodedImageFormat::Gray8,
3134        };
3135        let fill = Color::rgb(1.0, 0.0, 0.0);
3136        let mask = ImageMask::Stencil;
3137        let result = apply_image_mask(img, Some(&mask), Some(&fill), None);
3138
3139        assert_eq!(result.format, DecodedImageFormat::Rgba32);
3140        assert_eq!(result.data.len(), 8); // 2 pixels * 4 bytes
3141        // Pixel 0: red fill (nonzero mask)
3142        assert_eq!(result.data[0], 255); // R
3143        assert_eq!(result.data[1], 0); // G
3144        assert_eq!(result.data[2], 0); // B
3145        assert_eq!(result.data[3], 255); // A
3146        // Pixel 1: transparent (zero mask)
3147        assert_eq!(result.data[4], 0);
3148        assert_eq!(result.data[5], 0);
3149        assert_eq!(result.data[6], 0);
3150        assert_eq!(result.data[7], 0);
3151    }
3152
3153    #[test]
3154    fn test_apply_stencil_mask_no_fill_uses_black() {
3155        use crate::image::{DecodedImage, DecodedImageFormat};
3156        use rpdfium_page::display::ImageMask;
3157
3158        let img = DecodedImage {
3159            width: 1,
3160            height: 1,
3161            data: vec![128],
3162            format: DecodedImageFormat::Gray8,
3163        };
3164        let mask = ImageMask::Stencil;
3165        let result = apply_image_mask(img, Some(&mask), None, None);
3166
3167        assert_eq!(result.format, DecodedImageFormat::Rgba32);
3168        // Default fill is black
3169        assert_eq!(result.data[0], 0); // R
3170        assert_eq!(result.data[1], 0); // G
3171        assert_eq!(result.data[2], 0); // B
3172        assert_eq!(result.data[3], 255); // A
3173    }
3174
3175    #[test]
3176    fn test_apply_color_key_mask_gray8() {
3177        use crate::image::{DecodedImage, DecodedImageFormat};
3178        use rpdfium_page::display::ImageMask;
3179
3180        // 3 pixels: gray values 100, 150, 200
3181        // Mask range: [100, 160] → pixels 0 and 1 should be transparent
3182        let img = DecodedImage {
3183            width: 3,
3184            height: 1,
3185            data: vec![100, 150, 200],
3186            format: DecodedImageFormat::Gray8,
3187        };
3188        let mask = ImageMask::ColorKey {
3189            ranges: vec![[100, 160]],
3190        };
3191        let result = apply_image_mask(img, Some(&mask), None, None);
3192
3193        assert_eq!(result.format, DecodedImageFormat::Rgba32);
3194        // Pixel 0 (100): in range → transparent
3195        assert_eq!(result.data[3], 0);
3196        // Pixel 1 (150): in range → transparent
3197        assert_eq!(result.data[7], 0);
3198        // Pixel 2 (200): out of range → opaque
3199        assert_eq!(result.data[11], 255);
3200    }
3201
3202    #[test]
3203    fn test_apply_color_key_mask_rgb24() {
3204        use crate::image::{DecodedImage, DecodedImageFormat};
3205        use rpdfium_page::display::ImageMask;
3206
3207        // 2 pixels RGB: (255, 0, 0) and (0, 255, 0)
3208        // Mask ranges: R=[200,255], G=[0,10], B=[0,10] → only first pixel matches
3209        let img = DecodedImage {
3210            width: 2,
3211            height: 1,
3212            data: vec![255, 0, 0, 0, 255, 0],
3213            format: DecodedImageFormat::Rgb24,
3214        };
3215        let mask = ImageMask::ColorKey {
3216            ranges: vec![[200, 255], [0, 10], [0, 10]],
3217        };
3218        let result = apply_image_mask(img, Some(&mask), None, None);
3219
3220        assert_eq!(result.format, DecodedImageFormat::Rgba32);
3221        // Pixel 0: matches all ranges → transparent
3222        assert_eq!(result.data[0], 255); // R preserved
3223        assert_eq!(result.data[3], 0); // A = 0
3224        // Pixel 1: G=255 doesn't match [0,10] → opaque
3225        assert_eq!(result.data[4], 0); // R
3226        assert_eq!(result.data[5], 255); // G
3227        assert_eq!(result.data[7], 255); // A = 255
3228    }
3229
3230    #[test]
3231    fn test_apply_explicit_mask_passes_through() {
3232        use crate::image::{DecodedImage, DecodedImageFormat};
3233        use rpdfium_page::display::ImageMask;
3234
3235        let img = DecodedImage {
3236            width: 1,
3237            height: 1,
3238            data: vec![128],
3239            format: DecodedImageFormat::Gray8,
3240        };
3241        let mask = ImageMask::ExplicitMask {
3242            mask_object_id: rpdfium_core::ObjectId::new(5, 0),
3243        };
3244        let result = apply_image_mask(img, Some(&mask), None, None);
3245
3246        // ExplicitMask without decoder passes through unchanged
3247        assert_eq!(result.format, DecodedImageFormat::Gray8);
3248        assert_eq!(result.data, vec![128]);
3249    }
3250
3251    #[test]
3252    fn test_apply_soft_mask_passes_through() {
3253        use crate::image::{DecodedImage, DecodedImageFormat};
3254        use rpdfium_page::display::ImageMask;
3255
3256        let img = DecodedImage {
3257            width: 1,
3258            height: 1,
3259            data: vec![64, 128, 192],
3260            format: DecodedImageFormat::Rgb24,
3261        };
3262        let mask = ImageMask::SoftMask {
3263            smask_object_id: rpdfium_core::ObjectId::new(6, 0),
3264            matte: None,
3265        };
3266        let result = apply_image_mask(img, Some(&mask), None, None);
3267
3268        // SoftMask without decoder passes through unchanged
3269        assert_eq!(result.format, DecodedImageFormat::Rgb24);
3270        assert_eq!(result.data, vec![64, 128, 192]);
3271    }
3272
3273    #[test]
3274    fn test_renderer_pattern_fill_with_non_identity_matrix() {
3275        use rpdfium_page::pattern::{PaintType, TilingPattern, TilingType};
3276
3277        let pattern_tree = DisplayTree {
3278            root: DisplayNode::Group {
3279                blend_mode: BlendMode::Normal,
3280                clip: None,
3281                opacity: 1.0,
3282                isolated: true,
3283                knockout: false,
3284                soft_mask: None,
3285                children: vec![],
3286            },
3287        };
3288
3289        // Pattern with 2x scale matrix
3290        let pattern = TilingPattern {
3291            paint_type: PaintType::Colored,
3292            tiling_type: TilingType::ConstantSpacing,
3293            bbox: [0.0, 0.0, 5.0, 5.0],
3294            x_step: 5.0,
3295            y_step: 5.0,
3296            matrix: Matrix::from_scale(2.0, 2.0),
3297            resources_id: None,
3298            stream_id: rpdfium_core::ObjectId::new(1, 0),
3299        };
3300
3301        let tree = DisplayTree {
3302            root: DisplayNode::Group {
3303                blend_mode: BlendMode::Normal,
3304                clip: None,
3305                opacity: 1.0,
3306                isolated: false,
3307                knockout: false,
3308                soft_mask: None,
3309                children: vec![DisplayNode::PatternFill {
3310                    path_ops: vec![
3311                        PathOp::MoveTo { x: 0.0, y: 0.0 },
3312                        PathOp::LineTo { x: 20.0, y: 0.0 },
3313                        PathOp::LineTo { x: 20.0, y: 20.0 },
3314                        PathOp::LineTo { x: 0.0, y: 20.0 },
3315                        PathOp::Close,
3316                    ],
3317                    fill_rule: FillRule::EvenOdd,
3318                    pattern,
3319                    pattern_tree: Box::new(pattern_tree),
3320                    fill_color: None,
3321                    matrix: Matrix::identity(),
3322                }],
3323            },
3324        };
3325        let log = run_mock_renderer(&tree);
3326        // Should still clip and tile
3327        assert!(
3328            log.contains(&"push_clip".to_string()),
3329            "expected push_clip in {:?}",
3330            log
3331        );
3332        assert!(
3333            log.contains(&"pop_clip".to_string()),
3334            "expected pop_clip in {:?}",
3335            log
3336        );
3337    }
3338
3339    // ---- Transfer Function Tests ----
3340
3341    #[test]
3342    fn test_apply_transfer_identity_no_change() {
3343        use rpdfium_page::function::TransferFunction;
3344        let mut color = RgbaColor {
3345            r: 128,
3346            g: 64,
3347            b: 200,
3348            a: 255,
3349        };
3350        apply_transfer(&mut color, &TransferFunction::Identity);
3351        assert_eq!(color.r, 128);
3352        assert_eq!(color.g, 64);
3353        assert_eq!(color.b, 200);
3354        assert_eq!(color.a, 255);
3355    }
3356
3357    #[test]
3358    fn test_apply_transfer_single_function() {
3359        use rpdfium_page::function::{PdfFunction, TransferFunction};
3360        // Invert function: f(x) = 1 - x
3361        let func = PdfFunction::Type2 {
3362            domain: [0.0, 1.0],
3363            range: vec![],
3364            c0: vec![1.0],
3365            c1: vec![0.0],
3366            n: 1.0,
3367        };
3368        let tf = TransferFunction::Single(func);
3369        let mut color = RgbaColor {
3370            r: 0,
3371            g: 255,
3372            b: 128,
3373            a: 200,
3374        };
3375        apply_transfer(&mut color, &tf);
3376        // r=0 -> f(0) = 1.0 -> 255
3377        assert_eq!(color.r, 255);
3378        // g=255 -> f(1) = 0.0 -> 0
3379        assert_eq!(color.g, 0);
3380        // b=128 -> f(~0.502) = ~0.498 -> ~127
3381        assert!((color.b as i32 - 127).abs() <= 1);
3382        // Alpha unchanged
3383        assert_eq!(color.a, 200);
3384    }
3385
3386    #[test]
3387    fn test_apply_transfer_per_component() {
3388        use rpdfium_page::function::{PdfFunction, TransferFunction};
3389        // R: identity; G: always 1.0; B: always 0.0
3390        let r_fn = PdfFunction::Type2 {
3391            domain: [0.0, 1.0],
3392            range: vec![],
3393            c0: vec![0.0],
3394            c1: vec![1.0],
3395            n: 1.0,
3396        };
3397        let g_fn = PdfFunction::Type2 {
3398            domain: [0.0, 1.0],
3399            range: vec![],
3400            c0: vec![1.0],
3401            c1: vec![1.0],
3402            n: 1.0,
3403        };
3404        let b_fn = PdfFunction::Type2 {
3405            domain: [0.0, 1.0],
3406            range: vec![],
3407            c0: vec![0.0],
3408            c1: vec![0.0],
3409            n: 1.0,
3410        };
3411        let k_fn = PdfFunction::Type2 {
3412            domain: [0.0, 1.0],
3413            range: vec![],
3414            c0: vec![0.0],
3415            c1: vec![1.0],
3416            n: 1.0,
3417        };
3418        let tf = TransferFunction::PerComponent {
3419            r: Box::new(r_fn),
3420            g: Box::new(g_fn),
3421            b: Box::new(b_fn),
3422            k: Box::new(k_fn),
3423        };
3424        let mut color = RgbaColor {
3425            r: 128,
3426            g: 128,
3427            b: 128,
3428            a: 255,
3429        };
3430        apply_transfer(&mut color, &tf);
3431        // R: identity at 128/255 ~0.502 -> ~128
3432        assert!((color.r as i32 - 128).abs() <= 1);
3433        // G: always 1.0 -> 255
3434        assert_eq!(color.g, 255);
3435        // B: always 0.0 -> 0
3436        assert_eq!(color.b, 0);
3437        // Alpha unchanged
3438        assert_eq!(color.a, 255);
3439    }
3440
3441    #[test]
3442    fn test_transfer_function_identity_default_graphics_state() {
3443        // GraphicsState defaults to None (no transfer function)
3444        let gs = rpdfium_page::GraphicsState::default();
3445        assert!(gs.transfer_function.is_none());
3446    }
3447
3448    #[test]
3449    fn test_apply_transfer_identity_sampling_256() {
3450        use rpdfium_page::function::TransferFunction;
3451        // All 256 input values should round-trip through identity TF unchanged
3452        for v in 0..=255u8 {
3453            let mut color = RgbaColor {
3454                r: v,
3455                g: v,
3456                b: v,
3457                a: v,
3458            };
3459            apply_transfer(&mut color, &TransferFunction::Identity);
3460            assert_eq!(color.r, v, "r mismatch at {v}");
3461            assert_eq!(color.g, v, "g mismatch at {v}");
3462            assert_eq!(color.b, v, "b mismatch at {v}");
3463            assert_eq!(color.a, v, "a should be unchanged at {v}");
3464        }
3465    }
3466
3467    #[test]
3468    fn test_apply_transfer_per_component_rgb_independent() {
3469        use rpdfium_page::function::{PdfFunction, TransferFunction};
3470        // R: always 0.0; G: identity; B: always 1.0
3471        let r_fn = PdfFunction::Type2 {
3472            domain: [0.0, 1.0],
3473            range: vec![],
3474            c0: vec![0.0],
3475            c1: vec![0.0],
3476            n: 1.0,
3477        };
3478        let g_fn = PdfFunction::Type2 {
3479            domain: [0.0, 1.0],
3480            range: vec![],
3481            c0: vec![0.0],
3482            c1: vec![1.0],
3483            n: 1.0,
3484        };
3485        let b_fn = PdfFunction::Type2 {
3486            domain: [0.0, 1.0],
3487            range: vec![],
3488            c0: vec![1.0],
3489            c1: vec![1.0],
3490            n: 1.0,
3491        };
3492        let k_fn = PdfFunction::Type2 {
3493            domain: [0.0, 1.0],
3494            range: vec![],
3495            c0: vec![0.0],
3496            c1: vec![1.0],
3497            n: 1.0,
3498        };
3499        let tf = TransferFunction::PerComponent {
3500            r: Box::new(r_fn),
3501            g: Box::new(g_fn),
3502            b: Box::new(b_fn),
3503            k: Box::new(k_fn),
3504        };
3505        let mut color = RgbaColor {
3506            r: 200,
3507            g: 100,
3508            b: 50,
3509            a: 180,
3510        };
3511        apply_transfer(&mut color, &tf);
3512        // R: always 0
3513        assert_eq!(color.r, 0);
3514        // G: identity at 100/255 ~0.392 → ~100
3515        assert!((color.g as i32 - 100).abs() <= 1);
3516        // B: always 1.0 → 255
3517        assert_eq!(color.b, 255);
3518        // Alpha unchanged
3519        assert_eq!(color.a, 180);
3520    }
3521
3522    #[test]
3523    fn test_apply_transfer_zero_alpha_preserved() {
3524        use rpdfium_page::function::{PdfFunction, TransferFunction};
3525        // Invert function that would change alpha if it were applied
3526        let func = PdfFunction::Type2 {
3527            domain: [0.0, 1.0],
3528            range: vec![],
3529            c0: vec![1.0],
3530            c1: vec![0.0],
3531            n: 1.0,
3532        };
3533        let tf = TransferFunction::Single(func);
3534        let mut color = RgbaColor {
3535            r: 128,
3536            g: 128,
3537            b: 128,
3538            a: 0,
3539        };
3540        apply_transfer(&mut color, &tf);
3541        // Alpha must remain 0 — TF does NOT modify alpha
3542        assert_eq!(color.a, 0);
3543        // RGB inverted: 128/255 ~0.502 → 1-0.502=0.498 → ~127
3544        assert!((color.r as i32 - 127).abs() <= 1);
3545    }
3546
3547    #[test]
3548    fn test_apply_transfer_boundary_values() {
3549        use rpdfium_page::function::{PdfFunction, TransferFunction};
3550        // Identity-like function: f(x) = x
3551        let func = PdfFunction::Type2 {
3552            domain: [0.0, 1.0],
3553            range: vec![],
3554            c0: vec![0.0],
3555            c1: vec![1.0],
3556            n: 1.0,
3557        };
3558        let tf = TransferFunction::Single(func);
3559        let mut color = RgbaColor {
3560            r: 0,
3561            g: 255,
3562            b: 1,
3563            a: 42,
3564        };
3565        apply_transfer(&mut color, &tf);
3566        // r=0 → f(0)=0 → 0: no underflow
3567        assert_eq!(color.r, 0);
3568        // g=255 → f(1)=1 → 255: no overflow
3569        assert_eq!(color.g, 255);
3570        // b=1 → f(1/255) ≈ 0.00392 → 0.00392*255 ≈ 1.0 → 1
3571        assert!((color.b as i32 - 1).abs() <= 1);
3572        // Alpha unchanged
3573        assert_eq!(color.a, 42);
3574    }
3575
3576    // -----------------------------------------------------------------------
3577    // Rasterized glyph cache integration tests
3578    // -----------------------------------------------------------------------
3579
3580    #[test]
3581    fn test_renderer_has_raster_cache() {
3582        let mut backend = MockBackend::new();
3583        let mut surface = backend.create_surface(100, 100, &RgbaColor::WHITE);
3584        let renderer = DisplayRenderer::new(&mut backend, &mut surface, Matrix::identity(), None);
3585        // The raster cache should be initialized and empty
3586        assert!(renderer.raster_cache.is_empty());
3587    }
3588
3589    #[test]
3590    fn test_rasterize_glyph_alpha_simple_rect() {
3591        // A simple rectangle glyph outline
3592        let ops = vec![
3593            PathOp::MoveTo { x: 0.0, y: 0.0 },
3594            PathOp::LineTo { x: 100.0, y: 0.0 },
3595            PathOp::LineTo { x: 100.0, y: 100.0 },
3596            PathOp::LineTo { x: 0.0, y: 100.0 },
3597            PathOp::Close,
3598        ];
3599        let result = super::rasterize_glyph_alpha(&ops, 0.1, 10, 256);
3600        assert!(result.is_some());
3601        let glyph = result.unwrap();
3602        assert!(glyph.width > 0);
3603        assert!(glyph.height > 0);
3604        assert_eq!(glyph.alpha.len(), (glyph.width * glyph.height) as usize);
3605        // Some pixels should be non-zero (the fill)
3606        assert!(glyph.alpha.iter().any(|&a| a > 0));
3607    }
3608
3609    #[test]
3610    fn test_rasterize_glyph_alpha_empty_ops() {
3611        let result = super::rasterize_glyph_alpha(&[], 1.0, 12, 256);
3612        assert!(result.is_none());
3613    }
3614
3615    #[test]
3616    fn test_rasterize_glyph_alpha_zero_dim() {
3617        let ops = vec![
3618            PathOp::MoveTo { x: 0.0, y: 0.0 },
3619            PathOp::LineTo { x: 10.0, y: 10.0 },
3620        ];
3621        let result = super::rasterize_glyph_alpha(&ops, 1.0, 12, 0);
3622        assert!(result.is_none());
3623    }
3624
3625    // -----------------------------------------------------------------------
3626    // ExplicitMask with a real decoder
3627    // -----------------------------------------------------------------------
3628
3629    /// A mock ImageDecoder that returns a fixed grayscale mask image.
3630    struct MockImageDecoder {
3631        /// The mask image returned when any object is decoded.
3632        mask_data: Vec<u8>,
3633        mask_width: u32,
3634        mask_height: u32,
3635    }
3636
3637    impl crate::image::ImageDecoder for MockImageDecoder {
3638        fn decode_image(
3639            &self,
3640            _image_ref: &rpdfium_graphics::ImageRef,
3641            _matrix: &Matrix,
3642        ) -> Result<crate::image::DecodedImage, crate::error::RenderError> {
3643            Ok(crate::image::DecodedImage {
3644                width: self.mask_width,
3645                height: self.mask_height,
3646                data: self.mask_data.clone(),
3647                format: crate::image::DecodedImageFormat::Gray8,
3648            })
3649        }
3650
3651        fn decode_inline_image(
3652            &self,
3653            _properties: &std::collections::HashMap<rpdfium_core::Name, rpdfium_parser::Operand>,
3654            _data: &[u8],
3655            _matrix: &Matrix,
3656        ) -> Result<crate::image::DecodedImage, crate::error::RenderError> {
3657            Err(crate::error::RenderError::ImageDecode(
3658                "not supported".to_string(),
3659            ))
3660        }
3661    }
3662
3663    #[test]
3664    fn test_apply_explicit_mask_with_decoder_applies_alpha() {
3665        use crate::image::{DecodedImage, DecodedImageFormat};
3666        use rpdfium_page::display::ImageMask;
3667
3668        // 2x2 RGB image (all white)
3669        let img = DecodedImage {
3670            width: 2,
3671            height: 2,
3672            data: vec![
3673                255, 255, 255, // pixel (0,0)
3674                255, 255, 255, // pixel (1,0)
3675                255, 255, 255, // pixel (0,1)
3676                255, 255, 255, // pixel (1,1)
3677            ],
3678            format: DecodedImageFormat::Rgb24,
3679        };
3680
3681        // Mask: pixel (0,0)=0 (transparent), (1,0)=128 (semi), (0,1)=255 (opaque), (1,1)=0
3682        let decoder = MockImageDecoder {
3683            mask_data: vec![0, 128, 255, 0],
3684            mask_width: 2,
3685            mask_height: 2,
3686        };
3687
3688        let mask = ImageMask::ExplicitMask {
3689            mask_object_id: rpdfium_core::ObjectId::new(99, 0),
3690        };
3691
3692        let result = apply_image_mask(img, Some(&mask), None, Some(&decoder));
3693
3694        assert_eq!(result.format, DecodedImageFormat::Rgba32);
3695        assert_eq!(result.width, 2);
3696        assert_eq!(result.height, 2);
3697        // 4 pixels * 4 bytes = 16 bytes
3698        assert_eq!(result.data.len(), 16);
3699
3700        // pixel (0,0): alpha = 0 (mask=0 → transparent)
3701        assert_eq!(result.data[3], 0);
3702        // pixel (1,0): alpha = 128 (mask=128 → semi-transparent)
3703        assert_eq!(result.data[7], 128);
3704        // pixel (0,1): alpha = 255 (mask=255 → fully opaque)
3705        assert_eq!(result.data[11], 255);
3706        // pixel (1,1): alpha = 0 (mask=0 → transparent)
3707        assert_eq!(result.data[15], 0);
3708    }
3709
3710    #[test]
3711    fn test_apply_explicit_mask_with_decoder_scales_to_image_size() {
3712        use crate::image::{DecodedImage, DecodedImageFormat};
3713        use rpdfium_page::display::ImageMask;
3714
3715        // 4x4 gray image
3716        let img = DecodedImage {
3717            width: 4,
3718            height: 4,
3719            data: vec![200u8; 16],
3720            format: DecodedImageFormat::Gray8,
3721        };
3722
3723        // 2x2 mask (will be scaled to 4x4 via nearest-neighbor)
3724        // Top-left=255, top-right=0, bottom-left=0, bottom-right=255
3725        let decoder = MockImageDecoder {
3726            mask_data: vec![255, 0, 0, 255],
3727            mask_width: 2,
3728            mask_height: 2,
3729        };
3730
3731        let mask = ImageMask::ExplicitMask {
3732            mask_object_id: rpdfium_core::ObjectId::new(100, 0),
3733        };
3734
3735        let result = apply_image_mask(img, Some(&mask), None, Some(&decoder));
3736
3737        assert_eq!(result.format, DecodedImageFormat::Rgba32);
3738        // 4x4 pixels, each 4 bytes
3739        assert_eq!(result.data.len(), 64);
3740
3741        // Top-left quadrant (pixels 0,0 and 1,0 on row 0) should have mask=255
3742        assert_eq!(result.data[3], 255); // pixel (0,0)
3743        assert_eq!(result.data[7], 255); // pixel (1,0)
3744        // Top-right quadrant (pixels 2,0 and 3,0 on row 0) should have mask=0
3745        assert_eq!(result.data[11], 0); // pixel (2,0)
3746        assert_eq!(result.data[15], 0); // pixel (3,0)
3747    }
3748
3749    // -----------------------------------------------------------------------
3750    // SMask backdrop color tests
3751    // -----------------------------------------------------------------------
3752
3753    #[test]
3754    fn test_smask_backdrop_color_converts_to_rgba() {
3755        use rpdfium_page::display::{SoftMask, SoftMaskSubtype};
3756
3757        // Test the backdrop color conversion logic directly.
3758        // With backdrop [1.0, 1.0, 1.0] (white), the surface should start white.
3759        let sm = SoftMask {
3760            subtype: SoftMaskSubtype::Luminosity,
3761            group: DisplayTree {
3762                root: DisplayNode::Group {
3763                    blend_mode: BlendMode::Normal,
3764                    clip: None,
3765                    opacity: 1.0,
3766                    isolated: true,
3767                    knockout: false,
3768                    soft_mask: None,
3769                    children: vec![],
3770                },
3771            },
3772            backdrop_color: Some(vec![1.0, 1.0, 1.0]),
3773            transfer_function: None,
3774        };
3775
3776        // Use MockBackend which returns zeroed pixels (100x100).
3777        // With a white backdrop for luminosity, all pixels should report
3778        // full luminosity (white backdrop → lum ≈ 255).
3779        let mut backend = MockBackend::new();
3780        let surface = backend.create_surface(100, 100, &RgbaColor::WHITE);
3781        let result = render_soft_mask_alpha(&mut backend, &surface, &sm, Matrix::identity(), None);
3782
3783        // render_soft_mask_alpha should succeed
3784        assert!(result.is_some());
3785        let (alpha, w, h) = result.unwrap();
3786        assert_eq!(w, 100);
3787        assert_eq!(h, 100);
3788        assert_eq!(alpha.len(), 10_000);
3789        // MockBackend.surface_pixels returns all zeros, so with luminosity
3790        // on a zero pixel surface, alpha should all be 0.
3791        // The point of the test is to verify the code runs without error
3792        // when backdrop_color is Some.
3793    }
3794
3795    #[test]
3796    fn test_smask_no_backdrop_uses_transparent() {
3797        use rpdfium_page::display::{SoftMask, SoftMaskSubtype};
3798
3799        let sm = SoftMask {
3800            subtype: SoftMaskSubtype::Alpha,
3801            group: DisplayTree {
3802                root: DisplayNode::Group {
3803                    blend_mode: BlendMode::Normal,
3804                    clip: None,
3805                    opacity: 1.0,
3806                    isolated: true,
3807                    knockout: false,
3808                    soft_mask: None,
3809                    children: vec![],
3810                },
3811            },
3812            backdrop_color: None,
3813            transfer_function: None,
3814        };
3815
3816        let mut backend = MockBackend::new();
3817        let surface = backend.create_surface(100, 100, &RgbaColor::WHITE);
3818        let result = render_soft_mask_alpha(&mut backend, &surface, &sm, Matrix::identity(), None);
3819
3820        assert!(result.is_some());
3821        let (alpha, w, h) = result.unwrap();
3822        assert_eq!(w, 100);
3823        assert_eq!(h, 100);
3824        // All alpha should be 0 since surface_pixels returns zeros
3825        assert!(alpha.iter().all(|&a| a == 0));
3826    }
3827
3828    #[test]
3829    fn test_smask_backdrop_single_component_grayscale() {
3830        use rpdfium_page::display::{SoftMask, SoftMaskSubtype};
3831
3832        // Single-component backdrop [0.5] → should replicate to R=G=B
3833        let sm = SoftMask {
3834            subtype: SoftMaskSubtype::Alpha,
3835            group: DisplayTree {
3836                root: DisplayNode::Group {
3837                    blend_mode: BlendMode::Normal,
3838                    clip: None,
3839                    opacity: 1.0,
3840                    isolated: true,
3841                    knockout: false,
3842                    soft_mask: None,
3843                    children: vec![],
3844                },
3845            },
3846            backdrop_color: Some(vec![0.5]),
3847            transfer_function: None,
3848        };
3849
3850        let mut backend = MockBackend::new();
3851        let surface = backend.create_surface(10, 10, &RgbaColor::WHITE);
3852        let result = render_soft_mask_alpha(&mut backend, &surface, &sm, Matrix::identity(), None);
3853
3854        assert!(result.is_some());
3855    }
3856}