Skip to main content

pdfrum_edit/write/
mod.rs

1//! Writing a document out (ISO 32000-1 §7.5).
2//!
3//! [`save`] is one straight-line function calling six steps. The C++ spells
4//! the same sequence as a numbered stage machine driven by a resumable
5//! `Continue()` loop — that machinery exists to support pausable saving
6//! through its public API, a facility we do not offer, so the stage numbers
7//! survive here only as the order the steps run in.
8//!
9//! ```text
10//! header → old objects → new objects → encrypt dict → xref → trailer
11//! ```
12//!
13//! # Full and incremental are one path, not two
14//!
15//! The difference is entirely in what each step does:
16//!
17//! | | full save | incremental save |
18//! |---|---|---|
19//! | header | `%PDF-1.N` + a binary comment | the original file, byte for byte |
20//! | "new" objects | those the xref does not name, or names as free | *every* object in play |
21//! | old objects | reachable objects, garbage-collected | none |
22//! | cross-reference | a full table | a delta table, or a stream |
23//! | trailer | no `/Prev` | `/Prev` naming the original's last section |
24//!
25//! Two conditions silently downgrade an incremental save to a full one, both
26//! because appending would produce a file no reader could open: a **rebuilt**
27//! cross-reference (there is no previous section to chain from) and a
28//! **changed security key** (the appended objects would be keyed differently
29//! from the bytes before them).
30//!
31//! # An encrypted document stays encrypted
32//!
33//! Objects reach the writer plaintext, because the parser deciphered them on
34//! fetch. A save under a document's own security handler puts the cipher back
35//! on with the same file key, so the saved file opens with the same password;
36//! [`crate::encrypt`] holds the exemptions and the initialisation-vector
37//! story. [`SaveOptions::remove_security`] is the
38//! explicit opt-out, and turns the save into a plaintext rewrite with no
39//! `/Encrypt` in the trailer.
40//!
41//! Two mechanics follow from `/Encrypt` having to be an indirect object
42//! (ISO 32000-1 §7.6.1). A file that wrote it **inline** in the trailer has no
43//! object number for it, so the writer promotes it to a fresh one past the
44//! highest in play. And whichever number it ends up with, that object is the
45//! one thing the encryptor never touches.
46//!
47//! # The garbage collection is the point
48//!
49//! A full save writes only what the trailer can still reach. Removing every
50//! object from a page and regenerating its content really does produce a
51//! smaller file, rather than one that still carries the images nothing points
52//! at any more.
53
54mod header;
55pub(crate) mod id;
56pub(crate) mod object;
57mod reach;
58mod stream;
59mod trailer;
60mod xref;
61
62use std::io::Write;
63
64use pdfrum_common::PdfVersion;
65use pdfrum_object::{ObjRef, Object, Resolve, names};
66
67use crate::doc::EditDoc;
68use crate::encrypt;
69use crate::error::Error;
70use crate::font;
71use crate::write::header::write_header;
72use crate::write::id::{IdContext, IdSource};
73use crate::write::xref::ObjectOffsets;
74
75/// How a document is written back out.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub enum SaveMode {
78    /// Rewrite the whole file, dropping anything nothing points at.
79    #[default]
80    Full,
81    /// Append the changes after the original bytes, leaving them untouched.
82    ///
83    /// Downgraded to [`SaveMode::Full`] when the document's cross-reference
84    /// was rebuilt or its security key changed; see the module docs.
85    Incremental,
86}
87
88/// Everything a save may be asked to do differently.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct SaveOptions {
91    /// Whether to append or rewrite.
92    pub mode: SaveMode,
93    /// Keep the original bytes as the file's prefix. Only an incremental save
94    /// reads this; clearing it there turns the save into a rewrite that keeps
95    /// the appended shape.
96    pub keep_original: bool,
97    /// Drop the security handler and `/Encrypt`, writing the document in the
98    /// clear.
99    ///
100    /// Off by default: an encrypted document saves encrypted under its own
101    /// handler, and opens with the password it was opened with. Setting this
102    /// is the explicit way to decrypt one on the way out — and it forces a
103    /// full save, since plaintext cannot be appended behind ciphertext.
104    ///
105    /// Has no effect on an unencrypted document.
106    pub remove_security: bool,
107    /// Subset newly embedded fonts, dropping the glyphs no page shows.
108    ///
109    /// Off by default. When set, every font this save writes as a **new**
110    /// object and that a show operator on some page draws with is replaced by
111    /// a subset carrying only the glyphs still used, named `ABCDEF+Original`
112    /// after ISO 32000-1 §9.6.4.
113    ///
114    /// **What it subsets**: a `/Type0` font whose descendant is a
115    /// `CIDFontType2` with an embedded `/FontFile2`. Nothing else — a Type 1
116    /// (`/FontFile`) or `OpenType`-CFF (`/FontFile3`, or an `OTTO` program)
117    /// font is left alone, and so is a *simple* TrueType font, whose codes
118    /// reach glyphs through a `cmap` the subsetter removes.
119    ///
120    /// **What it does not disturb**: the character codes on the page, the
121    /// CIDs they map to, `/W`, and `/ToUnicode`. The subsetter renumbers
122    /// glyphs, and a rewritten `/CIDToGIDMap` absorbs that renumbering at the
123    /// one place ISO 32000-1 §9.7.4.2 already provides for it — so **no
124    /// content stream is regenerated**, and text extraction over the saved
125    /// file is unchanged.
126    ///
127    /// A font whose program will not subset, or whose subset would not be
128    /// smaller, is written unchanged.
129    pub subset_new_fonts: bool,
130    /// The version to declare in the header. 1.0 through 1.7 are honoured;
131    /// anything outside that range, and `None`, keep the document's own.
132    pub version: Option<PdfVersion>,
133    /// Where `/ID` and subset tags come from.
134    pub id_source: IdSource,
135    /// Encrypt an unencrypted document on the way out: AES-256, revision 6,
136    /// under these passwords and permissions. `None` leaves the document as
137    /// it is. A document that is already encrypted cannot be re-keyed here:
138    /// asking for it is [`Error::EncryptedSaveUnsupported`].
139    pub encrypt: Option<Encryption>,
140}
141
142/// How a document is to be encrypted on save (ISO 32000-2 §7.6.4.4).
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Encryption {
145    /// Opens the document with the rights `permissions` grants. Empty means
146    /// anyone can open it.
147    pub user_password: Vec<u8>,
148    /// Opens the document with every right. Empty means the user password
149    /// serves as both.
150    pub owner_password: Vec<u8>,
151    /// What a reader who opened with the user password may do.
152    pub permissions: pdfrum_crypt::Permissions,
153    /// Whether the document's XMP metadata stream is enciphered too.
154    pub encrypt_metadata: bool,
155}
156
157impl Default for SaveOptions {
158    fn default() -> Self {
159        Self {
160            mode: SaveMode::Full,
161            keep_original: true,
162            remove_security: false,
163            subset_new_fonts: false,
164            version: None,
165            id_source: IdSource::Random,
166            encrypt: None,
167        }
168    }
169}
170
171/// A sink that remembers how many bytes have gone through it.
172///
173/// Every cross-reference offset is a byte count from the start of the output,
174/// so the writer needs a running total. This is the C++'s buffered archive
175/// minus its hand-rolled 32 KiB buffer — buffering is the caller's choice,
176/// through a `BufWriter`.
177struct Counting<W: Write> {
178    inner: W,
179    written: u64,
180}
181
182impl<W: Write> Counting<W> {
183    fn new(inner: W) -> Self {
184        Self { inner, written: 0 }
185    }
186
187    fn write(&mut self, bytes: &[u8]) -> Result<(), Error> {
188        self.inner.write_all(bytes)?;
189        self.written = self.written.saturating_add(bytes.len() as u64);
190        Ok(())
191    }
192
193    /// The offset the next byte will land at.
194    const fn offset(&self) -> u64 {
195        self.written
196    }
197}
198
199/// Write `doc` to `out`.
200///
201/// # Errors
202///
203/// [`Error::EncryptedSaveUnsupported`] when the document declares `/Encrypt`
204/// but this reader never derived a key for it — an `/Identity` crypt filter,
205/// or a handler we opened as [`pdfrum_crypt::SecurityHandler::Identity`] —
206/// and `remove_security` was not set, because re-declaring a cipher over
207/// plaintext would produce a file nothing could open. And [`Error::Io`] when
208/// the sink refuses the bytes.
209///
210/// Damage in the input is not an error: an object that cannot be fetched is
211/// dropped from both the body and the cross-reference, exactly as the C++
212/// writer drops it.
213pub fn save(doc: &EditDoc<'_>, opts: &SaveOptions, out: &mut impl Write) -> Result<(), Error> {
214    let base = doc.base();
215
216    // The document's own handler, when the save is to stay encrypted. A
217    // `/Encrypt` we could not key — `/Identity`, or a filter this reader
218    // answered with the identity handler — would be re-declared over
219    // plaintext, which is the one shape that opens for nobody.
220    let SecurityPlan {
221        declared,
222        keep_security,
223        fresh,
224    } = security_plan(base, opts)?;
225    let handler = base.security_handler();
226
227    let id = file_id(base, opts);
228
229    // Three things force a full save. A rebuilt cross-reference has no
230    // previous section to name in `/Prev`; a rekey makes the original bytes
231    // unreadable under the new key; and removing security means the appended
232    // objects would be plaintext behind ciphertext.
233    let forced_full = base.xref_was_rebuilt()
234        || id.rekeyed
235        || (declared && opts.remove_security)
236        || fresh.is_some();
237    let incremental = opts.mode == SaveMode::Incremental && !forced_full;
238
239    // ---- the security seam ----
240    //
241    // The number the `/Encrypt` dictionary will be written as decides two
242    // things at once: which object the encryptor skips, and which one the
243    // body loops leave to the dedicated stage below.
244    let slot = choose_slot(
245        doc,
246        base,
247        fresh.as_ref().map(|(dict, _)| dict),
248        keep_security,
249    );
250    let encrypt_number = slot.as_ref().map(|s| s.number);
251    let active_handler = fresh.as_ref().map_or(handler, |(_, h)| h);
252    let security = if keep_security || fresh.is_some() {
253        Some(encrypt::Security {
254            handler: active_handler,
255            ivs: encrypt::IvSource::from_os()?,
256            encrypt_object: encrypt_number,
257        })
258    } else {
259        None
260    };
261
262    let mut sink = Counting::new(out);
263    let mut offsets = ObjectOffsets::new();
264
265    // ---- header, or the original bytes ----
266    write_front(&mut sink, base, opts, incremental)?;
267
268    // ---- partition ----
269    let (old_nums, new_nums) = partition(doc, incremental);
270
271    // ---- old objects, garbage-collected ----
272    //
273    // The trailer is the edited one: an `/Info` the session added is reached
274    // from it and named by it.
275    let trailer_dict = doc.trailer();
276    let reach = reach::walk(&trailer_dict, base.trailer_object_number(), doc);
277    for num in old_nums {
278        // A full save keeps only what the trailer can still reach.
279        if !reach.is_reachable(num) || encrypt_number == Some(num) {
280            continue;
281        }
282        write_one(&mut sink, &mut offsets, doc, num, security.as_ref())?;
283    }
284
285    // ---- new objects, written whether or not anything points at them ----
286    //
287    // The font subsetter is a lookup in this loop and nothing more, which is
288    // the shape `WriteNewObjs` (`:203-226`) has: it produces replacement
289    // objects for the font ones among the new numbers, and each object is
290    // written through the map. It may also mint the `/CIDToGIDMap` that
291    // absorbs the glyph renumbering, so the numbers it added are appended to
292    // this loop's list before it runs.
293    let mut new_nums = new_nums;
294    let overrides = subset_fonts(doc, opts, encrypt_number, &mut new_nums);
295    for num in new_nums.iter().copied() {
296        // A newly added object is written even when nothing references it:
297        // the caller added it on purpose, and the sweep above cannot see an
298        // intent that has not been wired up yet.
299        if encrypt_number == Some(num) {
300            continue;
301        }
302        match overrides.get(&num) {
303            Some(object) => write_override(&mut sink, &mut offsets, num, object, security.as_ref()),
304            None => write_one(&mut sink, &mut offsets, doc, num, security.as_ref()),
305        }?;
306    }
307
308    // ---- the encrypt dictionary ----
309    //
310    // Written here rather than by the loops above, whether the file held it
311    // inline or indirectly, for a reason that is not about encryption at all:
312    // it must be written **from the plaintext copy the trailer lookup found**,
313    // not from the object store. The store deciphers every string it hands
314    // out and has no exemption for this object, so fetching `/Encrypt`
315    // through it yields `/O` and `/U` run through a cipher keyed by the very
316    // material they carry. The C++ never has to think about this — it keeps
317    // the dictionary in a field beside the handler and writes that.
318    //
319    // A file that wrote the dictionary inline additionally needs the fresh
320    // object number `encrypt_slot` minted, since ISO 32000-1 §7.6.1 requires
321    // the trailer name it by reference.
322    if let Some(EncryptSlot { number, dict }) = &slot {
323        offsets.set(*number, sink.offset());
324        let mut bytes = Vec::new();
325        // And no encryptor, which is the rule ISO 32000-1 §7.6.1 states: a
326        // reader parses this dictionary before it has a key.
327        object::write_indirect(&mut bytes, *number, &Object::Dict(dict.clone()), None);
328        sink.write(&bytes)?;
329        if incremental && !new_nums.contains(number) {
330            // Appended without re-sorting. Safe for a promoted dictionary
331            // because its number is above everything already there, and for
332            // an indirect one because the guard above kept it out.
333            new_nums.push(*number);
334        }
335    }
336
337    let last_written = offsets.last();
338
339    // ---- cross-reference ----
340    let xref_start = sink.offset();
341    let as_stream = incremental && base.main_xref_is_stream();
342    let written: Vec<u32> = new_nums
343        .iter()
344        .copied()
345        .filter(|n| offsets.contains(*n))
346        .collect();
347    if !as_stream {
348        let mut table = Vec::new();
349        if incremental {
350            xref::classic_delta(&mut table, &offsets, &written);
351        } else {
352            xref::classic_full(&mut table, &offsets, last_written);
353        }
354        sink.write(&table)?;
355    }
356
357    // ---- trailer ----
358    let dict = trailer::build(trailer::TrailerParts {
359        source: &trailer_dict,
360        id: &id.array,
361        last_object_number: last_written,
362        prev: (incremental && base.last_xref_offset() > 0).then(|| base.last_xref_offset()),
363        encrypt: slot.as_ref().map(|s| s.number),
364    });
365
366    let mut tail = Vec::new();
367    if as_stream {
368        // The trailer object's own number comes from the document, not from
369        // the highest object written, so it can sit above `/Size − 2`.
370        let num = doc.last_object_number().saturating_add(1);
371        trailer::write_stream(&mut tail, num, &dict, &offsets, &written);
372    } else {
373        trailer::write_classic(&mut tail, &dict);
374    }
375    trailer::write_tail(&mut tail, xref_start);
376    sink.write(&tail)?;
377
378    Ok(())
379}
380
381/// Split the objects in play into the ones written from the file's own table
382/// and the ones written as additions.
383///
384/// A **full** save treats an object as new when the cross-reference does not
385/// name it, or names its slot free. Everything else is old — even an object
386/// the caller replaced, because the old path re-fetches through the overlay
387/// and so sees the replacement anyway.
388///
389/// An **incremental** save treats every object in play as new, because the
390/// appended section must carry a fresh copy of anything that changed. That is
391/// why an incremental save's size grows with how much of the document has
392/// been touched.
393fn partition(doc: &EditDoc<'_>, incremental: bool) -> (Vec<u32>, Vec<u32>) {
394    let base = doc.base();
395    let xref = base.xref();
396
397    if incremental {
398        let mut new: Vec<u32> = doc.edited().map(|(n, _)| n).collect();
399        new.sort_unstable();
400        new.dedup();
401        return (Vec::new(), new);
402    }
403
404    let last = xref.last_object_number();
405    let old: Vec<u32> = (1..=last)
406        .filter(|n| !doc.is_removed(*n))
407        .filter(|n| !matches!(xref.entry(*n), None | Some(pdfrum_parser::Entry::Free)))
408        .collect();
409
410    let mut new: Vec<u32> = doc
411        .edited()
412        .map(|(n, _)| n)
413        .filter(|n| {
414            !xref.is_valid_object_number(*n)
415                || matches!(xref.entry(*n), None | Some(pdfrum_parser::Entry::Free))
416        })
417        .collect();
418    new.sort_unstable();
419    new.dedup();
420    (old, new)
421}
422
423/// Where the `/Encrypt` dictionary goes on this save, and what to write there.
424///
425/// `dict` is the **plaintext** dictionary, taken from the trailer lookup that
426/// reads it through a store deciphering nothing — see the writing stage for
427/// why fetching it the ordinary way would corrupt it.
428#[derive(Debug, Clone)]
429struct EncryptSlot {
430    /// The object number the trailer's `/Encrypt` will point at.
431    number: u32,
432    /// The dictionary to write there.
433    dict: pdfrum_object::Dict,
434}
435
436/// Decide the `/Encrypt` dictionary's object number for this save.
437///
438/// A trailer naming it by reference already answers the question. One holding
439/// it inline does not, so the number is minted one past everything in play —
440/// which is what makes the incremental append-without-sorting sound, and what
441/// ISO 32000-1 §7.6.1 requires, since the trailer must name it by reference.
442///
443/// `None` when the trailer's `/Encrypt` is neither a dictionary nor a
444/// reference: there is no dictionary to point at, so the save writes no
445/// `/Encrypt`, and `save`'s plaintext check has already refused the one shape
446/// where that would produce an unopenable file.
447/// The trailer `/ID` this save writes, from the document's own and the
448/// options' source of fresh bytes.
449fn file_id(base: &pdfrum_parser::Document, opts: &SaveOptions) -> id::FileId {
450    id::build(
451        IdContext {
452            old: None,
453            encrypt: base.encrypt_dict().map(|(d, _)| d),
454            incremental: opts.mode == SaveMode::Incremental,
455        }
456        .with_old(base.trailer()),
457        opts.id_source,
458    )
459}
460
461/// The bytes before the first object: the original file when appending to
462/// it, a header otherwise. The original is copied verbatim; nothing in it is
463/// ever rewritten, which is what keeps signatures and byte-range digests
464/// valid.
465fn write_front(
466    sink: &mut Counting<impl Write>,
467    base: &pdfrum_parser::Document,
468    opts: &SaveOptions,
469    incremental: bool,
470) -> Result<(), Error> {
471    if incremental && opts.keep_original {
472        sink.write(base.bytes())
473    } else {
474        let mut header = Vec::new();
475        write_header(&mut header, opts.version, base.version());
476        sink.write(&header)
477    }
478}
479
480/// What the save does about security, decided before a byte is written.
481struct SecurityPlan {
482    /// The document carries an `/Encrypt` of its own.
483    declared: bool,
484    /// That handler stays in force for the output.
485    keep_security: bool,
486    /// A new handler, when the save is to encrypt an unencrypted document.
487    fresh: Option<(pdfrum_object::Dict, pdfrum_crypt::SecurityHandler)>,
488}
489
490fn security_plan(
491    base: &pdfrum_parser::Document,
492    opts: &SaveOptions,
493) -> Result<SecurityPlan, Error> {
494    let declared = base.encrypt_dict().is_some();
495    if declared && opts.encrypt.is_some() {
496        // Re-keying an encrypted document is not a save option: decrypt it
497        // (`remove_security`) and encrypt the result in a second save.
498        return Err(Error::EncryptedSaveUnsupported);
499    }
500    let keep_security = declared && !opts.remove_security;
501    if keep_security
502        && matches!(
503            base.security_handler(),
504            pdfrum_crypt::SecurityHandler::Identity
505        )
506    {
507        return Err(Error::EncryptedSaveUnsupported);
508    }
509    Ok(SecurityPlan {
510        declared,
511        keep_security,
512        fresh: fresh_encryption(opts)?,
513    })
514}
515
516/// A fresh handler when the save is to encrypt: built once, held for the
517/// writer's lifetime beside the document's own.
518fn fresh_encryption(
519    opts: &SaveOptions,
520) -> Result<Option<(pdfrum_object::Dict, pdfrum_crypt::SecurityHandler)>, Error> {
521    let Some(encryption) = &opts.encrypt else {
522        return Ok(None);
523    };
524    pdfrum_crypt::standard_r6(
525        &encryption.user_password,
526        &encryption.owner_password,
527        encryption.permissions,
528        encryption.encrypt_metadata,
529        &pdfrum_crypt::KeyMaterial::from_os().map_err(|_| Error::NoEntropy)?,
530    )
531    .map(Some)
532    .map_err(|_| Error::PasswordNotText)
533}
534
535/// Where the trailer's `/Encrypt` points: a new object for a fresh
536/// encryption, the document's own slot when its security is kept, nothing
537/// otherwise.
538fn choose_slot(
539    doc: &EditDoc<'_>,
540    base: &pdfrum_parser::Document,
541    fresh: Option<&pdfrum_object::Dict>,
542    keep_security: bool,
543) -> Option<EncryptSlot> {
544    match fresh {
545        Some(dict) => Some(EncryptSlot {
546            number: doc.last_object_number().saturating_add(1),
547            dict: dict.clone(),
548        }),
549        None => keep_security.then(|| encrypt_slot(doc, base)).flatten(),
550    }
551}
552
553fn encrypt_slot(doc: &EditDoc<'_>, base: &pdfrum_parser::Document) -> Option<EncryptSlot> {
554    let (dict, inline) = base.encrypt_dict()?;
555    let number = if inline {
556        doc.last_object_number().saturating_add(1)
557    } else {
558        base.trailer().reference(names::ENCRYPT)?.num
559    };
560    Some(EncryptSlot {
561        number,
562        dict: dict.clone(),
563    })
564}
565
566/// Run the font subsetter, if this save asked for it, and make room in the
567/// new-object list for anything it minted.
568///
569/// The map it returns is the one `WriteNewObjs` (`:203-226`) consults per
570/// object. An unset option, or a save with nothing new in it, gives an empty
571/// map and leaves `new_nums` alone.
572fn subset_fonts(
573    doc: &EditDoc<'_>,
574    opts: &SaveOptions,
575    encrypt_number: Option<u32>,
576    new_nums: &mut Vec<u32>,
577) -> font::overrides::Overrides {
578    if !opts.subset_new_fonts {
579        return font::overrides::Overrides::new();
580    }
581    // Where a `/CIDToGIDMap` the subsetter mints gets its number: one past
582    // everything in play, and past the `/Encrypt` slot too when this save is
583    // promoting an inline dictionary into a fresh number of its own.
584    let mut next = doc.last_object_number().saturating_add(1);
585    if let Some(number) = encrypt_number {
586        next = next.max(number.saturating_add(1));
587    }
588
589    let overrides = font::overrides::build(doc, new_nums, opts.id_source, &mut next);
590    // An override of an object already listed changes what is written there;
591    // one of a *minted* object adds a number the loop had not been going to
592    // visit, so the list grows and is re-sorted.
593    new_nums.extend(overrides.keys().copied());
594    new_nums.sort_unstable();
595    new_nums.dedup();
596    overrides
597}
598
599/// Write an object the subsetter produced in place of the document's own.
600///
601/// Separate from [`write_one`] because there is nothing to fetch and nothing
602/// that can fail: the object is already in hand, which is also why no offset
603/// ever has to be erased here.
604fn write_override<W: Write>(
605    sink: &mut Counting<W>,
606    offsets: &mut ObjectOffsets,
607    num: u32,
608    object: &Object,
609    security: Option<&encrypt::Security<'_>>,
610) -> Result<(), Error> {
611    offsets.set(num, sink.offset());
612    let enc = security.and_then(|s| s.for_object(num));
613    let mut bytes = Vec::new();
614    object::write_indirect(&mut bytes, num, object, enc.as_ref());
615    sink.write(&bytes)
616}
617
618/// Write one indirect object, recording where it landed.
619///
620/// The offset is recorded **before** the fetch and erased if the fetch fails,
621/// so a broken object vanishes from the body and the cross-reference together
622/// rather than leaving a table entry pointing at the next object's header.
623fn write_one<W: Write>(
624    sink: &mut Counting<W>,
625    offsets: &mut ObjectOffsets,
626    doc: &EditDoc<'_>,
627    num: u32,
628    security: Option<&encrypt::Security<'_>>,
629) -> Result<(), Error> {
630    offsets.set(num, sink.offset());
631    let Ok(obj) = doc.fetch(ObjRef::new(num, 0)) else {
632        offsets.erase(num);
633        return Ok(());
634    };
635    // A null carries no information a reader needs; the C++ writes it, but a
636    // free slot reads identically and costs nothing.
637    if obj.is_null() {
638        offsets.erase(num);
639        return Ok(());
640    }
641
642    // `for_object` is what refuses the `/Encrypt` dictionary its encryptor,
643    // so the rule lives in one place rather than at every call site.
644    let enc = security.and_then(|s| s.for_object(num));
645    let mut bytes = Vec::new();
646    object::write_indirect(&mut bytes, num, &obj, enc.as_ref());
647    sink.write(&bytes)
648}
649
650impl<'a> IdContext<'a> {
651    /// Fill in the trailer's own `/ID`, when it has one.
652    fn with_old(mut self, trailer: &'a pdfrum_object::Dict) -> Self {
653        self.old = match trailer.raw(names::ID) {
654            Some(Object::Array(a)) => Some(a),
655            _ => None,
656        };
657        self
658    }
659}