Files
effectcraft/crates/pdf/src/image.rs
T

311 lines
12 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.
//! Image XObjects and inline images (ISO 32000-1 §8.9): sample decoding (1–16 bits per
//! component, `/Decode` arrays), colour spaces (through [`crate::color`]: Indexed lookups and
//! ICCBased profiles by their `/Alternate` space), DCT (JPEG, through the pure-Rust `zune-jpeg`)
//! and the general filters, stencil masks (`/ImageMask`), soft masks (`/SMask`) and colour-key
//! and explicit masks (`/Mask`). The result is straight RGBA8.
use crate::color::{Cs, color_space};
use crate::object::{Dict, File, Obj, decode_stream};
pub(crate) struct Decoded {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The longest side of a decoded image (larger images are refused rather than exhausting
/// memory).
const MAX_SIDE: usize = 16384;
fn get<'a>(file: &'a File, d: &'a Dict, long: &str, short: &str) -> Option<&'a Obj> {
file.get(d, long).or_else(|| file.get(d, short))
}
fn filters(file: &File, d: &Dict) -> Vec<String> {
match get(file, d, "Filter", "F").map(|f| file.resolve(f)) {
Some(Obj::Name(n)) => vec![n.clone()],
Some(Obj::Array(a)) => a.iter().filter_map(|f| file.resolve(f).name().map(str::to_string)).collect(),
_ => vec![],
}
}
/// Inline-image abbreviations (§8.9.7) expanded to the full keys and names.
pub(crate) fn expand_inline(d: &Dict) -> Dict {
let mut out = Dict::new();
for (k, v) in d {
let key = match k.as_str() {
"BPC" => "BitsPerComponent",
"CS" => "ColorSpace",
"D" => "Decode",
"DP" => "DecodeParms",
"F" => "Filter",
"H" => "Height",
"IM" => "ImageMask",
"I" => "Interpolate",
"W" => "Width",
other => other,
};
let name = |n: &str| -> String {
match n {
"G" => "DeviceGray",
"RGB" => "DeviceRGB",
"CMYK" => "DeviceCMYK",
"I" => "Indexed",
"AHx" => "ASCIIHexDecode",
"A85" => "ASCII85Decode",
"LZW" => "LZWDecode",
"Fl" => "FlateDecode",
"RL" => "RunLengthDecode",
"CCF" => "CCITTFaxDecode",
"DCT" => "DCTDecode",
other => other,
}
.to_string()
};
let v = match v {
Obj::Name(n) => Obj::Name(name(n)),
Obj::Array(a) => Obj::Array(a.iter().map(|x| if let Obj::Name(n) = x { Obj::Name(name(n)) } else { x.clone() }).collect()),
other => other.clone(),
};
out.insert(key.to_string(), v);
}
out
}
/// The number of bytes an unfiltered inline image occupies.
pub(crate) fn inline_len(file: &File, d: &Dict, res: Option<&Dict>) -> Option<usize> {
if !filters(file, d).is_empty() {
return None;
}
let w = file.get_num(d, "Width")? as usize;
let h = file.get_num(d, "Height")? as usize;
let mask = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true)));
let bpc = if mask { 1 } else { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize };
let n = if mask { 1 } else { file.get(d, "ColorSpace").map(|c| color_space(file, c, res).components()).unwrap_or(1) };
Some((w * n * bpc).div_ceil(8) * h)
}
/// Decode an image's samples (all filters, including DCT). Returns (bytes, components from
/// a JPEG when it decided them itself).
fn samples(file: &File, d: &Dict, raw: &[u8]) -> Result<(Vec<u8>, Option<usize>), String> {
let fs = filters(file, d);
match fs.last().map(String::as_str) {
Some("DCTDecode" | "DCT") => {
let mut d2 = d.clone();
d2.remove("F");
let rest: Vec<Obj> = fs[..fs.len() - 1].iter().map(|n| Obj::Name(n.clone())).collect();
d2.insert("Filter".into(), Obj::Array(rest));
d2.remove("DecodeParms");
d2.remove("DP");
let jpeg = decode_stream(file, &d2, raw).ok_or("image (filters)")?;
jpeg_decode(&jpeg).map(|(b, n)| (b, Some(n)))
}
Some(f @ ("JPXDecode" | "JBIG2Decode")) => Err(format!("image ({f})")),
_ => decode_stream(file, d, raw).map(|b| (b, None)).ok_or_else(|| "image (filters)".to_string()),
}
}
fn jpeg_decode(data: &[u8]) -> Result<(Vec<u8>, usize), String> {
use zune_jpeg::JpegDecoder;
use zune_jpeg::zune_core::colorspace::ColorSpace;
use zune_jpeg::zune_core::options::DecoderOptions;
let mut probe = JpegDecoder::new(std::io::Cursor::new(data));
probe.decode_headers().map_err(|_| "image (JPEG)".to_string())?;
let n = probe.input_colorspace().map(|c| c.num_components()).unwrap_or(3);
let out = match n {
1 => ColorSpace::Luma,
4 => ColorSpace::CMYK,
_ => ColorSpace::RGB,
};
let opts = DecoderOptions::default().jpeg_set_out_colorspace(out).set_max_width(MAX_SIDE).set_max_height(MAX_SIDE);
let mut dec = JpegDecoder::new_with_options(std::io::Cursor::new(data), opts);
let mut px = dec.decode().map_err(|_| "image (JPEG)".to_string())?;
// Adobe CMYK JPEGs (an APP14 "Adobe" marker) store inverted values.
if n == 4 && data.windows(5).take(4096).any(|w| w == b"Adobe") {
for v in &mut px {
*v = 255 - *v;
}
}
Ok((px, n.max(1)))
}
/// Unpack `w`×`h` samples of `n` components at `bpc` bits into 0..=max integers.
fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec<u16> {
let row_bytes = (w * n * bpc).div_ceil(8);
let mut out = Vec::with_capacity(w * h * n);
for y in 0..h {
let row = data.get(y * row_bytes..((y + 1) * row_bytes).min(data.len())).unwrap_or(&[]);
for i in 0..w * n {
let v = match bpc {
8 => row.get(i).copied().unwrap_or(0) as u16,
16 => u16::from_be_bytes([row.get(2 * i).copied().unwrap_or(0), row.get(2 * i + 1).copied().unwrap_or(0)]),
1 | 2 | 4 => {
let bit = i * bpc;
let byte = row.get(bit / 8).copied().unwrap_or(0);
((byte >> (8 - bpc - bit % 8)) & ((1u8 << bpc) - 1)) as u16
}
_ => 0,
};
out.push(v);
}
}
out
}
/// Nearest-neighbour resample of a single-channel 8-bit mask to `w`×`h`.
fn resample(m: &[u8], mw: usize, mh: usize, w: usize, h: usize) -> Vec<u8> {
if mw == w && mh == h {
return m.to_vec();
}
let mut out = Vec::with_capacity(w * h);
for y in 0..h {
let sy = (y * mh / h.max(1)).min(mh.saturating_sub(1));
for x in 0..w {
let sx = (x * mw / w.max(1)).min(mw.saturating_sub(1));
out.push(m.get(sy * mw + sx).copied().unwrap_or(255));
}
}
out
}
/// A stencil or soft mask stream as 8-bit coverage.
fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec<u8>, usize, usize)> {
let Obj::Stream(d, raw) = file.resolve(o) else { return None };
let w = file.get_num(d, "Width")? as usize;
let h = file.get_num(d, "Height")? as usize;
if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE {
return None;
}
let (data, jpeg_n) = samples(file, d, raw).ok()?;
if jpeg_n.is_some() {
return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h));
}
let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 };
let max = ((1u32 << bpc) - 1).max(1) as f64;
let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2);
let v = unpack(&data, w, h, 1, bpc);
let out = v
.into_iter()
.map(|s| {
let t = s as f64 / max;
let t = match &dec {
Some(dd) => dd[0] + t * (dd[1] - dd[0]),
None => t,
};
// Explicit (stencil) masks: sample 1 masks out with the default Decode.
let a = if soft { t } else { 1.0 - t };
(a.clamp(0.0, 1.0) * 255.0).round() as u8
})
.collect();
Some((out, w, h))
}
/// Decode an image. `fill` is the current fill colour (stencil masks paint with it).
pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill: [f64; 3]) -> Result<Decoded, String> {
let w = file.get_num(d, "Width").unwrap_or(0.0) as usize;
let h = file.get_num(d, "Height").unwrap_or(0.0) as usize;
if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w * h > 64 << 20 {
return Err("image (size)".into());
}
let stencil = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true)));
let (data, jpeg_n) = samples(file, d, raw)?;
let decode_arr = file.get(d, "Decode").map(|x| file.nums(x)).unwrap_or_default();
let mut rgba = vec![0u8; w * h * 4];
if stencil {
let v = unpack(&data, w, h, 1, 1);
let paint_on = if decode_arr.first().copied().unwrap_or(0.0) > 0.5 { 1 } else { 0 };
let c = fill.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8);
for (i, s) in v.into_iter().enumerate() {
rgba[i * 4..i * 4 + 3].copy_from_slice(&c);
rgba[i * 4 + 3] = if s == paint_on { 255 } else { 0 };
}
return Ok(Decoded { width: w as u32, height: h as u32, rgba });
}
let cs = match file.get(d, "ColorSpace") {
Some(c) => color_space(file, c, res),
None if jpeg_n == Some(4) => Cs::Cmyk,
None if jpeg_n == Some(1) => Cs::Gray,
None => Cs::Rgb,
};
let n = match jpeg_n {
Some(n) => n,
None => cs.components().max(1),
};
let bpc = if jpeg_n.is_some() { 8 } else { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize };
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
return Err(format!("image ({bpc} bits per component)"));
}
let max = ((1u32 << bpc) - 1) as f64;
let raw_samples = unpack(&data, w, h, n, bpc);
// Decode ranges per component.
let indexed = matches!(cs, Cs::Indexed { .. });
let range = |k: usize| -> (f64, f64) {
match (decode_arr.get(2 * k), decode_arr.get(2 * k + 1)) {
(Some(a), Some(b)) => (*a, *b),
_ if indexed => (0.0, max),
_ => (0.0, 1.0),
}
};
let ranges: Vec<(f64, f64)> = (0..n).map(range).collect();
// Fast paths: a lookup per 8-bit value for Gray / RGB / CMYK / Indexed.
let mut comp = vec![0.0f64; n];
// Indexed images: one colour per palette entry.
let palette: Option<Vec<[f64; 3]>> = match &cs {
Cs::Indexed { hival, .. } if n == 1 => Some((0..=*hival).map(|i| cs.to_rgb(&[i as f64])).collect()),
_ => None,
};
for i in 0..w * h {
if let Some(pal) = &palette {
let s = raw_samples[i] as f64 / max;
let (a, b) = ranges[0];
let idx = (a + s * (b - a)).round().clamp(0.0, (pal.len() - 1) as f64) as usize;
let c = pal[idx];
rgba[i * 4..i * 4 + 4].copy_from_slice(&[(c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8, 255]);
continue;
}
for k in 0..n {
let s = raw_samples[i * n + k] as f64 / max;
let (a, b) = ranges[k];
comp[k] = a + s * (b - a);
}
let c = cs.to_rgb(&comp);
let o = i * 4;
rgba[o] = (c[0] * 255.0).round() as u8;
rgba[o + 1] = (c[1] * 255.0).round() as u8;
rgba[o + 2] = (c[2] * 255.0).round() as u8;
rgba[o + 3] = 255;
}
// Colour-key masking: samples inside every [min max] pair are transparent.
match file.get(d, "Mask").map(|m| file.resolve(m)) {
Some(Obj::Array(a)) => {
let keys: Vec<f64> = a.iter().filter_map(|x| file.resolve(x).num()).collect();
if keys.len() >= 2 * n {
for i in 0..w * h {
if (0..n).all(|k| {
let v = raw_samples[i * n + k] as f64;
v >= keys[2 * k] && v <= keys[2 * k + 1]
}) {
rgba[i * 4 + 3] = 0;
}
}
}
}
Some(m @ Obj::Stream(..)) => {
if let Some((m, mw, mh)) = mask_image(file, m, false) {
for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() {
rgba[i * 4 + 3] = a;
}
}
}
_ => {}
}
if let Some(sm) = file.get(d, "SMask")
&& let Some((m, mw, mh)) = mask_image(file, sm, true)
{
for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() {
rgba[i * 4 + 3] = ((rgba[i * 4 + 3] as u32 * a as u32) / 255) as u8;
}
}
Ok(Decoded { width: w as u32, height: h as u32, rgba })
}