Files
pdfcraft/crates/optimize/src/lib.rs
T
Grzegorz MartensandClaude Opus 5.5 9edd08ce07 M11: JBIG2 symbol coding for black-and-white images
pdfcraft_filters::encode_jbig2 stores each distinct mark of a 1-bit
image (8-connected black pixels, at most 256 a side) once, as a symbol
in a symbol dictionary, and places its exact copies with an immediate
lossless text region that refers to it. Larger marks, and in a second
plan marks that occur once, go to a generic region over their box. It
keeps whichever of that and the generic coding (encode_jbig2_generic,
unchanged) is smaller. Only exact copies share a symbol, so the stream
decodes to the same bits.

Written from the JBIG committee's ISO/IEC 14492 Final Committee Draft:
6.4 and 6.5 (text region and symbol dictionary decoding), 7.2, 7.4.2
and 7.4.3 (segment headers and syntax) and Annex A (the integer coders,
checked against its worked example and Table A.1).

On six pages of rendered 300 dpi text the output is 28% of the generic
coding's size and 17% of Group 4's, at about 100 ms a page; hayro-jbig2
and Poppler (pdfimages) both decode it to the original bits. Scans,
whose copies of a letter are seldom identical, gain little: that needs
refinement coding. A dictionary shared between pages (/JBIG2Globals)
is a further step.

The optimizer's Monochrome Images > JBIG2 (lossless) and doc_optimize
mono jbig2 use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 22:37:07 +01:00

583 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The PDF Optimizer (execution plan M11.1; Acrobat: Reduce File Size and Optimize PDF ▸
//! Advanced optimization).
//!
//! - **Merge image fragments:** a page picture some PDF printers draw as thousands of images one
//! pixel row high is put back together into one image first (see [`fragments`]), so the image
//! settings can work on it.
//! - **Images:** each image's effective resolution is measured where pages draw it (the
//! smallest over its uses, through form XObjects); colour and grayscale images above a
//! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate.
//! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode
//! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default,
//! JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker and `/ColorTransform`
//! disagree) are left alone. Black-and-white (1-bit) images are stored again as CCITT Group 4
//! or JBIG2, losslessly, when that is smaller.
//! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree),
//! print settings.
//! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted
//! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or
//! ASCIIHex layer from streams that have one; store LZW-compressed streams with Flate instead.
//!
//! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form
//! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine
//! runs alongside.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod audit;
pub mod fragments;
mod images;
mod links;
mod unused;
pub use audit::{SpaceCategory, SpaceUse, audit_space};
use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize};
use pdfcraft_filters::{Filter, Params};
pub use images::effective_resolutions;
#[derive(Debug, thiserror::Error)]
pub enum OptimizeError {
#[error("the document has no page tree")]
NoPages,
#[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")]
IncompatibleEncryption { required: &'static str, chosen: &'static str },
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
#[error("the optimization was cancelled")]
Cancelled,
}
/// The target header version and object-stream policy for an optimized copy.
///
/// Partial compatibility only: encryption requirements are checked, but content newer than
/// the target is neither detected nor converted. A lower header does not guarantee that
/// older readers can read the document.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Compatibility {
/// Keep the document's version (upgraded to 1.5 when object streams are written).
#[default]
Retain,
Pdf1_4,
Pdf1_5,
Pdf1_6,
Pdf1_7,
Pdf2_0,
}
impl Compatibility {
pub const ALL: [Compatibility; 6] =
[Compatibility::Retain, Compatibility::Pdf1_4, Compatibility::Pdf1_5, Compatibility::Pdf1_6, Compatibility::Pdf1_7, Compatibility::Pdf2_0];
/// The name tools use (`"retain"`, `"1.4"`, `"1.5"`, `"1.6"`, `"1.7"`, `"2.0"`).
pub fn name(self) -> &'static str {
match self {
Compatibility::Retain => "retain",
Compatibility::Pdf1_4 => "1.4",
Compatibility::Pdf1_5 => "1.5",
Compatibility::Pdf1_6 => "1.6",
Compatibility::Pdf1_7 => "1.7",
Compatibility::Pdf2_0 => "2.0",
}
}
pub fn from_name(name: &str) -> Option<Compatibility> {
let trimmed = name.trim();
let stripped = if trimmed.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("pdf ")) { &trimmed[4..] } else { trimmed };
Compatibility::ALL.into_iter().find(|c| c.name().eq_ignore_ascii_case(stripped))
}
/// The three-character `%PDF-` header version, when a target version is chosen.
pub fn version(self) -> Option<&'static str> {
match self {
Compatibility::Retain => None,
_ => Some(self.name()),
}
}
}
/// Where an optimization is, reported to [`optimize_with_progress`]'s callback.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
/// About to process image `done` (0-based) of `total`; `done == total` once all are done.
Images { done: usize, total: usize },
/// Merging the fragments of a page picture into one image (only when a page has some).
Fragments,
/// Discarding objects, cleaning up links and compressing unencoded streams.
CleanUp,
}
/// How resampled (or recompressed) images are stored.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Compression {
/// JPEG with a quality from 1 to 100.
Jpeg(u8),
/// Lossless (zlib).
Flate,
/// Keep the image's own compression (resampled images become Flate if they weren't JPEG).
Retain,
}
/// How black-and-white (1-bit) images are stored.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MonoCompression {
/// CCITT Group 4 (lossless), when smaller than the image as it is.
CcittG4,
/// JBIG2, lossless (a generic region, or symbols when the image's marks repeat), when smaller
/// than the image as it is. JBIG2Decode is PDF 1.4: an older document gets `/Version /1.4` in
/// its catalog.
Jbig2,
/// Keep each image's own compression.
Retain,
}
/// Acrobat's JPEG quality steps.
pub const QUALITIES: [(&str, u8); 5] = [("Minimum", 25), ("Low", 45), ("Medium", 60), ("High", 80), ("Maximum", 95)];
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ImageSettings {
/// Resample images whose effective resolution is above `above_ppi` down to `target_ppi`.
pub downsample: bool,
pub target_ppi: f64,
pub above_ppi: f64,
pub compression: Compression,
}
impl ImageSettings {
/// Reduce File Size and the Standard preset: bicubic to 150 ppi above 225 ppi, JPEG medium
/// quality.
pub const REDUCE: ImageSettings = ImageSettings { downsample: true, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Jpeg(60) };
/// The Mobile preset: bicubic to 96 ppi above 144 ppi, JPEG high quality. Pages read on a
/// screen; text in scans stays legible.
pub const MOBILE: ImageSettings = ImageSettings { downsample: true, target_ppi: 96.0, above_ppi: 144.0, compression: Compression::Jpeg(80) };
}
#[derive(Clone, Debug, PartialEq)]
pub struct Settings {
/// Target PDF version (`Retain` by default).
pub compatibility: Compatibility,
/// Put page pictures drawn as many stacked fragments back together first ([`fragments`]).
pub merge_fragments: bool,
pub color: ImageSettings,
pub gray: ImageSettings,
/// Black-and-white (1-bit) images, and image masks.
pub mono: MonoCompression,
pub discard_thumbnails: bool,
pub discard_alternate_images: bool,
pub discard_tags: bool,
pub discard_print_settings: bool,
/// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or
/// ASCIIHex layer from streams that have one.
pub flate_unencoded: bool,
/// Clean Up: pack non-stream objects into compressed object streams with a cross-reference
/// stream (PDF 1.5+, §7.5.7–7.5.8). Always off when `compatibility` is [`Compatibility::Pdf1_4`].
pub object_streams: bool,
/// Clean Up: decode LZW-compressed streams and store them with Flate instead.
pub lzw_to_flate: bool,
/// Clean Up: remove links (and bookmark destinations) that point nowhere.
pub remove_invalid_links: bool,
/// Clean Up: remove named destinations nothing refers to.
pub remove_unreferenced_dests: bool,
}
impl Settings {
/// Whether `write_full` should pack non-stream objects into compressed object streams:
/// enabled when `object_streams` is set and the target version is not PDF 1.4 (§7.5.7).
pub fn effective_object_streams(&self) -> bool {
self.object_streams && self.compatibility != Compatibility::Pdf1_4
}
}
impl Default for Settings {
/// Acrobat's Reduce File Size choices.
fn default() -> Self {
Self {
compatibility: Compatibility::Retain,
merge_fragments: true,
color: ImageSettings::REDUCE,
gray: ImageSettings::REDUCE,
mono: MonoCompression::CcittG4,
discard_thumbnails: true,
discard_alternate_images: true,
discard_tags: false,
discard_print_settings: false,
flate_unencoded: true,
object_streams: true,
lzw_to_flate: true,
remove_invalid_links: true,
remove_unreferenced_dests: true,
}
}
}
/// The optimizer's ready-made settings, as in Acrobat's Settings menu.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Preset {
/// Reduce File Size's choices ([`Settings::default`]).
Standard,
/// Smaller files for reading on a phone or screen: images to 96 ppi above 144, JPEG high.
Mobile,
}
impl Preset {
pub const ALL: [Preset; 2] = [Preset::Standard, Preset::Mobile];
/// The name tools use.
pub fn name(self) -> &'static str {
match self {
Preset::Standard => "standard",
Preset::Mobile => "mobile",
}
}
pub fn from_name(name: &str) -> Option<Preset> {
Preset::ALL.into_iter().find(|p| p.name() == name)
}
pub fn settings(self) -> Settings {
match self {
Preset::Standard => Settings::default(),
Preset::Mobile => Settings { color: ImageSettings::MOBILE, gray: ImageSettings::MOBILE, ..Settings::default() },
}
}
/// The preset `settings` are, if they are one.
pub fn of(settings: &Settings) -> Option<Preset> {
Preset::ALL.into_iter().find(|p| p.settings() == *settings)
}
}
/// What an optimization did.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Report {
/// Images merged from fragments, and how many fragments went into them.
pub merged_images: usize,
pub fragments_merged: usize,
pub images: usize,
pub images_resampled: usize,
pub images_recompressed: usize,
/// Encoded image bytes before and after (images that changed).
pub image_bytes_before: usize,
pub image_bytes_after: usize,
pub thumbnails: usize,
pub alternate_images: usize,
pub tags_removed: bool,
pub print_settings: usize,
pub streams_compressed: usize,
/// Streams whose LZW compression was replaced with Flate (Clean Up).
pub lzw_streams: usize,
/// Links removed and bookmarks whose destination was cleared (Clean Up).
pub invalid_links: usize,
pub invalid_bookmarks: usize,
pub unreferenced_dests: usize,
/// Images and forms the pages listed but never drew, dropped (Clean Up).
pub unused_xobjects: usize,
}
/// The minimum PDF version required by `doc`'s active security handler, when it uses features
/// introduced after PDF 1.4 (crypt filters in 1.5, AES-128 in 1.6, AES-256 in 1.7).
fn required_encryption_version(doc: &Document) -> Option<&'static str> {
let h = doc.output_handler()?;
let d = h.dict();
if d.v >= 5 || d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes256) {
Some("1.7")
} else if d.v == 4 {
if d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes128) { Some("1.6") } else { Some("1.5") }
} else {
None
}
}
/// Optimize `doc` in place.
pub fn optimize(doc: &mut Document, settings: &Settings) -> Result<Report, OptimizeError> {
optimize_with_progress(doc, settings, &mut |_| true)
}
/// [`optimize`], calling `progress` before each image and before the clean-up (and now and then
/// during a long one). Returning `false` stops with [`OptimizeError::Cancelled`]; `doc` is then
/// partly optimized and should be dropped.
pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: &mut dyn FnMut(Stage) -> bool) -> Result<Report, OptimizeError> {
// Lexical ordering is valid for these fixed versions with single-digit components.
if let Some(chosen) = settings.compatibility.version()
&& let Some(required) = required_encryption_version(doc)
&& chosen < required
{
return Err(OptimizeError::IncompatibleEncryption { required, chosen });
}
let mut report = Report::default();
let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?;
if settings.merge_fragments {
fragments::run(doc, &pages, &mut report, progress)?;
}
images::run(doc, &pages, settings, &mut report, progress)?;
if !progress(Stage::CleanUp) {
return Err(OptimizeError::Cancelled);
}
report.unused_xobjects = unused::remove_unused(doc, &pages, progress)?;
if settings.discard_thumbnails {
for p in &pages {
if doc.get(*p).as_dict().is_some_and(|d| d.contains(b"Thumb")) {
doc.update_dict(*p, |d| {
d.remove(b"Thumb");
})?;
report.thumbnails += 1;
}
}
}
if settings.discard_alternate_images {
for num in doc.object_numbers() {
let r = ObjRef { num, generation: doc.generation(num) };
let alt = matches!(&*doc.get(r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") && s.dict.contains(b"Alternates"));
if alt {
if let Object::Stream(s) = &*doc.get(r) {
let mut s = s.clone();
s.dict.remove(b"Alternates");
doc.set(r, Object::Stream(s));
}
report.alternate_images += 1;
}
}
}
let root = doc.root();
if let Some(chosen) = settings.compatibility.version() {
doc.set_version(chosen);
if let Some(root) = root
&& doc.get(root).as_dict().is_some_and(|c| c.contains(b"Version"))
{
doc.update_dict(root, |c| {
c.remove(b"Version");
})?;
}
}
if settings.discard_tags
&& let Some(root) = root
&& doc.get(root).as_dict().is_some_and(|c| c.contains(b"StructTreeRoot"))
{
doc.update_dict(root, |c| {
c.remove(b"StructTreeRoot");
c.remove(b"MarkInfo");
})?;
for p in &pages {
if doc.get(*p).as_dict().is_some_and(|d| d.contains(b"StructParents")) {
doc.update_dict(*p, |d| {
d.remove(b"StructParents");
})?;
}
}
report.tags_removed = true;
}
if settings.discard_print_settings
&& let Some(root) = root
{
const PRINT: [&[u8]; 7] = [b"PrintScaling", b"Duplex", b"PickTrayByPDFSize", b"PrintPageRange", b"NumCopies", b"PrintArea", b"PrintClip"];
let vp = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences").cloned());
let edit = |d: &mut pdfcraft_cos::Dict| PRINT.iter().filter(|k| d.remove(k).is_some()).count();
match vp {
Some(Object::Ref(r)) => {
let mut n = 0;
doc.update_dict(r, |d| n = edit(d))?;
report.print_settings += n;
}
Some(Object::Dict(_)) => {
let mut n = 0;
doc.update_dict(root, |c| {
if let Some(Object::Dict(d)) = c.get_mut(b"ViewerPreferences") {
n = edit(d);
}
})?;
report.print_settings += n;
}
_ => {}
}
}
if settings.remove_invalid_links {
let (l, b) = links::remove_invalid(doc, &pages)?;
report.invalid_links = l;
report.invalid_bookmarks = b;
}
if settings.remove_unreferenced_dests {
report.unreferenced_dests = links::remove_unreferenced_dests(doc)?;
}
// Before the unencoded-stream pass, which would otherwise take the ASCII layer off an
// `[/ASCII85Decode /LZWDecode]` stream first and count it twice.
if settings.lzw_to_flate {
for num in doc.object_numbers() {
let r = ObjRef { num, generation: doc.generation(num) };
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
if let Some(replacement) = lzw_to_flate(s, MAX_LZW_DECODED) {
doc.set(r, Object::Stream(replacement));
report.lzw_streams += 1;
}
}
}
if settings.flate_unencoded {
for num in doc.object_numbers() {
let r = ObjRef { num, generation: doc.generation(num) };
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
// XMP stays readable as plain text (PDF/A requires it); tiny streams don't gain.
if s.dict.name(b"Type") == Some(b"Metadata") || s.dict.name(b"Type") == Some(b"XRef") || s.raw.len() < 64 {
continue;
}
let replacement = if s.dict.contains(b"Filter") {
strip_ascii_wrapper(s)
} else {
let mut d = s.dict.clone();
d.remove(b"Length");
let packed = Stream::flate(d, &s.raw);
(packed.raw.len() < s.raw.len()).then_some(packed)
};
if let Some(replacement) = replacement {
doc.set(r, Object::Stream(replacement));
report.streams_compressed += 1;
}
}
}
Ok(report)
}
/// Largest decoded LZW stream converted, as for a page's content: the data and its check are
/// held together, so a few hundred kilobytes of hostile LZW can't ask for gigabytes.
const MAX_LZW_DECODED: usize = MAX_PAGE_DECODED;
/// `s` with its LZW compression replaced by Flate (Acrobat's "In streams that use LZW encoding,
/// use Flate instead"). The filters up to the last LZW one are decoded, strictly and with its
/// predictor; the result is Flate-encoded under the same predictor, without `EarlyChange`, and
/// the filters after it keep their encoded bytes. A stream that doesn't decode cleanly, or
/// whose filters or parameters can't be read for certain, or that decodes past `max` bytes, is
/// left as it is.
fn lzw_to_flate(s: &Stream, max: usize) -> Option<Stream> {
// An external file's data isn't in `raw`; cross-reference and object streams are rewritten
// on save, so converting them would change nothing in the file.
if s.dict.contains(b"F") || matches!(s.dict.name(b"Type"), Some(b"XRef" | b"ObjStm")) {
return None;
}
let names: Vec<&[u8]> = match s.dict.get(b"Filter")? {
Object::Name(n) => vec![n.as_slice()],
Object::Array(a) => a.iter().map(Object::as_name).collect::<Option<_>>()?,
_ => return None,
};
// `/Crypt` was applied when the object was loaded, and dropping it would change how the
// stream is encrypted.
if names.contains(&&b"Crypt"[..]) {
return None;
}
let lzw = names.iter().rposition(|n| matches!(*n, b"LZWDecode" | b"LZW"))?;
// One entry per filter, each a dictionary or null; anything else (an indirect reference, an
// array of the wrong length) would be read as defaults, so the data might not decode as
// written.
let parms: Vec<Option<&Dict>> = match s.dict.get(b"DecodeParms") {
None | Some(Object::Null) => vec![None; names.len()],
Some(Object::Dict(d)) if names.len() == 1 => vec![Some(d)],
Some(Object::Array(a)) if a.len() == names.len() => a
.iter()
.map(|p| match p {
Object::Dict(d) => Some(Some(d)),
Object::Null => Some(None),
_ => None,
})
.collect::<Option<_>>()?,
_ => return None,
};
let chain = s.filters();
let stages = chain.get(..=lzw)?;
let (_, params) = stages.last()?;
// The decoded stages' parameters must be direct integers: `filters` reads anything else (a
// reference, 12.5, a name) as the default, and the dictionary carried over would then
// describe the new data differently from how it was encoded.
let direct = |p: &Option<&Dict>| p.is_none_or(|d| d.iter().all(|(_, v)| matches!(v, Object::Int(_))));
if !parms.get(..=lzw)?.iter().all(direct) {
return None;
}
let data = pdfcraft_filters::decode(stages, &s.raw, max).ok()?;
let flate = Params { early_change: 1, ..params.clone() };
let raw = pdfcraft_filters::encode(&Filter::Flate, &flate, &data).ok()?;
// The predictor's encoder must give back exactly what its decoder read (a short last row,
// unusual parameters), or the stream would change. The limit counts a PNG predictor's tag
// bytes too, at most one per row of at least one byte.
if pdfcraft_filters::decode(&[(Filter::Flate, flate)], &raw, data.len().saturating_mul(2).saturating_add(1)).ok()? != data {
return None;
}
// Without a predictor the other parameters say nothing to Flate.
let first = parms.get(lzw).copied().flatten().filter(|_| params.predictor > 1).map(|d| {
let mut d = d.clone();
d.remove(b"EarlyChange");
Object::Dict(d)
});
let rest_names = names.get(lzw + 1..)?;
let rest_parms = parms.get(lzw + 1..)?;
let mut dict = s.dict.clone();
dict.remove(b"Length");
if rest_names.is_empty() {
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
match first {
Some(p) => dict.set(b"DecodeParms".to_vec(), p),
None => {
dict.remove(b"DecodeParms");
}
}
} else {
let filters = std::iter::once(Object::name("FlateDecode")).chain(rest_names.iter().map(|n| Object::Name(n.to_vec())));
dict.set(b"Filter".to_vec(), Object::Array(filters.collect()));
let parms: Vec<Object> = std::iter::once(first.unwrap_or(Object::Null))
.chain(rest_parms.iter().map(|p| p.map_or(Object::Null, |d| Object::Dict(d.clone()))))
.collect();
if parms.iter().all(|p| matches!(p, Object::Null)) {
dict.remove(b"DecodeParms");
} else {
dict.set(b"DecodeParms".to_vec(), Object::Array(parms));
}
}
Some(Stream::from_raw(dict, raw))
}
/// `s` without its outer ASCII85 or ASCIIHex layer (`[/ASCII85Decode /FlateDecode]` becomes
/// `[/FlateDecode]`), when that is smaller. Only the outer layer is decoded: strictly, and within
/// the stored size, which ASCII text never falls below, so the inner encoded bytes stay exact.
fn strip_ascii_wrapper(s: &Stream) -> Option<Stream> {
if s.dict.contains(b"DecodeParms") || s.dict.name(b"Type") == Some(b"ObjStm") || s.dict.name(b"Subtype") == Some(b"Image") {
return None;
}
let Some(Object::Array(filters)) = s.dict.get(b"Filter") else { return None };
let names = filters.iter().map(Object::as_name).collect::<Option<Vec<_>>>()?;
let (outer, inner) = names.split_first()?;
if !matches!(*outer, b"ASCII85Decode" | b"ASCIIHexDecode") || inner.is_empty() || inner.contains(&&b"Crypt"[..]) {
return None;
}
let data = s.decoded_first_filter_strict(s.raw.len()).ok()?;
let mut dict = s.dict.clone();
dict.remove(b"Length");
dict.set(b"Filter".to_vec(), Object::Array(inner.iter().map(|n| Object::Name(n.to_vec())).collect()));
let candidate = Stream::from_raw(dict, data);
(stored_len(&candidate)? < stored_len(s)?).then_some(candidate)
}
/// Bytes the stream object takes in the file: its dictionary with `/Length` set as the writer
/// sets it, then its data.
pub(crate) fn stored_len(s: &Stream) -> Option<usize> {
let mut dict = s.dict.clone();
dict.set(b"Length".to_vec(), Object::Int(i64::try_from(s.raw.len()).ok()?));
let mut out = Vec::new();
serialize(&Object::Dict(dict), &mut out);
out.len().checked_add(s.raw.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_lzw_stream_that_decodes_past_the_limit_is_left_as_it_is() {
let data = b"0 0 m 612 792 l S\n".repeat(64);
let raw = pdfcraft_filters::encode(&Filter::Lzw, &Params::default(), &data).unwrap();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("LZWDecode"));
let s = Stream::from_raw(dict, raw);
assert!(lzw_to_flate(&s, data.len() - 1).is_none());
let converted = lzw_to_flate(&s, data.len()).unwrap();
assert_eq!(converted.decoded_strict().unwrap(), data);
}
}