mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 13:08:48 -06:00
ROADMAP.md: kept both progress lines. crates/optimize/README.md: PR's decoder sentence plus the target-version section from main. Cargo.lock: auto-merged.
634 lines
27 KiB
Rust
634 lines
27 KiB
Rust
//! Image resampling and recompression.
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
|
||
use pdfcraft_content::{Matrix, Pieces};
|
||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
|
||
|
||
use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage};
|
||
|
||
/// Images with more pixels than this are left alone (memory).
|
||
const MAX_PIXELS: u64 = 80_000_000;
|
||
|
||
/// The resources of a page, inherited from the page tree if the page has none.
|
||
pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option<Dict> {
|
||
let mut node = Some(page);
|
||
for _ in 0..64 {
|
||
let d = doc.get(node?).as_dict()?.clone();
|
||
if let Some(r) = d.get(b"Resources") {
|
||
return doc.resolve(r).as_dict().cloned();
|
||
}
|
||
node = d.reference(b"Parent");
|
||
}
|
||
None
|
||
}
|
||
|
||
/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream
|
||
/// doesn't decode or the budget runs out: the content is not all known.
|
||
pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
|
||
let pieces = budget.decode_contents(doc, contents).into_iter().collect::<Result<Vec<_>, _>>().ok()?;
|
||
Some(Pieces::join(&pieces).data().to_vec())
|
||
}
|
||
|
||
/// Forms nested deeper than this aren't followed.
|
||
const MAX_FORM_DEPTH: usize = 12;
|
||
/// Walks of a form already walked once (other scale or caller) before the measurement gives up.
|
||
/// Each form's first walk is free: their number is bounded by the file's object count.
|
||
const MAX_REWALKS: usize = 100_000;
|
||
|
||
/// State of one [`effective_resolutions`] pass.
|
||
struct Walk<'a> {
|
||
doc: &'a Document,
|
||
/// The forms being walked right now, outermost first: a form drawing one of these is a cycle.
|
||
path: Vec<ObjRef>,
|
||
/// Forms with their own resources already walked, by the linear part of their matrix. Drawing
|
||
/// one again at the same scale (only moved) can't change any image's resolution.
|
||
walked: HashSet<(ObjRef, [u64; 4])>,
|
||
/// Forms walked at least once.
|
||
seen: HashSet<ObjRef>,
|
||
rewalks: usize,
|
||
/// Decoded bytes left for the page being walked.
|
||
budget: DecodeBudget,
|
||
/// False once a use may have been missed (depth or work limit).
|
||
complete: bool,
|
||
out: HashMap<ObjRef, f64>,
|
||
}
|
||
|
||
impl Walk<'_> {
|
||
/// Walk one content stream, recording each image's displayed resolution (pixels per inch, the
|
||
/// smaller of the two axes) and descending into form XObjects.
|
||
fn content(&mut self, data: &[u8], resources: &Dict, ctm: Matrix) {
|
||
let doc = self.doc;
|
||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||
let mut stack = vec![ctm];
|
||
for op in pdfcraft_content::parse(data).ops {
|
||
let top = stack.last().copied().unwrap_or(ctm);
|
||
match op.op.as_slice() {
|
||
b"q" => stack.push(top),
|
||
b"Q" if stack.len() > 1 => {
|
||
stack.pop();
|
||
}
|
||
b"cm" => {
|
||
if let Some(m) = Matrix::from_operands(&op.operands)
|
||
&& let Some(t) = stack.last_mut()
|
||
{
|
||
*t = m.then(&top);
|
||
}
|
||
}
|
||
b"Do" => {
|
||
let Some(name) = op.name(0) else { continue };
|
||
let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue };
|
||
let obj = doc.get(r);
|
||
let Object::Stream(s) = &*obj else { continue };
|
||
match s.dict.name(b"Subtype") {
|
||
Some(b"Image") => {
|
||
let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as f64, s.dict.int(b"Height").unwrap_or(0) as f64);
|
||
let [a, b, c, d, ..] = top.0;
|
||
let (sx, sy) = ((a * a + b * b).sqrt(), (c * c + d * d).sqrt());
|
||
if w > 0.0 && h > 0.0 && sx > 1e-6 && sy > 1e-6 {
|
||
let ppi = (w / (sx / 72.0)).min(h / (sy / 72.0));
|
||
let e = self.out.entry(r).or_insert(f64::INFINITY);
|
||
*e = e.min(ppi);
|
||
}
|
||
}
|
||
Some(b"Form") => self.form(r, s, resources, top),
|
||
_ => {}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`.
|
||
fn form(&mut self, r: ObjRef, s: &Stream, resources: &Dict, ctm: Matrix) {
|
||
if self.path.contains(&r) {
|
||
return;
|
||
}
|
||
if self.path.len() >= MAX_FORM_DEPTH {
|
||
self.complete = false;
|
||
return;
|
||
}
|
||
let doc = self.doc;
|
||
let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().map(|a| Matrix::from_operands(a))).flatten();
|
||
let inner = m.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).then(&ctm);
|
||
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||
// A form without resources uses its caller's, so the same form can draw other images.
|
||
if own.is_some() {
|
||
let [a, b, c, d, ..] = inner.0;
|
||
if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()])) {
|
||
return;
|
||
}
|
||
}
|
||
if !self.seen.insert(r) {
|
||
if self.rewalks >= MAX_REWALKS {
|
||
self.complete = false;
|
||
return;
|
||
}
|
||
self.rewalks += 1;
|
||
}
|
||
if !self.budget.enter_form() {
|
||
self.complete = false;
|
||
return;
|
||
}
|
||
let Ok(data) = self.budget.decode(s) else {
|
||
self.complete = false;
|
||
return;
|
||
};
|
||
self.path.push(r);
|
||
self.content(&data, own.as_ref().unwrap_or(resources), inner);
|
||
self.path.pop();
|
||
}
|
||
}
|
||
|
||
/// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses.
|
||
///
|
||
/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within
|
||
/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be
|
||
/// among those missed.
|
||
pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef, f64> {
|
||
let mut walk = Walk {
|
||
doc,
|
||
path: Vec::new(),
|
||
walked: HashSet::new(),
|
||
seen: HashSet::new(),
|
||
rewalks: 0,
|
||
budget: DecodeBudget::page(),
|
||
complete: true,
|
||
out: HashMap::new(),
|
||
};
|
||
for p in pages {
|
||
let Some(dict) = doc.get(*p).as_dict().cloned() else { continue };
|
||
let page = pdfcraft_model::Page { obj: *p, dict };
|
||
let Some(contents) = page.dict.get(b"Contents") else { continue };
|
||
let res = page_resources(doc, *p).unwrap_or_default();
|
||
walk.budget = DecodeBudget::page();
|
||
let Some(data) = content_bytes(doc, contents, &mut walk.budget) else {
|
||
walk.complete = false;
|
||
continue;
|
||
};
|
||
// Content coordinates are in page units; image resolution is in physical inches.
|
||
let unit = page.user_unit(doc);
|
||
walk.content(&data, &res, Matrix([unit, 0.0, 0.0, unit, 0.0, 0.0]));
|
||
}
|
||
if !walk.complete {
|
||
walk.out.values_mut().for_each(|v| *v = f64::INFINITY);
|
||
}
|
||
walk.out
|
||
}
|
||
|
||
/// Colour components of a colour space PdfCraft resamples: gray (1) or RGB (3).
|
||
pub(crate) fn components(doc: &Document, cs: &Object) -> Option<usize> {
|
||
let cs = doc.resolve(cs);
|
||
match &*cs {
|
||
Object::Name(n) => match n.as_slice() {
|
||
b"DeviceGray" | b"G" => Some(1),
|
||
b"DeviceRGB" | b"RGB" => Some(3),
|
||
_ => None,
|
||
},
|
||
Object::Array(a) => match a.first()?.as_name()? {
|
||
b"ICCBased" => match &*doc.resolve(a.get(1)?) {
|
||
Object::Stream(icc) => match icc.dict.int(b"N")? {
|
||
1 => Some(1),
|
||
3 => Some(3),
|
||
_ => None,
|
||
},
|
||
_ => None,
|
||
},
|
||
b"CalGray" => Some(1),
|
||
b"CalRGB" => Some(3),
|
||
_ => None,
|
||
},
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn is_dct(s: &Stream) -> Option<bool> {
|
||
match s.dict.get(b"Filter") {
|
||
None => Some(false),
|
||
Some(Object::Name(n)) => match n.as_slice() {
|
||
b"DCTDecode" | b"DCT" => Some(true),
|
||
b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"RunLengthDecode" | b"RL" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" => {
|
||
Some(false)
|
||
}
|
||
_ => None,
|
||
},
|
||
Some(Object::Array(a)) => {
|
||
let names: Vec<&[u8]> = a.iter().filter_map(Object::as_name).collect();
|
||
if names.len() != a.len() {
|
||
return None;
|
||
}
|
||
match names.as_slice() {
|
||
[b"DCTDecode" | b"DCT"] => Some(true),
|
||
n if n.iter().all(|f| {
|
||
matches!(
|
||
*f,
|
||
b"FlateDecode"
|
||
| b"Fl"
|
||
| b"LZWDecode"
|
||
| b"LZW"
|
||
| b"RunLengthDecode"
|
||
| b"RL"
|
||
| b"ASCII85Decode"
|
||
| b"A85"
|
||
| b"ASCIIHexDecode"
|
||
| b"AHx"
|
||
)
|
||
}) =>
|
||
{
|
||
Some(false)
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
thread_local! {
|
||
/// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding.
|
||
static DECODED_PIXELS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||
}
|
||
|
||
/// Counts `n` decoded pixels (tests only).
|
||
#[inline]
|
||
fn count_decoded(_n: u64) {
|
||
#[cfg(test)]
|
||
DECODED_PIXELS.with(|c| c.set(c.get().saturating_add(_n)));
|
||
}
|
||
|
||
/// 8-bit samples of an image (`n` components), decoded. `None` for a JPEG whose colour transform
|
||
/// readers may disagree about ([`ycc_encoded`]).
|
||
pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option<Vec<u8>> {
|
||
let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?);
|
||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||
return None;
|
||
}
|
||
let bytes = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)?;
|
||
if is_dct(s)? {
|
||
// The colour transform matters to three components only (§7.4.8). CMYK and YCCK JPEGs
|
||
// never get here: the colour space check excludes them. The JPEG's own size must be the
|
||
// dictionary's: `jpeg_samples` refuses another from its header, before any pixels.
|
||
let ycc = n == 3 && ycc_encoded(doc, s)?;
|
||
let v = jpeg_samples(&s.raw, usize::try_from(w).ok()?, usize::try_from(h).ok()?, n, ycc)?;
|
||
count_decoded(u64::from(w) * u64::from(h));
|
||
(v.len() == bytes).then_some(v)
|
||
} else {
|
||
let v = s.decoded().ok()?;
|
||
Some(v.get(..bytes)?.to_vec())
|
||
}
|
||
}
|
||
|
||
/// Whether the samples of a three-component JPEG are YCbCr, to be converted to RGB (ISO 32000-2
|
||
/// §7.4.8). An Adobe APP14 marker's transform flag says so, overriding the filter's
|
||
/// /ColorTransform; without a marker /ColorTransform does, 1 (YCbCr) by default.
|
||
///
|
||
/// `None`, so the image is left as it is, when readers may disagree: the marker and
|
||
/// /ColorTransform contradict each other (PdfCraft's renderer lets /ColorTransform 0 win where
|
||
/// the standard lets the marker win), either holds a value other than 0 or 1, or the parameters
|
||
/// or the JPEG's segments can't be read.
|
||
fn ycc_encoded(doc: &Document, s: &Stream) -> Option<bool> {
|
||
let entry = color_transform(doc, s)?;
|
||
match adobe_transform(&s.raw)? {
|
||
Some(t @ (0 | 1)) => entry.is_none_or(|c| c == i64::from(t)).then_some(t == 1),
|
||
Some(_) => None,
|
||
None => match entry {
|
||
None | Some(1) => Some(true),
|
||
Some(0) => Some(false),
|
||
Some(_) => None,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// The /ColorTransform of a JPEG stream's filter parameters: `Some(None)` without one. `None`
|
||
/// if it isn't an integer, or the parameters aren't a dictionary paired with the filter as
|
||
/// §7.4 pairs them (a dictionary for a filter name, a one-element array for a one-filter array).
|
||
fn color_transform(doc: &Document, s: &Stream) -> Option<Option<i64>> {
|
||
let Some(parms) = s.dict.get(b"DecodeParms") else { return Some(None) };
|
||
let parms = doc.resolve(parms);
|
||
let parms = match (s.dict.get(b"Filter"), &*parms) {
|
||
(Some(Object::Array(_)), Object::Array(a)) if a.len() == 1 => doc.resolve(a.first()?),
|
||
(Some(Object::Name(_)), _) | (_, Object::Null) => parms.clone(),
|
||
_ => return None,
|
||
};
|
||
match &*parms {
|
||
Object::Null => Some(None),
|
||
Object::Dict(d) => match d.get(b"ColorTransform").map(|c| doc.resolve(c)).as_deref() {
|
||
None => Some(None),
|
||
Some(Object::Int(c)) => Some(Some(*c)),
|
||
Some(_) => None,
|
||
},
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Markers and fill bytes [`adobe_transform`] reads before the first scan, at most. Encoders
|
||
/// write a few dozen segments.
|
||
const MAX_HEADER_STEPS: usize = 4096;
|
||
|
||
/// The transform flag of a JPEG's Adobe APP14 segment (0: samples stored as they are,
|
||
/// 1: YCbCr, 2: YCCK), the last one if there are several, as decoders read them; `Some(None)`
|
||
/// without one. `None` if the segments before the first scan can't be read, which includes a
|
||
/// stray byte between segments and a header of more than [`MAX_HEADER_STEPS`] steps.
|
||
///
|
||
/// Each step reads one marker or fill byte and jumps over its segment, so the work is bounded
|
||
/// by the step count, not by the size of the stream. That matters: Optimize asks again for every
|
||
/// group of fragments it tries, and the same image can be drawn many times.
|
||
fn adobe_transform(data: &[u8]) -> Option<Option<u8>> {
|
||
if data.get(..2)? != [0xFF, 0xD8] {
|
||
return None;
|
||
}
|
||
let mut flag = None;
|
||
let mut i = 2usize;
|
||
// Reading past the end returns.
|
||
for _ in 0..MAX_HEADER_STEPS {
|
||
// Decoders skip stray bytes between segments, but a stream can hold megabytes of them
|
||
// and each would be a step: the header counts as unreadable and the image is left as
|
||
// it is.
|
||
if *data.get(i)? != 0xFF {
|
||
return None;
|
||
}
|
||
let marker = *data.get(i.checked_add(1)?)?;
|
||
match marker {
|
||
// A fill byte before the marker.
|
||
0xFF => i = i.checked_add(1)?,
|
||
// The first scan (or the end): no header segments after it.
|
||
0xDA | 0xD9 => return Some(flag),
|
||
// Markers without a segment: TEM, RSTn, SOI.
|
||
0x01 | 0xD0..=0xD8 => i = i.checked_add(2)?,
|
||
_ => {
|
||
let len = usize::from(u16::from_be_bytes([*data.get(i.checked_add(2)?)?, *data.get(i.checked_add(3)?)?]));
|
||
// The length counts its own two bytes: anything shorter is malformed.
|
||
let end = i.checked_add(2)?.checked_add(len)?;
|
||
let segment = data.get(i.checked_add(4)?..end)?;
|
||
if marker == 0xEE
|
||
&& let Some(rest) = segment.strip_prefix(b"Adobe")
|
||
{
|
||
// Version, flags0 and flags1 (two bytes each), then the transform flag.
|
||
flag = Some(*rest.get(6)?);
|
||
}
|
||
i = end;
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// 8-bit samples of a JPEG `w` × `h` with `n` components (grey or RGB): YCbCr converted to RGB
|
||
/// if `ycc`, otherwise the samples as stored. `None` if the JPEG doesn't match (size or number
|
||
/// of components), is too large, or its decoder reads the samples otherwise (for example
|
||
/// components named R, G and B, which decoders take as stored, while `ycc` says YCbCr).
|
||
fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option<Vec<u8>> {
|
||
use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};
|
||
if (w.checked_mul(h)? as u64) > MAX_PIXELS {
|
||
return None;
|
||
}
|
||
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX));
|
||
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options);
|
||
dec.decode_headers().ok()?;
|
||
if dec.dimensions()? != (w, h) || usize::from(dec.info()?.components) != n {
|
||
return None;
|
||
}
|
||
let out = match (n, ycc, dec.input_colorspace()?) {
|
||
(1, _, ColorSpace::Luma) => ColorSpace::Luma,
|
||
(3, true, ColorSpace::YCbCr) => ColorSpace::RGB,
|
||
// As stored: no conversion either way. The decoder reports an Adobe marker's 0 as CMYK
|
||
// until it decodes, and then reads three such components as RGB.
|
||
(3, false, ColorSpace::YCbCr) => ColorSpace::YCbCr,
|
||
(3, false, ColorSpace::RGB | ColorSpace::CMYK) => ColorSpace::RGB,
|
||
_ => return None,
|
||
};
|
||
dec.set_options(options.jpeg_set_out_colorspace(out));
|
||
let px = dec.decode().ok()?;
|
||
(px.len() == w.checked_mul(h)?.checked_mul(n)?).then_some(px)
|
||
}
|
||
|
||
pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option<Vec<u8>> {
|
||
use image::imageops::{FilterType, resize};
|
||
Some(match n {
|
||
1 => resize(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(),
|
||
_ => resize(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(),
|
||
})
|
||
}
|
||
|
||
fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option<Vec<u8>> {
|
||
let mut out = Vec::new();
|
||
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
|
||
let ty = if n == 1 { image::ExtendedColorType::L8 } else { image::ExtendedColorType::Rgb8 };
|
||
enc.encode(px, w, h, ty).ok()?;
|
||
Some(out)
|
||
}
|
||
|
||
/// Whether [`pixels`] reads `s` as it is drawn: a codec it decodes, no decode array that
|
||
/// changes anything, and for an RGB JPEG a colour transform readers agree on. `None` when the
|
||
/// colour space isn't grey or RGB.
|
||
pub(crate) fn decodable(doc: &Document, s: &Stream) -> Option<bool> {
|
||
let n = components(doc, s.dict.get(b"ColorSpace")?)?;
|
||
let dct = is_dct(s);
|
||
Some(
|
||
dct.is_some()
|
||
&& s.dict.get(b"Decode").is_none_or(|d| is_identity_decode(doc, d, n))
|
||
&& (n != 3 || dct == Some(false) || ycc_encoded(doc, s).is_some()),
|
||
)
|
||
}
|
||
|
||
/// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes
|
||
/// nothing. Scanners write it on page images (#490).
|
||
fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool {
|
||
let decode = doc.resolve(decode);
|
||
let Some(a) = decode.as_array() else { return false };
|
||
a.len() == 2 * n && a.chunks(2).all(|p| matches!(p, [lo, hi] if doc.resolve(lo).as_f64() == Some(0.0) && doc.resolve(hi).as_f64() == Some(1.0)))
|
||
}
|
||
|
||
/// A new image stream from 8-bit samples: the original dictionary with the new size and filter.
|
||
/// A decode array is dropped: only images without one, or with the default one, get here. So are
|
||
/// the filter parameters: the samples are decoded, and a JPEG written here is plain YCbCr with no
|
||
/// Adobe marker, which is what a missing /ColorTransform means.
|
||
fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Compression, was_dct: bool) -> Option<Stream> {
|
||
let mut d = old.clone();
|
||
for k in [&b"Filter"[..], b"DecodeParms", b"Decode", b"Length", b"DL"] {
|
||
d.remove(k);
|
||
}
|
||
d.set(b"Width".to_vec(), Object::Int(w as i64));
|
||
d.set(b"Height".to_vec(), Object::Int(h as i64));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
let use_jpeg = match compression {
|
||
Compression::Jpeg(_) => true,
|
||
Compression::Flate => false,
|
||
Compression::Retain => was_dct,
|
||
};
|
||
if use_jpeg {
|
||
let q = match compression {
|
||
Compression::Jpeg(q) => q,
|
||
_ => 80,
|
||
};
|
||
let data = jpeg(px, n, w, h, q)?;
|
||
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||
Some(Stream::from_raw(d, data))
|
||
} else {
|
||
Some(Stream::flate(d, px))
|
||
}
|
||
}
|
||
|
||
/// One image: resample and/or recompress per `settings`; `ppi` is its effective resolution.
|
||
/// Returns the new image and its new soft mask, if the result is smaller.
|
||
fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: usize) -> Option<(Stream, Option<(ObjRef, Stream)>)> {
|
||
let d = &s.dict;
|
||
if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n))
|
||
|| d.contains(b"Mask")
|
||
|| d.name(b"ImageMask").is_some()
|
||
|| matches!(d.get(b"ImageMask"), Some(Object::Bool(true)))
|
||
{
|
||
return None;
|
||
}
|
||
if d.int(b"BitsPerComponent") != Some(8) {
|
||
return None;
|
||
}
|
||
let was_dct = is_dct(s)?;
|
||
let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?);
|
||
if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS {
|
||
return None;
|
||
}
|
||
let resize = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0;
|
||
let recompress = matches!(settings.compression, Compression::Jpeg(_)) || (matches!(settings.compression, Compression::Flate) && was_dct);
|
||
if !resize && !recompress {
|
||
return None;
|
||
}
|
||
// Tiny images (icons, rules) don't benefit and JPEG would blur them.
|
||
if !resize && (w as u64) * (h as u64) < 64 * 64 {
|
||
return None;
|
||
}
|
||
let (nw, nh) = if resize {
|
||
let k = settings.target_ppi / ppi;
|
||
(((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1))
|
||
} else {
|
||
(w, h)
|
||
};
|
||
let px = pixels(doc, s, n)?;
|
||
let px = if (nw, nh) != (w, h) { resample(&px, n, w, h, nw, nh)? } else { px };
|
||
let new = image_stream(d, &px, n, nw, nh, settings.compression, was_dct)?;
|
||
// A soft mask must keep the image's dimensions: resample it with the image (lossless).
|
||
let mut smask = None;
|
||
if let Some(mr) = d.reference(b"SMask") {
|
||
let mobj = doc.get(mr);
|
||
let Object::Stream(m) = &*mobj else { return None };
|
||
if (nw, nh) != (w, h) {
|
||
if m.dict.int(b"BitsPerComponent") != Some(8) || m.dict.contains(b"Matte") || m.dict.contains(b"Decode") {
|
||
return None;
|
||
}
|
||
let (mw, mh) = (u32::try_from(m.dict.int(b"Width")?).ok()?, u32::try_from(m.dict.int(b"Height")?).ok()?);
|
||
let mpx = pixels(doc, m, 1)?;
|
||
let mpx = resample(&mpx, 1, mw, mh, nw, nh)?;
|
||
smask = Some((mr, image_stream(&m.dict, &mpx, 1, nw, nh, Compression::Flate, false)?));
|
||
}
|
||
}
|
||
let before = s.raw.len()
|
||
+ smask.as_ref().map_or(0, |(r, _)| match &*doc.get(*r) {
|
||
Object::Stream(m) => m.raw.len(),
|
||
_ => 0,
|
||
});
|
||
let after = new.raw.len() + smask.as_ref().map_or(0, |(_, m)| m.raw.len());
|
||
(after < before).then_some((new, smask))
|
||
}
|
||
|
||
pub(crate) fn run(
|
||
doc: &mut Document,
|
||
pages: &[ObjRef],
|
||
settings: &Settings,
|
||
report: &mut Report,
|
||
progress: &mut dyn FnMut(Stage) -> bool,
|
||
) -> Result<(), OptimizeError> {
|
||
let ppi = effective_resolutions(doc, pages);
|
||
// Soft masks are processed with their image, not on their own.
|
||
let masks: HashSet<ObjRef> = ppi
|
||
.keys()
|
||
.filter_map(|r| match &*doc.get(*r) {
|
||
Object::Stream(s) => s.dict.reference(b"SMask"),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect();
|
||
refs.sort_by_key(|(r, _)| r.num);
|
||
let total = refs.len();
|
||
for (done, (r, ppi)) in refs.into_iter().enumerate() {
|
||
if !progress(Stage::Images { done, total }) {
|
||
return Err(OptimizeError::Cancelled);
|
||
}
|
||
let obj = doc.get(r);
|
||
let Object::Stream(s) = &*obj else { continue };
|
||
report.images += 1;
|
||
let Some(n) = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)) else { continue };
|
||
let st = if n == 1 { &settings.gray } else { &settings.color };
|
||
let Some((new, smask)) = process(doc, s, ppi, st, n) else { continue };
|
||
let resized = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height");
|
||
report.image_bytes_before += s.raw.len();
|
||
report.image_bytes_after += new.raw.len();
|
||
if resized {
|
||
report.images_resampled += 1;
|
||
} else {
|
||
report.images_recompressed += 1;
|
||
}
|
||
doc.set(r, Object::Stream(new));
|
||
if let Some((mr, m)) = smask {
|
||
doc.set(mr, Object::Stream(m));
|
||
}
|
||
}
|
||
if !progress(Stage::Images { done: total, total }) {
|
||
return Err(OptimizeError::Cancelled);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod dimension_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn pixel_reader_rejects_hostile_dimensions_before_decoding() {
|
||
for (width, height, channels) in [(-1, 1, 1), (0, 1, 1), (i64::from(u32::MAX) + 101, 1, 1), (80_000_001, 1, 1), (2, 2, usize::MAX)] {
|
||
let mut dict = Dict::new();
|
||
dict.set(b"Width".to_vec(), Object::Int(width));
|
||
dict.set(b"Height".to_vec(), Object::Int(height));
|
||
let stream = Stream::from_raw(dict, vec![0; 4]);
|
||
assert!(pixels(&Document::new_empty(), &stream, channels).is_none(), "{width} x {height} x {channels}");
|
||
}
|
||
let mut dict = Dict::new();
|
||
dict.set(b"Width".to_vec(), Object::Int(2));
|
||
dict.set(b"Height".to_vec(), Object::Int(2));
|
||
let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]);
|
||
assert_eq!(pixels(&Document::new_empty(), &stream, 1), Some(vec![1, 2, 3, 4]));
|
||
}
|
||
|
||
/// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded.
|
||
#[test]
|
||
fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() {
|
||
let data = jpeg(&vec![128; 1000 * 1000], 1, 1000, 1000, 50).unwrap_or_default();
|
||
let stream = |w: i64, h: i64| {
|
||
let mut dict = Dict::new();
|
||
dict.set(b"Width".to_vec(), Object::Int(w));
|
||
dict.set(b"Height".to_vec(), Object::Int(h));
|
||
dict.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||
Stream::from_raw(dict, data.clone())
|
||
};
|
||
DECODED_PIXELS.with(|c| c.set(0));
|
||
assert!(pixels(&Document::new_empty(), &stream(10, 10), 1).is_none());
|
||
assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded");
|
||
assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use pdfcraft_cos::{DecodeBudget, Dict, Document, MAX_PAGE_DECODED, Object, Stream};
|
||
|
||
#[test]
|
||
fn repeated_content_references_share_one_decode_budget() {
|
||
let mut doc = Document::new_empty();
|
||
let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||
let contents = Object::Array(vec![Object::Ref(stream); 300]);
|
||
let data = super::content_bytes(&doc, &contents, &mut DecodeBudget::page());
|
||
assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300));
|
||
}
|
||
}
|