M2.6: images decode outside the renderer only through an ImageBudget

`ImageBudget::decode` is now the one public way to decode an image's
CCITT fax or JBIG2 data: `Document::decoded_image` and
`decoded_image_within`, which decoded with no limit on the work, are
test-only in pdfcraft-cos, and pdfcraft-filters exports only
`decode_ccitt_within` and `decode_jbig2_within`, which spend from a
caller's allowance (`decode_jbig2` is gone, `decode_ccitt` private).
So an operation over many images can't decode them without sharing an
allowance; the compiler holds new callers to it. Doctests that must not
compile check both crates; with the old entry points public they fail.

Tests outside the crates decode through an unlimited ImageBudget.
Streams in general (`Stream::decoded`) are left for a follow-up.
This commit is contained in:
FredApps
2026-11-02 14:24:21 +01:00
parent 4ffe5908c8
commit ec25e7d327
12 changed files with 162 additions and 88 deletions
+43 -1
View File
@@ -80,7 +80,49 @@ impl ImageBudget {
Self { work, refused: 0 }
}
/// Image `s` decoded like [`Document::decoded_image_within`], charged to the allowance.
/// The samples of image `s`, decoded and charged to the allowance: the one way to decode an
/// image outside the renderer, so that an operation over many images can't decode them without
/// one.
///
/// They go through its filters ([`Stream::decoded`]), or, for a CCITT fax or JBIG2 image (its
/// last filter), through the filters before it and then [`pdfcraft_filters::decode_ccitt_within`]
/// with its [`Document::fax_params`], or [`pdfcraft_filters::decode_jbig2_within`] with its
/// `/JBIG2Globals`, as `CCITTFaxDecode` and `JBIG2Decode` give them (ISO 32000-2 §7.4.6,
/// §7.4.7). Parameters given by reference count, for every filter
/// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`, and a
/// JBIG2 image's page its `/Width` × `/Height`: PdfCraft's renderer draws the whole page into
/// the image. Up to its `/Height` rows are decoded, whatever a fax image's `/Rows` says; damaged
/// fax data gives fewer, so a caller checks it has the rows it needs. Damaged JBIG2 data, and
/// globals that can't be read (within [`MAX_JBIG2_GLOBALS`](crate::MAX_JBIG2_GLOBALS)), are an
/// error.
///
/// No stage, the filters before a fax or JBIG2 decoder included, may produce more than `max`
/// bytes, nor more than [`MAX_DECODED`](crate::MAX_DECODED). A JBIG2 image's `/JBIG2Globals`
/// are another stream, shared between images, and are decoded within `MAX_JBIG2_GLOBALS`
/// whatever `max` is. Each filter as it runs, the globals when they have to be read, and the fax
/// or JBIG2 decoder spend from the allowance, and the image then pays for its pixels; with
/// nothing left an image is refused before anything is read, its globals included.
///
/// ```
/// # let doc = pdfcraft_cos::Document::new_empty();
/// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new());
/// let samples = pdfcraft_cos::ImageBudget::new(&doc).decode(&doc, &s, 1 << 20);
/// assert_eq!(samples.unwrap(), b"");
/// ```
///
/// `Document` has no way of its own to decode an image:
///
/// ```compile_fail
/// # let doc = pdfcraft_cos::Document::new_empty();
/// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new());
/// let _ = doc.decoded_image(&s);
/// ```
///
/// ```compile_fail
/// # let doc = pdfcraft_cos::Document::new_empty();
/// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new());
/// let _ = doc.decoded_image_within(&s, 1 << 20);
/// ```
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.work);
self.count_refused(s, &decoded);
+10 -21
View File
@@ -274,34 +274,23 @@ impl Document {
.unwrap_or_default()
}
/// The samples of image `s`, decoded: through its filters ([`Stream::decoded`]), or, for a
/// CCITT fax or JBIG2 image (its last filter), through the filters before it and then
/// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], or
/// [`pdfcraft_filters::decode_jbig2`] with its `/JBIG2Globals`, as `CCITTFaxDecode` and
/// `JBIG2Decode` give them (ISO 32000-2 §7.4.6, §7.4.7). Parameters given by reference count,
/// for every filter ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its
/// `/Width`, and a JBIG2 image's page its `/Width` × `/Height`: PdfCraft's renderer draws the
/// whole page into the image. Up to its `/Height` rows are decoded, whatever a fax image's `/Rows` says,
/// within [`MAX_DECODED`] bytes; damaged fax data gives fewer, so a caller checks it has the
/// rows it needs. Damaged JBIG2 data, and globals that can't be read (within
/// [`MAX_JBIG2_GLOBALS`]), are an error.
pub fn decoded_image(&self, s: &Stream) -> Result<Vec<u8>, CosError> {
/// [`Document::decoded_image_spending`] with no limit on the work, for tests of what an image
/// decodes to: everything else decodes images through an [`ImageBudget`](crate::ImageBudget).
#[cfg(test)]
pub(crate) fn decoded_image(&self, s: &Stream) -> Result<Vec<u8>, CosError> {
self.decoded_image_within(s, MAX_DECODED)
}
/// [`Document::decoded_image`] for an image whose decoding has a tighter bound than
/// [`MAX_DECODED`]: no stage, the filters before a fax or JBIG2 decoder included, may produce
/// 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> {
/// [`Document::decoded_image`] within `max` bytes.
#[cfg(test)]
pub(crate) 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`], spending from `work`, what an operation may still do
/// across its images ([`ImageBudget`](crate::ImageBudget)): each filter as it runs, a JBIG2
/// image's globals when they have to be read, and the fax or JBIG2 decoder. With nothing left an
/// image is refused before anything is read, its globals included.
/// The samples of image `s`, decoded within `max` bytes ([`MAX_DECODED`] at most), spending from
/// `work`: see [`ImageBudget::decode`](crate::ImageBudget::decode), the way in from outside this
/// crate, which says what this decodes and how.
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);
+1 -1
View File
@@ -8272,7 +8272,7 @@ fn an_office_export_doesnt_call_an_unreadable_picture_truncated() {
}
/// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter
/// (ASCIIHexDecode) too: Export's decoding, through `Document::decoded_image`.
/// (ASCIIHexDecode) too: Export's decoding, through `ImageBudget::decode`.
#[test]
fn copy_image_copies_a_jbig2_picture_behind_another_filter() {
// 16 × 2: the left half of the top row black, the rest white.
+22 -19
View File
@@ -50,7 +50,7 @@ pub fn encode_g4(data: &[u8], columns: u32, rows: u32) -> Result<Vec<u8>, Filter
}
}
/// The widest row [`decode_ccitt`] decodes: its row buffer stays within 128 KiB.
/// The widest row [`decode_ccitt_within`] decodes: its row buffer stays within 128 KiB.
pub const MAX_CCITT_COLUMNS: u32 = 1 << 20;
/// A `CCITTFaxDecode` stream's `/DecodeParms` (ISO 32000-2 Table 11). [`Default`] gives the
@@ -74,22 +74,8 @@ impl Default for CcittParams {
}
}
/// CCITT fax `data` decoded as `CCITTFaxDecode` gives it: rows of `columns` 1-bit samples, most
/// significant bit first, each starting on a new byte, with black as 0 (as 1 with `black_is_1`).
///
/// At most `max_rows` rows are decoded (fewer when `rows` says so), so the output is bounded by
/// the caller. Damaged data gives the rows decoded before the damage, as viewers show them: a
/// caller that needs a number of rows checks it got them.
///
/// Group 3 rows on bytes (`k` from 0, `encoded_byte_align`) are read by their end-of-line codes,
/// whether the fill starts each code on a byte or ends it on one, when `data` starts with one (T.4
/// sends one before the first row when it sends them at all), and on bytes otherwise, whatever
/// `end_of_line` says. Group 4 is read as its parameters say. Data with codes but none before the
/// first row is read on bytes, as PdfCraft's renderer draws it; with `k` 0 and the fill ending each
/// code on a byte, that may be other rows than were coded.
///
/// Errors: `columns` that aren't from 1 to [`MAX_CCITT_COLUMNS`] ([`FilterError::Unsupported`]).
pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result<Vec<u8>, FilterError> {
/// What [`decode_ccitt_within`] decodes once it is paid for.
fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result<Vec<u8>, FilterError> {
let columns = u32::try_from(p.columns)
.ok()
.filter(|c| (1..=MAX_CCITT_COLUMNS).contains(c))
@@ -128,7 +114,24 @@ pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result<Vec<u
/// what a step of JBIG2 decoding takes.
const CODE_WORK: u64 = 4;
/// [`decode_ccitt`], spending from `work`, what decoding may still cost across a run of images: a
/// CCITT fax `data` decoded as `CCITTFaxDecode` gives it: rows of `columns` 1-bit samples, most
/// significant bit first, each starting on a new byte, with black as 0 (as 1 with `black_is_1`).
///
/// At most `max_rows` rows are decoded (fewer when `rows` says so), so the output is bounded by
/// the caller. Damaged data gives the rows decoded before the damage, as viewers show them: a
/// caller that needs a number of rows checks it got them.
///
/// Group 3 rows on bytes (`k` from 0, `encoded_byte_align`) are read by their end-of-line codes,
/// whether the fill starts each code on a byte or ends it on one, when `data` starts with one (T.4
/// sends one before the first row when it sends them at all), and on bytes otherwise, whatever
/// `end_of_line` says. Group 4 is read as its parameters say. Data with codes but none before the
/// first row is read on bytes, as PdfCraft's renderer draws it; with `k` 0 and the fill ending each
/// code on a byte, that may be other rows than were coded.
///
/// Errors: `columns` that aren't from 1 to [`MAX_CCITT_COLUMNS`] ([`FilterError::Unsupported`]).
///
/// Decoding spends from `work`, what decoding may still cost across a run of images, and there is
/// no way in without it: a
/// byte of rows a step, whether or not the data holds them, and [`CODE_WORK`] a byte of `data`.
/// A blank row takes a bit of code, so the rows are what a few bytes can ask for. It is all paid
/// before anything is decoded, and what `work` can't pay for spends all of it.
@@ -149,7 +152,7 @@ fn starts_with_end_of_line(data: &[u8]) -> bool {
/// Rows of 1-bit samples filled from a decoder's pixels: `columns` a row, each row on a new byte, at
/// most `rows` rows. Pixels past a row's end and rows past the last are dropped. The JBIG2 decoder
/// ([`crate::decode_jbig2`]) fills them too.
/// ([`crate::decode_jbig2_within`]) fills them too.
pub(crate) struct Rows {
pub(crate) out: Vec<u8>,
row: Vec<u8>,
+1 -1
View File
@@ -7,7 +7,7 @@
mod decode;
mod symbols;
pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2, decode_jbig2_within};
pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2_within};
use crate::FilterError;
use crate::mq::{Context, MqEncoder};
+11 -8
View File
@@ -3,7 +3,7 @@
use crate::FilterError;
use crate::ccitt::Rows;
/// The largest JBIG2 page [`decode_jbig2`] decodes, in pixels. The decoder decodes a page whole,
/// The largest JBIG2 page [`decode_jbig2_within`] decodes, in pixels. The decoder decodes a page whole,
/// and takes pages of up to about this many (20,000 × 10,000 decodes).
pub const MAX_JBIG2_PIXELS: u64 = 1 << 28;
@@ -22,13 +22,10 @@ pub const MAX_JBIG2_PIXELS: u64 = 1 << 28;
/// ([`FilterError::Corrupt`]); a page past `max` ([`FilterError::LimitExceeded`]) or
/// [`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
///
/// Decoding spends from `work`: what decoding may still cost across a run of images, in the
/// decoder's count of steps. There is no way in without it, so a run of images can't decode them
/// without sharing an allowance. 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`
@@ -80,6 +77,12 @@ mod tests {
use super::*;
use crate::{encode_jbig2, encode_jbig2_generic};
/// [`decode_jbig2_within`] with no limit on the work.
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)
}
/// `rows` rows of `columns` samples of noise, the bits that pad each row to a byte 0.
fn noise(columns: u32, rows: u32) -> Vec<u8> {
let stride = (columns as usize).div_ceil(8);
+23 -5
View File
@@ -7,9 +7,27 @@
//! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`),
//! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so
//! callers keep such data encoded. CCITT fax and JBIG2 images decode on their own
//! ([`decode_ccitt`], [`decode_jbig2`]), as `pdfcraft_cos::Document::decoded_image`
//! does after the filters before them. 1-bit samples can be encoded as CCITT Group 4
//! ([`encode_g4`]) or lossless JBIG2 ([`encode_jbig2`]).
//! ([`decode_ccitt_within`], [`decode_jbig2_within`]), each spending from an allowance its caller
//! shares across a run of images, as `pdfcraft_cos::ImageBudget::decode` does after the filters
//! before them. Neither decodes without one:
//!
//! ```compile_fail
//! let _ = pdfcraft_filters::decode_jbig2(&[], None, 1, 1, 1 << 20);
//! ```
//!
//! ```compile_fail
//! let _ = pdfcraft_filters::decode_ccitt(&[], &pdfcraft_filters::CcittParams::default(), 1);
//! ```
//!
//! ```
//! let mut work = 1 << 20;
//! assert!(pdfcraft_filters::decode_jbig2_within(&[], None, 1, 1, 1 << 20, &mut work).is_err());
//! let p = pdfcraft_filters::CcittParams { columns: 8, ..Default::default() };
//! assert!(pdfcraft_filters::decode_ccitt_within(&[], &p, 1, &mut work).is_ok());
//! ```
//!
//! 1-bit samples can be encoded as CCITT Group 4 ([`encode_g4`]) or lossless JBIG2
//! ([`encode_jbig2`]).
//!
//! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb
//! defence) and never panics on malformed input.
@@ -28,8 +46,8 @@ mod runlength;
use std::borrow::Cow;
pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, decode_ccitt_within, encode_g4};
pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2, decode_jbig2_within, encode_jbig2, encode_jbig2_generic};
pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt_within, encode_g4};
pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2_within, encode_jbig2, encode_jbig2_generic};
/// A PDF stream filter, identified by its `/Filter` name.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+1 -1
View File
@@ -811,7 +811,7 @@ type Pixel = fn(&[u8]) -> Option<bool>;
/// with its width and height, and, when they were packed from more bits, its dictionary as a 1-bit
/// image. These count:
/// - one bit per pixel in one colour component, or an image mask, stored with no filter, with
/// general-purpose ones, or with CCITT fax or JBIG2 last ([`Document::decoded_image_within`], no further
/// general-purpose ones, or with CCITT fax or JBIG2 last ([`ImageBudget::decode`], no further
/// than its rows can need);
/// - with `pack`, and with no colour-key `/Mask` (its ranges are in the image's own values):
/// - eight-bit grey (one component, not indexed) whose every sample is 0 or 255. A decode array
+31 -20
View File
@@ -9,6 +9,12 @@ use pdfcraft_optimize::{
effective_resolutions, optimize,
};
/// What image `s` decodes to, its fax or JBIG2 data included, with no limit on the work: the
/// samples an operation would read through its `ImageBudget`.
fn decoded_image(doc: &Document, s: &Stream) -> Result<Vec<u8>, pdfcraft_cos::CosError> {
pdfcraft_cos::ImageBudget::with_work(u64::MAX).decode(doc, s, pdfcraft_cos::MAX_DECODED)
}
fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option<ObjRef>) -> ObjRef {
// A smooth gradient with a little texture (photo-like).
let mut px = Vec::with_capacity((w * h) as usize * n);
@@ -910,7 +916,7 @@ fn rgb_and_two_colour_indexed_black_and_white_images_become_one_bit() {
(Some(1), Some(&b"CCITTFaxDecode"[..]), Some(&cs)),
"{i:?}"
);
assert_eq!(doc.decoded_image(&s).unwrap(), text, "{i:?}");
assert_eq!(decoded_image(&doc, &s).unwrap(), text, "{i:?}");
let decode = if i == rgb_inverted { Some(Object::Array(vec![1.into(), 0.into()])) } else { None };
assert_eq!(s.dict.get(b"Decode").cloned(), decode, "{i:?}");
}
@@ -1066,7 +1072,7 @@ fn retain_keeps_a_group_3_halftone_as_it_is() {
let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone())));
// 7.68 pt for 64 samples is 600 ppi.
page(&mut doc, &[("A", fax)], "q 7.68 0 0 7.68 100 600 cm /A Do Q");
assert_eq!(doc.decoded_image(&stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes");
assert_eq!(decoded_image(&doc, &stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes");
let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap();
assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}");
assert_eq!(stream(&doc, fax).raw[..], g3[..]);
@@ -1564,7 +1570,7 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() {
}
assert_eq!(raw(&kept, hex_at_400), raw(&doc, hex_at_400), "and nothing else changes");
for i in [flate, fax, hex_fax] {
assert_eq!((size(&kept, i), kept.decoded_image(&stream(&kept, i)).unwrap()), ((1200, 480), text.clone()), "{i:?}");
assert_eq!((size(&kept, i), decoded_image(&kept, &stream(&kept, i)).unwrap()), ((1200, 480), text.clone()), "{i:?}");
}
let bits = [eight, rgb, rgb_inverted].map(|i| stream(&kept, i).dict.int(b"BitsPerComponent"));
assert_eq!(bits, [Some(8); 3], "Retain leaves eight bits to the grey and colour settings");
@@ -1578,7 +1584,7 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() {
let s = stream(&doc, i);
assert_eq!((size(&doc, i), s.dict.int(b"BitsPerComponent")), ((1200, 480), if i == mask { None } else { Some(1) }));
assert!(matches!(s.filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "{i:?}");
assert_eq!(doc.decoded_image(&s).unwrap(), text.clone(), "{i:?}");
assert_eq!(decoded_image(&doc, &s).unwrap(), text.clone(), "{i:?}");
}
assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays");
// Grey now, with grey's decode pair, which makes 1 black: its ties are black too.
@@ -1588,12 +1594,12 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() {
(size(&doc, rgb_inverted), s.dict.get(b"ColorSpace"), s.dict.get(b"Decode")),
((1200, 480), Some(&Object::name("DeviceGray")), Some(&inverting))
);
assert_eq!(doc.decoded_image(&s).unwrap(), text.iter().map(|b| !b).collect::<Vec<u8>>(), "the same picture, ties black");
assert_eq!(decoded_image(&doc, &s).unwrap(), text.iter().map(|b| !b).collect::<Vec<u8>>(), "the same picture, ties black");
for i in [at_400, soft, hex_at_400] {
assert_eq!((size(&doc, i), stream(&doc, i).dict.name(b"Filter")), ((1200, 480), Some(&b"CCITTFaxDecode"[..])), "{i:?}");
}
assert!(raw(&doc, hex_at_400).len() < hex_len, "the ASCIIHexDecode is dropped");
assert_eq!(doc.decoded_image(&stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept");
assert_eq!(decoded_image(&doc, &stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept");
assert_eq!([fax_at_400].map(|r| raw(&doc, r)), untouched);
let after = render(&doc);
assert_eq!(after, before, "conservative retention preserves the rendered page exactly");
@@ -1681,7 +1687,7 @@ fn a_fax_image_behind_a_decompression_bomb_is_left_alone() {
let r = optimize(&mut doc, &Settings::default()).unwrap();
if padding == 0 {
assert_eq!(r.images_resampled, 0, "ambiguous local tones are retained: {r:?}");
assert_eq!(doc.decoded_image(&stream(&doc, img)).unwrap(), bilevel(w, h, false));
assert_eq!(decoded_image(&doc, &stream(&doc, img)).unwrap(), bilevel(w, h, false));
} else {
assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}");
assert_eq!(stream(&doc, img).raw, before);
@@ -1776,7 +1782,7 @@ fn monochrome_images_keep_what_they_should() {
assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey");
assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default");
assert!(matches!(stream(&doc, scan).filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "the scan: Group 4 or Flate");
assert_eq!(doc.decoded_image(&stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample");
assert_eq!(decoded_image(&doc, &stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample");
assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image");
let (mut doc, [_, wrapped, scan, _, _]) = build();
@@ -1785,7 +1791,7 @@ fn monochrome_images_keep_what_they_should() {
assert_eq!(raw(&doc, wrapped), before, "Retain: a wrapped Group 4 halftone stays as it is");
let s = stream(&doc, scan);
assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(1200), Some(&b"FlateDecode"[..])), "Retain: {r:?}");
assert_eq!(doc.decoded_image(&s).unwrap(), ragged, "Retain preserves every sample");
assert_eq!(decoded_image(&doc, &s).unwrap(), ragged, "Retain preserves every sample");
}
/// `rows` coded as CCITT Group 3, one-dimensional (`/K 0`): each row its runs in turn, white first,
@@ -1853,7 +1859,12 @@ fn group_3_fax_images_become_group_4() {
let g3 = group_3(&runs, k > 0, on_bytes);
let (end_of_line, encoded_byte_align) = (k > 0 && on_bytes.is_none(), on_bytes.is_some());
let p = pdfcraft_filters::CcittParams { k, columns: w.into(), end_of_line, encoded_byte_align, ..Default::default() };
assert_eq!(pdfcraft_filters::decode_ccitt(&g3, &p, h).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the Group 3 data is the picture");
let mut work = u64::MAX;
assert_eq!(
pdfcraft_filters::decode_ccitt_within(&g3, &p, h, &mut work).unwrap(),
bits,
"/K {k}, on bytes {on_bytes:?}: the Group 3 data is the picture"
);
let mut doc = Document::new_empty();
let mut d = Dict::new();
@@ -1886,7 +1897,7 @@ fn group_3_fax_images_become_group_4() {
let s = stream(&doc, fax);
assert_eq!(doc.fax_params(&s).map(|p| p.k), Some(-1), "/K {k}, on bytes {on_bytes:?}: Group 4");
assert!(s.raw.len() < g3.len(), "{} bytes, from {}", s.raw.len(), g3.len());
assert_eq!(doc.decoded_image(&s).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the same picture");
assert_eq!(decoded_image(&doc, &s).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the same picture");
assert!(render(&doc) == render(&control), "/K {k}, on bytes {on_bytes:?}: the page looks as the picture does");
}
}
@@ -2102,14 +2113,14 @@ fn jbig2_scans_are_averaged_and_stored_again() {
let im = mono_image(&mut doc, w, h, &squares, None, &grey);
page(&mut doc, &[("Im", im)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
assert_eq!(optimize(&mut doc, &settings).unwrap().images_resampled, 1, "Flate, {compression:?}");
let averaged = doc.decoded_image(&stream(&doc, im)).unwrap();
let averaged = decoded_image(&doc, &stream(&doc, im)).unwrap();
for (filters, globals) in [(&["JBIG2Decode"][..], false), (&["ASCIIHexDecode", "JBIG2Decode"][..], false), (&["JBIG2Decode"][..], true)] {
let (mut doc, im) = jbig2_page(&coded, filters, globals, (w, h), at_600);
let r = optimize(&mut doc, &settings).unwrap();
let s = stream(&doc, im);
let what = format!("{filters:?}, globals {globals}, {compression:?}: {r:?}");
assert_eq!((r.images_resampled, s.dict.int(b"Width"), s.dict.int(b"Height")), (1, Some(600), Some(240)), "{what}");
assert_eq!(doc.decoded_image(&s).unwrap(), averaged, "{what}");
assert_eq!(decoded_image(&doc, &s).unwrap(), averaged, "{what}");
assert!(!s.dict.contains(b"DecodeParms") || s.dict.name(b"Filter") == Some(&b"CCITTFaxDecode"[..]), "{what}");
let filter = s.dict.name(b"Filter").map(<[u8]>::to_vec);
// The smallest of what each compression may store; under Retain, JBIG2 again.
@@ -2140,13 +2151,13 @@ fn jbig2_scans_are_averaged_and_stored_again() {
let s = stream(&doc, im);
assert_eq!((r.images_resampled, r.images_recompressed, s.dict.name(b"Filter")), (0, 1, Some(&b"JBIG2Decode"[..])), "{r:?}");
assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, the generic region {}", s.raw.len(), generic.len());
assert_eq!(doc.decoded_image(&s).unwrap(), text);
assert_eq!(decoded_image(&doc, &s).unwrap(), text);
// Behind ASCIIHex it is stored again alone, being smaller so.
let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300);
optimize(&mut doc, &jbig2_asked).unwrap();
let s = stream(&doc, im);
assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"JBIG2Decode"[..])));
assert_eq!(doc.decoded_image(&s).unwrap(), text);
assert_eq!(decoded_image(&doc, &s).unwrap(), text);
// So it is as Group 4 when that is smaller: here the hex digits are spaced out, as ASCIIHex
// allows, so that the stream is many times the code.
let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300);
@@ -2156,7 +2167,7 @@ fn jbig2_scans_are_averaged_and_stored_again() {
optimize(&mut doc, &Settings::default()).unwrap();
let s = stream(&doc, im);
assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"CCITTFaxDecode"[..])), "Group 4 alone");
assert_eq!(doc.decoded_image(&s).unwrap(), text);
assert_eq!(decoded_image(&doc, &s).unwrap(), text);
// A JBIG2 page taller than the image: the renderer draws the whole page into it, so it isn't
// read, and stays as it is.
@@ -3570,7 +3581,7 @@ fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() {
optimize(&mut gray, &Settings::default()).unwrap();
let gray_after = render(&Document::open(Arc::new(write_full(&gray, &SaveOptions::default()).unwrap())).unwrap());
let gray_stream = stream(&gray, gray_image);
let gray_samples = gray.decoded_image(&gray_stream).unwrap();
let gray_samples = decoded_image(&gray, &gray_stream).unwrap();
assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240)));
assert!(gray_samples.iter().any(|b| *b != 0xFF), "the DeviceGray thin rule survives");
for white_first in [false, true] {
@@ -3603,7 +3614,7 @@ fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() {
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(600), Some(240)), "white-first={white_first}, inverted={inverted}");
// The rows are 600 samples, whole bytes: flipping each byte flips each sample.
let expected: Vec<u8> = gray_samples.iter().map(|b| if white_first != inverted { !b } else { *b }).collect();
assert_eq!(doc.decoded_image(&s).unwrap(), expected, "white-first={white_first}, inverted={inverted}: the rule is dark");
assert_eq!(decoded_image(&doc, &s).unwrap(), expected, "white-first={white_first}, inverted={inverted}: the rule is dark");
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(render(&saved) == gray_after, "palette white-first={white_first}, inverted={inverted}");
}
@@ -3664,7 +3675,7 @@ fn an_indexed_palette_is_read_by_the_luma_of_its_colours() {
page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
optimize(&mut doc, &Settings::default()).unwrap();
let s = stream(&doc, image);
(s.dict.int(b"Width"), doc.decoded_image(&s).unwrap())
(s.dict.int(b"Width"), decoded_image(&doc, &s).unwrap())
};
let (width, samples) = optimized("Indexed");
assert_eq!(width, Some(600));
@@ -3772,7 +3783,7 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin
optimize(&mut doc, &Settings::default()).unwrap();
let s = stream(&doc, image);
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into())));
assert_eq!(doc.decoded_image(&s).unwrap(), data, "screened samples survive, not a solid patch");
assert_eq!(decoded_image(&doc, &s).unwrap(), data, "screened samples survive, not a solid patch");
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(render(&saved), before, "local halftone {side}x{side}");
}
+2 -2
View File
@@ -2,7 +2,7 @@
//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is
//! written as a new image, Flate, or for a one-bit image of one component Group 4, or JBIG2 when it
//! was JBIG2, whichever is smallest; the original object is untouched (other pages may use it).
//! CCITT fax and JBIG2 images decode through `Document::decoded_image_within`, but for Group 3 on
//! CCITT fax and JBIG2 images decode through `ImageBudget::decode`, but for Group 3 on
//! bytes, which PdfCraft's renderer may draw as other rows, and a JBIG2 page of other rows than the
//! image's. Images whose codec PdfCraft can't decode (DCT, JPX), and those, return `None`, and the
//! caller removes the whole image instead; that is fail-closed.
@@ -54,7 +54,7 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL"
)
};
// A CCITT fax or JBIG2 image (its last filter) decodes through `Document::decoded_image_within`,
// A CCITT fax or JBIG2 image (its last filter) decodes through `ImageBudget::decode`,
// after the filters before it, so a scan stored as Group 4 or JBIG2 is cleared like any other
// image.
let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF"));
+13 -6
View File
@@ -520,10 +520,16 @@ fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document {
])
}
/// What image `s` decodes to, its fax or JBIG2 data included, with no limit on the work: the
/// samples an operation would read through its `ImageBudget`.
fn decoded_image(doc: &Document, s: &pdfcraft_cos::Stream) -> Result<Vec<u8>, pdfcraft_cos::CosError> {
pdfcraft_cos::ImageBudget::with_work(u64::MAX).decode(doc, s, pdfcraft_cos::MAX_DECODED)
}
/// The samples of the image a page's (possibly inherited) resources name `name`, decoded through
/// its image codec too (`Document::decoded_image`).
/// its image codec too ([`decoded_image`]).
fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option<Vec<u8>> {
named_stream(doc, page, name).and_then(|s| doc.decoded_image(&s).ok())
named_stream(doc, page, name).and_then(|s| decoded_image(doc, &s).ok())
}
/// The image a page's (possibly inherited) resources name `name`.
@@ -708,7 +714,8 @@ fn group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared() {
(-1, true, "<< /K -1 /Columns 16 /EncodedByteAlign true >>", vec![0x80, 0x80], vec![], false),
] {
let p = pdfcraft_filters::CcittParams { k, columns: 16, encoded_byte_align: on_bytes, ..Default::default() };
assert_eq!(pdfcraft_filters::decode_ccitt(&data, &p, 2).unwrap(), [0xFF; 4], "{parms}: the picture is white");
let mut work = u64::MAX;
assert_eq!(pdfcraft_filters::decode_ccitt_within(&data, &p, 2, &mut work).unwrap(), [0xFF; 4], "{parms}: the picture is white");
let dict = format!("{image} /DecodeParms {parms}");
let mut objs = vec![stream(&dict, data)];
objs.extend(extra);
@@ -770,7 +777,7 @@ fn no_stream_holds_the_pixels_under_a_mark_on_a_jbig2_scan() {
}
let holds = |doc: &Document, pixels: &[u8]| {
doc.scan_objects().any(|(_, object)| match &*object {
Object::Stream(s) => doc.decoded_image(s).is_ok_and(|d| d.windows(pixels.len()).any(|w| w == pixels)),
Object::Stream(s) => decoded_image(doc, s).is_ok_and(|d| d.windows(pixels.len()).any(|w| w == pixels)),
_ => false,
})
};
@@ -1262,7 +1269,7 @@ fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() {
let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?;
let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?;
match &*doc.get(xo.get(name)?.as_ref()?) {
Object::Stream(s) => doc.decoded_image(s).ok(),
Object::Stream(s) => decoded_image(doc, s).ok(),
_ => None,
}
};
@@ -2956,7 +2963,7 @@ fn assert_no_secret(doc: &Document, secret: &[u8]) {
assert!(!bytes.windows(secret.len()).any(|w| w == secret), "secret remains in object {r:?}");
if let Object::Stream(s) = &*object {
// Image codecs, a cleared scan coded as Group 4 or JBIG2, decode through the document.
let data = s.decoded().or_else(|_| saved.decoded_image(s)).unwrap();
let data = s.decoded().or_else(|_| decoded_image(&saved, s)).unwrap();
assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}");
}
}
+4 -3
View File
@@ -72,9 +72,10 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod
- PDF in: any revision chain, xref tables or streams, hybrid files, damaged files (repaired and
logged), all standard-security revisions. Image codecs (DCT, JPX) decode through the bootstrap
renderer; CCITT and JBIG2 decode through `pdfcraft-filters` (`decode_ccitt`, `decode_jbig2`,
behind `Document::decoded_image`) for export, redaction and the optimizer, and through the
renderer for display. Export ▸ Images decodes JPEG itself.
renderer; CCITT and JBIG2 decode through `pdfcraft-filters` (`decode_ccitt_within`,
`decode_jbig2_within`, behind `ImageBudget::decode`, the only way to decode an image outside
the renderer, which shares one allowance across an operation's images) for export, redaction
and the optimizer, and through the renderer for display. Export ▸ Images decodes JPEG itself.
- PDF out: incremental or full saves, object streams, re-encryption, resource de-duplication when
combining.
- Other formats: images and text in (`create`); PNG/JPEG/TIFF, text, RTF, Word and HTML out