mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 09:10:33 +00:00
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:
1 parent
4ffe5908c8
commit
ec25e7d327
12 files changed
+162
-88
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"));
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user