diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index e4373211e9..376b108be3 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -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, CosError> { let decoded = doc.decoded_image_spending(s, max, &mut self.work); self.count_refused(s, &decoded); diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index ec7078590f..2ac18cff03 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -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, 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, 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, CosError> { + /// [`Document::decoded_image`] within `max` bytes. + #[cfg(test)] + pub(crate) fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, 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, CosError> { let max = max.min(MAX_DECODED); let s = &*self.with_direct_parms(s); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4731c3f9ff..25a185e835 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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. diff --git a/crates/filters/src/ccitt.rs b/crates/filters/src/ccitt.rs index a7138bd485..2a038568ee 100644 --- a/crates/filters/src/ccitt.rs +++ b/crates/filters/src/ccitt.rs @@ -50,7 +50,7 @@ pub fn encode_g4(data: &[u8], columns: u32, rows: u32) -> Result, 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, FilterError> { +/// What [`decode_ccitt_within`] decodes once it is paid for. +fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result, 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 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, row: Vec, diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs index 471814f790..1b705b0c72 100644 --- a/crates/filters/src/jbig2.rs +++ b/crates/filters/src/jbig2.rs @@ -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}; diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index d067df4d6b..a018e75937 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -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, 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, 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 { let stride = (columns as usize).div_ceil(8); diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 9fb0cd4d77..181f96ab66 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -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)] diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 9cec3be367..92e68582c2 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -811,7 +811,7 @@ type Pixel = fn(&[u8]) -> Option; /// 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 diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 8eec68c841..96f9c91a72 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -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, 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 { // 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::>(), "the same picture, ties black"); + assert_eq!(decoded_image(&doc, &s).unwrap(), text.iter().map(|b| !b).collect::>(), "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 = 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}"); } diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index c7b1036725..2e737e2baf 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -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")); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 2b9a143a19..2d0268d525 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -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, 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> { - 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:?}"); } } diff --git a/docs/architecture.md b/docs/architecture.md index b5beb009b7..77e141d0de 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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