tytanic_core/doc/
render.rs

1//! Document pixel buffer rendering and diffing.
2
3use std::cmp::Ordering;
4
5use tiny_skia::BlendMode;
6use tiny_skia::FilterQuality;
7use tiny_skia::Pixmap;
8use tiny_skia::PixmapPaint;
9use tiny_skia::Transform;
10
11/// The origin of a documents page, this is used for comparisons of pages with
12/// different dimensions.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
14pub enum Origin {
15    /// The origin of pages on the top left corner, this is the default and used
16    /// in left-to-right read documents.
17    #[default]
18    TopLeft,
19
20    /// The origin of pages on the top right corner, this is used in
21    /// left-to-right read documents.
22    TopRight,
23
24    /// The origin of pages on the bottom left corner, this is included for
25    /// completeness.
26    BottomLeft,
27
28    /// The origin of pages on the bottom right corner, this is included for
29    /// completeness.
30    BottomRight,
31}
32
33impl Origin {
34    /// Whether this origin is at the left.
35    pub fn is_left(&self) -> bool {
36        matches!(self, Self::TopLeft | Self::BottomLeft)
37    }
38
39    /// Whether this origin is at the right.
40    pub fn is_right(&self) -> bool {
41        matches!(self, Self::TopRight | Self::BottomRight)
42    }
43
44    /// Whether this origin is at the top.
45    pub fn is_top(&self) -> bool {
46        matches!(self, Self::TopLeft | Self::TopRight)
47    }
48
49    /// Whether this origin is at the bottom.
50    pub fn is_bottom(&self) -> bool {
51        matches!(self, Self::BottomLeft | Self::BottomRight)
52    }
53}
54
55/// The factor used to convert pixel per pt to pixel per inch.
56pub const PPP_TO_PPI_FACTOR: f32 = 72.0;
57
58// NOTE(tinger): This doesn't seem to be quite exactly 2, so we use this to
59// ensure we get the same default value as typst-cli, this avoids spurious
60// failures when people migrate between the old and new version.
61
62/// The default pixel per pt value used for rendering pages to pixel buffers.
63pub const DEFAULT_PIXEL_PER_PT: f32 = 144.0 / PPP_TO_PPI_FACTOR;
64
65/// Converts a pixel-per-pt ratio to a pixel-per-inch ratio.
66pub fn ppp_to_ppi(pixel_per_pt: f32) -> f32 {
67    pixel_per_pt * PPP_TO_PPI_FACTOR
68}
69
70/// Converts a pixel-per-inch ratio to a pixel-per-pt ratio.
71pub fn ppi_to_ppp(pixel_per_inch: f32) -> f32 {
72    pixel_per_inch / PPP_TO_PPI_FACTOR
73}
74
75/// Render the visual diff of two pages. If the pages do not have matching
76/// dimensions, then the origin is used to align them, regions without overlap
77/// will simply be colored black.
78///
79/// The difference is created by `change` on top of `base` using a difference
80/// filter.
81pub fn page_diff(base: &Pixmap, change: &Pixmap, origin: Origin) -> Pixmap {
82    fn aligned_offset((a, b): (u32, u32), end: bool) -> (i32, i32) {
83        match Ord::cmp(&a, &b) {
84            Ordering::Less if end => (u32::abs_diff(a, b) as i32, 0),
85            Ordering::Greater if end => (0, u32::abs_diff(a, b) as i32),
86            _ => (0, 0),
87        }
88    }
89
90    let mut diff = Pixmap::new(
91        Ord::max(base.width(), change.width()),
92        Ord::max(base.height(), change.height()),
93    )
94    .expect("must be larger than zero");
95
96    let (base_x, change_x) = aligned_offset((base.width(), change.width()), origin.is_right());
97    let (base_y, change_y) = aligned_offset((base.height(), change.height()), origin.is_right());
98
99    diff.draw_pixmap(
100        base_x,
101        base_y,
102        base.as_ref(),
103        &PixmapPaint {
104            opacity: 1.0,
105            blend_mode: BlendMode::Source,
106            quality: FilterQuality::Nearest,
107        },
108        Transform::identity(),
109        None,
110    );
111
112    diff.draw_pixmap(
113        change_x,
114        change_y,
115        change.as_ref(),
116        &PixmapPaint {
117            opacity: 1.0,
118            blend_mode: BlendMode::Difference,
119            quality: FilterQuality::Nearest,
120        },
121        Transform::identity(),
122        None,
123    );
124
125    diff
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_page_diff_top_left() {
134        let mut base = Pixmap::new(10, 10).unwrap();
135        let mut change = Pixmap::new(15, 5).unwrap();
136        let mut diff = Pixmap::new(15, 10).unwrap();
137
138        base.fill(tiny_skia::Color::from_rgba8(255, 255, 255, 255));
139        change.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255));
140
141        let is_in = |x, y, pixmap: &Pixmap| x < pixmap.width() && y < pixmap.height();
142
143        for y in 0..10 {
144            for x in 0..15 {
145                let idx = diff.width().checked_mul(y).unwrap().checked_add(x).unwrap();
146                let px = diff.pixels_mut().get_mut(idx as usize).unwrap();
147
148                // NOTE(tinger): Despite some of these being invalid according
149                // to PremultipliedColorU8::new, this is indeed what is
150                // internally created when inverting.
151                //
152                // That's not surprising, but not allowing us to create those
153                // pixels when they're valid is.
154                *px = bytemuck::cast(match (is_in(x, y, &base), is_in(x, y, &change)) {
155                    // Proper difference where both are in bounds.
156                    (true, true) => [0u8, 255, 255, 255],
157                    // No difference to base where change is out of bounds.
158                    (true, false) => [255, 255, 255, 255],
159                    // No difference to change where base is out of bounds.
160                    (false, true) => [255, 0, 0, 255],
161                    // Dead area from size mismatch.
162                    (false, false) => [0, 0, 0, 0],
163                });
164            }
165        }
166
167        assert_eq!(
168            page_diff(&base, &change, Origin::TopLeft).data(),
169            diff.data()
170        );
171    }
172
173    #[test]
174    fn test_page_diff_bottom_right() {
175        let mut base = Pixmap::new(10, 10).unwrap();
176        let mut change = Pixmap::new(15, 5).unwrap();
177        let mut diff = Pixmap::new(15, 10).unwrap();
178
179        base.fill(tiny_skia::Color::from_rgba8(255, 255, 255, 255));
180        change.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255));
181
182        // similar as above, but mirrored across both axes
183        let is_in =
184            |x, y, pixmap: &Pixmap| (15 - x) <= pixmap.width() && (10 - y) <= pixmap.height();
185
186        for y in 0..10 {
187            for x in 0..15 {
188                let idx = diff.width().checked_mul(y).unwrap().checked_add(x).unwrap();
189                let px = diff.pixels_mut().get_mut(idx as usize).unwrap();
190
191                // NOTE(tinger): Despite some of these being invalid according
192                // to PremultipliedColorU8::new, this is indeed what is
193                // internally created when inverting.
194                //
195                // That's not surprising, but not allowing us to create those
196                // pixels when they're valid is.
197                *px = bytemuck::cast(match (is_in(x, y, &base), is_in(x, y, &change)) {
198                    // Proper difference where both are in bounds.
199                    (true, true) => [0u8, 255, 255, 255],
200                    // No difference to base where change is out of bounds.
201                    (true, false) => [255, 255, 255, 255],
202                    // No difference to change where base is out of bounds.
203                    (false, true) => [255, 0, 0, 255],
204                    // Dead area from size mismatch.
205                    (false, false) => [0, 0, 0, 0],
206                });
207            }
208        }
209
210        assert_eq!(
211            page_diff(&base, &change, Origin::BottomRight).data(),
212            diff.data()
213        );
214    }
215}