Skip to main content

compress_pdf/
config.rs

1//! Optimization settings expressed as plain data.
2//!
3//! Every knob is a field on [`Config`] so that presets are values rather than
4//! code paths. See AGENTS.md, section "Presets".
5
6use std::fmt;
7use std::ops::BitOr;
8use std::str::FromStr;
9
10/// Parse a comma-separated list of names against a name table; `none`
11/// (or an empty list) is the empty set.
12fn parse_set<T: Copy + BitOr<Output = T>>(
13    s: &str,
14    empty: T,
15    names: &[(T, &str)],
16) -> Result<T, String> {
17    let mut out = empty;
18    for item in s.split(',').map(str::trim).filter(|i| !i.is_empty()) {
19        if item == "none" {
20            continue;
21        }
22        let Some((value, _)) = names.iter().find(|(_, n)| *n == item) else {
23            let known: Vec<&str> = names.iter().map(|(_, n)| *n).collect();
24            return Err(format!(
25                "unknown name `{item}`; expected one of {}",
26                known.join(", ")
27            ));
28        };
29        out = out | *value;
30    }
31    Ok(out)
32}
33
34/// Set of codecs a stage may *try* for an image class. The image stage encodes
35/// with every member and keeps the smallest result. `SOURCE` means "the
36/// original bytes are also a candidate", which is what makes the pipeline
37/// safe: with `SOURCE` present an image can never grow.
38#[derive(Clone, Copy, PartialEq, Eq, Hash)]
39pub struct Codecs(u16);
40
41impl Codecs {
42    pub const NONE: Codecs = Codecs(0);
43    /// DCT (lossy), continuous-tone images.
44    pub const JPEG: Codecs = Codecs(1 << 0);
45    /// Flate with per-image predictor choice (lossless).
46    pub const FLATE: Codecs = Codecs(1 << 1);
47    /// CCITT Group 4, bitonal images.
48    pub const G4: Codecs = Codecs(1 << 2);
49    /// JBIG2 symbol mode with shared globals, bitonal images.
50    pub const JBIG2: Codecs = Codecs(1 << 3);
51    /// The original bytes as a candidate.
52    pub const SOURCE: Codecs = Codecs(1 << 4);
53
54    pub const fn contains(self, other: Codecs) -> bool {
55        self.0 & other.0 == other.0
56    }
57
58    pub const fn is_empty(self) -> bool {
59        self.0 == 0
60    }
61
62    const NAMES: [(Codecs, &'static str); 5] = [
63        (Codecs::JPEG, "jpeg"),
64        (Codecs::FLATE, "flate"),
65        (Codecs::G4, "g4"),
66        (Codecs::JBIG2, "jbig2"),
67        (Codecs::SOURCE, "source"),
68    ];
69}
70
71impl BitOr for Codecs {
72    type Output = Codecs;
73    fn bitor(self, rhs: Codecs) -> Codecs {
74        Codecs(self.0 | rhs.0)
75    }
76}
77
78impl FromStr for Codecs {
79    type Err = String;
80    /// `jpeg,flate,source`; `none` for the empty set.
81    fn from_str(s: &str) -> Result<Codecs, String> {
82        parse_set(s, Codecs::NONE, &Codecs::NAMES)
83    }
84}
85
86impl fmt::Debug for Codecs {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        if self.is_empty() {
89            return write!(f, "none");
90        }
91        let names: Vec<&str> = Codecs::NAMES
92            .iter()
93            .filter(|(c, _)| self.contains(*c))
94            .map(|(_, n)| *n)
95            .collect();
96        write!(f, "{}", names.join("|"))
97    }
98}
99
100/// Non-visual document parts that may be removed.
101#[derive(Clone, Copy, PartialEq, Eq, Hash)]
102pub struct Strip(u16);
103
104impl Strip {
105    pub const NONE: Strip = Strip(0);
106    pub const THREADS: Strip = Strip(1 << 0);
107    pub const METADATA: Strip = Strip(1 << 1);
108    pub const PIECE_INFO: Strip = Strip(1 << 2);
109    pub const STRUCT_TREE: Strip = Strip(1 << 3);
110    pub const THUMBNAILS: Strip = Strip(1 << 4);
111    pub const SPIDER: Strip = Strip(1 << 5);
112    pub const ALTERNATES: Strip = Strip(1 << 6);
113    pub const OUTPUT_INTENTS: Strip = Strip(1 << 7);
114
115    pub const fn contains(self, other: Strip) -> bool {
116        self.0 & other.0 == other.0
117    }
118
119    const NAMES: [(Strip, &'static str); 8] = [
120        (Strip::THREADS, "threads"),
121        (Strip::METADATA, "metadata"),
122        (Strip::PIECE_INFO, "piece-info"),
123        (Strip::STRUCT_TREE, "struct-tree"),
124        (Strip::THUMBNAILS, "thumbnails"),
125        (Strip::SPIDER, "spider"),
126        (Strip::ALTERNATES, "alternates"),
127        (Strip::OUTPUT_INTENTS, "output-intents"),
128    ];
129}
130
131impl BitOr for Strip {
132    type Output = Strip;
133    fn bitor(self, rhs: Strip) -> Strip {
134        Strip(self.0 | rhs.0)
135    }
136}
137
138impl FromStr for Strip {
139    type Err = String;
140    /// `threads,metadata,...`; `none` for nothing.
141    fn from_str(s: &str) -> Result<Strip, String> {
142        parse_set(s, Strip::NONE, &Strip::NAMES)
143    }
144}
145
146impl fmt::Debug for Strip {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        if self.0 == 0 {
149            return write!(f, "none");
150        }
151        let names: Vec<&str> = Strip::NAMES
152            .iter()
153            .filter(|(s, _)| self.contains(*s))
154            .map(|(_, n)| *n)
155            .collect();
156        write!(f, "{}", names.join("|"))
157    }
158}
159
160/// Target color space for images. Only what a preset uses exists here.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162#[non_exhaustive]
163pub enum ColorConversion {
164    None,
165    /// Convert every color image to RGB through ICC profiles.
166    Rgb,
167}
168
169/// Downsampling rule for one image class.
170#[derive(Debug, Clone, Copy, PartialEq)]
171pub struct Dpi {
172    /// Resolution to resample to.
173    pub target: f32,
174    /// Only images whose effective resolution exceeds this are touched.
175    /// Negative disables downsampling for the class.
176    pub threshold: f32,
177}
178
179impl Dpi {
180    pub const fn new(target: f32, threshold: f32) -> Dpi {
181        Dpi { target, threshold }
182    }
183
184    pub const fn disabled(target: f32) -> Dpi {
185        Dpi {
186            target,
187            threshold: -1.0,
188        }
189    }
190
191    pub fn enabled(&self) -> bool {
192        self.threshold >= 0.0
193    }
194
195    pub fn is_sane(&self) -> bool {
196        self.target > 0.0 && (!self.enabled() || self.threshold >= self.target)
197    }
198}
199
200#[derive(Debug, Clone, PartialEq)]
201#[non_exhaustive]
202pub struct Config {
203    // Image classes and the codecs to try for each.
204    pub bitonal: Codecs,
205    pub continuous: Codecs,
206    pub indexed: Codecs,
207
208    pub bitonal_dpi: Dpi,
209    pub gray_dpi: Dpi,
210    pub color_dpi: Dpi,
211
212    /// 1-100, JPEG quality index.
213    pub jpeg_quality: u8,
214    pub color_conversion: ColorConversion,
215    /// Crop image pixels that lie outside their clip path before recompressing.
216    pub clip_images: bool,
217    /// RGB-that-is-gray to gray, two-level gray to bitonal, flat images to 1x1,
218    /// opaque soft masks removed.
219    pub reduce_color_complexity: bool,
220
221    // Fonts.
222    pub subset_fonts: bool,
223    pub merge_fonts: bool,
224    pub remove_standard_fonts: bool,
225    pub convert_to_cff: bool,
226
227    // Structure.
228    pub optimize_resources: bool,
229    pub remove_redundant_objects: bool,
230    /// Rewrite content streams in canonical token form when smaller.
231    pub rebuild_content_streams: bool,
232    pub strip: Strip,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236#[non_exhaustive]
237pub enum Preset {
238    Less,
239    Standard,
240    More,
241}
242
243impl Config {
244    /// Engine defaults before any profile is applied: nothing is processed.
245    fn baseline() -> Config {
246        Config {
247            bitonal: Codecs::NONE,
248            continuous: Codecs::NONE,
249            indexed: Codecs::NONE,
250            bitonal_dpi: Dpi::disabled(200.0),
251            gray_dpi: Dpi::disabled(150.0),
252            color_dpi: Dpi::disabled(150.0),
253            jpeg_quality: 75,
254            color_conversion: ColorConversion::None,
255            clip_images: false,
256            reduce_color_complexity: false,
257            subset_fonts: false,
258            merge_fonts: false,
259            remove_standard_fonts: false,
260            convert_to_cff: false,
261            optimize_resources: false,
262            remove_redundant_objects: false,
263            rebuild_content_streams: false,
264            strip: Strip::NONE,
265        }
266    }
267
268    /// Shared base for the `Standard` and `More` presets: lossless passes
269    /// on, image recompression on, no downsampling until a preset sets DPI.
270    fn heavy_base() -> Config {
271        Config {
272            bitonal: Codecs::G4 | Codecs::SOURCE,
273            continuous: Codecs::JPEG | Codecs::FLATE | Codecs::SOURCE,
274            indexed: Codecs::FLATE | Codecs::SOURCE,
275            jpeg_quality: 80,
276            clip_images: true,
277            reduce_color_complexity: true,
278            subset_fonts: true,
279            merge_fonts: true,
280            convert_to_cff: true,
281            optimize_resources: true,
282            remove_redundant_objects: true,
283            rebuild_content_streams: true,
284            strip: Strip::THREADS
285                | Strip::PIECE_INFO
286                | Strip::STRUCT_TREE
287                | Strip::THUMBNAILS
288                | Strip::SPIDER,
289            ..Config::baseline()
290        }
291    }
292
293    pub fn preset(p: Preset) -> Config {
294        match p {
295            Preset::Less => Config {
296                bitonal: Codecs::JBIG2 | Codecs::SOURCE,
297                continuous: Codecs::JPEG | Codecs::SOURCE,
298                indexed: Codecs::NONE,
299                bitonal_dpi: Dpi::new(200.0, 400.0),
300                gray_dpi: Dpi::new(200.0, 400.0),
301                color_dpi: Dpi::new(200.0, 400.0),
302                jpeg_quality: 75,
303                optimize_resources: true,
304                remove_redundant_objects: true,
305                rebuild_content_streams: true,
306                remove_standard_fonts: true,
307                subset_fonts: true,
308                ..Config::baseline()
309            },
310            Preset::Standard => Config {
311                bitonal_dpi: Dpi::new(150.0, 150.0),
312                gray_dpi: Dpi::new(150.0, 150.0),
313                color_dpi: Dpi::new(150.0, 150.0),
314                jpeg_quality: 60,
315                strip: Strip::THREADS
316                    | Strip::METADATA
317                    | Strip::PIECE_INFO
318                    | Strip::THUMBNAILS
319                    | Strip::SPIDER
320                    | Strip::ALTERNATES
321                    | Strip::OUTPUT_INTENTS,
322                ..Config::heavy_base()
323            },
324            Preset::More => Config {
325                bitonal: Codecs::JBIG2 | Codecs::SOURCE,
326                continuous: Codecs::JPEG | Codecs::SOURCE,
327                bitonal_dpi: Dpi::new(72.0, 110.0),
328                gray_dpi: Dpi::new(72.0, 110.0),
329                color_dpi: Dpi::new(72.0, 110.0),
330                jpeg_quality: 60,
331                color_conversion: ColorConversion::Rgb,
332                remove_standard_fonts: true,
333                strip: Strip::THREADS
334                    | Strip::METADATA
335                    | Strip::PIECE_INFO
336                    | Strip::STRUCT_TREE
337                    | Strip::THUMBNAILS
338                    | Strip::SPIDER
339                    | Strip::ALTERNATES,
340                ..Config::heavy_base()
341            },
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn presets_are_sane() {
352        for p in [Preset::Less, Preset::Standard, Preset::More] {
353            let c = Config::preset(p);
354            assert!(c.bitonal_dpi.is_sane(), "{p:?} bitonal dpi");
355            assert!(c.gray_dpi.is_sane(), "{p:?} gray dpi");
356            assert!(c.color_dpi.is_sane(), "{p:?} color dpi");
357            assert!((1..=100).contains(&c.jpeg_quality));
358            // Every preset must keep the never-grow guarantee.
359            assert!(c.bitonal.is_empty() || c.bitonal.contains(Codecs::SOURCE));
360            assert!(c.continuous.is_empty() || c.continuous.contains(Codecs::SOURCE));
361            assert!(c.indexed.is_empty() || c.indexed.contains(Codecs::SOURCE));
362        }
363    }
364
365    #[test]
366    fn standard_does_not_convert_color() {
367        assert_eq!(
368            Config::preset(Preset::Standard).color_conversion,
369            ColorConversion::None
370        );
371    }
372
373    #[test]
374    fn flag_debug_is_readable() {
375        assert_eq!(
376            format!("{:?}", Codecs::JPEG | Codecs::SOURCE),
377            "jpeg|source"
378        );
379        assert_eq!(format!("{:?}", Strip::NONE), "none");
380    }
381}