tytanic_core/doc/
render.rs

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