Files
catena/crates/pdfgutter/src/ink.rs
T

211 lines
6.7 KiB
Rust
Raw Normal View History

//! Reading a rendered page as a column-by-column measure of how much ink is
//! on it.
//!
//! The page comes in as a greymap — the format `pdftoppm` writes and the
//! simplest image format there is: a header, then one byte per pixel. Only
//! the column sums are wanted, so nothing is kept but the profile.
use std::fmt;
/// What can be wrong with a greymap.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// It does not begin `P5`.
NotAGreymap,
/// The header ended before the three numbers did.
Truncated,
/// A number in the header was not one.
BadHeader,
/// Sixteen-bit samples, which nothing here produces.
SampleTooWide(u32),
/// The header promises more pixels than the file holds.
ShortImage {
/// How many bytes the header calls for.
expected: usize,
/// How many are there.
found: usize,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::NotAGreymap => write!(f, "not a binary greymap"),
Error::Truncated => write!(f, "the greymap header is cut short"),
Error::BadHeader => write!(f, "the greymap header is not three numbers"),
Error::SampleTooWide(max) => write!(f, "{max} levels of grey, which is more than 255"),
Error::ShortImage { expected, found } => {
write!(f, "the greymap promises {expected} bytes and holds {found}")
}
}
}
}
impl std::error::Error for Error {}
/// The fraction of each column that is ink.
///
/// One number per pixel column, left to right, each between zero and one.
pub fn profile(greymap: &[u8], threshold: u8) -> Result<Vec<f64>, Error> {
let (width, height, levels, pixels) = header(greymap)?;
if levels > 255 {
return Err(Error::SampleTooWide(levels));
}
let wanted = width.checked_mul(height).ok_or(Error::BadHeader)?;
if pixels.len() < wanted {
return Err(Error::ShortImage {
expected: wanted,
found: pixels.len(),
});
}
if width == 0 || height == 0 {
return Ok(Vec::new());
}
// A greymap need not go all the way to white, and the threshold is on the
// usual nought-to-255 scale, so compare in that scale rather than the
// file's.
let dark = |value: u8| (u32::from(value) * 255 / levels.max(1)) < u32::from(threshold);
let mut counts = vec![0usize; width];
for row in pixels[..wanted].chunks_exact(width) {
for (column, value) in row.iter().enumerate() {
if dark(*value) {
counts[column] += 1;
}
}
}
Ok(counts
.into_iter()
.map(|count| count as f64 / height as f64)
.collect())
}
/// Width, height, levels of grey, and where the pixels start.
fn header(greymap: &[u8]) -> Result<(usize, usize, u32, &[u8]), Error> {
let rest = greymap.strip_prefix(b"P5").ok_or(Error::NotAGreymap)?;
let mut at = 0;
let mut numbers = [0u32; 3];
for number in &mut numbers {
at += skip(&rest[at..]);
let digits = rest[at..]
.iter()
.position(|byte| !byte.is_ascii_digit())
.ok_or(Error::Truncated)?;
if digits == 0 {
return Err(Error::BadHeader);
}
*number = std::str::from_utf8(&rest[at..at + digits])
.map_err(|_| Error::BadHeader)?
.parse()
.map_err(|_| Error::BadHeader)?;
at += digits;
}
// Exactly one byte of whitespace separates the header from the pixels,
// and a pixel may itself be a whitespace byte.
let pixels = rest.get(at + 1..).ok_or(Error::Truncated)?;
Ok((numbers[0] as usize, numbers[1] as usize, numbers[2], pixels))
}
/// Whitespace and comments, which a greymap header may carry anywhere.
fn skip(bytes: &[u8]) -> usize {
let mut at = 0;
loop {
let was = at;
while bytes.get(at).is_some_and(u8::is_ascii_whitespace) {
at += 1;
}
if bytes.get(at) == Some(&b'#') {
at += bytes[at..]
.iter()
.position(|byte| *byte == b'\n')
.map_or(bytes.len() - at, |end| end + 1);
}
if at == was {
return at;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Four columns, two rows: black, white, black, white.
const STRIPES: &[u8] = b"P5\n4 2\n255\n\x00\xff\x00\xff\x00\xff\x00\xff";
#[test]
fn a_column_of_black_is_all_ink_and_a_column_of_white_is_none() {
assert_eq!(
profile(STRIPES, 160).expect("a greymap"),
[1.0, 0.0, 1.0, 0.0]
);
}
#[test]
fn grey_falls_on_the_side_of_the_threshold_it_belongs_to() {
let greys = b"P5\n3 1\n255\n\x9e\x9f\xa0"; // 158, 159, 160
assert_eq!(profile(greys, 160).expect("a greymap"), [1.0, 1.0, 0.0]);
}
#[test]
fn comments_and_extra_whitespace_in_the_header_are_skipped() {
let commented = b"P5\n# written by something\n4 2\n255\n\x00\xff\x00\xff\x00\xff\x00\xff";
assert_eq!(
profile(commented, 160).expect("a greymap"),
[1.0, 0.0, 1.0, 0.0]
);
}
#[test]
fn a_pixel_that_is_a_whitespace_byte_is_still_a_pixel() {
// 0x20 is a space and also a very dark grey. Eating it as header
// whitespace would shift every row by one and smear the profile.
let spaces = b"P5\n2 1\n255\n\x20\xff";
assert_eq!(profile(spaces, 160).expect("a greymap"), [1.0, 0.0]);
}
#[test]
fn fewer_levels_of_grey_are_scaled_to_the_usual_range() {
// Maximum 1, so 0 is black and 1 is white however it is written.
let bilevel = b"P5\n2 1\n1\n\x00\x01";
assert_eq!(profile(bilevel, 160).expect("a greymap"), [1.0, 0.0]);
}
#[test]
fn what_is_not_a_greymap_says_so() {
assert_eq!(
profile(b"P6\n1 1\n255\n\x00\x00\x00", 160),
Err(Error::NotAGreymap)
);
assert_eq!(profile(b"", 160), Err(Error::NotAGreymap));
assert_eq!(profile(b"P5\n4 2\n", 160), Err(Error::Truncated));
assert_eq!(profile(b"P5\nwide 2\n255\n", 160), Err(Error::BadHeader));
assert_eq!(
profile(b"P5\n1 1\n65535\n\x00\x00", 160),
Err(Error::SampleTooWide(65535))
);
}
#[test]
fn a_greymap_cut_short_is_not_padded_with_white() {
assert_eq!(
profile(b"P5\n4 2\n255\n\x00\xff\x00", 160),
Err(Error::ShortImage {
expected: 8,
found: 3
})
);
}
#[test]
fn a_greymap_with_no_pixels_has_no_profile() {
assert_eq!(
profile(b"P5\n0 0\n255\n", 160).expect("a greymap"),
Vec::<f64>::new()
);
}
}