Skip to main content

compress_pdf/
compress.rs

1//! One call that compresses a PDF the way the command line does: run the
2//! stages, serialize under the never-grow rule, check the output with the
3//! independent parser, and optionally render both documents and compare
4//! them. The binary is a thin client of [`compress`]; so is any program
5//! that wants the same guarantees.
6
7use std::fmt;
8
9use lopdf::Document;
10
11use crate::config::{Config, Preset};
12use crate::error::Refusal;
13use crate::report::Report;
14use crate::verify::Verification;
15use crate::verify::render::Comparison;
16use crate::{pipeline, verify};
17
18/// How much of the output to check before handing it back.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum Verify {
22    /// Re-parse the output with a reader that shares no code with the
23    /// writer; a problem the input did not have rejects the output.
24    Structural,
25    /// Structural, plus every page of input and output rendered and
26    /// compared. A page under the preset's similarity floor is a warning
27    /// in the report, or with `strict` a rejection.
28    Render { preset: Preset, strict: bool },
29}
30
31/// A compressed document and the report of what was done to it.
32#[derive(Debug)]
33#[non_exhaustive]
34pub struct Compressed {
35    /// The output bytes; the input bytes unchanged when nothing was smaller.
36    pub output: Vec<u8>,
37    pub report: Report,
38    /// The structural check of the output; `None` when the output is the
39    /// input unchanged and nothing was checked.
40    pub verification: Option<Verification>,
41    /// The page comparison, when rendering was asked for and both
42    /// documents rendered.
43    pub render: Option<Comparison>,
44}
45
46/// Why nothing was produced, with the report up to that point.
47#[derive(Debug)]
48#[non_exhaustive]
49pub struct Rejected {
50    pub report: Report,
51    pub reason: Refusal,
52}
53
54impl fmt::Display for Rejected {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "{}", self.reason)
57    }
58}
59
60impl std::error::Error for Rejected {}
61
62/// What accumulates while a document is processed, kept whether or not
63/// it succeeds so a rejection still carries the report.
64#[derive(Default)]
65struct Progress {
66    report: Report,
67    verification: Option<Verification>,
68    render: Option<Comparison>,
69}
70
71/// Compresses `input` under `config`. Refuses input the pipeline cannot
72/// handle safely (a password it needs, damage it cannot see past), and
73/// output that verifies worse than the input. The rejection is boxed
74/// because it carries the whole report.
75pub fn compress(
76    input: &[u8],
77    config: &Config,
78    verify: Verify,
79) -> Result<Compressed, Box<Rejected>> {
80    let mut progress = Progress {
81        report: Report::new(input.len()),
82        ..Progress::default()
83    };
84    match run(input, config, verify, &mut progress) {
85        Ok(output) => Ok(Compressed {
86            output,
87            report: progress.report,
88            verification: progress.verification,
89            render: progress.render,
90        }),
91        Err(reason) => Err(Box::new(Rejected {
92            report: progress.report,
93            reason,
94        })),
95    }
96}
97
98fn run(
99    input: &[u8],
100    config: &Config,
101    verify: Verify,
102    progress: &mut Progress,
103) -> Result<Vec<u8>, Refusal> {
104    let mut doc = Document::load_mem(input).map_err(Refusal::Unparseable)?;
105    let pages = doc.get_pages().len();
106    pipeline::run(&mut doc, config, &mut progress.report)?;
107    let output = pipeline::serialize(&mut doc, input, &mut progress.report)?;
108    // Nothing to verify when the output is the input unchanged.
109    if output != input {
110        progress.verification = Some(check_structure(
111            input,
112            &output,
113            pages,
114            &mut progress.report,
115        )?);
116        progress.render = check_render(input, &output, verify, &mut progress.report)?;
117    }
118    Ok(output)
119}
120
121/// Problems the input already had are warnings; new ones are bugs.
122fn check_structure(
123    input: &[u8],
124    output: &[u8],
125    pages: usize,
126    report: &mut Report,
127) -> Result<Verification, Refusal> {
128    let verification = verify::verify(output, pages);
129    report.note(verification.to_string());
130    if verification.is_ok() {
131        return Ok(verification);
132    }
133    let baseline = verify::verify(input, pages);
134    let regressions = verification.regressions_from(&baseline);
135    if !regressions.is_empty() {
136        return Err(Refusal::VerificationRegressed(regressions));
137    }
138    report.note("warning: the input already had these problems; output written anyway");
139    Ok(verification)
140}
141
142fn check_render(
143    input: &[u8],
144    output: &[u8],
145    verify: Verify,
146    report: &mut Report,
147) -> Result<Option<Comparison>, Refusal> {
148    let Verify::Render { preset, strict } = verify else {
149        return Ok(None);
150    };
151    let comparison = match verify::render::compare(input, output, preset) {
152        Ok(comparison) => comparison,
153        Err(e) => {
154            report.note(format!("render: skipped, {e}"));
155            return Ok(None);
156        }
157    };
158    report.note(comparison.to_string());
159    if !comparison.below_floor().is_empty() {
160        if strict {
161            return Err(Refusal::BelowSimilarityFloor(comparison));
162        }
163        report.note("warning: pages below the similarity floor; output written anyway");
164    }
165    Ok(Some(comparison))
166}
167
168#[cfg(test)]
169mod tests {
170    use lopdf::{Object, dictionary};
171
172    use super::*;
173
174    /// A one-page document with an uncompressed content stream, as bytes.
175    fn small_pdf() -> Vec<u8> {
176        let mut doc = Document::with_version("1.5");
177        let pages_id = doc.new_object_id();
178        // Uncompressed and repetitive, so the structure stage has something
179        // to shrink and the output is not the input unchanged.
180        let content = doc.add_object(lopdf::Stream::new(
181            dictionary! {},
182            b"0 0 1 rg 10 10 100 100 re f\n".repeat(200),
183        ));
184        let page = doc.add_object(dictionary! {
185            "Type" => "Page", "Parent" => pages_id, "Contents" => content,
186            "MediaBox" => vec![0.into(), 0.into(), 200.into(), 200.into()],
187        });
188        doc.objects.insert(
189            pages_id,
190            Object::Dictionary(
191                dictionary! { "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1 },
192            ),
193        );
194        let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
195        doc.trailer.set("Root", catalog);
196        let mut bytes = Vec::new();
197        doc.save_to(&mut bytes).unwrap();
198        bytes
199    }
200
201    #[test]
202    fn compresses_and_verifies_a_small_document() {
203        let input = small_pdf();
204        let config = Config::preset(Preset::Standard);
205        let verify = Verify::Render {
206            preset: Preset::Standard,
207            strict: true,
208        };
209        let done = compress(&input, &config, verify).unwrap();
210        assert!(done.output.len() <= input.len());
211        assert!(done.verification.is_some_and(|v| v.is_ok()));
212        assert!(done.render.is_some_and(|r| r.min().unwrap_or(0.0) > 0.99));
213    }
214
215    #[test]
216    fn unparseable_input_is_rejected_with_its_report() {
217        let config = Config::preset(Preset::Less);
218        let rejected = compress(b"not a pdf", &config, Verify::Structural).unwrap_err();
219        assert!(matches!(rejected.reason, Refusal::Unparseable(_)));
220        assert!(rejected.to_string().contains("parsing the input"));
221        assert_eq!(rejected.report.notes.len(), 0);
222    }
223}