Files
pdfcraft/crates/optimize/src/samples.rs
T

313 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! An image's samples read out and written back at the same size, for tools that change the
//! pixels of an image in place (Scan & OCR ▸ Enhance ▸ Scanned document). The image dictionary
//! stays as it was apart from the encoding: its size, colour space, masks, `/Interpolate`,
//! `/Intent`, `/Metadata` and every other entry are kept.
use pdfcraft_cos::{Document, ImageBudget, Object, Stream};
use crate::images::{MAX_PIXELS, allowance, components, is_dct, is_identity_decode, jpeg, pixels, ycc_encoded};
/// An image's samples, one byte each, row-major.
#[derive(Clone, Debug, PartialEq)]
pub struct Samples {
pub width: u32,
pub height: u32,
/// 1 (grey) or 3 (RGB).
pub components: usize,
/// The samples. A bi-level image's are 0 (black) or 255 (white) as drawn, whatever its
/// decode array.
pub data: Vec<u8>,
/// Stored as a JPEG (written back as one); otherwise written back losslessly (Flate).
pub jpeg: bool,
/// One bit per pixel (written back as one).
pub bilevel: bool,
/// A bi-level image whose 1 bits are black (decode array `[1 0]`): written back the same way,
/// so its decode array stays true.
pub inverted: bool,
}
/// Why an image's samples were not read. Each says what the image is, so a report can name it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum NotDecoded {
#[error("it is a stencil mask, not a picture")]
StencilMask,
#[error("its colour space is not grey or RGB (CMYK, indexed and special colours are not supported yet)")]
ColourSpace,
#[error("it has {0} bits per sample (8, and 1 for black and white, are supported)")]
BitDepth(i64),
#[error("its decode array remaps the samples")]
Decode,
#[error("it is compressed in a format PdfCraft cannot read back yet (JPEG 2000, JBIG2 or CCITT fax)")]
Filter,
#[error("its JPEG colour transform is ambiguous, so readers may disagree about its colours")]
Ambiguous,
#[error("it is larger than 80 million pixels")]
TooLarge,
#[error("its data could not be decoded, or is shorter than its size says")]
Corrupt,
}
/// Read the samples of image stream `s` (a dictionary with `/Subtype /Image`), decoding it within
/// `budget` (an operation over many images shares one).
pub fn decode_samples(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<Samples, NotDecoded> {
let d = &s.dict;
if matches!(d.get(b"ImageMask").map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))) {
return Err(NotDecoded::StencilMask);
}
// The filter first: a JPEG 2000 image may leave its colour space and bit depth to the data,
// and the report should name the format, not the missing entries.
let jpeg = is_dct(s).ok_or(NotDecoded::Filter)?;
let n = d.get(b"ColorSpace").and_then(|cs| components(doc, cs)).ok_or(NotDecoded::ColourSpace)?;
let bpc = d.get(b"BitsPerComponent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).unwrap_or(0);
let bilevel = match bpc {
8 => false,
1 if n == 1 => true,
other => return Err(NotDecoded::BitDepth(other)),
};
let width = d.get(b"Width").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = d.get(b"Height").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 {
return Err(NotDecoded::Corrupt);
}
if u64::from(width) * u64::from(height) > MAX_PIXELS {
return Err(NotDecoded::TooLarge);
}
if bilevel {
if jpeg {
// A JPEG has 8 bits per sample: the dictionary contradicts the data.
return Err(NotDecoded::Corrupt);
}
let inverted = match d.get(b"Decode") {
None => false,
Some(a) if is_identity_decode(doc, a, 1) => false,
Some(a) if is_inverted_decode(doc, a) => true,
Some(_) => return Err(NotDecoded::Decode),
};
let data = unpack_bits(doc, s, width, height, inverted, budget).ok_or(NotDecoded::Corrupt)?;
return Ok(Samples { width, height, components: 1, data, jpeg: false, bilevel: true, inverted });
}
if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n)) {
return Err(NotDecoded::Decode);
}
if jpeg && n == 3 && ycc_encoded(doc, s).is_none() {
return Err(NotDecoded::Ambiguous);
}
let data = pixels(doc, s, n, budget).ok_or(NotDecoded::Corrupt)?;
Ok(Samples { width, height, components: n, data, jpeg, bilevel: false, inverted: false })
}
/// `[1 0]`: a bi-level image whose 1 bits are black.
fn is_inverted_decode(doc: &Document, decode: &Object) -> bool {
let decode = doc.resolve(decode);
matches!(decode.as_array().map(Vec::as_slice), Some([a, b]) if doc.resolve(a).as_f64() == Some(1.0) && doc.resolve(b).as_f64() == Some(0.0))
}
/// The bytes a `w` × `h` bi-level image takes: each row padded to whole bytes.
fn packed_len(w: u32, h: u32) -> Option<usize> {
usize::try_from(w).ok()?.div_ceil(8).checked_mul(usize::try_from(h).ok()?)
}
/// A bi-level image's pixels, 0 (black) or 255 (white) as drawn, decoded within `budget` with
/// its `/DecodeParms` resolved, as the 8-bit images are.
fn unpack_bits(doc: &Document, s: &Stream, w: u32, h: u32, inverted: bool, budget: &mut ImageBudget) -> Option<Vec<u8>> {
let stride = usize::try_from(w).ok()?.div_ceil(8);
let need = packed_len(w, h)?;
let bits = budget.decode_filters(doc, s, allowance(need, h)?).ok()?;
let bits = bits.get(..need)?;
let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
let mut out = Vec::with_capacity(w.checked_mul(h)?);
for row in bits.chunks(stride).take(h) {
for x in 0..w {
let byte = row.get(x / 8).copied().unwrap_or(0);
let one = byte & (0x80 >> (x % 8)) != 0;
// Without a decode array 0 is black and 1 white; [1 0] swaps them.
out.push(if one != inverted { 255 } else { 0 });
}
}
Some(out)
}
/// The pixels (0 black … 255 white, threshold at half) packed back to bits.
fn pack_bits(px: &[u8], w: u32, h: u32, inverted: bool) -> Option<Vec<u8>> {
let stride = usize::try_from(w).ok()?.div_ceil(8);
let mut out = vec![0u8; packed_len(w, h)?];
let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
if px.len() != w.checked_mul(h)? {
return None;
}
for (y, row) in px.chunks(w.max(1)).enumerate() {
for (x, &v) in row.iter().enumerate() {
let white = v >= 128;
if white != inverted
&& let Some(b) = out.get_mut(y * stride + x / 8)
{
*b |= 0x80 >> (x % 8);
}
}
}
Some(out)
}
/// Image stream `old` with its samples replaced by `samples` (same size and components): a JPEG
/// stays a JPEG (at `quality`, 1–100), anything else becomes Flate, and a bi-level image stays
/// one bit per pixel with its decode array. Filter parameters go (the new data needs none); every
/// other entry stays. `None` if the samples don't fit the image.
pub fn encode_samples(old: &Stream, samples: &Samples, quality: u8) -> Option<Stream> {
let Samples { width, height, components: n, data, jpeg: as_jpeg, bilevel, inverted } = samples;
let (w, h, n) = (*width, *height, *n);
if !(n == 1 || n == 3) || data.len() != usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)? {
return None;
}
let mut d = old.dict.clone();
for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] {
d.remove(k);
}
if *bilevel {
if n != 1 {
return None;
}
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
return Some(Stream::flate(d, &pack_bits(data, w, h, *inverted)?));
}
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
if *as_jpeg {
let bytes = jpeg(data, n, w, h, quality)?;
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
Some(Stream::from_raw(d, bytes))
} else {
Some(Stream::flate(d, data))
}
}
#[cfg(test)]
mod tests {
use super::*;
use pdfcraft_cos::Dict;
fn image(dict: &[(&str, Object)], raw: Vec<u8>) -> Stream {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
for (k, v) in dict {
d.set(k.as_bytes().to_vec(), v.clone());
}
Stream::from_raw(d, raw)
}
fn doc() -> Document {
let pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
Document::open(std::sync::Arc::new(pdf.to_vec())).unwrap()
}
#[test]
fn grey_samples_round_trip_losslessly_and_keep_the_other_entries() {
let px: Vec<u8> = (0..12 * 5).map(|i| (i * 4) as u8).collect();
let s = image(
&[
("Width", Object::Int(12)),
("Height", Object::Int(5)),
("ColorSpace", Object::name("DeviceGray")),
("BitsPerComponent", Object::Int(8)),
("Interpolate", Object::Bool(true)),
],
px.clone(),
);
let got = decode_samples(&doc(), &s, &mut ImageBudget::with_work(u64::MAX)).unwrap();
assert_eq!((got.width, got.height, got.components, got.jpeg, got.bilevel), (12, 5, 1, false, false));
assert_eq!(got.data, px);
let back = encode_samples(&s, &got, 90).unwrap();
assert_eq!(back.dict.name(b"Filter"), Some(&b"FlateDecode"[..]));
assert_eq!(back.dict.get(b"Interpolate"), Some(&Object::Bool(true)));
assert_eq!(decode_samples(&doc(), &back, &mut ImageBudget::with_work(u64::MAX)).unwrap().data, px);
}
#[test]
fn bilevel_samples_keep_their_decode_array_and_bits() {
// 10 × 2: first row black on the left half, second row all white; [1 0] makes 1 black.
let raw = vec![0b1111_1000, 0b0000_0000, 0, 0];
let s = image(
&[
("Width", Object::Int(10)),
("Height", Object::Int(2)),
("ColorSpace", Object::name("DeviceGray")),
("BitsPerComponent", Object::Int(1)),
("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)])),
],
raw.clone(),
);
let got = decode_samples(&doc(), &s, &mut ImageBudget::with_work(u64::MAX)).unwrap();
assert!(got.bilevel);
assert_eq!(&got.data[..10], &[0, 0, 0, 0, 0, 255, 255, 255, 255, 255]);
assert!(got.data[10..].iter().all(|&v| v == 255));
assert!(got.inverted);
let back = encode_samples(&s, &got, 90).unwrap();
assert_eq!(back.dict.int(b"BitsPerComponent"), Some(1));
assert!(back.dict.get(b"Decode").is_some());
assert_eq!(back.decoded().unwrap(), raw);
}
#[test]
fn bilevel_decode_parms_by_reference_decode_like_direct_ones() {
// 10 × 2 with a TIFF predictor: each 1 bit flips the pixel from the one to its left.
let packed = vec![0b1000_0000, 0b0000_0000, 0b0100_0000, 0b0000_0000];
let parms = || {
let mut p = pdfcraft_cos::Dict::new();
for (k, v) in [("Predictor", 2), ("Colors", 1), ("BitsPerComponent", 1), ("Columns", 10)] {
p.set(k.as_bytes().to_vec(), Object::Int(v));
}
Object::Dict(p)
};
let dict = [
("Width", Object::Int(10)),
("Height", Object::Int(2)),
("ColorSpace", Object::name("DeviceGray")),
("BitsPerComponent", Object::Int(1)),
];
let mut d = doc();
let mut direct = Stream::flate(image(&dict, vec![]).dict, &packed);
direct.dict.set(b"DecodeParms".to_vec(), parms());
let mut indirect = direct.clone();
let r = d.add(parms());
indirect.dict.set(b"DecodeParms".to_vec(), Object::Ref(r));
let a = decode_samples(&d, &direct, &mut ImageBudget::with_work(u64::MAX)).unwrap();
let b = decode_samples(&d, &indirect, &mut ImageBudget::with_work(u64::MAX)).unwrap();
// Row 1: white from x = 0 on; row 2: black at x = 0, white from x = 1 on.
assert_eq!(&a.data[..10], &[255; 10]);
assert_eq!(&a.data[10..], &[0, 255, 255, 255, 255, 255, 255, 255, 255, 255]);
assert_eq!(b, a);
}
#[test]
fn what_is_not_supported_says_why_and_never_panics() {
let base = |extra: &[(&str, Object)], raw: Vec<u8>| {
let mut d: Vec<(&str, Object)> = vec![
("Width", Object::Int(4)),
("Height", Object::Int(4)),
("ColorSpace", Object::name("DeviceGray")),
("BitsPerComponent", Object::Int(8)),
];
d.extend(extra.iter().cloned());
decode_samples(&doc(), &image(&d, raw), &mut ImageBudget::with_work(u64::MAX))
};
assert_eq!(base(&[("ColorSpace", Object::name("DeviceCMYK"))], vec![0; 64]), Err(NotDecoded::ColourSpace));
assert_eq!(base(&[("BitsPerComponent", Object::Int(4))], vec![0; 8]), Err(NotDecoded::BitDepth(4)));
assert_eq!(base(&[("Filter", Object::name("JBIG2Decode"))], vec![0; 8]), Err(NotDecoded::Filter));
// JPEG 2000 may leave the colour space and bit depth to its data: the format is the reason.
let jpx = |dict: &[(&str, Object)]| decode_samples(&doc(), &image(dict, vec![0; 8]), &mut ImageBudget::with_work(u64::MAX));
let size = [("Width", Object::Int(4)), ("Height", Object::Int(4)), ("Filter", Object::name("JPXDecode"))];
assert_eq!(jpx(&size), Err(NotDecoded::Filter));
assert_eq!(jpx(&[size[0].clone(), size[1].clone(), size[2].clone(), ("ColorSpace", Object::name("DeviceRGB"))]), Err(NotDecoded::Filter));
assert_eq!(base(&[("ImageMask", Object::Bool(true))], vec![0; 8]), Err(NotDecoded::StencilMask));
assert_eq!(base(&[("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)]))], vec![0; 16]), Err(NotDecoded::Decode));
// Too short, a huge size, a broken JPEG, no size at all.
assert_eq!(base(&[], vec![0; 5]), Err(NotDecoded::Corrupt));
assert_eq!(base(&[("Width", Object::Int(1 << 30)), ("Height", Object::Int(1 << 30))], vec![0; 16]), Err(NotDecoded::TooLarge));
assert_eq!(base(&[("Filter", Object::name("DCTDecode"))], vec![0xFF, 0xD8, 1, 2, 3]), Err(NotDecoded::Corrupt));
assert_eq!(base(&[("Width", Object::Int(-3))], vec![0; 16]), Err(NotDecoded::Corrupt));
// Samples that don't fit the image are refused when written back.
let s = image(&[("Width", Object::Int(4)), ("Height", Object::Int(4))], vec![0; 16]);
let wrong = Samples { width: 4, height: 4, components: 1, data: vec![0; 15], jpeg: false, bilevel: false, inverted: false };
assert!(encode_samples(&s, &wrong, 90).is_none());
}
}