pdf-manip 1.2.0

PDF manipulation: pages, encryption, watermarks, optimization, bookmarks
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
//! Font subsetting for embedded PDF fonts.
//!
//! Reduces embedded font data to only the glyphs actually used in the document,
//! using the `subsetter` crate for OpenType/TrueType subsetting.

// Copyright (c) 2026 Innovation Trigger B.V.
//
// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
// the PDFluent Commercial Licence. See the LICENSE file in this repository --
// that file travels with the copy you received, which a URL does not.

use crate::error::{ManipError, Result};
use crate::flate_decode::decode_zlib;
use flate2::write::ZlibEncoder;
use flate2::Compression;
use lopdf::{Document, Object, ObjectId};
use std::collections::HashSet;
use std::io::Write;

/// Report from a font subsetting pass.
#[derive(Debug, Clone)]
pub struct SubsetReport {
    /// Number of embedded fonts found.
    pub fonts_processed: usize,
    /// Number of fonts actually subsetted (size reduced).
    pub fonts_subsetted: usize,
    /// Total bytes saved.
    pub bytes_saved: usize,
    /// Lettertypen die zijn overgeslagen omdat het subsetten tabellen kwijtraakte.
    pub fonts_skipped_incomplete: usize,
    /// Which tables those were, per font name.
    pub tables_lost: Vec<(String, Vec<String>)>,
}

/// The names of the SFNT tables in a font program.
///
/// Returns an empty list for bare CFF (FontFile3 without an SFNT wrapper):
/// there is no table directory there, so there is nothing to compare.
fn sfnt_table_names(data: &[u8]) -> Vec<String> {
    if data.len() < 12 {
        return Vec::new();
    }
    let magic = &data[..4];
    if magic != b"\x00\x01\x00\x00" && magic != b"true" && magic != b"ttcf" && magic != b"OTTO" {
        return Vec::new();
    }
    let count = u16::from_be_bytes([data[4], data[5]]) as usize;
    // A plausible upper bound; above this the header cannot be trusted.
    if count > 64 {
        return Vec::new();
    }
    (0..count)
        .filter_map(|i| {
            let at = 12 + i * 16;
            data.get(at..at + 4)
                .map(|tag| String::from_utf8_lossy(tag).into_owned())
        })
        .collect()
}

/// Tables the original had and the subset does not, or `None` if nothing was lost.
/// Exposed for the guard's test: the predicate is the whole protection, and
/// testing it through a full document would need corpus fonts this machine
/// does not carry.
pub fn tables_lost_for_test(original: &[u8], subsetted: &[u8]) -> Option<Vec<String>> {
    tables_lost(original, subsetted)
}

fn tables_lost(original: &[u8], subsetted: &[u8]) -> Option<Vec<String>> {
    let before = sfnt_table_names(original);
    if before.is_empty() {
        return None;
    }
    let after: HashSet<String> = sfnt_table_names(subsetted).into_iter().collect();
    let lost: Vec<String> = before.into_iter().filter(|t| !after.contains(t)).collect();
    (!lost.is_empty()).then_some(lost)
}

/// Subset all embedded fonts in the document to only used glyphs.
pub fn subset_fonts(doc: &mut Document) -> Result<SubsetReport> {
    let mut report = SubsetReport {
        fonts_processed: 0,
        fonts_subsetted: 0,
        bytes_saved: 0,
        fonts_skipped_incomplete: 0,
        tables_lost: Vec::new(),
    };

    // Step 1: Find all Font objects and collect used character codes per font.
    let font_usage = collect_font_usage(doc);

    // Step 2: Find embedded font streams and their glyph usage.
    let font_ids: Vec<ObjectId> = doc.objects.keys().copied().collect();

    for font_id in font_ids {
        let font_file_info = {
            let Some(Object::Dictionary(dict)) = doc.objects.get(&font_id) else {
                continue;
            };

            // Check if this is a Font Descriptor with an embedded font.
            if get_name(dict, b"Type").as_deref() != Some("FontDescriptor") {
                continue;
            }

            // Find the font stream reference.
            let font_file_key = if dict.has(b"FontFile2") {
                b"FontFile2".as_slice()
            } else if dict.has(b"FontFile3") {
                b"FontFile3".as_slice()
            } else {
                continue;
            };

            let font_stream_id = match dict.get(font_file_key).ok() {
                Some(Object::Reference(id)) => *id,
                _ => continue,
            };

            // Get the font name for looking up usage.
            let font_name = get_name(dict, b"FontName").unwrap_or_default();

            Some((font_stream_id, font_name, font_file_key == b"FontFile2"))
        };

        let Some((font_stream_id, font_name, is_truetype)) = font_file_info else {
            continue;
        };

        // Get glyph IDs used by this font.
        let glyph_ids = match font_usage.get(&font_name) {
            Some(ids) => ids,
            None => continue, // Font not used in any content stream.
        };

        if glyph_ids.is_empty() {
            continue;
        }

        report.fonts_processed += 1;

        // Get the font stream data.
        let (font_data, was_compressed) = {
            let Some(Object::Stream(stream)) = doc.objects.get(&font_stream_id) else {
                continue;
            };
            let compressed = get_name(&stream.dict, b"Filter")
                .as_deref()
                .map(|f| f == "FlateDecode")
                .unwrap_or(false);
            let data = if compressed {
                decompress_stream(&stream.content)?
            } else {
                stream.content.clone()
            };
            (data, compressed)
        };

        let original_size = font_data.len();

        // Subset the font using the subsetter crate.
        let glyph_vec: Vec<u16> = glyph_ids.iter().copied().collect();
        let mapper = subsetter::GlyphRemapper::new_from_glyphs(&glyph_vec);

        let subsetted = match subsetter::subset(&font_data, 0, &mapper) {
            Ok(data) => data,
            Err(_) => continue, // Subsetting failed — skip this font.
        };

        if subsetted.len() >= original_size {
            continue; // No size reduction.
        }

        // Nooit een lettertype terugschrijven dat minder tabellen heeft dan het
        // origineel.
        //
        // Gemeten op 24-08-2026: zonder deze regel verloor het subsetten de
        // `cmap`-tabel. Tekstextractie merkte daar niets van (die leest
        // ToUnicode) en de pagina's renderden pixelidentiek, maar FreeType
        // meldde "could not find any cmaps" en veraPDF kon de bestanden niet
        // meer valideren -- op 17 holdout-documenten ging 17 conform naar 0.
        //
        // Kleiner en kapot is geen besparing. Zolang de subsetter geen
        // vervangende `cmap` opbouwt (#184), slaan we zo'n lettertype over.
        if let Some(kwijt) = tables_lost(&font_data, &subsetted) {
            report.fonts_skipped_incomplete += 1;
            report.tables_lost.push((font_name.clone(), kwijt));
            continue;
        }

        let bytes_saved = original_size - subsetted.len();

        // Re-compress if originally compressed.
        let final_data = if was_compressed {
            compress_data(&subsetted)?
        } else {
            subsetted.clone()
        };

        // Replace the font stream.
        if let Some(Object::Stream(stream)) = doc.objects.get_mut(&font_stream_id) {
            let new_len = final_data.len();
            stream.set_content(final_data);
            stream.dict.set("Length", Object::Integer(new_len as i64));
            // Update Length1 (uncompressed size) for TrueType.
            if is_truetype {
                stream
                    .dict
                    .set("Length1", Object::Integer(subsetted.len() as i64));
            }
        }

        report.fonts_subsetted += 1;
        report.bytes_saved += bytes_saved;
    }

    Ok(report)
}

/// Collect which glyph IDs are used by each font in the document.
/// Returns a map of font name → set of glyph IDs (u16).
fn collect_font_usage(doc: &Document) -> std::collections::HashMap<String, HashSet<u16>> {
    let mut usage: std::collections::HashMap<String, HashSet<u16>> =
        std::collections::HashMap::new();

    // Find all Font objects.
    for obj in doc.objects.values() {
        let Object::Dictionary(dict) = obj else {
            continue;
        };

        if get_name(dict, b"Type").as_deref() != Some("Font") {
            continue;
        }

        let font_name = get_base_font_name(dict);
        if font_name.is_empty() {
            continue;
        }

        // For Type0/CIDFont: look at descendant fonts for W array.
        // For simple fonts: look at /Widths array to determine used range.
        let glyph_set = usage.entry(font_name).or_default();

        // Collect from /W array (CID fonts).
        if let Ok(Object::Array(w_array)) = dict.get(b"W") {
            collect_glyph_ids_from_w_array(w_array, glyph_set);
        }

        // Collect from /Widths array (simple fonts) using /FirstChar.
        if let Ok(Object::Array(widths)) = dict.get(b"Widths") {
            let first_char = match dict.get(b"FirstChar").ok() {
                Some(Object::Integer(n)) => *n as u16,
                _ => 0,
            };
            for (i, w) in widths.iter().enumerate() {
                if let Object::Integer(n) = w {
                    if *n > 0 {
                        glyph_set.insert(first_char + i as u16);
                    }
                }
                if let Object::Real(n) = w {
                    if *n > 0.0 {
                        glyph_set.insert(first_char + i as u16);
                    }
                }
            }
        }

        // Always include .notdef (glyph 0) and space (code 32).
        glyph_set.insert(0);
        glyph_set.insert(32);
    }

    usage
}

fn collect_glyph_ids_from_w_array(w_array: &[Object], glyph_set: &mut HashSet<u16>) {
    let mut i = 0;
    while i < w_array.len() {
        if let Object::Integer(start_cid) = &w_array[i] {
            let start = *start_cid as u16;
            if i + 1 < w_array.len() {
                match &w_array[i + 1] {
                    Object::Array(widths) => {
                        // Format: start [w1 w2 w3 ...]
                        for (j, _w) in widths.iter().enumerate() {
                            glyph_set.insert(start + j as u16);
                        }
                        i += 2;
                    }
                    Object::Integer(end_cid) => {
                        // Format: start end width
                        let end = *end_cid as u16;
                        for cid in start..=end {
                            glyph_set.insert(cid);
                        }
                        i += 3;
                    }
                    _ => {
                        i += 1;
                    }
                }
            } else {
                i += 1;
            }
        } else {
            i += 1;
        }
    }
}

fn get_name(dict: &lopdf::Dictionary, key: &[u8]) -> Option<String> {
    match dict.get(key).ok()? {
        Object::Name(n) => String::from_utf8(n.clone()).ok(),
        _ => None,
    }
}

fn get_base_font_name(dict: &lopdf::Dictionary) -> String {
    match dict.get(b"BaseFont").ok() {
        Some(Object::Name(n)) => {
            let name = String::from_utf8_lossy(n).to_string();
            // Strip subset prefix (e.g. "ABCDEF+ArialMT" → "ArialMT").
            if name.len() > 7 && name.as_bytes()[6] == b'+' {
                name[7..].to_string()
            } else {
                name
            }
        }
        _ => String::new(),
    }
}

fn decompress_stream(data: &[u8]) -> Result<Vec<u8>> {
    decode_zlib(data, |e| {
        ManipError::Other(format!("FlateDecode failed: {e}"))
    })
}

fn compress_data(data: &[u8]) -> Result<Vec<u8>> {
    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
    encoder
        .write_all(data)
        .map_err(|e| ManipError::Other(format!("compression failed: {e}")))?;
    encoder
        .finish()
        .map_err(|e| ManipError::Other(format!("compression finalize: {e}")))
}

#[cfg(test)]
mod tests {
    use super::*;
    use lopdf::{dictionary, Stream};

    fn make_doc_with_font() -> Document {
        let mut doc = Document::with_version("1.7");
        let pages_id = doc.new_object_id();

        // Create a minimal TrueType font stream (not real font data, but tests the flow).
        let font_stream = Stream::new(
            dictionary! {
                "Length1" => Object::Integer(100),
            },
            vec![0u8; 100],
        );
        let font_stream_id = doc.add_object(Object::Stream(font_stream));

        // Font descriptor.
        let font_descriptor = dictionary! {
            "Type" => "FontDescriptor",
            "FontName" => "TestFont",
            "FontFile2" => Object::Reference(font_stream_id),
            "Flags" => Object::Integer(32),
            "FontBBox" => Object::Array(vec![
                Object::Integer(0), Object::Integer(-200),
                Object::Integer(1000), Object::Integer(800),
            ]),
            "ItalicAngle" => Object::Integer(0),
            "Ascent" => Object::Integer(800),
            "Descent" => Object::Integer(-200),
            "CapHeight" => Object::Integer(700),
            "StemV" => Object::Integer(80),
        };
        let fd_id = doc.add_object(Object::Dictionary(font_descriptor));

        // Font dictionary.
        let font_dict = dictionary! {
            "Type" => "Font",
            "Subtype" => "TrueType",
            "BaseFont" => "TestFont",
            "FirstChar" => Object::Integer(32),
            "LastChar" => Object::Integer(122),
            "Widths" => Object::Array(
                (32..=122).map(|_| Object::Integer(500)).collect()
            ),
            "FontDescriptor" => Object::Reference(fd_id),
        };
        let font_id = doc.add_object(Object::Dictionary(font_dict));

        // Page with content.
        let content = Stream::new(dictionary! {}, b"BT /F1 12 Tf (Hello) Tj ET".to_vec());
        let content_id = doc.add_object(Object::Stream(content));

        let mut font_res = lopdf::Dictionary::new();
        font_res.set("F1", Object::Reference(font_id));
        let mut res_dict = lopdf::Dictionary::new();
        res_dict.set("Font", Object::Dictionary(font_res));

        let page = dictionary! {
            "Type" => "Page",
            "Parent" => Object::Reference(pages_id),
            "MediaBox" => Object::Array(vec![
                Object::Integer(0), Object::Integer(0),
                Object::Integer(612), Object::Integer(792),
            ]),
            "Contents" => Object::Reference(content_id),
            "Resources" => Object::Dictionary(res_dict),
        };
        let page_id = doc.add_object(Object::Dictionary(page));

        let pages = dictionary! {
            "Type" => "Pages",
            "Count" => Object::Integer(1),
            "Kids" => Object::Array(vec![Object::Reference(page_id)]),
        };
        doc.objects.insert(pages_id, Object::Dictionary(pages));

        let catalog = dictionary! {
            "Type" => "Catalog",
            "Pages" => Object::Reference(pages_id),
        };
        let catalog_id = doc.add_object(Object::Dictionary(catalog));
        doc.trailer.set("Root", Object::Reference(catalog_id));

        doc
    }

    #[test]
    fn test_subset_report_structure() {
        let mut doc = make_doc_with_font();
        let report = subset_fonts(&mut doc).unwrap();
        // The test font data is not valid TrueType, so subsetter will skip it.
        // But the report should still be valid.
        assert_eq!(report.fonts_processed, 1);
        // subsetter will fail on invalid data, so fonts_subsetted should be 0.
        assert_eq!(report.fonts_subsetted, 0);
    }

    #[test]
    fn test_collect_font_usage() {
        let doc = make_doc_with_font();
        let usage = collect_font_usage(&doc);
        assert!(usage.contains_key("TestFont"));
        let glyphs = &usage["TestFont"];
        assert!(glyphs.contains(&0)); // .notdef always included
    }

    #[test]
    fn test_empty_document() {
        let mut doc = Document::with_version("1.7");
        let pages_id = doc.new_object_id();
        let pages = dictionary! {
            "Type" => "Pages",
            "Count" => Object::Integer(0),
            "Kids" => Object::Array(vec![]),
        };
        doc.objects.insert(pages_id, Object::Dictionary(pages));
        let catalog = dictionary! {
            "Type" => "Catalog",
            "Pages" => Object::Reference(pages_id),
        };
        let catalog_id = doc.add_object(Object::Dictionary(catalog));
        doc.trailer.set("Root", Object::Reference(catalog_id));

        let report = subset_fonts(&mut doc).unwrap();
        assert_eq!(report.fonts_processed, 0);
        assert_eq!(report.fonts_subsetted, 0);
    }

    #[test]
    fn test_get_base_font_name_with_prefix() {
        let dict = dictionary! {
            "BaseFont" => "ABCDEF+ArialMT",
        };
        assert_eq!(get_base_font_name(&dict), "ArialMT");
    }

    #[test]
    fn test_get_base_font_name_without_prefix() {
        let dict = dictionary! {
            "BaseFont" => "Helvetica",
        };
        assert_eq!(get_base_font_name(&dict), "Helvetica");
    }
}