use pdfboss_core::geom::{Matrix, Point, Rect};
#[cfg(test)]
use pdfboss_core::{block_on, Document, Immediate};
use pdfboss_core::{AsyncObjectSource, Dict, Object};
use crate::color::ColorSpace;
use crate::raster::Mask;
use crate::Pixmap;
const MAX_PIXELS: usize = 1 << 26;
const MAX_DIM: usize = 1 << 16;
pub(crate) struct DrawParams<'a> {
pub ctm: Matrix,
pub alpha: f32,
pub fill_rgb: [u8; 3],
pub clip: Option<&'a Mask>,
}
struct Rgba<'a> {
width: usize,
height: usize,
pixels: Pixels<'a>,
truncated: bool,
}
enum Pixels<'a> {
Quads(Vec<u8>),
Packed {
data: &'a [u8],
bpc: usize,
row_bytes: usize,
lut: Vec<[u8; 4]>,
},
}
impl Rgba<'_> {
fn at(&self, i: usize, j: usize) -> [u8; 4] {
match &self.pixels {
Pixels::Quads(data) => {
let off = (j * self.width + i) * 4;
data.get(off..off + 4)
.and_then(|s| <[u8; 4]>::try_from(s).ok())
.unwrap_or([0; 4])
}
Pixels::Packed {
data,
bpc,
row_bytes,
lut,
} => {
let bit = i * bpc;
let byte = data.get(j * row_bytes + bit / 8).copied().unwrap_or(0);
let shift = 8 - bpc - bit % 8;
let mask = ((1u16 << bpc) - 1) as u8;
lut.get(usize::from((byte >> shift) & mask))
.copied()
.unwrap_or([0; 4])
}
}
}
}
pub(crate) enum Drawn {
Whole,
Truncated,
Nothing,
Failed(String),
Degraded(Vec<String>),
}
pub(crate) struct ImageMeta {
dct: bool,
jpx: bool,
smask_in_data: i64,
width: Option<f64>,
height: Option<f64>,
pub(crate) stencil: bool,
decode: Option<Vec<f32>>,
cs: Option<ColorSpace>,
bpc: Option<f64>,
}
impl ImageMeta {
#[cfg(test)]
pub(crate) fn read(doc: &Document, dict: &Dict, cs_obj: Option<&Object>) -> ImageMeta {
block_on(Self::read_with(&Immediate(doc), dict, cs_obj))
}
pub(crate) async fn read_with<S: AsyncObjectSource>(
src: &S,
dict: &Dict,
cs_obj: Option<&Object>,
) -> ImageMeta {
let cs = match cs_obj {
Some(obj) => Some(ColorSpace::parse_with(src, obj).await),
None => None,
};
ImageMeta {
dct: is_dct(src, dict).await,
jpx: is_jpx(src, dict).await,
smask_in_data: num_of(src, dict, "SMaskInData")
.await
.map(|v| v as i64)
.unwrap_or(0),
width: num_of(src, dict, "Width").await,
height: num_of(src, dict, "Height").await,
stencil: bool_of(src, dict, "ImageMask").await.unwrap_or(false),
decode: floats_of(src, dict, "Decode").await,
cs,
bpc: num_of(src, dict, "BitsPerComponent").await,
}
}
}
pub(crate) fn draw(pix: &mut Pixmap, meta: &ImageMeta, data: &[u8], p: &DrawParams) -> Drawn {
if meta.jpx {
return draw_jpx(pix, meta, data, p);
}
match decode_rgba(meta, data, p.fill_rgb) {
Some(img) => {
let truncated = img.truncated;
draw_rgba(pix, &img, p);
if truncated {
Drawn::Truncated
} else {
Drawn::Whole
}
}
None => Drawn::Nothing,
}
}
async fn num_of<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<f64> {
src.resolve(dict.get(key)?).await.ok()?.as_f64()
}
async fn bool_of<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<bool> {
src.resolve(dict.get(key)?).await.ok()?.as_bool()
}
async fn floats_of<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<Vec<f32>> {
let arr = match src.resolve(dict.get(key)?).await {
Ok(Object::Array(a)) => a,
_ => return None,
};
let mut out = Vec::with_capacity(arr.len());
for item in &arr {
let v = src.resolve(item).await.ok()?.as_f64()? as f32;
if !v.is_finite() {
return None;
}
out.push(v);
}
Some(out)
}
async fn is_dct<S: AsyncObjectSource>(src: &S, dict: &Dict) -> bool {
matches!(
pdfboss_core::filters::trailing_filter_with(src, dict)
.await
.as_ref()
.map(|n| n.0.as_str()),
Some("DCTDecode" | "DCT")
)
}
async fn is_jpx<S: AsyncObjectSource>(src: &S, dict: &Dict) -> bool {
matches!(
pdfboss_core::filters::trailing_filter_with(src, dict)
.await
.as_ref()
.map(|n| n.0.as_str()),
Some("JPXDecode")
)
}
fn sample_bits(data: &[u8], bit: usize, bpc: usize) -> u32 {
if bpc == 8 {
return u32::from(data.get(bit / 8).copied().unwrap_or(0));
}
let mut v = 0u32;
for i in 0..bpc {
let b = bit + i;
let byte = data.get(b / 8).copied().unwrap_or(0);
v = (v << 1) | u32::from((byte >> (7 - b % 8)) & 1);
}
v
}
fn short_of_samples(data: &[u8], row_bits: usize, height: usize) -> bool {
data.len() < (row_bits / 8).saturating_mul(height)
}
fn decode_rgba<'a>(meta: &ImageMeta, data: &'a [u8], fill_rgb: [u8; 3]) -> Option<Rgba<'a>> {
if meta.dct {
return decode_jpeg(data);
}
let width = meta.width? as usize;
let height = meta.height? as usize;
if width == 0 || height == 0 || width > MAX_DIM || height > MAX_DIM {
return None;
}
width.checked_mul(height).filter(|&n| n <= MAX_PIXELS)?;
let decode = meta.decode.as_deref();
if meta.stencil {
return Some(decode_stencil(width, height, data, decode, fill_rgb));
}
let cs = meta.cs.as_ref().unwrap_or(&ColorSpace::DeviceGray);
let bpc = match meta.bpc.map(|v| v as i64) {
Some(v @ (1 | 2 | 4 | 8 | 16)) => v as usize,
_ => 8,
};
Some(decode_samples(width, height, data, cs, bpc, decode))
}
fn decode_stencil(
width: usize,
height: usize,
data: &[u8],
decode: Option<&[f32]>,
fill_rgb: [u8; 3],
) -> Rgba<'static> {
let invert = matches!(decode, Some([d0, d1, ..]) if d0 > d1);
let stride_bits = width.div_ceil(8) * 8;
let mut out = vec![0u8; width * height * 4];
for y in 0..height {
for x in 0..width {
let raw = sample_bits(data, y * stride_bits + x, 1);
if (raw == 0) != invert {
let off = (y * width + x) * 4;
out[off..off + 3].copy_from_slice(&fill_rgb);
out[off + 3] = 255;
}
}
}
Rgba {
width,
height,
pixels: Pixels::Quads(out),
truncated: short_of_samples(data, stride_bits, height),
}
}
fn decode_samples<'a>(
width: usize,
height: usize,
data: &'a [u8],
cs: &ColorSpace,
bpc: usize,
decode: Option<&[f32]>,
) -> Rgba<'a> {
let ncomp = cs.components().clamp(1, 8);
let max = ((1u32 << bpc) - 1) as f32;
let default_hi = if matches!(cs, ColorSpace::Indexed { .. }) {
max
} else {
1.0
};
let ranges: Vec<(f32, f32)> = (0..ncomp)
.map(|c| match decode {
Some(d) if d.len() >= 2 * (c + 1) => (d[2 * c], d[2 * c + 1]),
_ => (0.0, default_hi),
})
.collect();
let stride_bits = (ncomp * bpc * width).div_ceil(8) * 8;
let truncated = short_of_samples(data, stride_bits, height);
if ncomp == 1 && bpc <= 8 {
return Rgba {
width,
height,
pixels: Pixels::Packed {
data,
bpc,
row_bytes: (width * bpc).div_ceil(8),
lut: sample_lut(cs, bpc, ranges[0], max),
},
truncated,
};
}
let mut out = vec![0u8; width * height * 4];
let mut comps = [0.0f32; 8];
for y in 0..height {
for x in 0..width {
let bit0 = y * stride_bits + x * ncomp * bpc;
for (c, comp) in comps.iter_mut().enumerate().take(ncomp) {
let raw = sample_bits(data, bit0 + c * bpc, bpc) as f32;
let (d0, d1) = ranges[c];
*comp = d0 + raw * (d1 - d0) / max;
}
let rgb = cs.to_rgb(&comps[..ncomp]);
let off = (y * width + x) * 4;
for (i, v) in rgb.iter().enumerate() {
out[off + i] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
out[off + 3] = 255;
}
}
Rgba {
width,
height,
pixels: Pixels::Quads(out),
truncated,
}
}
fn sample_lut(cs: &ColorSpace, bpc: usize, range: (f32, f32), max: f32) -> Vec<[u8; 4]> {
let (d0, d1) = range;
(0..1usize << bpc)
.map(|raw| {
let rgb = cs.to_rgb(&[d0 + raw as f32 * (d1 - d0) / max]);
let mut px = [0u8, 0, 0, 255];
for (slot, v) in px.iter_mut().zip(rgb) {
*slot = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
px
})
.collect()
}
fn decode_jpeg<'a>(data: &[u8]) -> Option<Rgba<'a>> {
let mut dec = jpeg_decoder::Decoder::new(data);
dec.read_info().ok()?;
let info = dec.info()?;
let (w, h) = (info.width as usize, info.height as usize);
if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM {
return None;
}
w.checked_mul(h).filter(|&n| n <= MAX_PIXELS)?;
dec.set_max_decoding_buffer_size(MAX_PIXELS * 4);
let pixels = dec.decode().ok()?;
let mut out = vec![255u8; w * h * 4];
match info.pixel_format {
jpeg_decoder::PixelFormat::L8 => {
for (i, &g) in pixels.iter().enumerate().take(w * h) {
out[i * 4..i * 4 + 3].copy_from_slice(&[g, g, g]);
}
}
jpeg_decoder::PixelFormat::L16 => {
for (i, pair) in pixels.chunks_exact(2).enumerate().take(w * h) {
let g = pair[0]; out[i * 4..i * 4 + 3].copy_from_slice(&[g, g, g]);
}
}
jpeg_decoder::PixelFormat::RGB24 => {
for (i, rgb) in pixels.chunks_exact(3).enumerate().take(w * h) {
out[i * 4..i * 4 + 3].copy_from_slice(rgb);
}
}
jpeg_decoder::PixelFormat::CMYK32 => {
for (i, cmyk) in pixels.chunks_exact(4).enumerate().take(w * h) {
let rgb = inverted_cmyk_to_rgb([cmyk[0], cmyk[1], cmyk[2], cmyk[3]]);
out[i * 4..i * 4 + 3].copy_from_slice(&rgb);
}
}
}
Some(Rgba {
width: w,
height: h,
pixels: Pixels::Quads(out),
truncated: false,
})
}
fn jpx_limits() -> pdfboss_jpx::DecodeLimits {
pdfboss_jpx::DecodeLimits {
max_pixels: MAX_PIXELS as u64,
max_components: 9,
max_decoded_bytes: (MAX_PIXELS as u64) * 4,
..pdfboss_jpx::DecodeLimits::default()
}
}
fn draw_jpx(pix: &mut Pixmap, meta: &ImageMeta, data: &[u8], p: &DrawParams) -> Drawn {
let decoded = match pdfboss_jpx::decode(data, &jpx_limits()) {
Ok(decoded) => decoded,
Err(e) => return Drawn::Failed(format!("JPXDecode: {e}")),
};
let converted = if meta.stencil {
jpx_stencil(meta, &decoded, p.fill_rgb)
} else {
jpx_rgba(meta, decoded)
};
let (img, notes) = match converted {
Ok(converted) => converted,
Err(reason) => return Drawn::Failed(reason),
};
draw_rgba(pix, &img, p);
if notes.is_empty() {
Drawn::Whole
} else {
Drawn::Degraded(notes)
}
}
fn jpx_dimensions(img: &pdfboss_jpx::DecodedImage) -> Result<(usize, usize), String> {
let width = img.width as usize;
let height = img.height as usize;
if width == 0 || height == 0 || width > MAX_DIM || height > MAX_DIM {
return Err(format!("JPXDecode: bad dimensions {width}x{height}"));
}
width
.checked_mul(height)
.filter(|&n| n <= MAX_PIXELS)
.ok_or_else(|| format!("JPXDecode: {width}x{height} exceeds the pixel cap"))?;
let components = usize::from(img.components);
if components == 0 || img.samples.len() < width * height * components {
return Err("JPXDecode: the decoder returned too few samples".to_string());
}
Ok((width, height))
}
fn jpx_stencil(
meta: &ImageMeta,
img: &pdfboss_jpx::DecodedImage,
fill_rgb: [u8; 3],
) -> Result<(Rgba<'static>, Vec<String>), String> {
let (width, height) = jpx_dimensions(img)?;
if img.components != 1 {
return Err(format!(
"JPXDecode: /ImageMask true but the codestream carries {} channels \
where ISO 32000-1 7.4.9 demands a single one",
img.components
));
}
let notes = material_jpx_warnings(&img.warnings);
let row_bytes = width.div_ceil(8);
let mut packed = vec![0u8; row_bytes * height];
for y in 0..height {
for x in 0..width {
if img.samples[y * width + x] >= 128 {
packed[y * row_bytes + x / 8] |= 128 >> (x % 8);
}
}
}
Ok((
decode_stencil(width, height, &packed, meta.decode.as_deref(), fill_rgb),
notes,
))
}
fn jpx_rgba(
meta: &ImageMeta,
img: pdfboss_jpx::DecodedImage,
) -> Result<(Rgba<'static>, Vec<String>), String> {
let (width, height) = jpx_dimensions(&img)?;
let components = usize::from(img.components);
let alpha = img.alpha_index.map(usize::from).filter(|&a| a < components);
let color_count = components - usize::from(alpha.is_some());
if color_count == 0 {
return Err("JPXDecode: the codestream carries only an opacity channel".to_string());
}
let mut notes = material_jpx_warnings(&img.warnings);
let mapped;
let cs: &ColorSpace = match meta.cs.as_ref() {
Some(cs) => {
if cs.components() != color_count {
return Err(format!(
"JPXDecode: /ColorSpace expects {} component(s) but the codestream \
carries {color_count} colour channel(s)",
cs.components()
));
}
cs
}
None => {
mapped = match img.color {
pdfboss_jpx::ColorKind::Gray => ColorSpace::DeviceGray,
pdfboss_jpx::ColorKind::Rgb => ColorSpace::DeviceRGB,
pdfboss_jpx::ColorKind::Cmyk => ColorSpace::DeviceCMYK,
_ => {
notes.push(format!(
"JPXDecode: colour approximated from {color_count} channel(s); \
the codestream's colour declaration is not interpreted"
));
match color_count {
1 => ColorSpace::DeviceGray,
3 => ColorSpace::DeviceRGB,
4 => ColorSpace::DeviceCMYK,
n => {
return Err(format!(
"JPXDecode: no colour space for {n} colour channel(s)"
))
}
}
}
};
if mapped.components() != color_count {
return Err(format!(
"JPXDecode: the codestream declares {} colour but carries \
{color_count} colour channel(s)",
mapped.components()
));
}
&mapped
}
};
let masking = alpha.is_some() && matches!(meta.smask_in_data, 1 | 2);
let premultiplied = masking && meta.smask_in_data == 2;
let mut color_data = Vec::with_capacity(width * height * color_count);
let mut alpha_data = Vec::with_capacity(if masking { width * height } else { 0 });
let mut zero_alpha_color = false;
for px in img.samples.chunks_exact(components) {
let a = alpha.map_or(255, |i| px[i]);
for (i, &s) in px.iter().enumerate() {
if Some(i) == alpha {
continue;
}
color_data.push(if premultiplied {
if a == 0 {
zero_alpha_color |= s != 0;
0
} else {
((u32::from(s) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8
}
} else {
s
});
}
if masking {
alpha_data.push(a);
}
}
if zero_alpha_color {
notes.push(
"JPXDecode: premultiplied colour under zero opacity clamped to zero \
(/SMaskInData 2)"
.to_string(),
);
}
if matches!(cs, ColorSpace::Indexed { .. }) {
let channel = usize::from(alpha == Some(0));
let depth = img.component_depths.get(channel).copied().unwrap_or(8);
if depth < 8 {
for v in &mut color_data {
*v = jpx_palette_index(*v, depth);
}
} else if depth > 8 {
notes.push(format!(
"JPXDecode: palette indices carried in a {depth}-bit channel lost \
their low bits in the 8-bit normalization; the palette lookup is \
approximate"
));
}
}
let converted = decode_samples(width, height, &color_data, cs, 8, None);
let mut quads = owned_quads(converted);
if masking {
for (px, &a) in quads.chunks_exact_mut(4).zip(&alpha_data) {
px[3] = a;
}
}
Ok((
Rgba {
width,
height,
pixels: Pixels::Quads(quads),
truncated: false,
},
notes,
))
}
fn jpx_palette_index(v: u8, depth: u8) -> u8 {
let max = (1u32 << depth) - 1;
((u32::from(v) * max + 127) / 255) as u8
}
fn owned_quads(img: Rgba<'_>) -> Vec<u8> {
if let Pixels::Quads(quads) = img.pixels {
return quads;
}
let mut out = vec![0u8; img.width * img.height * 4];
for y in 0..img.height {
for x in 0..img.width {
let off = (y * img.width + x) * 4;
out[off..off + 4].copy_from_slice(&img.at(x, y));
}
}
out
}
fn material_jpx_warnings(warnings: &[pdfboss_jpx::JpxWarning]) -> Vec<String> {
warnings
.iter()
.filter(|w| w.data_loss)
.map(|w| format!("JPXDecode: {}", w.message))
.collect()
}
fn inverted_cmyk_to_rgb(px: [u8; 4]) -> [u8; 3] {
let ink = |v: u8| 1.0 - f32::from(v) / 255.0;
let rgb = ColorSpace::DeviceCMYK.to_rgb(&[ink(px[0]), ink(px[1]), ink(px[2]), ink(px[3])]);
[
(rgb[0] * 255.0 + 0.5) as u8,
(rgb[1] * 255.0 + 0.5) as u8,
(rgb[2] * 255.0 + 0.5) as u8,
]
}
fn composite_over(dst: &mut [u8], rgb: [u8; 3], a: f32) {
let da = f32::from(dst[3]) / 255.0;
let oa = a + da * (1.0 - a);
if oa <= 0.0 {
dst.copy_from_slice(&[0, 0, 0, 0]);
return;
}
for i in 0..3 {
let s = f32::from(rgb[i]);
let d = f32::from(dst[i]);
dst[i] = ((s * a + d * da * (1.0 - a)) / oa + 0.5) as u8;
}
dst[3] = (oa * 255.0 + 0.5) as u8;
}
fn draw_rgba(pix: &mut Pixmap, img: &Rgba<'_>, p: &DrawParams) {
let Some(inv) = p.ctm.invert() else {
return;
};
let alpha = if p.alpha.is_finite() {
p.alpha.clamp(0.0, 1.0)
} else {
1.0
};
if alpha <= 0.0 {
return;
}
let bbox = Rect::new(0.0, 0.0, 1.0, 1.0).transform(p.ctm);
let x0 = bbox.x0.floor().max(0.0) as u32;
let y0 = bbox.y0.floor().max(0.0) as u32;
let x1 = (bbox.x1.ceil().max(0.0) as u32).min(pix.width);
let y1 = (bbox.y1.ceil().max(0.0) as u32).min(pix.height);
for py in y0..y1 {
for px in x0..x1 {
let u = inv.apply(Point::new(px as f32 + 0.5, py as f32 + 0.5));
if !(0.0..1.0).contains(&u.x) || !(0.0..1.0).contains(&u.y) {
continue;
}
let i = ((u.x * img.width as f32) as usize).min(img.width - 1);
let j = (((1.0 - u.y) * img.height as f32) as usize).min(img.height - 1);
let s = img.at(i, j);
let mut a = f32::from(s[3]) / 255.0 * alpha;
if let Some(mask) = p.clip {
a *= f32::from(mask.coverage(px, py)) / 255.0;
}
if a <= 0.0 {
continue;
}
let off = ((py * pix.width + px) * 4) as usize;
let dst = &mut pix.data[off..off + 4];
if a >= 1.0 {
dst.copy_from_slice(&[s[0], s[1], s[2], 255]);
} else {
composite_over(dst, [s[0], s[1], s[2]], a);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pdfboss_core::parser::{NoResolve, Parser};
use pdfboss_testkit::PdfBuilder;
fn test_doc() -> Document {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
Document::load(b.build(1)).unwrap()
}
fn dict(src: &[u8]) -> Dict {
match Parser::new(src).parse_object(&NoResolve).unwrap() {
Object::Dict(d) => d,
other => panic!("expected dict, got {other:?}"),
}
}
fn obj(src: &[u8]) -> Object {
Parser::new(src).parse_object(&NoResolve).unwrap()
}
fn rgba_at(img: &Rgba<'_>, x: usize, y: usize) -> [u8; 4] {
img.at(x, y)
}
fn decode_rgba<'a>(
doc: &Document,
dict: &Dict,
data: &'a [u8],
cs_obj: Option<&Object>,
fill_rgb: [u8; 3],
) -> Option<Rgba<'a>> {
super::decode_rgba(&ImageMeta::read(doc, dict, cs_obj), data, fill_rgb)
}
#[test]
fn sample_bits_all_depths() {
let data = [0b1011_0110, 0b0101_0011];
assert_eq!(sample_bits(&data, 0, 1), 1);
assert_eq!(sample_bits(&data, 1, 1), 0);
assert_eq!(sample_bits(&data, 0, 2), 0b10);
assert_eq!(sample_bits(&data, 2, 2), 0b11);
assert_eq!(sample_bits(&data, 0, 4), 0b1011);
assert_eq!(sample_bits(&data, 4, 4), 0b0110);
assert_eq!(sample_bits(&data, 0, 8), 0b1011_0110);
assert_eq!(sample_bits(&data, 8, 8), 0b0101_0011);
assert_eq!(sample_bits(&data, 0, 16), 0b1011_0110_0101_0011);
assert_eq!(sample_bits(&data, 16, 8), 0);
}
#[test]
fn gray_bpc_variants_decode() {
let doc = test_doc();
let d = dict(b"<< /Width 2 /Height 2 /BitsPerComponent 8 >>");
let img = decode_rgba(&doc, &d, &[0, 85, 170, 255], None, [0; 3]).unwrap();
assert_eq!(rgba_at(&img, 0, 0), [0, 0, 0, 255]);
assert_eq!(rgba_at(&img, 1, 0), [85, 85, 85, 255]);
assert_eq!(rgba_at(&img, 1, 1), [255, 255, 255, 255]);
let d = dict(b"<< /Width 2 /Height 1 /BitsPerComponent 1 >>");
let img = decode_rgba(&doc, &d, &[0b1000_0000], None, [0; 3]).unwrap();
assert_eq!(rgba_at(&img, 0, 0), [255, 255, 255, 255]);
assert_eq!(rgba_at(&img, 1, 0), [0, 0, 0, 255]);
let d = dict(b"<< /Width 2 /Height 1 /BitsPerComponent 4 >>");
let img = decode_rgba(&doc, &d, &[0xF0], None, [0; 3]).unwrap();
assert_eq!(rgba_at(&img, 0, 0), [255, 255, 255, 255]);
assert_eq!(rgba_at(&img, 1, 0), [0, 0, 0, 255]);
let d = dict(b"<< /Width 1 /Height 1 /BitsPerComponent 16 >>");
let img = decode_rgba(&doc, &d, &[0x80, 0x00], None, [0; 3]).unwrap();
let [r, ..] = rgba_at(&img, 0, 0);
assert!((127..=129).contains(&r), "16-bit mid gray {r}");
}
#[test]
fn the_lookup_table_paints_what_per_pixel_conversion_would() {
let cs = ColorSpace::DeviceGray;
let width = 13;
let height = 3;
for bpc in [1usize, 2, 4, 8] {
let row_bytes = (width * bpc).div_ceil(8);
let full: Vec<u8> = (0..row_bytes * height)
.map(|i| (i as u8).wrapping_mul(37).wrapping_add(11))
.collect();
for data in [&full[..], &full[..row_bytes + 1]] {
for range in [(0.0f32, 1.0f32), (1.0, 0.0), (0.25, 0.75)] {
let max = ((1u32 << bpc) - 1) as f32;
let decode = [range.0, range.1];
let got = decode_samples(width, height, data, &cs, bpc, Some(&decode));
let stride_bits = row_bytes * 8;
for y in 0..height {
for x in 0..width {
let raw = sample_bits(data, y * stride_bits + x * bpc, bpc) as f32;
let (d0, d1) = range;
let rgb = cs.to_rgb(&[d0 + raw * (d1 - d0) / max]);
let mut want = [0u8, 0, 0, 255];
for (slot, v) in want.iter_mut().zip(rgb) {
*slot = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
assert_eq!(
got.at(x, y),
want,
"bpc {bpc} range {range:?} at ({x},{y}) \
with {} bytes",
data.len()
);
}
}
}
}
}
}
#[test]
fn rows_are_byte_aligned() {
let doc = test_doc();
let d = dict(b"<< /Width 3 /Height 2 /BitsPerComponent 1 >>");
let img = decode_rgba(&doc, &d, &[0b1010_0000, 0b0100_0000], None, [0; 3]).unwrap();
assert_eq!(rgba_at(&img, 0, 0)[0], 255);
assert_eq!(rgba_at(&img, 1, 0)[0], 0);
assert_eq!(rgba_at(&img, 2, 0)[0], 255);
assert_eq!(rgba_at(&img, 0, 1)[0], 0);
assert_eq!(rgba_at(&img, 1, 1)[0], 255);
assert_eq!(rgba_at(&img, 2, 1)[0], 0);
}
#[test]
fn decode_array_inverts_gray() {
let doc = test_doc();
let d = dict(b"<< /Width 2 /Height 1 /BitsPerComponent 8 /Decode [1 0] >>");
let img = decode_rgba(&doc, &d, &[0, 255], None, [0; 3]).unwrap();
assert_eq!(rgba_at(&img, 0, 0), [255, 255, 255, 255], "0 inverts to 1");
assert_eq!(rgba_at(&img, 1, 0), [0, 0, 0, 255], "255 inverts to 0");
}
#[test]
fn rgb_and_cmyk_samples_decode() {
let doc = test_doc();
let d = dict(b"<< /Width 2 /Height 1 /BitsPerComponent 8 >>");
let cs = obj(b"/DeviceRGB");
let img = decode_rgba(&doc, &d, &[255, 0, 0, 0, 0, 255], Some(&cs), [0; 3]).unwrap();
assert_eq!(rgba_at(&img, 0, 0), [255, 0, 0, 255]);
assert_eq!(rgba_at(&img, 1, 0), [0, 0, 255, 255]);
let d = dict(b"<< /Width 1 /Height 1 /BitsPerComponent 8 >>");
let cs = obj(b"/DeviceCMYK");
let img = decode_rgba(&doc, &d, &[255, 0, 0, 0], Some(&cs), [0; 3]).unwrap();
assert_eq!(rgba_at(&img, 0, 0), [0, 255, 255, 255], "pure cyan");
}
#[test]
fn indexed_lookup_via_palette() {
let doc = test_doc();
let cs = obj(b"[/Indexed /DeviceRGB 3 <FF0000 00FF00 0000FF 000000>]");
let d = dict(b"<< /Width 4 /Height 1 /BitsPerComponent 2 >>");
let img = decode_rgba(&doc, &d, &[0b00_01_10_11], Some(&cs), [0; 3]).unwrap();
assert_eq!(rgba_at(&img, 0, 0), [255, 0, 0, 255]);
assert_eq!(rgba_at(&img, 1, 0), [0, 255, 0, 255]);
assert_eq!(rgba_at(&img, 2, 0), [0, 0, 255, 255]);
assert_eq!(rgba_at(&img, 3, 0), [0, 0, 0, 255]);
}
#[test]
fn stencil_and_inverted_stencil() {
let doc = test_doc();
let d = dict(b"<< /Width 2 /Height 2 /ImageMask true /BitsPerComponent 1 >>");
let img = decode_rgba(&doc, &d, &[0x40, 0x80], None, [10, 20, 30]).unwrap();
assert_eq!(rgba_at(&img, 0, 0), [10, 20, 30, 255], "0 paints");
assert_eq!(rgba_at(&img, 1, 0), [0, 0, 0, 0], "1 transparent");
assert_eq!(rgba_at(&img, 0, 1), [0, 0, 0, 0]);
assert_eq!(rgba_at(&img, 1, 1), [10, 20, 30, 255]);
let d = dict(b"<< /Width 2 /Height 2 /ImageMask true /BitsPerComponent 1 /Decode [1 0] >>");
let img = decode_rgba(&doc, &d, &[0x40, 0x80], None, [10, 20, 30]).unwrap();
assert_eq!(rgba_at(&img, 0, 0), [0, 0, 0, 0], "inverted: 0 transparent");
assert_eq!(rgba_at(&img, 1, 0), [10, 20, 30, 255], "inverted: 1 paints");
}
fn tiny_jpeg() -> Vec<u8> {
let mut j = vec![0xFF, 0xD8]; j.extend_from_slice(&[0xFF, 0xDB, 0x00, 0x43, 0x00]); j.extend_from_slice(&[1u8; 64]);
j.extend_from_slice(&[
0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00,
]);
j.extend_from_slice(&[0xFF, 0xC4, 0x00, 0x14, 0x00, 0x01]);
j.extend_from_slice(&[0u8; 15]);
j.push(0x00);
j.extend_from_slice(&[0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01]);
j.extend_from_slice(&[0u8; 15]);
j.push(0x00);
j.extend_from_slice(&[
0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x3F,
]);
j.extend_from_slice(&[0xFF, 0xD9]); j
}
#[test]
fn dct_image_decodes_via_jpeg() {
let doc = test_doc();
let d = dict(
b"<< /Width 1 /Height 1 /BitsPerComponent 8 /Filter /DCTDecode \
/ColorSpace /DeviceGray >>",
);
let jpeg = tiny_jpeg();
let img = decode_rgba(&doc, &d, &jpeg, None, [0; 3]).expect("jpeg decodes");
assert_eq!((img.width, img.height), (1, 1));
let [r, g, b, a] = rgba_at(&img, 0, 0);
assert_eq!((r, g), (r, r), "gray");
assert!((120..=136).contains(&r), "mid gray, got {r}");
assert_eq!((g, b, a), (r, r, 255));
assert!(decode_rgba(&doc, &d, &[1, 2, 3], None, [0; 3]).is_none());
}
#[test]
fn jpeg_with_huge_sof_dimensions_is_rejected_before_decoding() {
let doc = test_doc();
let d = dict(
b"<< /Width 1 /Height 1 /BitsPerComponent 8 /Filter /DCTDecode \
/ColorSpace /DeviceGray >>",
);
let mut j = tiny_jpeg();
let sof = j.windows(2).position(|w| w == [0xFF, 0xC0]).expect("SOF0");
j[sof + 5..sof + 9].copy_from_slice(&[0xFF; 4]);
let start = std::time::Instant::now();
assert!(decode_rgba(&doc, &d, &j, None, [0; 3]).is_none());
assert!(
start.elapsed() < std::time::Duration::from_secs(2),
"header-only rejection must not attempt a decode-sized allocation"
);
let mut j = tiny_jpeg();
let sof = j.windows(2).position(|w| w == [0xFF, 0xC0]).expect("SOF0");
j[sof + 5..sof + 9].copy_from_slice(&[0, 0, 0, 0]);
assert!(decode_rgba(&doc, &d, &j, None, [0; 3]).is_none());
}
fn jpx_image(
width: u32,
height: u32,
components: u8,
samples: Vec<u8>,
color: pdfboss_jpx::ColorKind,
alpha_index: Option<u8>,
) -> pdfboss_jpx::DecodedImage {
pdfboss_jpx::DecodedImage {
width,
height,
components,
samples,
component_depths: vec![8; usize::from(components)],
color,
alpha_index,
warnings: Vec::new(),
}
}
fn jpx_meta(doc: &Document, dict_src: &[u8], cs_src: Option<&[u8]>) -> ImageMeta {
let cs = cs_src.map(obj);
ImageMeta::read(doc, &dict(dict_src), cs.as_ref())
}
#[test]
fn jpx_gray_maps_to_device_gray_and_ignores_decode() {
let doc = test_doc();
let image = || jpx_image(2, 1, 1, vec![0, 255], pdfboss_jpx::ColorKind::Gray, None);
let meta = jpx_meta(&doc, b"<< >>", None);
let (img, notes) = jpx_rgba(&meta, image()).expect("gray decodes");
assert!(notes.is_empty(), "no degradation: {notes:?}");
assert_eq!(rgba_at(&img, 0, 0), [0, 0, 0, 255]);
assert_eq!(rgba_at(&img, 1, 0), [255, 255, 255, 255]);
let meta = jpx_meta(&doc, b"<< /Decode [1 0] /BitsPerComponent 1 >>", None);
let (img, _) = jpx_rgba(&meta, image()).expect("gray decodes");
assert_eq!(rgba_at(&img, 0, 0), [0, 0, 0, 255]);
assert_eq!(rgba_at(&img, 1, 0), [255, 255, 255, 255]);
}
#[test]
fn jpx_dict_colorspace_overrides_the_codestream() {
let doc = test_doc();
let color = pdfboss_jpx::ColorKind::Other {
enumeration: 9,
components: 3,
};
let image = jpx_image(1, 1, 3, vec![255, 0, 10], color, None);
let meta = jpx_meta(&doc, b"<< >>", Some(b"/DeviceRGB"));
let (img, notes) = jpx_rgba(&meta, image).expect("rgb decodes");
assert!(notes.is_empty(), "an override is not a guess: {notes:?}");
assert_eq!(rgba_at(&img, 0, 0), [255, 0, 10, 255]);
}
#[test]
fn jpx_colorspace_component_mismatch_is_a_named_failure() {
let doc = test_doc();
let image = jpx_image(1, 1, 1, vec![7], pdfboss_jpx::ColorKind::Gray, None);
let meta = jpx_meta(&doc, b"<< >>", Some(b"/DeviceRGB"));
let reason = match jpx_rgba(&meta, image) {
Err(reason) => reason,
Ok(..) => panic!("1 channel is not RGB"),
};
assert!(
reason.contains("3 component(s)") && reason.contains("1 colour channel(s)"),
"{reason}"
);
}
#[test]
fn jpx_uninterpreted_color_is_approximated_by_channel_count_with_a_note() {
let doc = test_doc();
let color = pdfboss_jpx::ColorKind::IccGuess { components: 3 };
let image = jpx_image(1, 1, 3, vec![0, 255, 0], color, None);
let meta = jpx_meta(&doc, b"<< >>", None);
let (img, notes) = jpx_rgba(&meta, image).expect("3 channels read as RGB");
assert_eq!(rgba_at(&img, 0, 0), [0, 255, 0, 255]);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("colour approximated"), "{notes:?}");
}
#[test]
fn jpx_alpha_channel_masks_only_when_smask_in_data_asks() {
let doc = test_doc();
let image = || {
jpx_image(
1,
1,
4,
vec![255, 0, 0, 128],
pdfboss_jpx::ColorKind::Rgb,
Some(3),
)
};
let meta = jpx_meta(&doc, b"<< >>", None);
let (img, _) = jpx_rgba(&meta, image()).expect("decodes");
assert_eq!(rgba_at(&img, 0, 0), [255, 0, 0, 255]);
let meta = jpx_meta(&doc, b"<< /SMaskInData 1 >>", None);
let (img, notes) = jpx_rgba(&meta, image()).expect("decodes");
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(rgba_at(&img, 0, 0), [255, 0, 0, 128]);
}
#[test]
fn jpx_premultiplied_color_is_divided_back_out() {
let doc = test_doc();
let image = jpx_image(
1,
1,
4,
vec![128, 0, 0, 128],
pdfboss_jpx::ColorKind::Rgb,
Some(3),
);
let meta = jpx_meta(&doc, b"<< /SMaskInData 2 >>", None);
let (img, notes) = jpx_rgba(&meta, image).expect("decodes");
assert!(
notes.is_empty(),
"a clean division is not a loss: {notes:?}"
);
assert_eq!(rgba_at(&img, 0, 0), [255, 0, 0, 128]);
}
#[test]
fn jpx_premultiplied_color_under_zero_alpha_clamps_with_one_note() {
let doc = test_doc();
let image = jpx_image(
2,
1,
2,
vec![9, 0, 17, 0],
pdfboss_jpx::ColorKind::Gray,
Some(1),
);
let meta = jpx_meta(&doc, b"<< /SMaskInData 2 >>", None);
let (img, notes) = jpx_rgba(&meta, image).expect("decodes");
assert_eq!(rgba_at(&img, 0, 0), [0, 0, 0, 0], "clamped, fully clear");
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("zero opacity"), "{notes:?}");
}
#[test]
fn jpx_data_loss_warnings_surface_and_benign_notes_do_not() {
let doc = test_doc();
let mut image = jpx_image(1, 1, 1, vec![0], pdfboss_jpx::ColorKind::Gray, None);
image.warnings = vec![
pdfboss_jpx::JpxWarning {
message: "2 tile(s) ship more tile-parts than their declared TNsot \
(violates T.800 A.4.2); tolerated for compatibility"
.to_string(),
data_loss: false,
},
pdfboss_jpx::JpxWarning {
message: "a benign note mentioning a corrupt code-block".to_string(),
data_loss: false,
},
pdfboss_jpx::JpxWarning {
message: "tile 3 rendered as background".to_string(),
data_loss: true,
},
];
let meta = jpx_meta(&doc, b"<< >>", None);
let (_, notes) = jpx_rgba(&meta, image).expect("decodes");
assert_eq!(notes.len(), 1, "{notes:?}");
assert_eq!(notes[0], "JPXDecode: tile 3 rendered as background");
}
#[test]
fn trailing_filter_skips_non_name_entries_like_decode_stream() {
let doc = test_doc();
let d = dict(b"<< /Width 1 /Height 1 /Filter [/JPXDecode null] >>");
let meta = ImageMeta::read(&doc, &d, None);
assert!(meta.jpx, "the trailing Name is JPXDecode");
let d = dict(b"<< /Width 1 /Height 1 /Filter [/FlateDecode /DCTDecode null] >>");
let meta = ImageMeta::read(&doc, &d, None);
assert!(meta.dct, "the trailing Name is DCTDecode");
let d = dict(b"<< /Width 1 /Height 1 /Filter [null] >>");
let meta = ImageMeta::read(&doc, &d, None);
assert!(!meta.jpx && !meta.dct, "no Name, no codec");
}
#[test]
fn jpx_palette_index_reverses_the_normalization_exactly() {
for depth in 1u8..8 {
let max = (1u32 << depth) - 1;
for index in 0..=max {
let normalized = ((index * 255 + max / 2) / max) as u8;
assert_eq!(
u32::from(jpx_palette_index(normalized, depth)),
index,
"depth {depth} index {index} normalized {normalized}"
);
}
}
}
#[test]
fn jpx_indexed_4bit_samples_recover_their_palette_indices() {
let palette: String = (0..16)
.map(|i| if i == 8 { "00FF00" } else { "FF0000" })
.collect();
let cs = format!("[/Indexed /DeviceRGB 15 <{palette}>]");
let doc = test_doc();
let mut image = jpx_image(1, 1, 1, vec![136], pdfboss_jpx::ColorKind::Gray, None);
image.component_depths = vec![4];
let meta = jpx_meta(&doc, b"<< >>", Some(cs.as_bytes()));
let (img, notes) = jpx_rgba(&meta, image).expect("decodes");
assert!(notes.is_empty(), "an exact reversal is silent: {notes:?}");
assert_eq!(rgba_at(&img, 0, 0), [0, 255, 0, 255]);
}
#[test]
fn jpx_indexed_deep_channel_passes_through_with_a_note() {
let doc = test_doc();
let mut image = jpx_image(1, 1, 1, vec![2], pdfboss_jpx::ColorKind::Gray, None);
image.component_depths = vec![12];
let meta = jpx_meta(
&doc,
b"<< >>",
Some(b"[/Indexed /DeviceRGB 3 <FF0000 00FF00 0000FF 000000>]"),
);
let (img, notes) = jpx_rgba(&meta, image).expect("decodes");
assert_eq!(rgba_at(&img, 0, 0), [0, 0, 255, 255], "index 2 as stored");
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("12-bit"), "{notes:?}");
}
#[test]
fn jpx_stencil_paints_fill_where_the_sample_maps_to_zero() {
let doc = test_doc();
let image = || {
let mut image = jpx_image(2, 1, 1, vec![0, 255], pdfboss_jpx::ColorKind::Gray, None);
image.component_depths = vec![1];
image
};
let meta = jpx_meta(&doc, b"<< /ImageMask true >>", None);
let (img, notes) = jpx_stencil(&meta, &image(), [10, 20, 30]).expect("stencils");
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(rgba_at(&img, 0, 0), [10, 20, 30, 255], "0 paints fill");
assert_eq!(rgba_at(&img, 1, 0), [0, 0, 0, 0], "1 stays clear");
let meta = jpx_meta(&doc, b"<< /ImageMask true /Decode [1 0] >>", None);
let (img, _) = jpx_stencil(&meta, &image(), [10, 20, 30]).expect("stencils");
assert_eq!(rgba_at(&img, 0, 0), [0, 0, 0, 0], "inverted: 0 clear");
assert_eq!(rgba_at(&img, 1, 0), [10, 20, 30, 255], "inverted: 1 paints");
}
#[test]
fn jpx_stencil_rejects_a_multichannel_codestream() {
let doc = test_doc();
let image = jpx_image(1, 1, 3, vec![1, 2, 3], pdfboss_jpx::ColorKind::Rgb, None);
let meta = jpx_meta(&doc, b"<< /ImageMask true >>", None);
let reason = match jpx_stencil(&meta, &image, [0; 3]) {
Err(reason) => reason,
Ok(..) => panic!("three channels are not a stencil"),
};
assert!(
reason.contains("3 channels") && reason.contains("ImageMask"),
"{reason}"
);
}
#[test]
fn inverted_cmyk_conversion() {
assert_eq!(inverted_cmyk_to_rgb([255, 255, 255, 255]), [255, 255, 255]);
assert_eq!(inverted_cmyk_to_rgb([255, 255, 255, 0]), [0, 0, 0]);
assert_eq!(inverted_cmyk_to_rgb([0, 255, 255, 255]), [0, 255, 255]);
}
#[test]
fn bad_dimensions_are_rejected() {
let doc = test_doc();
for src in [
b"<< /Width 0 /Height 2 >>".as_slice(),
b"<< /Height 2 >>".as_slice(),
b"<< /Width 100000 /Height 100000 >>".as_slice(),
] {
assert!(
decode_rgba(&doc, &dict(src), &[], None, [0; 3]).is_none(),
"{}",
String::from_utf8_lossy(src)
);
}
}
fn quad_image() -> Rgba<'static> {
Rgba {
width: 2,
height: 2,
pixels: Pixels::Quads(vec![
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
]),
truncated: false,
}
}
fn pix_at(pix: &Pixmap, x: u32, y: u32) -> [u8; 4] {
let off = ((y * pix.width + x) * 4) as usize;
pix.data[off..off + 4].try_into().unwrap()
}
#[test]
fn draw_maps_row_zero_to_the_v1_edge() {
let mut pix = Pixmap::new(8, 8);
let p = DrawParams {
ctm: Matrix::scale(8.0, 8.0),
alpha: 1.0,
fill_rgb: [0; 3],
clip: None,
};
draw_rgba(&mut pix, &quad_image(), &p);
assert_eq!(pix_at(&pix, 1, 1), [0, 0, 255, 255], "row 1 left on top");
assert_eq!(pix_at(&pix, 6, 1), [255, 255, 255, 255], "row 1 right");
assert_eq!(pix_at(&pix, 1, 6), [255, 0, 0, 255], "row 0 left below");
assert_eq!(pix_at(&pix, 6, 6), [0, 255, 0, 255], "row 0 right");
}
#[test]
fn draw_respects_offset_alpha_and_clip() {
let mut pix = Pixmap::new(8, 8);
pix.fill([255, 255, 255, 255]);
let ctm = Matrix::scale(4.0, 4.0).concat(Matrix::translate(4.0, 0.0));
let mut clip = Mask::new(8, 8);
clip.data.iter_mut().for_each(|c| *c = 255);
for y in 0..8 {
clip.data[y * 8 + 7] = 0;
}
let p = DrawParams {
ctm,
alpha: 0.5,
fill_rgb: [0; 3],
clip: Some(&clip),
};
draw_rgba(&mut pix, &quad_image(), &p);
assert_eq!(pix_at(&pix, 1, 1), [255, 255, 255, 255], "outside image");
let [r, g, b, _] = pix_at(&pix, 5, 1);
assert_eq!(b, 255, "blue keeps its own channel");
assert!((127..=129).contains(&r), "50% blend r {r}");
assert!((127..=129).contains(&g), "50% blend g {g}");
assert_eq!(pix_at(&pix, 7, 1), [255, 255, 255, 255], "clipped column");
}
#[test]
fn an_opaque_source_composites_to_a_plain_copy() {
for &under in &[
[0, 0, 0, 0],
[255, 255, 255, 255],
[17, 200, 3, 128],
[9, 9, 9, 1],
] {
for &rgb in &[[0, 0, 0], [255, 255, 255], [12, 34, 56]] {
let mut dst = under;
composite_over(&mut dst, rgb, 1.0);
assert_eq!(
dst,
[rgb[0], rgb[1], rgb[2], 255],
"opaque {rgb:?} over {under:?}"
);
}
}
}
#[test]
fn degenerate_ctm_draws_nothing() {
let mut pix = Pixmap::new(4, 4);
let p = DrawParams {
ctm: Matrix::scale(0.0, 0.0),
alpha: 1.0,
fill_rgb: [0; 3],
clip: None,
};
draw_rgba(&mut pix, &quad_image(), &p);
assert!(pix.data.iter().all(|&b| b == 0), "pixmap untouched");
}
}