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