1use otf_pixels_core::{ChannelLayout, PixelsError, Region, Result, Tile};
21
22use crate::convert::to_rgba;
23
24#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Trim {
27 background: Option<[u8; 4]>,
28 threshold: f32,
29 line_art: bool,
30}
31
32impl Default for Trim {
33 fn default() -> Self {
36 Self {
37 background: None,
38 threshold: 10.0,
39 line_art: false,
40 }
41 }
42}
43
44impl Trim {
45 #[must_use]
47 pub const fn with_background(mut self, rgba: [u8; 4]) -> Self {
48 self.background = Some(rgba);
49 self
50 }
51
52 pub fn with_threshold(mut self, threshold: f32) -> Result<Self> {
60 if !threshold.is_finite() || threshold < 0.0 {
61 return Err(PixelsError::invalid_argument(
62 "threshold",
63 format!("must be finite and not negative, got {threshold}"),
64 ));
65 }
66 self.threshold = threshold;
67 Ok(self)
68 }
69
70 #[must_use]
73 pub const fn line_art(mut self, line_art: bool) -> Self {
74 self.line_art = line_art;
75 self
76 }
77
78 pub fn find(&self, tile: &Tile<'_>) -> Result<Option<Region>> {
87 let region = tile.region();
88 if region.width < 3 && region.height < 3 {
89 return Err(PixelsError::invalid_argument(
90 "image",
91 format!(
92 "an image to trim must be at least 3 pixels on a side, got {}x{}",
93 region.width, region.height
94 ),
95 ));
96 }
97 let format = tile.pixel();
98 let bpp = format.bytes_per_pixel();
99 let alpha = matches!(
100 format.layout(),
101 ChannelLayout::GrayAlpha | ChannelLayout::Rgba
102 );
103
104 let background = match self.background {
106 Some(rgba) => rgba.map(|v| f32::from(v) / 255.0),
107 None => {
108 let first = tile
109 .row(region.y)
110 .and_then(|row| row.get(..bpp))
111 .ok_or_else(|| PixelsError::graph("trim was given an empty tile"))?;
112 to_rgba(first, format)
113 }
114 };
115 let flattened = |y: u32| -> Result<Vec<[f32; 4]>> {
116 let row = tile
117 .row(y)
118 .ok_or_else(|| PixelsError::graph(format!("trim tile is missing row {y}")))?;
119 Ok(row
120 .chunks_exact(bpp)
121 .map(|pixel| {
122 let [r, g, b, a] = to_rgba(pixel, format);
123 let over = |c: f32, under: f32| c * a + under * (1.0 - a);
124 [
125 over(r, background[0]),
126 over(g, background[1]),
127 over(b, background[2]),
128 a,
129 ]
130 })
131 .collect())
132 };
133
134 let level = self.threshold / 255.0 - 1e-6;
137 let content = |pixel: [f32; 4]| {
138 let differs = |c: f32, b: f32| (c - b).abs() >= level;
139 let colour = pixel
140 .iter()
141 .zip(&background)
142 .take(3)
143 .any(|(&c, &b)| differs(c, b));
144 colour || (alpha && differs(pixel[3], background[3]))
145 };
146
147 let (width, height) = (region.width as usize, region.height as usize);
148 let mut found: Option<(usize, usize, usize, usize)> = None;
149 let mut mark = |x: usize, y: usize| {
150 found = Some(match found {
151 None => (x, y, x, y),
152 Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
153 });
154 };
155
156 if self.line_art {
157 for y in 0..height {
158 for (x, &pixel) in flattened(region.y + y as u32)?.iter().enumerate() {
159 if content(pixel) {
160 mark(x, y);
161 }
162 }
163 }
164 } else {
165 let last = height - 1;
168 let mut window = [
169 flattened(region.y)?,
170 flattened(region.y)?,
171 flattened(region.y + u32::from(last > 0))?,
172 ];
173 for y in 0..height {
174 if y > 0 {
175 window.rotate_left(1);
176 window[2] = flattened(region.y + (y + 1).min(last) as u32)?;
177 }
178 for x in 0..width {
179 let columns = [x.saturating_sub(1), x, (x + 1).min(width - 1)];
180 let mut pixel = [0.0; 4];
181 for (channel, value) in pixel.iter_mut().enumerate() {
182 let mut values = [0.0_f32; 9];
183 for (slot, (row, column)) in values.iter_mut().zip(
184 window
185 .iter()
186 .flat_map(|row| columns.iter().map(move |&c| (row, c))),
187 ) {
188 *slot = row
189 .get(column)
190 .and_then(|p| p.get(channel))
191 .copied()
192 .unwrap_or(0.0);
193 }
194 values.sort_unstable_by(f32::total_cmp);
195 *value = values[4];
196 }
197 if content(pixel) {
198 mark(x, y);
199 }
200 }
201 }
202 }
203
204 Ok(found.map(|(x0, y0, x1, y1)| {
205 Region::new(
206 region.x + x0 as u32,
207 region.y + y0 as u32,
208 (x1 - x0 + 1) as u32,
209 (y1 - y0 + 1) as u32,
210 )
211 }))
212 }
213}
214
215#[cfg(test)]
216#[allow(
217 clippy::unwrap_used,
218 clippy::indexing_slicing,
219 reason = "tests operate on known-good values and assert shapes directly"
220)]
221mod tests {
222 use super::*;
223 use otf_pixels_core::{PixelFormat, TileBuf};
224
225 fn image(
228 width: u32,
229 height: u32,
230 format: PixelFormat,
231 fill: &[u8],
232 paint: impl Fn(u32, u32) -> Option<Vec<u8>>,
233 ) -> TileBuf {
234 let mut bytes = Vec::new();
235 for y in 0..height {
236 for x in 0..width {
237 bytes.extend(paint(x, y).unwrap_or_else(|| fill.to_vec()));
238 }
239 }
240 TileBuf::from_vec(Region::new(0, 0, width, height), format, bytes).unwrap()
241 }
242
243 fn find(trim: Trim, buf: &TileBuf) -> Option<Region> {
244 trim.find(&buf.as_tile().unwrap()).unwrap()
245 }
246
247 #[test]
248 fn a_block_on_a_plain_border_is_found() {
249 let buf = image(12, 10, PixelFormat::Rgb8, &[250, 250, 250], |x, y| {
250 ((3..7).contains(&x) && (2..6).contains(&y)).then(|| vec![20, 30, 40])
251 });
252 assert_eq!(find(Trim::default(), &buf), Some(Region::new(3, 2, 4, 4)));
253 }
254
255 #[test]
256 fn a_lone_pixel_is_noise_unless_the_image_is_line_art() {
257 let buf = image(9, 9, PixelFormat::Gray8, &[255], |x, y| {
258 (x == 4 && y == 5).then(|| vec![0])
259 });
260 assert_eq!(find(Trim::default(), &buf), None);
261 assert_eq!(
262 find(Trim::default().line_art(true), &buf),
263 Some(Region::new(4, 5, 1, 1))
264 );
265 }
266
267 #[test]
268 fn the_threshold_is_the_smallest_difference_that_counts() {
269 let with = |value: u8| {
270 image(5, 5, PixelFormat::Gray8, &[100], move |x, y| {
271 (x >= 2 && y >= 2).then(|| vec![value])
272 })
273 };
274 let trim = Trim::default().line_art(true);
275 assert_eq!(find(trim, &with(109)), None, "9 is under the default 10");
276 assert_eq!(find(trim, &with(110)), Some(Region::new(2, 2, 3, 3)));
277 assert_eq!(find(trim, &with(91)), None, "differences are absolute");
278 let loose = trim.with_threshold(30.0).unwrap();
279 assert_eq!(find(loose, &with(110)), None);
280 assert!(Trim::default().with_threshold(-1.0).is_err());
281 assert!(Trim::default().with_threshold(f32::NAN).is_err());
282 }
283
284 #[test]
285 fn an_explicit_background_replaces_the_top_left_pixel() {
286 let buf = image(6, 6, PixelFormat::Rgb8, &[0, 0, 0], |x, y| {
289 ((x == 0 && y == 0) || ((2..4).contains(&x) && (2..4).contains(&y)))
290 .then(|| vec![255, 0, 0])
291 });
292 let trim = Trim::default().line_art(true);
293 assert_eq!(find(trim, &buf), Some(Region::new(0, 0, 6, 6)));
294 assert_eq!(
295 find(trim.with_background([0, 0, 0, 255]), &buf),
296 Some(Region::new(0, 0, 4, 4))
297 );
298 }
299
300 #[test]
301 fn a_transparent_border_is_trimmed_whatever_colour_it_hides() {
302 let buf = image(8, 8, PixelFormat::Rgba8, &[0, 0, 0, 0], |x, y| {
303 if (2..6).contains(&x) && (3..6).contains(&y) {
304 Some(vec![0, 0, 0, 255])
305 } else {
306 Some(vec![(x * 40) as u8, (y * 30) as u8, 7, 0])
308 }
309 });
310 assert_eq!(find(Trim::default(), &buf), Some(Region::new(2, 3, 4, 3)));
311 }
312
313 #[test]
314 fn an_image_of_nothing_but_background_has_no_box() {
315 let buf = image(
316 4,
317 4,
318 PixelFormat::Rgba16,
319 &[1, 2, 3, 4, 5, 6, 7, 8],
320 |_, _| None,
321 );
322 assert_eq!(find(Trim::default(), &buf), None);
323 }
324
325 #[test]
326 fn wide_formats_measure_the_threshold_in_8_bit_units() {
327 let with = |value: u16| {
329 image(
330 4,
331 4,
332 PixelFormat::Gray16,
333 &0_u16.to_ne_bytes(),
334 move |x, _| (x == 3).then(|| value.to_ne_bytes().to_vec()),
335 )
336 };
337 let trim = Trim::default().line_art(true);
338 assert_eq!(find(trim, &with(9 * 257 + 128)), None);
339 assert_eq!(find(trim, &with(16 * 257)), Some(Region::new(3, 0, 1, 4)));
340 }
341
342 #[test]
343 fn a_tiny_image_is_refused_as_sharp_refuses_it() {
344 let buf = image(2, 2, PixelFormat::Gray8, &[0], |_, _| None);
345 assert!(Trim::default().find(&buf.as_tile().unwrap()).is_err());
346 let buf = image(1, 5, PixelFormat::Gray8, &[0], |_, _| None);
348 assert!(Trim::default().find(&buf.as_tile().unwrap()).is_ok());
349 }
350}