use std::sync::Arc;
use pdfboss_core::geom::{Matrix, Point, Rect};
#[cfg(test)]
use pdfboss_core::{block_on, Document, Immediate};
use pdfboss_core::{AsyncObjectSource, Dict, Object};
use pdfboss_icc::DeviceSpace;
use crate::color::ColorSpace;
use crate::raster::{BlendMode, Mask};
use crate::shading::MAX_COMPS;
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>,
pub blend: BlendMode,
pub smask: Option<&'a SampleMask>,
}
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])
}
}
}
fn reduced(&self, rx: usize, ry: usize) -> Rgba<'static> {
if let Pixels::Packed {
data,
bpc: 1,
row_bytes,
lut,
} = &self.pixels
{
if let [zero, one] = lut[..] {
return self.reduced_bilevel(rx, ry, data, *row_bytes, zero, one);
}
}
let rw = self.width.div_ceil(rx);
let rh = self.height.div_ceil(ry);
let mut quads = vec![0u8; rw * rh * 4];
let mut sums = vec![[0u32; 4]; rw];
for jr in 0..rh {
sums.fill([0; 4]);
let j1 = ((jr + 1) * ry).min(self.height);
for j in jr * ry..j1 {
for i in 0..self.width {
let s = self.at(i, j);
for (acc, v) in sums[i / rx].iter_mut().zip(s) {
*acc += u32::from(v);
}
}
}
let rows = (j1 - jr * ry) as u32;
let out = quads[jr * rw * 4..(jr + 1) * rw * 4].as_chunks_mut::<4>().0;
for (ir, (texel, sum)) in out.iter_mut().zip(&sums).enumerate() {
let n = rows * rx.min(self.width - ir * rx) as u32;
for (t, acc) in texel.iter_mut().zip(sum) {
*t = ((acc + n / 2) / n) as u8;
}
}
}
Rgba {
width: rw,
height: rh,
pixels: Pixels::Quads(quads),
truncated: self.truncated,
}
}
fn reduced_bilevel(
&self,
rx: usize,
ry: usize,
data: &[u8],
row_bytes: usize,
zero: [u8; 4],
one: [u8; 4],
) -> Rgba<'static> {
let rw = self.width.div_ceil(rx);
let rh = self.height.div_ceil(ry);
let mut quads = vec![0u8; rw * rh * 4];
let mut ones = vec![0u32; rw];
for jr in 0..rh {
ones.fill(0);
let j1 = ((jr + 1) * ry).min(self.height);
for j in jr * ry..j1 {
let row = data.get(j * row_bytes..).unwrap_or(&[]);
let row = &row[..row.len().min(row_bytes)];
for (b, &byte) in row.iter().enumerate() {
if byte == 0 {
continue;
}
let bit0 = b * 8;
if bit0 + 8 <= self.width && bit0 / rx == (bit0 + 7) / rx {
ones[bit0 / rx] += byte.count_ones();
continue;
}
for k in 0..8.min(self.width - bit0) {
ones[(bit0 + k) / rx] += u32::from((byte >> (7 - k)) & 1);
}
}
}
let rows = (j1 - jr * ry) as u32;
let out = quads[jr * rw * 4..(jr + 1) * rw * 4].as_chunks_mut::<4>().0;
for (ir, (texel, k)) in out.iter_mut().zip(&ones).enumerate() {
let n = rows * rx.min(self.width - ir * rx) as u32;
for c in 0..4 {
texel[c] =
((k * u32::from(one[c]) + (n - k) * u32::from(zero[c]) + n / 2) / n) as u8;
}
}
}
Rgba {
width: rw,
height: rh,
pixels: Pixels::Quads(quads),
truncated: self.truncated,
}
}
fn averaged(&self, ic: f32, jc: f32, hx: f32, hy: f32) -> [u8; 4] {
let i0 = ((ic - hx).round().max(0.0) as usize).min(self.width - 1);
let i1 = (((ic + hx).round()).max(0.0) as usize).clamp(i0 + 1, self.width);
let j0 = ((jc - hy).round().max(0.0) as usize).min(self.height - 1);
let j1 = (((jc + hy).round()).max(0.0) as usize).clamp(j0 + 1, self.height);
let jstep = (j1 - j0).div_ceil(FOOTPRINT_SAMPLES).max(1);
if let Pixels::Packed {
data,
bpc: 1,
row_bytes,
lut,
} = &self.pixels
{
if let [zero, one] = lut[..] {
if i1 - i0 <= FOOTPRINT_SAMPLES * 8 {
let mut ones = 0u32;
let mut n = 0u32;
for j in (j0..j1).step_by(jstep) {
ones += ones_in_row(data, j * row_bytes, i0, i1);
n += (i1 - i0) as u32;
}
let zeros = n - ones;
let mix = |c: usize| {
((ones * u32::from(one[c]) + zeros * u32::from(zero[c]) + n / 2) / n) as u8
};
return [mix(0), mix(1), mix(2), mix(3)];
}
}
}
let istep = (i1 - i0).div_ceil(FOOTPRINT_SAMPLES).max(1);
let mut sum = [0u32; 4];
let mut n = 0u32;
for j in (j0..j1).step_by(jstep) {
for i in (i0..i1).step_by(istep) {
let s = self.at(i, j);
for (acc, v) in sum.iter_mut().zip(s) {
*acc += u32::from(v);
}
n += 1;
}
}
[
((sum[0] + n / 2) / n) as u8,
((sum[1] + n / 2) / n) as u8,
((sum[2] + n / 2) / n) as u8,
((sum[3] + n / 2) / n) as u8,
]
}
}
fn ones_in_row(data: &[u8], row: usize, i0: usize, i1: usize) -> u32 {
let first = i0 / 8;
let last = (i1 - 1) / 8;
let mut ones = 0u32;
for b in first..=last {
let mut byte = data.get(row + b).copied().unwrap_or(0);
if b == first {
byte &= 0xFF >> (i0 % 8);
}
if b == last {
byte &= 0xFF << (7 - (i1 - 1) % 8);
}
ones += byte.count_ones();
}
ones
}
const MINIFICATION_THRESHOLD: f32 = 1.25;
const FOOTPRINT_SAMPLES: usize = 64;
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,
&crate::color::IccCache::default(),
))
}
pub(crate) async fn read_with<S: AsyncObjectSource>(
src: &S,
dict: &Dict,
cs_obj: Option<&Object>,
icc: &crate::color::IccCache,
) -> ImageMeta {
let cs = match cs_obj {
Some(obj) => Some(ColorSpace::parse_with(src, obj, icc).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)
}
pub(crate) struct SampleMask {
width: usize,
height: usize,
data: Vec<u8>,
}
impl SampleMask {
fn sample(&self, u: f32, v: f32) -> u8 {
let i = ((u * self.width as f32) as usize).min(self.width - 1);
let j = (((1.0 - v) * self.height as f32) as usize).min(self.height - 1);
self.data[j * self.width + i]
}
}
pub(crate) fn decode_alpha(meta: &ImageMeta, data: &[u8]) -> Option<SampleMask> {
let img = decode_rgba(meta, data, [255, 255, 255])?;
let (width, height) = (img.width, img.height);
if width == 0 || height == 0 {
return None;
}
let mut out = vec![0u8; width.checked_mul(height)?];
for j in 0..height {
for i in 0..width {
let px = img.at(i, j);
out[j * width + i] = if meta.stencil { px[3] } else { px[0] };
}
}
Some(SampleMask {
width,
height,
data: out,
})
}
pub(crate) fn color_key_mask(meta: &ImageMeta, data: &[u8], key: &[i64]) -> Option<SampleMask> {
if meta.dct || meta.jpx || meta.stencil {
return None;
}
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 cs = meta.cs.as_ref().unwrap_or(&ColorSpace::DeviceGray);
let ncomp = cs.components().clamp(1, 8);
if key.len() < 2 * ncomp {
return None;
}
let bpc = match meta.bpc.map(|v| v as i64) {
Some(v @ (1 | 2 | 4 | 8 | 16)) => v as usize,
_ => 8,
};
let stride_bits = (ncomp * bpc * width).div_ceil(8) * 8;
let mut out = vec![0u8; width * height];
for y in 0..height {
for x in 0..width {
let base = y * stride_bits + x * ncomp * bpc;
let inside = (0..ncomp).all(|c| {
let v = i64::from(sample_bits(data, base + c * bpc, bpc));
v >= key[2 * c] && v <= key[2 * c + 1]
});
out[y * width + x] = if inside { 0 } else { 255 };
}
}
Some(SampleMask {
width,
height,
data: out,
})
}
pub(crate) fn decode_native(
meta: &ImageMeta,
data: &[u8],
smask: Option<&SampleMask>,
) -> Option<Pixmap> {
let img = if meta.jpx {
let decoded = pdfboss_jpx::decode(data, &jpx_limits()).ok()?;
jpx_rgba(meta, decoded).ok()?.0
} else {
decode_rgba(meta, data, [0, 0, 0])?
};
let (w, h) = (img.width, img.height);
if w == 0 || h == 0 {
return None;
}
let mut pix = Pixmap::new(w as u32, h as u32);
if smask.is_none() {
if let Pixels::Quads(quads) = &img.pixels {
if quads.len() == w * h * 4 {
pix.data.copy_from_slice(quads);
return Some(pix);
}
}
}
for j in 0..h {
for i in 0..w {
let mut s = img.at(i, j);
if let Some(m) = smask {
let u = (i as f32 + 0.5) / w as f32;
let v = 1.0 - (j as f32 + 0.5) / h as f32;
s[3] = (f32::from(s[3]) * f32::from(m.sample(u, v)) / 255.0).round() as u8;
}
let off = (j * w + i) * 4;
pix.data[off..off + 4].copy_from_slice(&s);
}
}
Some(pix)
}
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 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]),
_ => cs.default_decode(c, max),
})
.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.as_chunks::<2>().0.iter().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.as_chunks::<3>().0.iter().enumerate().take(w * h) {
out[i * 4..i * 4 + 3].copy_from_slice(rgb);
}
}
jpeg_decoder::PixelFormat::CMYK32 => {
for (i, cmyk) in pixels.as_chunks::<4>().0.iter().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,
_ => jpx_declared_color(&img, color_count, &mut notes)?,
};
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 were \
rounded away by 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.as_chunks_mut::<4>().0.iter_mut().zip(&alpha_data) {
px[3] = a;
}
}
Ok((
Rgba {
width,
height,
pixels: Pixels::Quads(quads),
truncated: false,
},
notes,
))
}
fn jpx_declared_color(
img: &pdfboss_jpx::DecodedImage,
color_count: usize,
notes: &mut Vec<String>,
) -> Result<ColorSpace, String> {
match &img.icc_profile {
Some(data) if color_count <= MAX_COMPS => match pdfboss_icc::parse(data) {
Ok(profile) if profile.channels() == color_count => {
return Ok(match profile.device_equivalent() {
Some(DeviceSpace::Rgb) => ColorSpace::DeviceRGB,
Some(DeviceSpace::Gray) => ColorSpace::DeviceGray,
None => ColorSpace::Icc {
profile: Arc::new(profile),
n: color_count,
},
});
}
Ok(profile) => notes.push(format!(
"JPXDecode: the embedded ICC profile expects {} component(s) but the \
codestream carries {color_count} colour channel(s); colour \
approximated from the channel count",
profile.channels()
)),
Err(err) => notes.push(format!(
"JPXDecode: the embedded ICC profile did not parse ({err}); colour \
approximated from {color_count} channel(s)"
)),
},
_ => notes.push(format!(
"JPXDecode: colour approximated from {color_count} channel(s); \
the codestream's colour declaration is not interpreted"
)),
}
match color_count {
1 => Ok(ColorSpace::DeviceGray),
3 => Ok(ColorSpace::DeviceRGB),
4 => Ok(ColorSpace::DeviceCMYK),
n => Err(format!(
"JPXDecode: no colour space for {n} colour channel(s)"
)),
}
}
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);
let fx = (inv.a.abs() + inv.c.abs()) * img.width as f32;
let fy = (inv.b.abs() + inv.d.abs()) * img.height as f32;
let minified = fx.is_finite()
&& fy.is_finite()
&& (fx > MINIFICATION_THRESHOLD || fy > MINIFICATION_THRESHOLD);
let mut rx = 1;
let mut ry = 1;
if minified {
rx = (fx as usize).max(1);
ry = (fy as usize).max(1);
let visible = u64::from(x1.saturating_sub(x0)) * u64::from(y1.saturating_sub(y0));
let texels = (img.width.div_ceil(rx) as u64) * (img.height.div_ceil(ry) as u64);
if visible * 4 < texels {
rx = 1;
ry = 1;
}
}
let reduced = (rx > 1 || ry > 1).then(|| img.reduced(rx, ry));
let img = reduced.as_ref().unwrap_or(img);
let (fx, fy) = (fx / rx as f32, fy / ry as f32);
let footprint = (minified && (fx > MINIFICATION_THRESHOLD || fy > MINIFICATION_THRESHOLD))
.then_some((fx.max(1.0) / 2.0, fy.max(1.0) / 2.0));
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 ic = u.x * img.width as f32;
let jc = (1.0 - u.y) * img.height as f32;
let i = (ic as usize).min(img.width - 1);
let j = (jc as usize).min(img.height - 1);
let s = match footprint {
None => img.at(i, j),
Some((hx, hy)) => img.averaged(ic, jc, hx, hy),
};
let mut a = f32::from(s[3]) / 255.0 * alpha;
if let Some(m) = p.smask {
a *= f32::from(m.sample(u.x, u.y)) / 255.0;
}
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];
let s = if p.blend == BlendMode::Normal {
s
} else {
let b = p.blend.blend([dst[0], dst[1], dst[2]], [s[0], s[1], s[2]]);
[b[0], b[1], b[2], s[3]]
};
if a >= 1.0 && p.blend == BlendMode::Normal {
dst.copy_from_slice(&[s[0], s[1], s[2], 255]);
} else 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,
icc_profile: None,
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:?}");
}
fn icc_fx(v: f64) -> [u8; 4] {
(((v * 65536.0).round()) as i32).to_be_bytes()
}
fn icc_srgb_trc() -> Vec<u8> {
let mut out = b"para\0\0\0\0\0\x03\0\0".to_vec();
for v in [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045] {
out.extend_from_slice(&icc_fx(v));
}
out
}
fn icc_gamma_trc(g: f64) -> Vec<u8> {
let mut out = b"curv\0\0\0\0\0\0\0\x01".to_vec();
out.extend_from_slice(&((g * 256.0).round() as u16).to_be_bytes());
out
}
fn icc_build(space: &[u8; 4], tags: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut header = vec![0u8; 128];
header[8] = 4;
header[16..20].copy_from_slice(space);
header[20..24].copy_from_slice(b"XYZ ");
header[36..40].copy_from_slice(b"acsp");
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
let mut body = Vec::new();
let mut at = 132 + 12 * tags.len();
for (sig, data) in tags {
table.extend_from_slice(sig);
table.extend_from_slice(&(at as u32).to_be_bytes());
table.extend_from_slice(&(data.len() as u32).to_be_bytes());
body.extend_from_slice(data);
at += data.len();
}
let mut out = header;
out.extend_from_slice(&table);
out.extend_from_slice(&body);
let size = (out.len() as u32).to_be_bytes();
out[0..4].copy_from_slice(&size);
out
}
fn icc_rgb_profile(trc: &[u8]) -> Vec<u8> {
let columns: [[f64; 3]; 3] = [
[0.4360, 0.2225, 0.0139],
[0.3851, 0.7169, 0.0971],
[0.1431, 0.0606, 0.7139],
];
let mut tags: Vec<([u8; 4], Vec<u8>)> = Vec::new();
for (sig, col) in [*b"rXYZ", *b"gXYZ", *b"bXYZ"].iter().zip(columns) {
let mut data = b"XYZ \0\0\0\0".to_vec();
for v in col {
data.extend_from_slice(&icc_fx(v));
}
tags.push((*sig, data));
}
for sig in [*b"rTRC", *b"gTRC", *b"bTRC"] {
tags.push((sig, trc.to_vec()));
}
icc_build(b"RGB ", &tags)
}
#[test]
fn jpx_embedded_device_equivalent_profiles_keep_the_device_path() {
let doc = test_doc();
let meta = jpx_meta(&doc, b"<< >>", None);
let mut image = jpx_image(
1,
1,
3,
vec![10, 200, 30],
pdfboss_jpx::ColorKind::IccGuess { components: 3 },
None,
);
image.icc_profile = Some(icc_rgb_profile(&icc_srgb_trc()));
let (img, notes) = jpx_rgba(&meta, image).expect("decodes");
assert!(
notes.is_empty(),
"an interpreted profile is silent: {notes:?}"
);
assert_eq!(rgba_at(&img, 0, 0), [10, 200, 30, 255]);
let mut image = jpx_image(
1,
1,
1,
vec![77],
pdfboss_jpx::ColorKind::IccGuess { components: 1 },
None,
);
image.icc_profile = Some(icc_build(b"GRAY", &[(*b"kTRC", icc_srgb_trc())]));
let (img, notes) = jpx_rgba(&meta, image).expect("decodes");
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(rgba_at(&img, 0, 0), [77, 77, 77, 255]);
}
#[test]
fn jpx_embedded_profile_transforms_the_samples() {
let doc = test_doc();
let meta = jpx_meta(&doc, b"<< >>", None);
let want = (pdfboss_icc::srgb_encode((128.0f32 / 255.0).powf(1.8)) * 255.0 + 0.5) as u8;
let mut image = jpx_image(
1,
1,
3,
vec![128, 128, 128],
pdfboss_jpx::ColorKind::IccGuess { components: 3 },
None,
);
image.icc_profile = Some(icc_rgb_profile(&icc_gamma_trc(1.8)));
let (img, notes) = jpx_rgba(&meta, image).expect("decodes");
assert!(notes.is_empty(), "{notes:?}");
let px = rgba_at(&img, 0, 0);
for c in &px[..3] {
assert!(c.abs_diff(want) <= 1, "{px:?} want {want}");
}
assert!(px[0] > 140, "gamma 1,8 must brighten mid-gray: {px:?}");
let mut image = jpx_image(
1,
1,
1,
vec![128],
pdfboss_jpx::ColorKind::IccGuess { components: 1 },
None,
);
image.icc_profile = Some(icc_build(b"GRAY", &[(*b"kTRC", icc_gamma_trc(1.8))]));
let (img, notes) = jpx_rgba(&meta, image).expect("decodes");
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(rgba_at(&img, 0, 0)[0], want);
}
#[test]
fn jpx_unusable_embedded_profiles_fall_back_with_a_note() {
let doc = test_doc();
let meta = jpx_meta(&doc, b"<< >>", None);
let mut image = jpx_image(
1,
1,
3,
vec![0, 255, 0],
pdfboss_jpx::ColorKind::IccGuess { components: 3 },
None,
);
image.icc_profile = Some(vec![1, 2, 3]);
let (img, notes) = jpx_rgba(&meta, image).expect("decodes");
assert_eq!(rgba_at(&img, 0, 0), [0, 255, 0, 255]);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("did not parse"), "{notes:?}");
let mut image = jpx_image(
1,
1,
4,
vec![0, 0, 0, 0],
pdfboss_jpx::ColorKind::IccGuess { components: 4 },
None,
);
image.icc_profile = Some(icc_rgb_profile(&icc_srgb_trc()));
let (img, notes) = jpx_rgba(&meta, image).expect("decodes");
assert_eq!(rgba_at(&img, 0, 0), [255, 255, 255, 255], "zero ink");
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(
notes[0].contains("expects 3 component(s)") && notes[0].contains("4 colour"),
"{notes:?}"
);
}
#[test]
fn jpx_profile_over_more_channels_than_the_rasterizer_reads_never_builds() {
let doc = test_doc();
let meta = jpx_meta(&doc, b"<< >>", None);
let mut image = jpx_image(
1,
1,
9,
vec![0; 9],
pdfboss_jpx::ColorKind::IccGuess { components: 9 },
None,
);
image.icc_profile = Some(icc_rgb_profile(&icc_srgb_trc()));
let reason = match jpx_rgba(&meta, image) {
Err(reason) => reason,
Ok(..) => panic!("9 colour channels fit no colour space"),
};
assert!(reason.contains("no colour space for 9"), "{reason}");
}
#[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,
blend: BlendMode::Normal,
smask: 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),
blend: BlendMode::Normal,
smask: None,
};
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 minified_halftone_averages_to_its_gray() {
let mut quads = Vec::with_capacity(8 * 8 * 4);
for j in 0..8u8 {
for i in 0..8u8 {
let v = if (i + j) % 2 == 0 { 0 } else { 255 };
quads.extend_from_slice(&[v, v, v, 255]);
}
}
let img = Rgba {
width: 8,
height: 8,
pixels: Pixels::Quads(quads),
truncated: false,
};
let mut pix = Pixmap::new(2, 2);
let p = DrawParams {
ctm: Matrix::scale(2.0, 2.0),
alpha: 1.0,
fill_rgb: [0; 3],
clip: None,
blend: BlendMode::Normal,
smask: None,
};
draw_rgba(&mut pix, &img, &p);
for y in 0..2 {
for x in 0..2 {
let [r, _, _, a] = pix_at(&pix, x, y);
assert!((112..=144).contains(&r), "({x},{y}) gray {r}, want ~128");
assert_eq!(a, 255, "({x},{y}) opaque");
}
}
}
fn checkerboard(side: u8) -> Rgba<'static> {
let mut quads = Vec::with_capacity(usize::from(side) * usize::from(side) * 4);
for j in 0..side {
for i in 0..side {
let v = if (i + j) % 2 == 0 { 0 } else { 255 };
quads.extend_from_slice(&[v, v, v, 255]);
}
}
Rgba {
width: usize::from(side),
height: usize::from(side),
pixels: Pixels::Quads(quads),
truncated: false,
}
}
#[test]
fn fractional_minification_still_averages() {
let mut pix = Pixmap::new(4, 4);
let p = DrawParams {
ctm: Matrix::scale(4.0, 4.0),
alpha: 1.0,
fill_rgb: [0; 3],
clip: None,
blend: BlendMode::Normal,
smask: None,
};
draw_rgba(&mut pix, &checkerboard(11), &p);
for y in 1..3 {
for x in 1..3 {
let [r, _, _, _] = pix_at(&pix, x, y);
assert!((100..=156).contains(&r), "({x},{y}) gray {r}, want ~128");
}
}
}
#[test]
fn minified_sliver_averages_without_a_reduction_pass() {
let mut pix = Pixmap::new(2, 2);
let p = DrawParams {
ctm: Matrix::scale(8.0, 8.0),
alpha: 1.0,
fill_rgb: [0; 3],
clip: None,
blend: BlendMode::Normal,
smask: None,
};
draw_rgba(&mut pix, &checkerboard(32), &p);
for y in 0..2 {
for x in 0..2 {
let [r, _, _, _] = pix_at(&pix, x, y);
assert!((112..=144).contains(&r), "({x},{y}) gray {r}, want ~128");
}
}
}
#[test]
fn minified_packed_bilevel_averages_to_its_gray() {
let data: &[u8] = &[0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA];
let img = Rgba {
width: 8,
height: 8,
pixels: Pixels::Packed {
data,
bpc: 1,
row_bytes: 1,
lut: vec![[0, 0, 0, 255], [255, 255, 255, 255]],
},
truncated: false,
};
let mut pix = Pixmap::new(2, 2);
let p = DrawParams {
ctm: Matrix::scale(2.0, 2.0),
alpha: 1.0,
fill_rgb: [0; 3],
clip: None,
blend: BlendMode::Normal,
smask: None,
};
draw_rgba(&mut pix, &img, &p);
for y in 0..2 {
for x in 0..2 {
let [r, _, _, _] = pix_at(&pix, x, y);
assert!((112..=144).contains(&r), "({x},{y}) gray {r}, want ~128");
}
}
}
#[test]
fn minified_stencil_paints_fractional_coverage() {
let mut quads = Vec::with_capacity(8 * 8 * 4);
for j in 0..8u8 {
for i in 0..8u8 {
let a = if (i + j) % 2 == 0 { 255 } else { 0 };
quads.extend_from_slice(&[0, 0, 0, a]);
}
}
let img = Rgba {
width: 8,
height: 8,
pixels: Pixels::Quads(quads),
truncated: false,
};
let mut pix = Pixmap::new(2, 2);
pix.fill([255, 255, 255, 255]);
let p = DrawParams {
ctm: Matrix::scale(2.0, 2.0),
alpha: 1.0,
fill_rgb: [0; 3],
clip: None,
blend: BlendMode::Normal,
smask: None,
};
draw_rgba(&mut pix, &img, &p);
let [r, _, _, _] = pix_at(&pix, 1, 1);
assert!((112..=144).contains(&r), "coverage gray {r}, want ~128");
}
#[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,
blend: BlendMode::Normal,
smask: None,
};
draw_rgba(&mut pix, &quad_image(), &p);
assert!(pix.data.iter().all(|&b| b == 0), "pixmap untouched");
}
}