Skip to main content

justpdf_render/
display_list.rs

1use tiny_skia::{
2    BlendMode, Color, FillRule, Mask, Paint, Path, Pixmap, PixmapPaint, Stroke, Transform,
3};
4
5/// A single recorded rendering command.
6#[derive(Debug, Clone)]
7pub enum DisplayCommand {
8    /// Fill a path.
9    FillPath {
10        path: Path,
11        fill_rule: FillRule,
12        transform: Transform,
13        color: Color,
14        alpha: f32,
15        blend_mode: BlendMode,
16    },
17    /// Stroke a path.
18    StrokePath {
19        path: Path,
20        stroke: Stroke,
21        transform: Transform,
22        color: Color,
23        alpha: f32,
24        blend_mode: BlendMode,
25    },
26    /// Draw an image.
27    DrawImage {
28        pixmap: Pixmap,
29        transform: Transform,
30        alpha: f32,
31        blend_mode: BlendMode,
32    },
33    /// Push a clip path.
34    PushClip {
35        path: Path,
36        fill_rule: FillRule,
37        transform: Transform,
38    },
39    /// Pop the last clip path.
40    PopClip,
41    /// Save graphics state.
42    Save,
43    /// Restore graphics state.
44    Restore,
45    /// Begin a transparency group.
46    BeginGroup {
47        opacity: f32,
48        blend_mode: BlendMode,
49        isolated: bool,
50    },
51    /// End a transparency group.
52    EndGroup,
53}
54
55/// A recorded list of rendering commands that can be replayed onto a pixmap.
56///
57/// The display list captures rendering operations so they can be executed later,
58/// potentially multiple times or at different scales. This is useful for caching
59/// page rendering results, implementing tiled rendering, and print preview.
60#[derive(Debug, Clone)]
61pub struct DisplayList {
62    commands: Vec<DisplayCommand>,
63    width: u32,
64    height: u32,
65}
66
67impl DisplayList {
68    /// Create a new empty display list with the given target dimensions.
69    pub fn new(width: u32, height: u32) -> Self {
70        Self {
71            commands: Vec::new(),
72            width,
73            height,
74        }
75    }
76
77    /// Record a command.
78    pub fn push(&mut self, cmd: DisplayCommand) {
79        self.commands.push(cmd);
80    }
81
82    /// Number of recorded commands.
83    pub fn len(&self) -> usize {
84        self.commands.len()
85    }
86
87    /// Returns true if no commands have been recorded.
88    pub fn is_empty(&self) -> bool {
89        self.commands.is_empty()
90    }
91
92    /// Get all commands.
93    pub fn commands(&self) -> &[DisplayCommand] {
94        &self.commands
95    }
96
97    /// Replay all commands onto a pixmap using the identity transform.
98    pub fn replay(&self, pixmap: &mut Pixmap) {
99        self.replay_with_transform(pixmap, Transform::identity());
100    }
101
102    /// Replay all commands onto a pixmap with an extra transform applied
103    /// to every drawing operation. This is useful for rendering at different
104    /// scales or offsets without re-recording the display list.
105    pub fn replay_with_transform(&self, pixmap: &mut Pixmap, extra_transform: Transform) {
106        let mut clip_stack: Vec<Mask> = Vec::new();
107        let mut current_mask: Option<Mask> = None;
108        let mut group_stack: Vec<GroupState> = Vec::new();
109
110        for cmd in &self.commands {
111            match cmd {
112                DisplayCommand::FillPath {
113                    path,
114                    fill_rule,
115                    transform,
116                    color,
117                    alpha,
118                    blend_mode,
119                } => {
120                    let target = group_target(&mut group_stack, pixmap);
121                    let combined = extra_transform.post_concat(*transform);
122                    let mut paint = Paint::default();
123                    let c = apply_alpha(*color, *alpha);
124                    paint.set_color(c);
125                    paint.anti_alias = true;
126                    paint.blend_mode = *blend_mode;
127                    let mask_ref = current_mask.as_ref();
128                    target.fill_path(path, &paint, *fill_rule, combined, mask_ref);
129                }
130
131                DisplayCommand::StrokePath {
132                    path,
133                    stroke,
134                    transform,
135                    color,
136                    alpha,
137                    blend_mode,
138                } => {
139                    let target = group_target(&mut group_stack, pixmap);
140                    let combined = extra_transform.post_concat(*transform);
141                    let mut paint = Paint::default();
142                    let c = apply_alpha(*color, *alpha);
143                    paint.set_color(c);
144                    paint.anti_alias = true;
145                    paint.blend_mode = *blend_mode;
146                    let mask_ref = current_mask.as_ref();
147                    target.stroke_path(path, &paint, stroke, combined, mask_ref);
148                }
149
150                DisplayCommand::DrawImage {
151                    pixmap: img,
152                    transform,
153                    alpha,
154                    blend_mode,
155                } => {
156                    let target = group_target(&mut group_stack, pixmap);
157                    let combined = extra_transform.post_concat(*transform);
158                    let mut ppaint = PixmapPaint::default();
159                    ppaint.opacity = *alpha;
160                    ppaint.blend_mode = *blend_mode;
161                    ppaint.quality = tiny_skia::FilterQuality::Bilinear;
162                    let mask_ref = current_mask.as_ref();
163                    target.draw_pixmap(0, 0, img.as_ref(), &ppaint, combined, mask_ref);
164                }
165
166                DisplayCommand::PushClip {
167                    path,
168                    fill_rule,
169                    transform,
170                } => {
171                    // Save the current mask before pushing.
172                    if let Some(m) = current_mask.take() {
173                        clip_stack.push(m);
174                    }
175                    let target = group_target(&mut group_stack, pixmap);
176                    let w = target.width();
177                    let h = target.height();
178                    let combined = extra_transform.post_concat(*transform);
179                    if let Some(mut mask) = Mask::new(w, h) {
180                        mask.fill_path(path, *fill_rule, true, combined);
181                        // Intersect with the previous mask if there was one.
182                        if let Some(prev) = clip_stack.last() {
183                            intersect_masks(&mut mask, prev);
184                        }
185                        current_mask = Some(mask);
186                    }
187                }
188
189                DisplayCommand::PopClip => {
190                    current_mask = clip_stack.pop();
191                }
192
193                DisplayCommand::Save => {
194                    // Save is a no-op for replay since we track clips explicitly.
195                }
196
197                DisplayCommand::Restore => {
198                    // Restore is a no-op for replay since we track clips explicitly.
199                }
200
201                DisplayCommand::BeginGroup {
202                    opacity,
203                    blend_mode,
204                    isolated: _,
205                } => {
206                    let target = group_target(&mut group_stack, pixmap);
207                    let w = target.width();
208                    let h = target.height();
209                    if let Some(group_pixmap) = Pixmap::new(w, h) {
210                        group_stack.push(GroupState {
211                            pixmap: group_pixmap,
212                            opacity: *opacity,
213                            blend_mode: *blend_mode,
214                        });
215                    }
216                }
217
218                DisplayCommand::EndGroup => {
219                    if let Some(group) = group_stack.pop() {
220                        let target = group_target(&mut group_stack, pixmap);
221                        let mut ppaint = PixmapPaint::default();
222                        ppaint.opacity = group.opacity;
223                        ppaint.blend_mode = group.blend_mode;
224                        let mask_ref = current_mask.as_ref();
225                        target.draw_pixmap(
226                            0,
227                            0,
228                            group.pixmap.as_ref(),
229                            &ppaint,
230                            Transform::identity(),
231                            mask_ref,
232                        );
233                    }
234                }
235            }
236        }
237    }
238
239    /// Compute the axis-aligned bounding box that encloses all paths in the
240    /// display list. Returns `None` if no paths have been recorded.
241    pub fn bounds(&self) -> Option<tiny_skia::Rect> {
242        let mut result: Option<tiny_skia::Rect> = None;
243
244        for cmd in &self.commands {
245            let path_bounds = match cmd {
246                DisplayCommand::FillPath { path, .. }
247                | DisplayCommand::StrokePath { path, .. }
248                | DisplayCommand::PushClip { path, .. } => Some(path.bounds()),
249                _ => None,
250            };
251
252            if let Some(b) = path_bounds {
253                result = Some(match result {
254                    Some(r) => union_rect(r, b),
255                    None => b,
256                });
257            }
258        }
259
260        result
261    }
262
263    /// Remove redundant command sequences that have no visual effect.
264    ///
265    /// Currently optimizes:
266    /// - Consecutive `Save` / `Restore` pairs with nothing between them.
267    /// - Consecutive `BeginGroup` / `EndGroup` pairs with nothing between them.
268    pub fn optimize(&mut self) {
269        loop {
270            let before = self.commands.len();
271            let mut optimized = Vec::with_capacity(self.commands.len());
272            let mut i = 0;
273            while i < self.commands.len() {
274                if i + 1 < self.commands.len() {
275                    let is_noop_pair = matches!(
276                        (&self.commands[i], &self.commands[i + 1]),
277                        (DisplayCommand::Save, DisplayCommand::Restore)
278                            | (DisplayCommand::BeginGroup { .. }, DisplayCommand::EndGroup)
279                            | (DisplayCommand::PushClip { .. }, DisplayCommand::PopClip)
280                    );
281                    if is_noop_pair {
282                        i += 2;
283                        continue;
284                    }
285                }
286                optimized.push(self.commands[i].clone());
287                i += 1;
288            }
289            self.commands = optimized;
290            // Repeat until stable (nested removals may expose new pairs).
291            if self.commands.len() == before {
292                break;
293            }
294        }
295    }
296
297    /// Width of the target surface in pixels.
298    pub fn width(&self) -> u32 {
299        self.width
300    }
301
302    /// Height of the target surface in pixels.
303    pub fn height(&self) -> u32 {
304        self.height
305    }
306
307    /// Render a rectangular tile of the display list.
308    ///
309    /// The tile is specified by its top-left corner (`tile_left`, `tile_top`)
310    /// in the display list's coordinate system, and its dimensions in pixels.
311    /// The display list is replayed with a translation so that the tile region
312    /// maps to (0, 0) on the output pixmap.
313    ///
314    /// Returns `None` if the pixmap cannot be created (e.g. zero dimensions).
315    pub fn render_tile(
316        &self,
317        tile_left: f32,
318        tile_top: f32,
319        tile_width: u32,
320        tile_height: u32,
321    ) -> Option<Pixmap> {
322        let mut pixmap = Pixmap::new(tile_width, tile_height)?;
323        pixmap.fill(Color::TRANSPARENT);
324        let transform = Transform::from_translate(-tile_left, -tile_top);
325        self.replay_with_transform(&mut pixmap, transform);
326        Some(pixmap)
327    }
328
329    /// Render the display list as a grid of tiles and composite them into
330    /// a single output pixmap. This can reduce peak memory usage compared
331    /// to rendering the full page at once when combined with command culling.
332    ///
333    /// Returns `None` if the output pixmap cannot be created.
334    pub fn render_tiled(&self, tile_size: u32, background: Color) -> Option<Pixmap> {
335        let mut output = Pixmap::new(self.width, self.height)?;
336        output.fill(background);
337
338        let cols = (self.width + tile_size - 1) / tile_size;
339        let rows = (self.height + tile_size - 1) / tile_size;
340
341        for row in 0..rows {
342            for col in 0..cols {
343                let tx = (col * tile_size) as f32;
344                let ty = (row * tile_size) as f32;
345                let tw = tile_size.min(self.width - col * tile_size);
346                let th = tile_size.min(self.height - row * tile_size);
347
348                if let Some(tile) = self.render_tile(tx, ty, tw, th) {
349                    let paint = PixmapPaint::default();
350                    output.draw_pixmap(
351                        tx as i32,
352                        ty as i32,
353                        tile.as_ref(),
354                        &paint,
355                        Transform::identity(),
356                        None,
357                    );
358                }
359            }
360        }
361
362        Some(output)
363    }
364}
365
366// ---------------------------------------------------------------------------
367// Internal helpers
368// ---------------------------------------------------------------------------
369
370/// Tracks state for a transparency group during replay.
371struct GroupState {
372    pixmap: Pixmap,
373    opacity: f32,
374    blend_mode: BlendMode,
375}
376
377/// Returns a mutable reference to the top-most group pixmap, or to the root
378/// pixmap when there is no active group.
379fn group_target<'a>(stack: &'a mut Vec<GroupState>, root: &'a mut Pixmap) -> &'a mut Pixmap {
380    if let Some(top) = stack.last_mut() {
381        &mut top.pixmap
382    } else {
383        root
384    }
385}
386
387/// Apply an alpha multiplier to a color's existing alpha channel.
388fn apply_alpha(color: Color, alpha: f32) -> Color {
389    Color::from_rgba(color.red(), color.green(), color.blue(), color.alpha() * alpha)
390        .unwrap_or(color)
391}
392
393/// Compute the union of two axis-aligned rectangles.
394fn union_rect(a: tiny_skia::Rect, b: tiny_skia::Rect) -> tiny_skia::Rect {
395    let l = a.left().min(b.left());
396    let t = a.top().min(b.top());
397    let r = a.right().max(b.right());
398    let bot = a.bottom().max(b.bottom());
399    tiny_skia::Rect::from_ltrb(l, t, r, bot).unwrap_or(a)
400}
401
402/// Intersect mask `dst` with `src` by AND-ing their alpha values.
403fn intersect_masks(dst: &mut Mask, src: &Mask) {
404    let dst_data = dst.data_mut();
405    let src_data = src.data();
406    let len = dst_data.len().min(src_data.len());
407    for i in 0..len {
408        dst_data[i] = ((dst_data[i] as u16 * src_data[i] as u16) / 255) as u8;
409    }
410}
411
412// ---------------------------------------------------------------------------
413// Tests
414// ---------------------------------------------------------------------------
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use tiny_skia::{PathBuilder, Stroke};
420
421    /// Helper: create a simple rectangular path.
422    fn rect_path(x: f32, y: f32, w: f32, h: f32) -> Path {
423        let mut pb = PathBuilder::new();
424        pb.move_to(x, y);
425        pb.line_to(x + w, y);
426        pb.line_to(x + w, y + h);
427        pb.line_to(x, y + h);
428        pb.close();
429        pb.finish().unwrap()
430    }
431
432    #[test]
433    fn test_empty_display_list() {
434        let dl = DisplayList::new(100, 100);
435        assert!(dl.is_empty());
436        assert_eq!(dl.len(), 0);
437        assert_eq!(dl.width(), 100);
438        assert_eq!(dl.height(), 100);
439        assert!(dl.bounds().is_none());
440    }
441
442    #[test]
443    fn test_record_and_replay_fill() {
444        let mut dl = DisplayList::new(100, 100);
445        dl.push(DisplayCommand::FillPath {
446            path: rect_path(10.0, 10.0, 50.0, 50.0),
447            fill_rule: FillRule::Winding,
448            transform: Transform::identity(),
449            color: Color::from_rgba8(255, 0, 0, 255),
450            alpha: 1.0,
451            blend_mode: BlendMode::SourceOver,
452        });
453        assert_eq!(dl.len(), 1);
454
455        let mut pixmap = Pixmap::new(100, 100).unwrap();
456        pixmap.fill(Color::from_rgba8(255, 255, 255, 255));
457        dl.replay(&mut pixmap);
458
459        // The centre of the filled rectangle should be red.
460        let pixel = pixmap.pixel(35, 35).unwrap();
461        assert_eq!(pixel.red(), 255);
462        assert_eq!(pixel.green(), 0);
463        assert_eq!(pixel.blue(), 0);
464    }
465
466    #[test]
467    fn test_record_and_replay_stroke() {
468        let mut dl = DisplayList::new(100, 100);
469        let mut stroke = Stroke::default();
470        stroke.width = 4.0;
471
472        dl.push(DisplayCommand::StrokePath {
473            path: rect_path(10.0, 10.0, 80.0, 80.0),
474            stroke,
475            transform: Transform::identity(),
476            color: Color::from_rgba8(0, 0, 255, 255),
477            alpha: 1.0,
478            blend_mode: BlendMode::SourceOver,
479        });
480        assert_eq!(dl.len(), 1);
481
482        let mut pixmap = Pixmap::new(100, 100).unwrap();
483        pixmap.fill(Color::from_rgba8(255, 255, 255, 255));
484        dl.replay(&mut pixmap);
485
486        // A pixel on the top edge of the stroke (y=10) should be blue.
487        let pixel = pixmap.pixel(50, 10).unwrap();
488        assert_eq!(pixel.blue(), 255);
489    }
490
491    #[test]
492    fn test_display_list_length() {
493        let mut dl = DisplayList::new(10, 10);
494        assert_eq!(dl.len(), 0);
495
496        dl.push(DisplayCommand::Save);
497        assert_eq!(dl.len(), 1);
498
499        dl.push(DisplayCommand::FillPath {
500            path: rect_path(0.0, 0.0, 5.0, 5.0),
501            fill_rule: FillRule::Winding,
502            transform: Transform::identity(),
503            color: Color::BLACK,
504            alpha: 1.0,
505            blend_mode: BlendMode::SourceOver,
506        });
507        assert_eq!(dl.len(), 2);
508
509        dl.push(DisplayCommand::Restore);
510        assert_eq!(dl.len(), 3);
511    }
512
513    #[test]
514    fn test_optimize_removes_redundant_save_restore() {
515        let mut dl = DisplayList::new(10, 10);
516        dl.push(DisplayCommand::Save);
517        dl.push(DisplayCommand::Restore);
518        dl.push(DisplayCommand::FillPath {
519            path: rect_path(0.0, 0.0, 5.0, 5.0),
520            fill_rule: FillRule::Winding,
521            transform: Transform::identity(),
522            color: Color::BLACK,
523            alpha: 1.0,
524            blend_mode: BlendMode::SourceOver,
525        });
526        dl.push(DisplayCommand::Save);
527        dl.push(DisplayCommand::Restore);
528
529        assert_eq!(dl.len(), 5);
530        dl.optimize();
531        // Both empty Save/Restore pairs should be removed.
532        assert_eq!(dl.len(), 1);
533        assert!(matches!(dl.commands()[0], DisplayCommand::FillPath { .. }));
534    }
535
536    #[test]
537    fn test_optimize_nested_noop() {
538        // Save, Save, Restore, Restore should reduce to nothing.
539        let mut dl = DisplayList::new(10, 10);
540        dl.push(DisplayCommand::Save);
541        dl.push(DisplayCommand::Save);
542        dl.push(DisplayCommand::Restore);
543        dl.push(DisplayCommand::Restore);
544
545        dl.optimize();
546        assert!(dl.is_empty());
547    }
548
549    #[test]
550    fn test_replay_with_transform_scales() {
551        let mut dl = DisplayList::new(200, 200);
552        dl.push(DisplayCommand::FillPath {
553            path: rect_path(0.0, 0.0, 50.0, 50.0),
554            fill_rule: FillRule::Winding,
555            transform: Transform::identity(),
556            color: Color::from_rgba8(0, 255, 0, 255),
557            alpha: 1.0,
558            blend_mode: BlendMode::SourceOver,
559        });
560
561        let mut pixmap = Pixmap::new(200, 200).unwrap();
562        pixmap.fill(Color::from_rgba8(0, 0, 0, 255));
563
564        // Replay at 2x scale: the 50x50 rect becomes 100x100.
565        let scale = Transform::from_scale(2.0, 2.0);
566        dl.replay_with_transform(&mut pixmap, scale);
567
568        // Pixel at (75, 75) should be green (inside the scaled 100x100 rect).
569        let inside = pixmap.pixel(75, 75).unwrap();
570        assert_eq!(inside.green(), 255);
571
572        // Pixel at (150, 150) should still be black (outside the scaled rect).
573        let outside = pixmap.pixel(150, 150).unwrap();
574        assert_eq!(outside.red(), 0);
575        assert_eq!(outside.green(), 0);
576        assert_eq!(outside.blue(), 0);
577    }
578
579    #[test]
580    fn test_render_tile_basic() {
581        let mut dl = DisplayList::new(100, 100);
582        dl.push(DisplayCommand::FillPath {
583            path: rect_path(0.0, 0.0, 100.0, 100.0),
584            fill_rule: FillRule::Winding,
585            transform: Transform::identity(),
586            color: Color::from_rgba8(255, 0, 0, 255),
587            alpha: 1.0,
588            blend_mode: BlendMode::SourceOver,
589        });
590
591        // Render a tile covering the top-left 50x50 region.
592        let tile = dl.render_tile(0.0, 0.0, 50, 50).unwrap();
593        assert_eq!(tile.width(), 50);
594        assert_eq!(tile.height(), 50);
595        // Should be red everywhere in the tile.
596        let pixel = tile.pixel(25, 25).unwrap();
597        assert_eq!(pixel.red(), 255);
598        assert_eq!(pixel.green(), 0);
599    }
600
601    #[test]
602    fn test_render_tiled_matches_full() {
603        let mut dl = DisplayList::new(100, 100);
604        dl.push(DisplayCommand::FillPath {
605            path: rect_path(10.0, 10.0, 80.0, 80.0),
606            fill_rule: FillRule::Winding,
607            transform: Transform::identity(),
608            color: Color::from_rgba8(0, 128, 255, 255),
609            alpha: 1.0,
610            blend_mode: BlendMode::SourceOver,
611        });
612
613        // Full render
614        let mut full = Pixmap::new(100, 100).unwrap();
615        full.fill(Color::WHITE);
616        dl.replay(&mut full);
617
618        // Tiled render
619        let tiled = dl.render_tiled(32, Color::WHITE).unwrap();
620
621        // Compare a few sample pixels.
622        for &(x, y) in &[(50, 50), (5, 5), (95, 95), (10, 10)] {
623            let fp = full.pixel(x, y).unwrap();
624            let tp = tiled.pixel(x, y).unwrap();
625            assert_eq!(
626                (fp.red(), fp.green(), fp.blue()),
627                (tp.red(), tp.green(), tp.blue()),
628                "mismatch at ({x}, {y})"
629            );
630        }
631    }
632
633    #[test]
634    fn test_bounds() {
635        let mut dl = DisplayList::new(100, 100);
636        dl.push(DisplayCommand::FillPath {
637            path: rect_path(10.0, 20.0, 30.0, 40.0),
638            fill_rule: FillRule::Winding,
639            transform: Transform::identity(),
640            color: Color::BLACK,
641            alpha: 1.0,
642            blend_mode: BlendMode::SourceOver,
643        });
644        dl.push(DisplayCommand::StrokePath {
645            path: rect_path(50.0, 60.0, 10.0, 5.0),
646            stroke: Stroke::default(),
647            transform: Transform::identity(),
648            color: Color::BLACK,
649            alpha: 1.0,
650            blend_mode: BlendMode::SourceOver,
651        });
652
653        let b = dl.bounds().unwrap();
654        assert!((b.left() - 10.0).abs() < 0.01);
655        assert!((b.top() - 20.0).abs() < 0.01);
656        assert!((b.right() - 60.0).abs() < 0.01);
657        assert!((b.bottom() - 65.0).abs() < 0.01);
658    }
659}