M2.6: the JBIG2 images of an operation or a page share one allowance of decoding

One image has its own limit, about 6 s to 17 s, but nothing bounded a file of thousands of images
made to reach it: an operation took that long for each, and an image sharing /JBIG2Globals decodes
them again.

Image::decode_within (hayro-jbig2 patch 11) spends from what a run may still do, starting with its
page's pixels, and charge_reading charges reading a stream and its globals, 4 steps a byte, before
anything is parsed, so an image that fails has paid too. pdfcraft_filters::decode_jbig2_within takes
it, and ImageBudget in cos is that allowance: 2^34 steps, about 1,000 scanned pages, and 2^13 more for
each byte of the file, so a long scan is read whole. An image past it spends all that's left, and
later ones aren't read, globals included.

Optimize leaves such an image as it is and counts it in Report::images_refused. Image export leaves it
out, with the reason, and stops at 2 GiB of files held. Office export shares one allowance, leaves the
picture out and says the page was truncated. Redaction removes it whole, as any image it can't read.

Rendering gives a page's images 2^30 steps between them, inside the watchdog, and each page starts
afresh in Context::new as its other budgets do (hayro-syntax patch 31, hayro-interpret patch 73).
It's a thread-local, unbounded until a page sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz Martens
2026-10-31 13:21:45 +01:00
co-authored by Claude Opus 5.5
parent 78d4877ae2
commit 0ed339901e
32 changed files with 705 additions and 85 deletions
+1
View File
@@ -410,6 +410,7 @@ impl Automation {
"images": o.images,
"images_resampled": o.images_resampled,
"images_recompressed": o.images_recompressed,
"images_refused": o.images_refused,
"image_bytes_before": o.image_bytes_before,
"image_bytes_after": o.image_bytes_after,
"thumbnails": o.thumbnails,
+2 -1
View File
@@ -93,5 +93,6 @@ recognisable in a diff and byte-stable when re-serialized.
- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax and JBIG2 decoding
(`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB,
their filters undone once a document for the images that share them, up to 64 MiB kept, while
their segments are decoded for every image);
their segments are decoded for every image, and `ImageBudget` for an operation over many images,
which share one allowance of JBIG2 decoding and are refused past it);
DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6).
+135
View File
@@ -6,6 +6,17 @@ use pdfcraft_filters::FilterError;
use crate::{CosError, Document, ObjRef, Object, Stream};
/// The steps of JBIG2 decoding ([`hayro_jbig2::Image::decode_within`]) an operation over a
/// document's images may take whatever the file's size. A step is about a pixel, and takes 6 to 16
/// ns to decode, so this is 2 to 5 minutes, and about as long again for what an operation does with
/// the pixels. A page scanned at 300 dpi takes about 17 million, 8.4 for its pixels and that again
/// to decode them, so this alone reads about 1,000 of them.
const IMAGE_WORK: u64 = 1 << 34;
/// The steps each byte of the file adds to [`IMAGE_WORK`], so that a long scan is read whole: such a
/// page pays for itself with about 2 KB of the file, about the smallest a page of text is stored
/// in. So what a hostile file can ask for grows with its length, by about 14 minutes for 10 MB.
const IMAGE_WORK_PER_BYTE: u64 = 1 << 13;
/// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every
/// form XObject it enters, each occurrence counted again.
pub const MAX_PAGE_DECODED: usize = 256 << 20;
@@ -16,6 +27,52 @@ pub const MAX_PAGE_FORMS: usize = 50_000;
/// the error, each with that count.
type Decoder = fn(&Stream, usize) -> Result<(Vec<u8>, usize), (FilterError, usize)>;
/// What one operation over a document's images may still spend decoding them: exporting them,
/// optimizing them, clearing them for redaction.
///
/// An image has its own limit, which doesn't bound a file of thousands of images made to reach it.
/// JBIG2 is what such a file would use: a few hundred bytes can ask for seconds of decoding, and an
/// image that shares `/JBIG2Globals` decodes them again. So those images share this allowance, and
/// past it they aren't decoded. A caller then treats them as it does an image it can't read, and
/// [`ImageBudget::refused`] says how many there were.
#[derive(Debug)]
pub struct ImageBudget {
jbig2_work: u64,
refused: usize,
}
impl ImageBudget {
/// The allowance for an operation on `doc`, which a longer file makes larger.
pub fn new(doc: &Document) -> Self {
let bytes = u64::try_from(doc.bytes().len()).unwrap_or(u64::MAX);
Self::with_work(IMAGE_WORK.saturating_add(bytes.saturating_mul(IMAGE_WORK_PER_BYTE)))
}
/// An allowance of `jbig2_work` steps of JBIG2 decoding.
pub fn with_work(jbig2_work: u64) -> Self {
Self { jbig2_work, refused: 0 }
}
/// Image `s` decoded like [`Document::decoded_image_within`], charged to the allowance.
pub fn decode(&mut self, doc: &Document, s: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
let decoded = doc.decoded_image_spending(s, max, &mut self.jbig2_work);
if decoded.is_err() && self.jbig2_work == 0 && matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))) {
self.refused += 1;
}
decoded
}
/// The JBIG2 images refused for lack of allowance.
pub fn refused(&self) -> usize {
self.refused
}
/// The steps of JBIG2 decoding still allowed.
pub fn work_left(&self) -> u64 {
self.jbig2_work
}
}
/// What one operation may still decode and enter across all the streams it reads.
///
/// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose
@@ -143,6 +200,84 @@ impl DecodeBudget {
}
}
#[cfg(test)]
mod image_tests {
use super::*;
use crate::Dict;
fn jbig2(parms: Option<Object>) -> Stream {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(16));
dict.set(b"Height".to_vec(), Object::Int(2));
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
if let Some(parms) = parms {
dict.set(b"DecodeParms".to_vec(), parms);
}
Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())
}
/// Each JBIG2 image spends what it took from one allowance, and those past it are refused and
/// counted. Other images aren't charged, and a failure of theirs isn't counted as one of these.
#[test]
fn the_images_of_an_operation_share_its_allowance() {
let doc = Document::new_empty();
let image = jbig2(None);
let mut budget = ImageBudget::with_work(u64::MAX);
let samples = budget.decode(&doc, &image, 1 << 20).unwrap();
let cost = u64::MAX - budget.work_left();
assert!(cost > 0);
let mut budget = ImageBudget::with_work(3 * cost);
for left in [2, 1, 0] {
assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap(), samples);
assert_eq!(budget.work_left(), left * cost);
}
assert_eq!(budget.refused(), 0, "the allowance held exactly three");
for refused in [1, 2] {
assert!(budget.decode(&doc, &image, 1 << 20).is_err());
assert_eq!(budget.refused(), refused);
}
let flate = Stream::flate(Dict::new(), b"samples");
assert_eq!(budget.decode(&doc, &flate, 1 << 20).unwrap(), b"samples", "not JBIG2: no charge");
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err());
assert_eq!(budget.refused(), 2, "not JBIG2: not one of these");
}
/// The images of a file share its globals, so an image refused doesn't read them: this one's
/// can't be read, which is how to tell.
#[test]
fn an_image_past_the_allowance_does_not_read_its_globals() {
let mut doc = Document::new_empty();
let mut unreadable = Dict::new();
unreadable.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let globals = doc.add(Object::Stream(Stream::from_raw(unreadable, b"data".to_vec())));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
let image = jbig2(Some(Object::Dict(parms)));
let read = ImageBudget::with_work(u64::MAX).decode(&doc, &image, 1 << 20).unwrap_err().to_string();
assert!(read.contains("globals"), "{read}");
let refused = ImageBudget::with_work(0).decode(&doc, &image, 1 << 20).unwrap_err().to_string();
assert!(refused.contains("more JBIG2 decoding than"), "{refused}");
}
/// A longer file gets more, by its length, and an empty one the fixed part.
#[test]
fn a_longer_file_allows_more_decoding() {
assert_eq!(ImageBudget::new(&Document::new_empty()).work_left(), IMAGE_WORK);
let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec();
let len = pdf.len() as u64;
let doc = Document::open(std::sync::Arc::new(pdf)).unwrap();
assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + len * IMAGE_WORK_PER_BYTE);
}
// What the constants' comments promise, for a page scanned at 300 dpi: about 1,000 of them
// whatever the file's size, and a page paying for itself with 2 KB of the file.
const PAGE: u64 = 17_000_000;
const _: () = assert!(IMAGE_WORK >= 1_000 * PAGE);
const _: () = assert!(IMAGE_WORK_PER_BYTE * 2_100 >= PAGE);
}
#[cfg(test)]
mod tests {
use super::*;
+12 -2
View File
@@ -294,6 +294,14 @@ impl Document {
/// more than `max` bytes. A JBIG2 image's `/JBIG2Globals` are another stream, shared between
/// images, and are decoded within [`MAX_JBIG2_GLOBALS`] whatever `max` is.
pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
let mut unbounded = u64::MAX;
self.decoded_image_spending(s, max, &mut unbounded)
}
/// [`Document::decoded_image_within`], a JBIG2 image spending from `work`, what an operation
/// may still do across its images ([`ImageBudget`](crate::ImageBudget)). With nothing left a
/// JBIG2 image is refused before its globals are read.
pub(crate) fn decoded_image_spending(&self, s: &Stream, max: usize, work: &mut u64) -> Result<Vec<u8>, CosError> {
let max = max.min(MAX_DECODED);
let s = &*self.with_direct_parms(s);
let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string());
@@ -310,8 +318,10 @@ impl Document {
let (Some(columns), Some(height)) = (s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()), height) else {
return Err(CosError::Filter("a JBIG2 image's /Width or /Height isn't a size".into()));
};
let globals = self.jbig2_globals(s)?;
return pdfcraft_filters::decode_jbig2(&undo(before)?, globals.as_deref().map(Vec::as_slice), columns, height, max).map_err(filter);
// Nothing to read for what can't be decoded: the images of a file share its globals.
let globals = if *work == 0 { None } else { self.jbig2_globals(s)? };
let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before)? };
return pdfcraft_filters::decode_jbig2_within(&data, globals.as_deref().map(Vec::as_slice), columns, height, max, work).map_err(filter);
}
let Some(p) = self.fax_params(s) else { return s.decoded_within(max) };
// As many rows as the image has: a /Rows below /Height would cut the picture short, and
+1 -1
View File
@@ -22,7 +22,7 @@ mod parser;
mod security;
mod writer;
pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS};
pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS};
pub use bytes::Bytes;
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream};
+3 -3
View File
@@ -27,12 +27,12 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
JBIG2 images and masks are decoded through `pdfcraft_cos::Document::decoded_image` (`pdfcraft_filters::decode_jbig2`), behind
JBIG2 images and masks are decoded through `pdfcraft_cos::ImageBudget` (`pdfcraft_filters::decode_jbig2_within`), behind
other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image
whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded),
or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a
limit of their own), is skipped; such a mask is left out and the image exported opaque, as JPX and CCITT
masks are.
limit of their own), is skipped, and so is one past what one export may decode, which its images share; such a mask is left out and
the image exported opaque, as JPX and CCITT masks are.
Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
Missing, null or malformed Decode arrays keep the default mapping.
Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries.
+39 -16
View File
@@ -6,7 +6,7 @@
use std::collections::HashSet;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_DECODED, ObjRef, Object, Stream};
/// One image, ready to write.
#[derive(Clone, Debug, PartialEq)]
@@ -31,12 +31,23 @@ pub struct ImageExport {
/// Images larger than this many pixels are skipped (memory).
const MAX_PIXELS: u64 = 100_000_000;
/// What one export may hold of the files it makes: they are all in memory until it ends, and a file
/// of a few KB can make gigabytes of PNG. Images past it are left out, with the reason.
const MAX_EXPORT_BYTES: usize = 2 << 30;
/// The images of `pages` (0-based; each image once, on the first page using it), skipping
/// those with fewer than `min_side` pixels on their shorter side.
pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageExport {
extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), MAX_EXPORT_BYTES)
}
/// [`extract_images`] within `budget`, and holding `max_bytes` of files. An image past either is
/// left out, with the reason.
pub(crate) fn extract_images_within(doc: &Document, pages: &[usize], min_side: u32, budget: &mut ImageBudget, max_bytes: usize) -> ImageExport {
let all = pdfcraft_model::pages(doc);
let mut out = ImageExport::default();
let mut seen = HashSet::new();
let mut held = 0usize;
for &p in pages {
let Some(page) = all.get(p) else { continue };
let mut refs = Vec::new();
@@ -53,8 +64,15 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx
if w <= 0 || h <= 0 || (w.min(h) as u64) < min_side as u64 {
continue;
}
match image(doc, s) {
Ok((extension, data)) => out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data }),
if held >= max_bytes {
out.skipped.push((p, r, "more images than one export can hold".into()));
continue;
}
match image(doc, s, budget) {
Ok((extension, data)) => {
held = held.saturating_add(data.len());
out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data });
}
Err(why) => out.skipped.push((p, r, why)),
}
}
@@ -64,8 +82,13 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx
/// One image XObject as a file: JPEG as is, else PNG ("Save image as").
pub fn image_file(doc: &Document, image: ObjRef) -> Result<(&'static str, Vec<u8>), String> {
image_file_within(doc, image, &mut ImageBudget::new(doc))
}
/// [`image_file`] within `budget`, for a caller making a file of many images.
pub fn image_file_within(doc: &Document, image: ObjRef, budget: &mut ImageBudget) -> Result<(&'static str, Vec<u8>), String> {
match &*doc.get(image) {
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s),
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s, budget),
_ => Err("not an image".into()),
}
}
@@ -100,20 +123,20 @@ fn filters(s: &Stream) -> Vec<Vec<u8>> {
}
}
/// The samples of image `s`, decoded ([`Document::decoded_image`]): through its filters, with a
/// The samples of image `s`, decoded ([`ImageBudget::decode`]): through its filters, with a
/// `/DecodeParms` dictionary given by reference, or as an array's entry, or with numbers inside by
/// reference, read too (a predictor given so would otherwise be lost); a CCITT fax or JBIG2 image
/// through its decoder too, behind other filters as well, a JBIG2 image with its `/JBIG2Globals`.
/// A JBIG2 page has to be the image's size, which the caller checked against the export's limit,
/// before the page is decoded.
fn decoded(doc: &Document, s: &Stream) -> Result<Vec<u8>, String> {
doc.decoded_image(s).map_err(|e| e.to_string())
fn decoded(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<Vec<u8>, String> {
budget.decode(doc, s, MAX_DECODED).map_err(|e| e.to_string())
}
/// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft
/// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as
/// alpha, JPEG images included, so cut-out images keep their transparent background.
fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String> {
fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'static str, Vec<u8>), String> {
let f = filters(s);
let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1;
match f.last().map(Vec::as_slice) {
@@ -132,7 +155,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
};
let mask = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true)));
// A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2).
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h, budget).or_else(|| stencil_mask(doc, s, w, h, budget)) };
let key = if mask { None } else { colour_key(doc, s) };
// Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images.
let inverted = (jpeg || mask)
@@ -156,7 +179,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
space = decoded;
(samples, 8)
} else {
let data = decoded(doc, s)?;
let data = decoded(doc, s, budget)?;
let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) };
let bpc = match bpc {
1 => 1,
@@ -459,25 +482,25 @@ fn cmyk(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] {
}
/// The soft mask (`/SMask`) as 8-bit alpha, scaled to the image's size when it has another.
fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option<Vec<u8>> {
let r = s.dict.get(b"SMask")?.as_ref()?;
let obj = doc.get(r);
let Object::Stream(m) = &*obj else { return None };
gray_plane(doc, m, w, h)
gray_plane(doc, m, w, h, budget)
}
/// A stencil `/Mask` (an image mask stream, 8.9.6.3) as 8-bit alpha: its 1 samples (0 with
/// `/Decode [1 0]`) hide the image.
fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option<Vec<u8>> {
let r = s.dict.get(b"Mask")?.as_ref()?;
let obj = doc.get(r);
let Object::Stream(m) = &*obj else { return None };
Some(gray_plane(doc, m, w, h)?.into_iter().map(|v| 255 - v).collect())
Some(gray_plane(doc, m, w, h, budget)?.into_iter().map(|v| 255 - v).collect())
}
/// A one-component mask image as 8-bit values (its `/Decode` interval applied), scaled to
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000 masks).
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option<Vec<u8>> {
let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?;
let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true)));
let f = filters(m);
@@ -498,7 +521,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
_ => return None,
};
let max = (1u32 << bpc.min(8)) - 1;
unpack(&decoded(doc, m).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
unpack(&decoded(doc, m, budget).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
}
};
if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) {
+1 -1
View File
@@ -6,7 +6,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize}
mod extract;
mod upright_image;
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file};
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within};
use pdfcraft_fonts::{literal, win_ansi, wrap};
#[derive(Debug, thiserror::Error, PartialEq)]
+42
View File
@@ -2233,3 +2233,45 @@ fn jbig2_globals_are_decoded_within_a_limit_of_their_own() {
assert!(why.contains("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}");
}
}
/// One allowance for all the images of an export: an image past it is left out, saying so.
#[test]
fn jbig2_images_past_the_exports_allowance_are_left_out_with_the_reason() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(16));
dict.set(b"Height".to_vec(), Object::Int(2));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(1));
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let image = Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap());
let mut cost = ImageBudget::with_work(u64::MAX);
cost.decode(&Document::new_empty(), &image, 1 << 20).unwrap();
let cost = u64::MAX - cost.work_left();
let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec();
let mut doc = Document::open(std::sync::Arc::new(pdf)).unwrap();
let (a, b) = (doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image)));
let mut xobjects = Dict::new();
xobjects.set(b"A".to_vec(), Object::Ref(a));
xobjects.set(b"B".to_vec(), Object::Ref(b));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
let page = pdfcraft_model::pages(&doc).swap_remove(0).obj;
doc.update_dict(page, |p| p.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
let export = crate::extract::extract_images_within(&doc, &[0], 1, &mut ImageBudget::with_work(cost), usize::MAX);
assert_eq!(export.images.len(), 1);
assert_eq!(export.skipped.len(), 1);
assert!(export.skipped[0].2.contains("more JBIG2 decoding than"), "{:?}", export.skipped);
// What an export holds is bounded too: the files are all in memory until it ends.
let export = crate::extract::extract_images_within(&doc, &[0], 1, &mut ImageBudget::with_work(u64::MAX), 1);
assert_eq!(export.images.len(), 1, "the first goes past it");
assert_eq!(export.skipped.len(), 1);
assert!(export.skipped[0].2.contains("than one export can hold"), "{:?}", export.skipped);
// A file of many images shares one allowance too, image by image.
let mut budget = ImageBudget::with_work(cost);
assert!(crate::extract::image_file_within(&doc, a, &mut budget).is_ok());
assert!(crate::extract::image_file_within(&doc, b, &mut budget).is_err());
}
+19 -4
View File
@@ -173,14 +173,15 @@ pub struct OfficeExport {
/// The exported file.
pub bytes: Vec<u8>,
/// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them
/// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops
/// reading there to stay bounded on hostile files, so the text and images of those forms, or
/// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`] or past what one export
/// may decode: export stops reading there to stay bounded on hostile files, so the text and images of those forms, or
/// those pictures, are missing from `bytes`.
pub truncated_pages: Vec<usize>,
}
/// An image object prepared for Office export: its file extension and encoded bytes, or why it is
/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded).
/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`], or what the export may decode;
/// `Err(false)`: it can't be encoded).
type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>;
impl crate::Document {
@@ -194,6 +195,18 @@ impl crate::Document {
/// pictures went past `image_budget` bytes ([`OfficeExport::truncated_pages`]). Without
/// `images` no picture is prepared.
pub(crate) fn export_pages_checked(&self, images: bool, image_budget: usize) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
let Some(editor) = self.editor.as_ref() else { return (Vec::new(), Vec::new()) };
self.export_pages_within(images, image_budget, &mut pdfcraft_cos::ImageBudget::new(&editor.cos))
}
/// [`Self::export_pages_checked`], its pictures decoded within `image_work`, which is shared
/// by the whole export: a picture past it is left out like one past `image_budget`.
pub(crate) fn export_pages_within(
&self,
images: bool,
image_budget: usize,
image_work: &mut pdfcraft_cos::ImageBudget,
) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) };
let mut truncated = Vec::new();
// Each image object is encoded once and shared by every place that draws it.
@@ -244,7 +257,9 @@ impl crate::Document {
.filter_map(|im| {
let object = im.object?;
let prepared = encoded.entry(object).or_insert_with(|| {
let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?;
let refused = image_work.refused();
let (ext, bytes) =
pdfcraft_create::image_file_within(cos, object, &mut *image_work).map_err(|_| image_work.refused() > refused)?;
image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?;
Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes)))
});
+7
View File
@@ -8200,6 +8200,13 @@ fn copy_image_reads_the_globals_of_a_jbig2_picture() {
let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap();
assert_eq!((w, h), (16, 2));
assert!(rgba.chunks(4).all(|p| p == [0, 0, 0, 255]), "the page's default pixel, from the globals");
// An Office export shares one allowance across its pictures. One past it is left out, and the
// export says the page is missing something, as for a picture past its bytes.
let (pages, truncated) = d.export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX));
assert_eq!((pages[0].images.len(), truncated.len()), (1, 0));
let (pages, truncated) = d.export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(0));
assert!(pages[0].images.is_empty());
assert_eq!(truncated, [0]);
}
/// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter
+1 -1
View File
@@ -7,7 +7,7 @@
mod decode;
mod symbols;
pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2};
pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2, decode_jbig2_within};
use crate::FilterError;
use crate::mq::{Context, MqEncoder};
+57 -1
View File
@@ -23,9 +23,26 @@ pub const MAX_JBIG2_PIXELS: u64 = 1 << 28;
/// [`MAX_JBIG2_PIXELS`] ([`FilterError::Unsupported`], or `Corrupt` from the decoder, which has the
/// same cap and can refuse the page first).
pub fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize) -> Result<Vec<u8>, FilterError> {
let mut unbounded = u64::MAX;
decode_jbig2_within(data, globals, columns, rows, max, &mut unbounded)
}
/// [`decode_jbig2`], spending from `work`: what decoding may still cost across a run of images, in
/// the decoder's count of steps. Reading `data` and `globals` is charged first
/// ([`hayro_jbig2::charge_reading`]), so an image that turns out not to decode has paid for that,
/// then what decoding takes ([`hayro_jbig2::Image::decode_within`]). An image takes at most what
/// one decode may on top of its reading and its page, whatever `work` allows, and one that `work`
/// can't pay for spends all of it. With nothing left an image isn't read at all, so the images after
/// that one cost nothing but this check, however large their globals.
pub fn decode_jbig2_within(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize, work: &mut u64) -> Result<Vec<u8>, FilterError> {
if rows == 0 {
return Ok(Vec::new());
}
let spent = || FilterError::Unsupported("more JBIG2 decoding than one operation may do".into());
if *work == 0 {
return Err(spent());
}
hayro_jbig2::charge_reading(work, data.len().saturating_add(globals.map_or(0, <[u8]>::len))).map_err(|_| spent())?;
let corrupt = |detail: String| FilterError::Corrupt { filter: "JBIG2Decode", detail };
let image = hayro_jbig2::Image::new_embedded(data, globals).map_err(|e| corrupt(e.to_string()))?;
let (width, height) = (image.width(), image.height());
@@ -41,7 +58,7 @@ pub fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32
return Err(FilterError::LimitExceeded(max));
}
let mut out = Rows::new(width, height, false);
image.decode(&mut out).map_err(|e| corrupt(e.to_string()))?;
image.decode_within(&mut out, work).map_err(|e| corrupt(e.to_string()))?;
Ok(out.out)
}
@@ -123,6 +140,45 @@ mod tests {
assert_eq!(decode_jbig2(&symbols, None, w, h, usize::MAX).unwrap(), marks);
}
/// What a page costs is the same however much a run has left. Given exactly that it decodes,
/// and less stops it: spending all that's left, and what comes after isn't read. A page that
/// fails still spends what it took.
#[test]
fn a_run_of_images_spends_its_allowance_down_to_the_step() {
let (w, h) = (203u32, 41u32);
let data = encode_jbig2_generic(&noise(w, h), w, h).unwrap();
let whole = decode_jbig2(&data, None, w, h, usize::MAX).unwrap();
let mut work = u64::MAX;
assert_eq!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(), whole);
let cost = u64::MAX - work;
assert!(cost >= u64::from(w) * u64::from(h), "a pixel at a time: {cost}");
let mut work = cost;
assert_eq!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(), whole, "exactly enough");
assert_eq!(work, 0);
let mut work = cost + 7;
decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap();
assert_eq!(work, 7, "only what it took");
let mut work = cost - 1;
assert!(matches!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work), Err(FilterError::Corrupt { .. })), "a step short");
assert_eq!(work, 0, "spends all that was left");
// Nothing left: not read at all, so this garbage isn't reported as garbage.
assert!(matches!(decode_jbig2_within(b"garbage", None, w, h, usize::MAX, &mut work), Err(FilterError::Unsupported(_))));
// Too little even for its page.
let mut work = 3;
assert!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).is_err());
assert_eq!(work, 0);
// A region that can't be read after the one that can: the page fails having spent the
// first, and the segment it fails on, and that isn't being out of allowance.
let bad_region = [0, 0, 0, 9, 38, 0, 1, 0, 0, 0, 1, 0xFF];
let mut work = u64::MAX;
assert!(decode_jbig2_within(&[&data[..], &bad_region].concat(), None, w, h, usize::MAX, &mut work).is_err());
assert_eq!(u64::MAX - work, cost + 1 + 4 * bad_region.len() as u64, "the first region, a segment more, and reading it");
// Not a stream at all: it pays for being read, and globals count with it.
let mut work = u64::MAX;
assert!(matches!(decode_jbig2_within(b"garbage", Some(b"globals"), w, h, usize::MAX, &mut work), Err(FilterError::Corrupt { .. })));
assert_eq!(u64::MAX - work, 4 * 14);
}
/// The page has to be the image's size, and is bounded before it is decoded, being decoded
/// whole: within `max` as rows of one bit, and within [`MAX_JBIG2_PIXELS`]. Globals are given
/// to the decoder: ones it skips decode, ones that aren't JBIG2 are an error, as is damaged
+1 -1
View File
@@ -29,7 +29,7 @@ mod runlength;
use std::borrow::Cow;
pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4};
pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2, encode_jbig2, encode_jbig2_generic};
pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2, decode_jbig2_within, encode_jbig2, encode_jbig2_generic};
/// A PDF stream filter, identified by its `/Filter` name.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+3 -1
View File
@@ -100,7 +100,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
- Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when
smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2
images, with bounded shared globals.
images, with bounded shared globals. All of a run's JBIG2 images share one allowance of
decoding (`pdfcraft_cos::ImageBudget`): about 2,000 scanned pages' worth, plus more for each
byte of the file. Images past it stay as they are, and `Report::images_refused` counts them.
- **Discard objects:** thumbnails, alternate images, document tags, print settings.
- **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex
layer, that layer is removed when the result is smaller (the inner filters keep their encoded
+66 -7
View File
@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use pdfcraft_content::{Matrix, Pieces};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, Stream};
use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage};
@@ -799,7 +799,7 @@ type Pixel = fn(&[u8]) -> Option<bool>;
/// - an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1, which stay 0 and
/// 1 at one bit, whatever their colours, so the colour space stays. A decode array other than
/// the default stops it: in index units it would mean something else at one bit.
fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Vec<u8>, u32, u32, Option<Dict>)> {
fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool, budget: &mut ImageBudget) -> Option<(Vec<u8>, u32, u32, Option<Dict>)> {
let d = &s.dict;
// Through u32 first, as everywhere here, so the product can't overflow.
let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?);
@@ -820,7 +820,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve
_ => allowance(len, h)?,
};
count_decoded(u64::from(w) * u64::from(h));
let mut data = doc.decoded_image_within(s, max).ok()?;
let mut data = budget.decode(doc, s, max).ok()?;
if data.len() < len {
return None;
}
@@ -1088,7 +1088,7 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i
/// The samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for
/// black-and-white RGB, which becomes grey with one decode pair, and an indexed image's default
/// decode array, which goes as the default at one bit.
fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSettings) -> Option<(Stream, bool)> {
fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSettings, budget: &mut ImageBudget) -> Option<(Stream, bool)> {
let d = &s.dict;
let downsample = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && !d.contains(b"SMask");
let retain = settings.compression == MonoCompression::Retain;
@@ -1105,7 +1105,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
if only_to_resample && !downsample {
return None;
}
let (data, w, h, packed) = one_bit(doc, s, !retain, defaults)?;
let (data, w, h, packed) = one_bit(doc, s, !retain, defaults, budget)?;
let packed_from_more = packed.is_some();
let k = settings.target_ppi / ppi;
// `downsample_bits` keeps a counter for each column: no more columns than a fax row may have,
@@ -1178,6 +1178,19 @@ pub(crate) fn run(
settings: &Settings,
report: &mut Report,
progress: &mut dyn FnMut(Stage) -> bool,
) -> Result<(), OptimizeError> {
let mut budget = ImageBudget::new(doc);
run_within(doc, pages, settings, report, progress, &mut budget)
}
/// [`run`] within `budget`. An image past it stays as it is, and is counted in the report.
fn run_within(
doc: &mut Document,
pages: &[ObjRef],
settings: &Settings,
report: &mut Report,
progress: &mut dyn FnMut(Stage) -> bool,
budget: &mut ImageBudget,
) -> Result<(), OptimizeError> {
let (ppi, mut defaults, read) = walk_pages(doc, pages);
// Black-and-white RGB becomes grey, which default colour spaces would draw otherwise, and an
@@ -1209,7 +1222,7 @@ pub(crate) fn run(
// Packed from more bits a sample, it is lossless at one bit, but the grey or colour
// settings can make it smaller still (below).
let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1);
let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono) {
let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono, budget) {
Some(m) if packed_from_more => (Some(m), None),
m => (None, m),
};
@@ -1266,6 +1279,7 @@ pub(crate) fn run(
doc.set(mr, Object::Stream(m));
}
}
report.images_refused = budget.refused();
if !progress(Stage::Images { done: total, total }) {
return Err(OptimizeError::Cancelled);
}
@@ -1329,7 +1343,7 @@ mod dimension_tests {
let settings = MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE };
let decoded = |s: &Stream, settings: &MonoSettings| {
DECODED_PIXELS.with(|c| c.set(0));
mono(&Document::new_empty(), s, 300.0, false, settings);
mono(&Document::new_empty(), s, 300.0, false, settings, &mut ImageBudget::with_work(u64::MAX));
DECODED_PIXELS.with(|c| c.get())
};
let alone = image(Object::name("JBIG2Decode"), coded.clone());
@@ -1340,6 +1354,51 @@ mod dimension_tests {
assert!(decoded(&alone, &jbig2) > 0, "stored as JBIG2 its symbols may come out smaller");
}
/// A run has one allowance for all its JBIG2 images. Those past it stay as they are, and the
/// report says how many: a file of thousands of them can't hold a run for hours.
#[test]
fn jbig2_images_past_the_runs_allowance_stay_as_they_are() {
let coded = pdfcraft_filters::encode_jbig2_generic(&[0xF0; 4], 16, 2).unwrap();
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("XObject"));
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(16));
dict.set(b"Height".to_vec(), Object::Int(2));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(1));
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let image = Stream::from_raw(dict, coded);
let mut cost = ImageBudget::with_work(u64::MAX);
cost.decode(&Document::new_empty(), &image, 1 << 20).unwrap();
let cost = u64::MAX - cost.work_left();
let mut doc = Document::new_empty();
let refs = [doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image.clone()))];
let mut xobjects = Dict::new();
for (i, r) in refs.iter().enumerate() {
xobjects.set(format!("I{i}").into_bytes(), Object::Ref(*r));
}
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
// Drawn small enough, at 720 ppi, for the settings to resample all three.
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 1.6 0 0 .2 0 0 cm /I0 Do /I1 Do /I2 Do Q")));
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Resources".to_vec(), Object::Dict(resources));
page.set(b"Contents".to_vec(), Object::Ref(content));
let page = doc.add(Object::Dict(page));
let settings = Settings { mono: MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }, ..Settings::default() };
let mut report = Report::default();
let mut budget = ImageBudget::with_work(2 * cost);
run_within(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap();
assert_eq!(report.images_refused, 1);
assert_eq!(budget.work_left(), 0, "two decoded and the third refused");
assert_eq!(report.images_resampled, 2);
let changed = refs.iter().filter(|r| matches!(&*doc.get(**r), Object::Stream(s) if *s != image)).count();
assert_eq!(changed, 2, "the third is as it was");
}
#[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();
+2
View File
@@ -279,6 +279,8 @@ pub struct Report {
pub images: usize,
pub images_resampled: usize,
pub images_recompressed: usize,
/// JBIG2 images left as they are: decoding them all would take more than one run may spend.
pub images_refused: usize,
/// Encoded image bytes before and after (images that changed).
pub image_bytes_before: usize,
pub image_bytes_after: usize,
+2 -1
View File
@@ -24,7 +24,8 @@ clear_marks(&mut doc, None)?; // remove marks without app
is Flate, and refers to no globals: when no other image uses them, they leave the file with the
original on the full rewrite; when one does, they stay as they are, and may hold the shapes of
marks under the area (not where they were). Codecs redaction doesn't decode (DCT, JPX) are
removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer
removed whole (fail-closed), and so is a JBIG2 image past what one redaction may decode
(`pdfcraft_cos::ImageBudget`), one whose page isn't its size (the renderer
draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more
than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's
renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other
+5 -5
View File
@@ -7,7 +7,7 @@
//! caller removes the whole image instead; that is fail-closed.
use pdfcraft_content::{Matrix, overlaps};
use pdfcraft_cos::{Document, Object, Stream};
use pdfcraft_cos::{Document, ImageBudget, Object, Stream};
/// Most pixels an image may have to be cleared in place (larger ones are removed).
const MAX_PIXELS: u64 = 64 << 20;
@@ -38,11 +38,11 @@ fn components(doc: &Document, s: &Stream) -> Option<usize> {
/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects,
/// `Err(())` when the image can't be cleared (the caller removes it).
#[allow(clippy::result_unit_err)]
pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result<Option<Stream>, ()> {
decode_and_clear(doc, s, ctm, rects).ok_or(())
pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]], budget: &mut ImageBudget) -> Result<Option<Stream>, ()> {
decode_and_clear(doc, s, ctm, rects, budget).ok_or(())
}
fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Option<Option<Stream>> {
fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]], budget: &mut ImageBudget) -> Option<Option<Stream>> {
// Separate masks can retain the original image data.
if s.dict.contains(b"SMask") || s.dict.contains(b"Mask") {
return None;
@@ -111,7 +111,7 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
// alone would skip such a predictor, and the cleared bytes would then miss the covered pixels),
// and refuses a JBIG2 page of another size than the image: the renderer draws the whole page
// into the image, so the rows cleared wouldn't be the ones a mark covers on screen.
let mut data = doc.decoded_image_within(s, allowance).ok()?;
let mut data = budget.decode(doc, s, allowance).ok()?;
if data.len() < bytes {
return None;
}
+6 -3
View File
@@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string};
use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream};
use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ImageBudget, MAX_PAGE_FORMS, ObjRef, Object, Stream};
use crate::{RedactError, Report, image, page_streams, tags};
use pdfcraft_fonts::pdf::Metrics;
@@ -105,6 +105,8 @@ pub(crate) struct Scope<'a> {
pub rects: &'a [[f64; 4]],
pub mode: Mode,
pub report: &'a mut Report,
/// What the images it clears may spend, shared by all the pages of a run.
pub images: &'a mut ImageBudget,
/// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box).
pub hidden_text: Option<[f64; 4]>,
/// Sanitize: optional content groups that are off; content marked with them is removed.
@@ -134,11 +136,12 @@ pub(crate) struct Scope<'a> {
impl<'a> Scope<'a> {
/// A scope whose form walk spends `budget`, what is left of the page's allowance after its
/// content streams were decoded.
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self {
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget, images: &'a mut ImageBudget) -> Self {
Scope {
rects,
mode,
report,
images,
hidden_text: None,
hidden_layers: HashSet::new(),
layers_enabled: false,
@@ -750,7 +753,7 @@ fn xobject(
out.doomed.push(r);
return Some(None);
}
match image::clear(doc, s, &gs.ctm, scope.rects) {
match image::clear(doc, s, &gs.ctm, scope.rects, &mut *scope.images) {
Ok(None) => None,
Ok(Some(new)) => {
let nr = doc.add(Object::Stream(new));
+9 -3
View File
@@ -412,6 +412,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
let widget_owner: Vec<(ObjRef, ObjRef)> =
pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::<Vec<_>>()).collect();
let listed_before = listed_annotations(doc);
let images = pdfcraft_cos::ImageBudget::new(doc);
let mut applying = Applying {
report,
doomed_fields: Vec::new(),
@@ -419,6 +420,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
retired_properties: Vec::new(),
widget_owner,
removed_everywhere: HashSet::new(),
images,
};
for &(pi, page_marks) in &groups {
let page = pdfcraft_model::pages(doc).swap_remove(pi);
@@ -522,7 +524,9 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect();
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut scratch = Report::default();
let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget);
// Verifying doesn't decode images: what clearing left is read as the page draws it.
let mut images = pdfcraft_cos::ImageBudget::with_work(0);
let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget, &mut images);
residue += process(doc, &mut scope, &original, &resources, pdfcraft_content::Matrix::IDENTITY).residue;
}
if residue > 0 {
@@ -542,6 +546,8 @@ struct Applying {
retired_properties: Vec<Object>,
widget_owner: Vec<(ObjRef, ObjRef)>,
removed_everywhere: HashSet<ObjRef>,
/// What clearing images may spend, across the run's pages.
images: pdfcraft_cos::ImageBudget,
}
/// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows.
@@ -552,13 +558,13 @@ fn redact_page_content(
page: &pdfcraft_model::Page,
rects: &[[f64; 4]],
) -> Result<(), RedactError> {
let Applying { report, doomed_xobjects, retired_properties, .. } = applying;
let Applying { report, doomed_xobjects, retired_properties, images, .. } = applying;
// 1. Content.
let mut budget = pdfcraft_cos::DecodeBudget::page();
let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?;
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let out = {
let mut scope = Scope::new(rects, Mode::Apply, report, budget);
let mut scope = Scope::new(rects, Mode::Apply, report, budget, images);
process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY)
};
// Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot
+2 -1
View File
@@ -369,7 +369,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut report = Report::default();
let mode = if write { Mode::Apply } else { Mode::Verify };
let mut scope = Scope::new(&[], mode, &mut report, budget);
let mut images = pdfcraft_cos::ImageBudget::new(doc);
let mut scope = Scope::new(&[], mode, &mut report, budget, &mut images);
if text {
let c = page.crop(doc);
scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]);
+20 -1
View File
@@ -52,7 +52,8 @@ fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize {
let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap();
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut rep = Report::default();
let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget);
let mut images = pdfcraft_cos::ImageBudget::with_work(0);
let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget, &mut images);
process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue
}
@@ -746,6 +747,24 @@ fn jbig2_globals(secret: &[u8]) -> Vec<u8> {
g
}
/// An image the run's allowance can't decode can't be cleared, so it is removed whole, like any
/// other image that can't be read: what a mark covers never stays.
#[test]
fn a_jbig2_image_past_the_runs_allowance_is_removed_whole() {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(16));
dict.set(b"Height".to_vec(), Object::Int(2));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(1));
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let image = Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap());
let doc = Document::new_empty();
let half = [[0.0, 0.0, 0.5, 1.0]];
let clear = |work| crate::image::clear(&doc, &image, &pdfcraft_content::Matrix::IDENTITY, &half, &mut pdfcraft_cos::ImageBudget::with_work(work));
assert!(matches!(clear(u64::MAX), Ok(Some(_))), "cleared in a copy");
assert!(matches!(clear(0), Err(())), "removed whole");
}
/// A JBIG2 image (a scan the optimizer stored as JBIG2) has its covered pixels cleared in a copy,
/// like a fax image, instead of being removed whole: with `/JBIG2Globals`, its `/DecodeParms` by
/// reference, behind another filter with its globals at its own place, or as a mask, its decode
+8 -2
View File
@@ -83,6 +83,10 @@ pub struct RenderConfig {
/// viewer and preview rasters: exports, printing and OCR render without it, so nothing drawn with an
/// installed font is saved or printed (AGENTS.md §1.2, §1.4).
pub system_fonts: bool,
/// Steps of JBIG2 decoding a render may take across the page's images, instead of the usual
/// `hayro_syntax::MAX_PAGE_JBIG2_WORK`. Those past it aren't drawn, like an image that can't
/// be decoded.
pub jbig2_work: Option<u64>,
}
impl RenderConfig {
@@ -100,7 +104,8 @@ impl RenderConfig {
/// Passwords belong to parser construction; request-specific preview overrides are applied
/// by `render_page`, which also preserves the caller's warning and cancellation hooks.
fn settings(&self) -> InterpreterSettings {
let standard = InterpreterSettings::default().font_resolver;
let defaults = InterpreterSettings::default();
let standard = defaults.font_resolver.clone();
let system_fonts = self.system_fonts;
InterpreterSettings {
ocg_overrides: self.layers.clone(),
@@ -108,7 +113,8 @@ impl RenderConfig {
font_resolver: Arc::new(move |query| {
japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query))
}),
..InterpreterSettings::default()
jbig2_work: self.jbig2_work.unwrap_or(defaults.jbig2_work),
..defaults
}
}
}
+115 -1
View File
@@ -3592,7 +3592,10 @@ fn jbig2_image_draws_with(stream: &[u8], space: &str, entries: &str, objects: &[
pdf.extend_from_slice(b"\nendstream endobj\n");
pdf.extend_from_slice(objects);
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
// What one image may take, which the page's allowance, tested on its own, would otherwise reach
// first: it is the same size, and the page itself comes out of it.
let config = RenderConfig { jbig2_work: Some(u64::MAX), ..RenderConfig::default() };
let mut r = PageRenderer::new(Arc::new(pdf), config);
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
@@ -3604,6 +3607,117 @@ fn jbig2_image_draws_with(stream: &[u8], space: &str, entries: &str, objects: &[
}
}
/// The JBIG2 images of a page share one allowance, down to the step, and the next page starts with
/// its own. The images differ by an extension segment: one image decoded again isn't decoded twice.
#[test]
fn the_jbig2_images_of_a_page_share_one_allowance() {
let image = |number: u8, id: u8| {
let mut stream = black_jbig2_page();
jbig2_segment(&mut stream, 1, 62, &[], &[0, 0, 0, 0, id]);
let mut object = format!(
"{number} 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n",
stream.len()
)
.into_bytes();
object.extend_from_slice(&stream);
object.extend_from_slice(b"\nendstream endobj\n");
object
};
let page = |number: u8, content: u8, xobjects: &str| {
format!("{number} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents {content} 0 R /Resources << /XObject << {xobjects} >> >> >> endobj\n").into_bytes()
};
let content = |number: u8, text: &str| format!("{number} 0 obj << /Length {} >> stream\n{text}\nendstream endobj\n", text.len()).into_bytes();
let mut pdf =
b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n".to_vec();
pdf.extend(page(3, 5, "/A 7 0 R /B 8 0 R"));
pdf.extend(page(4, 6, "/C 9 0 R"));
pdf.extend(content(5, "q 20 0 0 40 0 0 cm /A Do Q q 20 0 0 40 20 0 cm /B Do Q"));
pdf.extend(content(6, "q 20 0 0 40 0 0 cm /C Do Q"));
pdf.extend(image(7, 1));
pdf.extend(image(8, 2));
pdf.extend(image(9, 3));
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
let pdf = Arc::new(pdf);
let black = |page: &RenderedPage, x: usize| match &page.rgba[((20 * 40 + x) * 4)..][..4] {
[0, 0, 0, 255] => true,
[255, 255, 255, 255] => false,
other => panic!("neither an image nor the page: {other:?}"),
};
let pages = |work: u64| {
let mut renderer = PageRenderer::new(pdf.clone(), RenderConfig { jbig2_work: Some(work), ..RenderConfig::default() });
[0, 1].map(|page| renderer.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }))
};
let drawn = |work: u64| {
let [first, _] = pages(work);
assert!(first.error.is_none(), "{:?}", first.error);
(black(&first, 10), black(&first, 30))
};
let one = (1..1_000).find(|work| drawn(*work).0).expect("a small image is cheap");
assert_eq!(drawn(one - 1), (false, false), "a step short of the first");
assert_eq!(drawn(one), (true, false), "enough for one");
assert_eq!(drawn(2 * one - 1), (true, false), "a step short of both");
assert_eq!(drawn(2 * one), (true, true), "enough for both");
let [_, second] = pages(one);
assert!(black(&second, 10), "the next page has its own allowance, though this one spent all of it");
let usual = PageRenderer::new(pdf.clone(), RenderConfig::default()).render(RenderRequest {
page: 0,
kind: RequestKind::Pixels,
tile: None,
scale: 1.0,
tag: 0,
..Default::default()
});
assert!(black(&usual, 10) && black(&usual, 30), "the usual allowance is far above a page like this");
}
/// An image that can't be decoded has still cost its reading, which a page repeats for each time
/// it draws one, since such an image isn't kept. So what it reads comes out of the page's allowance,
/// and an image after it has less. Allowed exactly what the good one takes, it isn't drawn behind
/// the bad one, and is drawn alone.
#[test]
fn an_image_that_fails_spends_the_pages_allowance_too() {
let image = |number: u8, stream: &[u8]| {
let mut object = format!(
"{number} 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n",
stream.len()
)
.into_bytes();
object.extend_from_slice(stream);
object.extend_from_slice(b"\nendstream endobj\n");
object
};
let pdf = |content: &str| {
let mut pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Bad 5 0 R /Good 6 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n",
content.len()
)
.into_bytes();
pdf.extend(image(5, &[0xFF; 64]));
pdf.extend(image(6, &black_jbig2_page()));
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
Arc::new(pdf)
};
let good = "q 40 0 0 40 0 0 cm /Good Do Q";
let drawn = |content: &str, work: u64| {
let config = RenderConfig { jbig2_work: Some(work), ..RenderConfig::default() };
let page = PageRenderer::new(pdf(content), config).render(RenderRequest {
page: 0,
kind: RequestKind::Pixels,
tile: None,
scale: 1.0,
tag: 0,
..Default::default()
});
assert!(page.error.is_none(), "{:?}", page.error);
page.rgba[((20 * 40 + 20) * 4)..][..4] == [0, 0, 0, 255]
};
let cost = (1..2_000).find(|work| drawn(good, *work)).expect("a small image is cheap");
assert!(!drawn(&format!("q 1 0 0 1 0 0 cm /Bad Do Q {good}"), cost), "the bad one spent some of it");
assert!(drawn(&format!("q 1 0 0 1 0 0 cm /Bad Do Q {good}"), cost + 4 * 64), "and what it spent is its reading");
}
/// Bits written from the top of each byte, as JBIG2's Huffman-coded data is.
#[derive(Default)]
struct Bits {
+2 -2
View File
@@ -7408,8 +7408,8 @@ milestone = "M11"
status = "partial"
commands = ["optimize.advanced", "optimize.reduce"]
tools = ["doc_optimize", "doc_reduce"]
evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again"]
notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples."
evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are"]
notes = "All of a run's JBIG2 images share one allowance of decoding (about 2,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples."
[[feature]]
id = "optimize.only-if-smaller"
+3 -3
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -218,6 +218,8 @@ impl<'a> Context<'a> {
NESTED_PAINTS.with(|n| n.set(0));
CONTENT_LEFT.with(|c| c.set(MAX_PAGE_CONTENT));
CONTENT_TRUNCATED.with(|truncated| truncated.set(false));
// PdfCraft patch (73): and fresh JBIG2 decoding, which every image of it shares.
hayro_syntax::set_page_jbig2_work(settings.jbig2_work);
// PdfCraft patch (55): a fallback left over from another page is not this page's.
crate::color::take_color_fallback();
crate::encode::reset_mesh_budget();
+5
View File
@@ -120,6 +120,10 @@ pub struct InterpreterSettings {
/// costs a decode and a resample each for pixels nobody can see. Never set it for a final
/// render (tiles, whole pages, print, export).
pub preview: bool,
/// PdfCraft patch (73): the steps of JBIG2 decoding a page's images may take between them
/// ([`hayro_syntax::MAX_PAGE_JBIG2_WORK`] by default). Images past it aren't drawn, each with
/// [`InterpreterWarning::ImageDecodeFailure`].
pub jbig2_work: u64,
}
impl Default for InterpreterSettings {
@@ -142,6 +146,7 @@ impl Default for InterpreterSettings {
ocg_overrides: Arc::new(Vec::new()),
cancelled: None,
preview: false,
jbig2_work: hayro_syntax::MAX_PAGE_JBIG2_WORK,
}
}
}
+64 -22
View File
@@ -195,11 +195,37 @@ impl<'a> Image<'a> {
/// This is useful in case you want to convert multiple JBIG2 images,
/// as it allows `hayro-jbig2` to reuse allocations during decoding.
pub fn decode_with<D: Decoder>(&self, decoder: &mut D, ctx: &mut DecoderContext) -> Result<()> {
decode_segments(&self.segments, self.height_from_stripes, ctx)?;
let mut work_left = bitmap::MAX_WORK;
decode_segments(&self.segments, self.height_from_stripes, ctx, &mut work_left)?;
emit_bitmap(&ctx.page_bitmap, decoder);
Ok(())
}
/// PdfCraft patch: [`Image::decode`], spending from `work`, what decoding may still cost
/// across a run of images. An image spends what it took, failing or not, starting with its
/// page's pixels, which whoever asked for them will go through, and its segments, so images
/// that decode nothing add up too. On top of that, it takes at most [`bitmap::MAX_WORK`]
/// whatever `work` says. One that `work` can't pay for spends all of it, so a run ends rather
/// than trying every image it has left. Reading the stream comes before this and is charged
/// by [`charge_reading`].
pub fn decode_within<D: Decoder>(&self, decoder: &mut D, work: &mut u64) -> Result<()> {
let start = bitmap::decode_work(self.width, self.height).saturating_add(self.segments.len() as u64);
if charge_work(work, start).is_err() {
*work = 0;
bail!(OverflowError::DecodingWork);
}
let allowed = bitmap::MAX_WORK.min(*work);
let mut left = allowed;
let mut ctx = DecoderContext::default();
let decoded = decode_segments(&self.segments, self.height_from_stripes, &mut ctx, &mut left);
let refused = decoded == Err(OverflowError::DecodingWork.into());
*work = if refused && allowed < bitmap::MAX_WORK { 0 } else { *work - (allowed - left) };
decoded?;
emit_bitmap(&ctx.page_bitmap, &mut *decoder);
Ok(())
}
}
fn emit_bitmap<D: Decoder>(bitmap: &Bitmap, decoder: &mut D) {
@@ -259,13 +285,29 @@ fn emit_bitmap<D: Decoder>(bitmap: &Bitmap, decoder: &mut D) {
}
}
/// PdfCraft patch: count `work` more decoding work, refusing past [`bitmap::MAX_WORK`] before what
/// asks for it is decoded.
/// PdfCraft patch: count `work` more decoding work, refusing past what is `left` of
/// [`bitmap::MAX_WORK`] or a run's allowance before what asks for it is decoded.
fn charge_work(left: &mut u64, work: u64) -> Result<()> {
*left = left.checked_sub(work).ok_or(OverflowError::DecodingWork)?;
Ok(())
}
/// PdfCraft patch: the steps of reading a byte of a stream or its globals: parsing and sorting
/// segments, and building their tables, measured at about 3 a byte at the worst.
const READ_WORK: u64 = 4;
/// PdfCraft patch: charge `work`, what a run of images may still cost, for reading `bytes` of a stream
/// and its globals. This comes before anything is parsed, as it counts whether or not they turn out
/// to decode: an image that fails has still cost its reading, and it is read again for each use. What
/// `work` can't pay for spends all of it.
pub fn charge_reading(work: &mut u64, bytes: usize) -> Result<()> {
if charge_work(work, (bytes as u64).saturating_mul(READ_WORK)).is_err() {
*work = 0;
bail!(OverflowError::DecodingWork);
}
Ok(())
}
/// PdfCraft patch: the work of a region decoded pixel by pixel.
fn region_work(region: &decode::RegionSegmentInfo) -> u64 {
bitmap::decode_work(region.width, region.height)
@@ -275,6 +317,7 @@ fn decode_segments(
segments: &[segment::Segment<'_>],
height_from_stripes: Option<u32>,
decoder_ctx: &mut DecoderContext,
work_left: &mut u64,
) -> Result<()> {
// Find and parse page information segment first.
if let Some(page_info) = segments
@@ -295,7 +338,6 @@ fn decode_segments(
let page_bitmap = &mut decoder_ctx.page_bitmap;
let page_state = &mut decoder_ctx.page_state;
let scratch_buffers = &mut decoder_ctx.scratch_buffers;
let mut work_left = bitmap::MAX_WORK;
// Process all segments.
for seg in segments {
@@ -308,7 +350,7 @@ fn decode_segments(
SegmentType::ImmediateGenericRegion | SegmentType::ImmediateLosslessGenericRegion => {
let had_unknown_length = seg.header.data_length.is_none();
let header = generic::parse(&mut reader, had_unknown_length)?;
charge_work(&mut work_left, region_work(&header.region_info))?;
charge_work(work_left, region_work(&header.region_info))?;
if page_state.can_decode_directly(page_bitmap, &header.region_info, false) {
generic::decode_into(&header, page_bitmap, scratch_buffers)?;
@@ -326,13 +368,13 @@ fn decode_segments(
SegmentType::IntermediateGenericRegion => {
// Intermediate segments cannot have unknown length.
let header = generic::parse(&mut reader, false)?;
charge_work(&mut work_left, region_work(&header.region_info))?;
charge_work(work_left, region_work(&header.region_info))?;
let region = generic::decode(&header, scratch_buffers)?;
page_state.store_region(seg.header.segment_number, region.bitmap)?;
}
SegmentType::PatternDictionary => {
let header = pattern::parse(&mut reader)?;
charge_work(&mut work_left, header.work())?;
charge_work(work_left, header.work())?;
let dictionary = pattern::decode(&header, scratch_buffers, page_state.retained_room())?;
page_state.store_pattern_dictionary(seg.header.segment_number, dictionary)?;
}
@@ -341,7 +383,7 @@ fn decode_segments(
// (6.5.5, step 1)
// Collect references to avoid cloning; symbols are only cloned if re-exported.
let input_symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?;
charge_work(&mut work_left, input_symbols.len() as u64)?;
charge_work(work_left, input_symbols.len() as u64)?;
// Collect Huffman tables from referred table segments.
let referred_tables: Vec<HuffmanTable> = seg
@@ -368,14 +410,14 @@ fn decode_segments(
&page_state.standard_tables,
retained_contexts,
page_state.retained_room(),
&mut work_left,
work_left,
)?;
page_state.store_symbol_dictionary(seg.header.segment_number, dictionary)?;
}
SegmentType::ImmediateTextRegion | SegmentType::ImmediateLosslessTextRegion => {
// Collect symbols from referred symbol dictionaries (SBSYMS).
let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?;
charge_work(&mut work_left, symbols.len() as u64)?;
charge_work(work_left, symbols.len() as u64)?;
// Collect Huffman tables from referred table segments.
// "These user-supplied Huffman decoding tables may be supplied either
@@ -389,10 +431,10 @@ fn decode_segments(
.collect();
let header = text::parse(&mut reader, symbols.len() as u32)?;
charge_work(&mut work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?;
charge_work(work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?;
if header.flags.use_huffman {
// Its symbol ID table has a line for each symbol.
charge_work(&mut work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?;
charge_work(work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?;
}
if page_state.can_decode_directly(
@@ -407,7 +449,7 @@ fn decode_segments(
&page_state.standard_tables,
page_bitmap,
scratch_buffers,
&mut work_left,
work_left,
)?;
} else {
let region = text::decode(
@@ -416,7 +458,7 @@ fn decode_segments(
&referred_tables,
&page_state.standard_tables,
scratch_buffers,
&mut work_left,
work_left,
)?;
page_bitmap.combine(
&region.bitmap,
@@ -430,7 +472,7 @@ fn decode_segments(
SegmentType::IntermediateTextRegion => {
// Collect symbols from referred symbol dictionaries (SBSYMS).
let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?;
charge_work(&mut work_left, symbols.len() as u64)?;
charge_work(work_left, symbols.len() as u64)?;
// Collect Huffman tables from referred table segments.
let referred_tables: Vec<HuffmanTable> = seg
@@ -442,10 +484,10 @@ fn decode_segments(
.collect();
let header = text::parse(&mut reader, symbols.len() as u32)?;
charge_work(&mut work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?;
charge_work(work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?;
if header.flags.use_huffman {
// Its symbol ID table has a line for each symbol.
charge_work(&mut work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?;
charge_work(work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?;
}
let region = text::decode(
&header,
@@ -453,7 +495,7 @@ fn decode_segments(
&referred_tables,
&page_state.standard_tables,
scratch_buffers,
&mut work_left,
work_left,
)?;
page_state.store_region(seg.header.segment_number, region.bitmap)?;
}
@@ -466,7 +508,7 @@ fn decode_segments(
.ok_or(SegmentError::MissingPatternDictionary)?;
let header = halftone::parse(&mut reader)?;
charge_work(&mut work_left, halftone::work(&header, pattern_dict))?;
charge_work(work_left, halftone::work(&header, pattern_dict))?;
if page_state.can_decode_directly(
page_bitmap,
@@ -494,7 +536,7 @@ fn decode_segments(
.ok_or(SegmentError::MissingPatternDictionary)?;
let header = halftone::parse(&mut reader)?;
charge_work(&mut work_left, halftone::work(&header, pattern_dict))?;
charge_work(work_left, halftone::work(&header, pattern_dict))?;
let region = halftone::decode(&header, pattern_dict, scratch_buffers)?;
page_state.store_region(seg.header.segment_number, region.bitmap)?;
}
@@ -508,7 +550,7 @@ fn decode_segments(
.unwrap_or(page_bitmap);
let header = generic_refinement::parse(&mut reader)?;
charge_work(&mut work_left, region_work(&header.region_info))?;
charge_work(work_left, region_work(&header.region_info))?;
let region = generic_refinement::decode(&header, reference, scratch_buffers)?;
page_state.store_region(seg.header.segment_number, region.bitmap)?;
}
@@ -526,7 +568,7 @@ fn decode_segments(
.and_then(|&num| page_state.get_referred_segment(num));
let header = generic_refinement::parse(&mut reader)?;
charge_work(&mut work_left, region_work(&header.region_info))?;
charge_work(work_left, region_work(&header.region_info))?;
if let Some(referred_segment) = referred_segment
&& page_state.can_decode_directly(page_bitmap, &header.region_info, false)
+66 -2
View File
@@ -9,6 +9,64 @@ use alloc::vec;
use alloc::vec::Vec;
use core::iter;
/// PdfCraft patch (31): the steps of decoding a page's JBIG2 images may take between them, as many
/// as one may. Each has its own limit, which doesn't bound a page of many made to reach it. That
/// is about 6 s to 17 s, inside a render's watchdog; an image of the largest size costs a quarter of
/// it to decode and a quarter for its page, and ordinary ones are far below that.
pub const MAX_PAGE_JBIG2_WORK: u64 = 1 << 30;
#[cfg(feature = "std")]
std::thread_local! {
// Unbounded until a page says otherwise: what isn't drawing a page has no page to run out on.
static PAGE_WORK_LEFT: core::cell::Cell<u64> = const { core::cell::Cell::new(u64::MAX) };
}
/// PdfCraft patch (31): what the JBIG2 images decoded next on this thread may take, for a new page.
#[cfg(feature = "std")]
pub fn set_page_jbig2_work(work: u64) {
PAGE_WORK_LEFT.with(|left| left.set(work));
}
/// Pays for reading `bytes` of a stream and its globals out of what the page has left, or `None`.
/// An image that doesn't decode isn't kept, so a page drawing it again and again reads it again.
#[cfg(feature = "std")]
fn charge_page_reading(bytes: usize) -> Option<()> {
let mut left = PAGE_WORK_LEFT.with(core::cell::Cell::get);
let charged = hayro_jbig2::charge_reading(&mut left, bytes);
PAGE_WORK_LEFT.with(|cell| cell.set(left));
charged.ok()
}
#[cfg(not(feature = "std"))]
fn charge_page_reading(_: usize) -> Option<()> {
Some(())
}
/// `image` decoded through `decoder`, spending what the page has left, if there is anything.
#[cfg(feature = "std")]
fn decode_for_page<D: hayro_jbig2::Decoder>(image: &hayro_jbig2::Image<'_>, decoder: &mut D) -> Option<()> {
let mut left = PAGE_WORK_LEFT.with(core::cell::Cell::get);
let decoded = image.decode_within(decoder, &mut left);
PAGE_WORK_LEFT.with(|cell| cell.set(left));
decoded.ok()
}
#[cfg(not(feature = "std"))]
fn decode_for_page<D: hayro_jbig2::Decoder>(image: &hayro_jbig2::Image<'_>, decoder: &mut D) -> Option<()> {
image.decode(decoder).ok()
}
/// Whether the page has nothing left for another image, which then isn't read at all.
#[cfg(feature = "std")]
fn page_work_spent() -> bool {
PAGE_WORK_LEFT.with(core::cell::Cell::get) == 0
}
#[cfg(not(feature = "std"))]
fn page_work_spent() -> bool {
false
}
/// Decode JBIG2 data from a PDF stream.
///
/// The `params` dictionary may contain a `JBIG2Globals` entry pointing to
@@ -18,6 +76,10 @@ pub(crate) fn decode(
params: &Dict<'_>,
image_params: &ImageDecodeParams,
) -> Option<FilterResult<'static>> {
if page_work_spent() {
return None;
}
// PdfCraft patch: globals are raw segments, so JBIG2 inside them means nothing, and decoding
// it reads its own globals in turn: a chain of 16,000 such streams overflowed the stack, which
// aborts the process. Such a stream reads as globals that can't be decoded.
@@ -26,6 +88,8 @@ pub(crate) fn decode(
.filter(|g| !g.filters().iter().any(|f| matches!(f, Filter::Jbig2Decode)))
.and_then(|g| g.decoded().ok());
charge_page_reading(data.len().saturating_add(globals.as_deref().map_or(0, <[u8]>::len)))?;
let image = hayro_jbig2::Image::new_embedded(data, globals.as_deref()).ok()?;
// Whenever possible (if we don't have an indexed color space), we convert
@@ -60,7 +124,7 @@ pub(crate) fn decode(
let writer = BitWriter::new(&mut packed, 1)?;
let mut decoder = BitWriterDecoder { writer };
image.decode(&mut decoder).ok()?;
decode_for_page(&image, &mut decoder)?;
(packed, 1)
} else {
@@ -83,7 +147,7 @@ pub(crate) fn decode(
}
let mut decoder = Luma8Decoder { output: Vec::new() };
image.decode(&mut decoder).ok()?;
decode_for_page(&image, &mut decoder)?;
(decoder.output, 8)
};
+4
View File
@@ -9,6 +9,10 @@ mod dct;
#[cfg(feature = "images")]
mod jbig2;
#[cfg(feature = "images")]
pub use jbig2::MAX_PAGE_JBIG2_WORK;
#[cfg(all(feature = "images", feature = "std"))]
pub use jbig2::set_page_jbig2_work;
#[cfg(feature = "images")]
mod jpx;
mod lzw_flate;
mod run_length;