Skip to main content

oxideav_pdf/
objects.rs

1//! PDF object model and serializer (ISO 32000-1 §7.3 / §7.5).
2//!
3//! Round 1 ships the minimum surface a single-page writer needs:
4//! booleans, numerics, names, strings, arrays, dictionaries, streams,
5//! null, indirect references. The crate emits **only** these and walks
6//! a [`Document`] of [`IndirectObject`]s into the standard
7//! header / body / xref / trailer layout (§7.5.2 — §7.5.5).
8//!
9//! No parser. The writer never reads back any byte it emits.
10
11use std::io::{self, Write};
12
13use crate::encrypt::EncryptionState;
14use crate::error::PdfError;
15
16/// A PDF "any" value — every primitive plus the composite ones.
17///
18/// Round 1 keeps the variant set tight; future rounds (text, encryption,
19/// outlines) can extend without breaking writer-only call sites.
20#[derive(Clone, Debug)]
21pub enum Object {
22    Null,
23    Bool(bool),
24    Integer(i64),
25    Real(f64),
26    /// PDF Name object (`/Foo`). The leading slash is added by the
27    /// serializer; values must use the unescaped name characters
28    /// (ISO 32000-1 §7.3.5 — printable ASCII excluding the delimiters).
29    Name(String),
30    /// Literal string `(...)` — bytes go through PDF escape rules.
31    LiteralString(Vec<u8>),
32    /// Hexadecimal string `<...>` — used when content might confuse
33    /// the literal-string parser (e.g. images embedded inline).
34    HexString(Vec<u8>),
35    Array(Vec<Object>),
36    Dict(Dict),
37    /// Indirect reference (`<n> <gen> R`). Generation is always 0 in
38    /// the writer's output (objects never get re-released).
39    Reference(ObjectId),
40    /// Stream object — dictionary describing the payload + the bytes.
41    Stream(Stream),
42}
43
44/// Tagged identifier of an indirect object inside a [`Document`].
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46pub struct ObjectId {
47    pub number: u32,
48    pub generation: u16,
49}
50
51impl ObjectId {
52    pub const fn new(number: u32) -> Self {
53        Self {
54            number,
55            generation: 0,
56        }
57    }
58}
59
60/// A PDF dictionary `<< /Key Value >>`. Iteration order is insertion
61/// order so generated PDFs are byte-stable across runs.
62#[derive(Clone, Debug, Default)]
63pub struct Dict {
64    entries: Vec<(String, Object)>,
65}
66
67impl Dict {
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Insert (or overwrite) `key`. Returns `&mut self` for chaining.
73    pub fn set(&mut self, key: &str, value: Object) -> &mut Self {
74        if let Some(slot) = self.entries.iter_mut().find(|(k, _)| k == key) {
75            slot.1 = value;
76        } else {
77            self.entries.push((key.to_owned(), value));
78        }
79        self
80    }
81
82    /// Insert (or overwrite) and return self by value (builder style).
83    pub fn with(mut self, key: &str, value: Object) -> Self {
84        self.set(key, value);
85        self
86    }
87
88    pub fn entries(&self) -> &[(String, Object)] {
89        &self.entries
90    }
91
92    pub fn is_empty(&self) -> bool {
93        self.entries.is_empty()
94    }
95}
96
97/// A PDF stream — its dictionary describes the payload and is required
98/// to carry `/Length`. The serializer fills `/Length` from `data.len()`
99/// at write time so callers don't have to.
100#[derive(Clone, Debug)]
101pub struct Stream {
102    pub dict: Dict,
103    pub data: Vec<u8>,
104}
105
106impl Stream {
107    /// Wrap raw uncompressed bytes. The serializer adds `/Length`; any
108    /// other dictionary entries (filters, decode parameters, type tags)
109    /// are the caller's responsibility.
110    pub fn new(dict: Dict, data: Vec<u8>) -> Self {
111        Self { dict, data }
112    }
113}
114
115/// One indirect object stored in a [`Document`]. Each gets its own
116/// `<n> <gen> obj … endobj` block in the output.
117#[derive(Clone, Debug)]
118pub struct IndirectObject {
119    pub id: ObjectId,
120    pub object: Object,
121}
122
123/// The whole PDF document — an append-only list of indirect objects
124/// plus a /Root reference (and optional /Info reference) for the
125/// trailer dictionary.
126#[derive(Default)]
127pub struct Document {
128    objects: Vec<IndirectObject>,
129    next_id: u32,
130    pub root: Option<ObjectId>,
131    /// Optional document-level information dictionary. When set, the
132    /// reference is written into the trailer as `/Info <n> <gen> R` —
133    /// PDF readers surface the dictionary's `/Title`, `/Author`, etc.
134    /// keys as the document's metadata. ISO 32000-1 §14.3.3 also
135    /// allows arbitrary additional keys, used by the round-2 writer
136    /// to round-trip custom scene metadata.
137    pub info: Option<ObjectId>,
138    /// Optional encryption state. When set, every string and stream
139    /// payload in the body is encrypted via the standard handler
140    /// (Algorithms 1 + 4/5 / 8/9 / etc.) and the trailer carries
141    /// `/Encrypt <n> 0 R` + a matching `/ID` array. The dictionary
142    /// itself (the Encrypt object) is **not** encrypted — see
143    /// ISO 32000-1 §7.6.1.
144    pub encryption: Option<EncryptionState>,
145    /// When `true`, [`Self::write_to`] emits a PDF 1.5+ cross-reference
146    /// *stream* (`/Type /XRef`, ISO 32000-1 §7.5.8) instead of the
147    /// classical `xref`-keyword table. The trailer dict is folded into
148    /// the stream's own dictionary (per §7.5.8.2), so the file no
149    /// longer carries a separate `trailer << ... >>` block.
150    ///
151    /// The xref stream uses `/W [1 4 2]` — one byte for the entry
152    /// type, four bytes for the offset (or compressed-stream id), two
153    /// bytes for the generation (or in-stream index). The body is
154    /// flate-compressed with the PNG-Up `/Predictor 12` so the
155    /// reader's predictor reversal is exercised end-to-end.
156    pub xref_stream: bool,
157    /// When `true` (and [`Self::xref_stream`] is also `true`),
158    /// [`Self::write_to`] packs every compressible indirect object
159    /// (non-stream, non-Encrypt, non-Catalog-when-flagged) into one
160    /// `/Type /ObjStm` container per ISO 32000-1 §7.5.7. The xref
161    /// stream's type-2 entries point at the container; the round-7
162    /// reader's [`crate::reader::DocumentReader::resolve`] follows
163    /// them. Implies a PDF 1.5+ header (already implied by
164    /// [`Self::xref_stream`]).
165    ///
166    /// Stream objects (content streams, image XObjects, the xref
167    /// stream itself, the encryption-metadata stream, etc.) cannot
168    /// live inside an ObjStm (§7.5.7) and remain at their own byte
169    /// offsets. The Encrypt indirect object (when set) is excluded
170    /// because §7.6.1 forbids it from being compressed.
171    pub object_stream: bool,
172    /// Optional pointer at a previous cross-reference section's byte
173    /// offset. When `Some(off)`, the trailer dict (or, with
174    /// [`Self::xref_stream`], the xref-stream dict) carries
175    /// `/Prev <off>` per ISO 32000-1 §7.5.6 — the marker that lets a
176    /// reader walk a chain of incremental updates. Set by
177    /// [`crate::write_pdf_incremental_update`] on the new revision's
178    /// document; unused for one-shot writes.
179    pub prev_xref_offset: Option<u64>,
180    /// When emitting an incremental update, the reader's view of
181    /// `/Size` must be at least the original revision's `/Size` —
182    /// this honours that minimum even when the new revision adds
183    /// only one or two indirect objects past the old maximum.
184    pub min_size: Option<u32>,
185    /// When emitting an incremental update, the body section is
186    /// appended to a previous file's bytes. The xref subsection
187    /// header(s) the writer emits must list only the *changed* slots
188    /// and skip the unchanged ones. When set, the xref emitter (both
189    /// classical and stream forms) groups slots into contiguous
190    /// subsections covering only these ids (and id 0 for the
191    /// free-list head); when `None` the writer emits one subsection
192    /// covering `[0, max_id]`.
193    pub xref_only_ids: Option<Vec<u32>>,
194}
195
196impl Document {
197    pub fn new() -> Self {
198        Self {
199            objects: Vec::new(),
200            next_id: 1,
201            root: None,
202            info: None,
203            encryption: None,
204            xref_stream: false,
205            object_stream: false,
206            prev_xref_offset: None,
207            min_size: None,
208            xref_only_ids: None,
209        }
210    }
211
212    /// Pre-seed the next-id allocator past `max_id`. Used by the
213    /// incremental-update writer to make new objects pick up after
214    /// the previous revision's maximum id.
215    pub fn set_next_id(&mut self, next_id: u32) {
216        self.next_id = next_id;
217    }
218
219    /// The next id [`Self::allocate_id`] will hand out. Useful for
220    /// the incremental-update writer to know where the new revision's
221    /// id range starts.
222    pub fn next_id(&self) -> u32 {
223        self.next_id
224    }
225
226    /// Reserve a fresh id without committing the object body. Useful
227    /// when two objects need to reference each other (page → resources,
228    /// resources → page); allocate both ids first, then fill them in.
229    pub fn allocate_id(&mut self) -> ObjectId {
230        let id = ObjectId::new(self.next_id);
231        self.next_id += 1;
232        id
233    }
234
235    /// Add an object that already has an id (one obtained via
236    /// [`Self::allocate_id`]).
237    pub fn add_object(&mut self, id: ObjectId, object: Object) {
238        self.objects.push(IndirectObject { id, object });
239    }
240
241    /// Allocate-and-add in one step. Returns the assigned id.
242    pub fn add(&mut self, object: Object) -> ObjectId {
243        let id = self.allocate_id();
244        self.add_object(id, object);
245        id
246    }
247
248    /// Number of indirect objects committed so far.
249    pub fn object_count(&self) -> usize {
250        self.objects.len()
251    }
252
253    /// Borrow the object body of the indirect object at `id` for
254    /// mutation. Used by writer code paths that need to extend the
255    /// catalog dictionary after [`crate::page::build_pages`] has
256    /// returned (e.g. to attach `/Metadata <ref>` per ISO 32000-1
257    /// §14.3.2). Returns `None` when `id` was never committed.
258    pub fn object_mut(&mut self, id: ObjectId) -> Option<&mut Object> {
259        self.objects
260            .iter_mut()
261            .find(|o| o.id == id)
262            .map(|o| &mut o.object)
263    }
264
265    /// Walk this document into the on-wire layout: header + body +
266    /// xref + trailer + startxref. Bytes for sub-objects are emitted
267    /// in insertion order.
268    pub fn write_to(&self, out: &mut Vec<u8>) -> Result<(), PdfError> {
269        let root = self
270            .root
271            .ok_or_else(|| PdfError::other("Document::write_to: missing /Root reference"))?;
272        if self.object_stream && !self.xref_stream {
273            return Err(PdfError::other(
274                "Document::write_to: object_stream=true requires xref_stream=true (ObjStm \
275                 containers can only be referenced from a /Type /XRef stream — \
276                 ISO 32000-1 §7.5.7)",
277            ));
278        }
279        // ---- Header ---------------------------------------------------
280        // PDF 1.4 magic + the four >0x80 bytes that mark the file as
281        // binary so PDF readers don't treat it as ASCII (ISO 32000-1
282        // §7.5.2). Any byte ≥0x80 satisfies the rule; we use 0xE2 0xE3
283        // 0xCF 0xD3 — the canonical pdftk / Acrobat marker.
284        //
285        // Skip the header when we're appending an incremental update —
286        // the previous revision's bytes already carry one (§7.5.6).
287        if self.prev_xref_offset.is_none() {
288            let header_version: &[u8] = if self
289                .encryption
290                .as_ref()
291                .map(|e| e.handler.revision >= 5)
292                .unwrap_or(false)
293            {
294                // V=5 was introduced in PDF 1.7 + ISO 32000-2 (2.0). Bump
295                // the magic so PDF 2.0 readers don't flag the file as
296                // pre-1.7-using-1.7-features.
297                b"%PDF-2.0\n"
298            } else if self.xref_stream {
299                // XRef streams require PDF 1.5+ readers (§7.5.8). Bump
300                // the header so older parsers refuse the file rather than
301                // silently misinterpreting the cross-reference section.
302                b"%PDF-1.5\n"
303            } else {
304                b"%PDF-1.4\n"
305            };
306            out.extend_from_slice(header_version);
307            out.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
308        }
309
310        // ---- ObjStm packing happens BEFORE encryption ---------------
311        // §7.5.7 + §7.6.1 interaction: when an ObjStm container lives
312        // inside an encrypted file, the *container body* is encrypted
313        // as one unit (per the ObjStm container's own object id), but
314        // strings and stream bodies inside the compressed objects are
315        // NOT separately encrypted ("In an encrypted file (i.e., entire
316        // object stream is encrypted), strings occurring anywhere in
317        // an object stream shall not be separately encrypted." —
318        // §7.5.7). So the partition has to happen first, leaving the
319        // compressible bodies cleartext while the kept (non-objstm)
320        // objects still go through per-object encryption below.
321        let mut objects_to_emit: Vec<IndirectObject> = self.objects.clone();
322        let mut compressed_map: std::collections::HashMap<u32, (u32, u32)> =
323            std::collections::HashMap::new();
324        let objstm_id_opt: Option<ObjectId> = if self.object_stream {
325            let mut compressible: Vec<IndirectObject> = Vec::new();
326            let mut keep: Vec<IndirectObject> = Vec::new();
327            for ind in objects_to_emit.drain(..) {
328                let is_stream = matches!(ind.object, Object::Stream(_));
329                let is_root = ind.id == root;
330                if !is_stream && !is_root {
331                    compressible.push(ind);
332                } else {
333                    keep.push(ind);
334                }
335            }
336            objects_to_emit = keep;
337
338            if compressible.is_empty() {
339                None
340            } else {
341                // Allocate a fresh id for the ObjStm container past
342                // every existing object id.
343                let max_kept = objects_to_emit
344                    .iter()
345                    .map(|o| o.id.number)
346                    .max()
347                    .unwrap_or(0);
348                let max_compressed = compressible.iter().map(|o| o.id.number).max().unwrap_or(0);
349                // Reserve room for an Encrypt id past max_kept too,
350                // because encryption assigns the Encrypt id from
351                // `objects_to_emit.iter().map(id).max() + 1` after we
352                // return — bump by 2 here so the ObjStm id and any
353                // future Encrypt id can both be placed without clash.
354                let mut next_id = max_kept
355                    .max(max_compressed)
356                    .max(self.next_id.saturating_sub(1))
357                    + 1;
358                if self.encryption.is_some() {
359                    // Leave one id slot for the /Encrypt indirect
360                    // object that gets allocated below.
361                    next_id += 1;
362                }
363                let objstm_id = ObjectId::new(next_id);
364
365                // §7.5.7: header is a whitespace-separated sequence of
366                // `obj_num offset` decimal pairs (offsets relative to
367                // /First, the start of the body region in the *decoded*
368                // stream); body is the concatenation of each compressed
369                // object's serialised form (no wrappers).
370                let mut bodies: Vec<Vec<u8>> = Vec::with_capacity(compressible.len());
371                for ind in &compressible {
372                    let mut b = Vec::new();
373                    write_object(&mut b, &ind.object).map_err(PdfError::Io)?;
374                    bodies.push(b);
375                }
376                let mut header = String::new();
377                let mut running = 0usize;
378                for (ind, body) in compressible.iter().zip(bodies.iter()) {
379                    if !header.is_empty() {
380                        header.push(' ');
381                    }
382                    header.push_str(&format!("{} {}", ind.id.number, running));
383                    running += body.len();
384                }
385                header.push(' ');
386
387                let header_bytes = header.into_bytes();
388                let first = header_bytes.len();
389                let n_compressed = compressible.len();
390
391                let mut payload =
392                    Vec::with_capacity(first + bodies.iter().map(|b| b.len()).sum::<usize>());
393                payload.extend_from_slice(&header_bytes);
394                for body in &bodies {
395                    payload.extend_from_slice(body);
396                }
397
398                let compressed = flate_compress(&payload);
399
400                let dict = Dict::new()
401                    .with("Type", Object::Name("ObjStm".into()))
402                    .with("N", Object::Integer(n_compressed as i64))
403                    .with("First", Object::Integer(first as i64))
404                    .with("Filter", Object::Name("FlateDecode".into()));
405
406                objects_to_emit.push(IndirectObject {
407                    id: objstm_id,
408                    object: Object::Stream(Stream::new(dict, compressed)),
409                });
410
411                for (idx, ind) in compressible.into_iter().enumerate() {
412                    compressed_map.insert(ind.id.number, (objstm_id.number, idx as u32));
413                }
414
415                Some(objstm_id)
416            }
417        } else {
418            None
419        };
420
421        // ---- Encryption -------------------------------------------------
422        // Per-object encryption now runs on the kept set only (the
423        // ObjStm container Stream is in `objects_to_emit` and gets its
424        // body encrypted as a unit; the compressed bodies inside it are
425        // NOT separately encrypted — §7.5.7). The Encrypt indirect
426        // object itself is NOT encrypted (§7.6.1).
427        let encrypt_id_opt: Option<ObjectId> = if let Some(state) = &self.encryption {
428            let max_id_now = objects_to_emit
429                .iter()
430                .map(|o| o.id.number)
431                .max()
432                .unwrap_or(0);
433            let id = ObjectId::new(max_id_now + 1);
434            for ind in &mut objects_to_emit {
435                encrypt_object_in_place(&mut ind.object, ind.id, state)?;
436            }
437            objects_to_emit.push(IndirectObject {
438                id,
439                object: Object::Dict(state.encrypt_dict.clone()),
440            });
441            Some(id)
442        } else {
443            None
444        };
445
446        // ---- XRef-stream branch: the cross-reference itself is an
447        // indirect object so we have to allocate its id BEFORE we
448        // emit the body (its offset depends on its position, but its
449        // own xref entry has to know its id to record itself). We
450        // pre-allocate the id and add a placeholder Stream that the
451        // post-body fix-up populates with the real binary table.
452        let xref_stream_id_opt: Option<ObjectId> = if self.xref_stream {
453            let mut max_existing = objects_to_emit
454                .iter()
455                .map(|o| o.id.number)
456                .max()
457                .unwrap_or(0);
458            // Account for any previously-allocated next_id (incremental
459            // updates) — the xref stream's id must not clash with an
460            // id that was reserved but not yet committed.
461            if self.next_id.saturating_sub(1) > max_existing {
462                max_existing = self.next_id - 1;
463            }
464            // Account for compressed-object ids (they're not in
465            // `objects_to_emit` after the ObjStm-packing drain, but
466            // they still occupy id slots in the cross-reference).
467            if let Some(top) = compressed_map.keys().max() {
468                if *top > max_existing {
469                    max_existing = *top;
470                }
471            }
472            Some(ObjectId::new(max_existing + 1))
473        } else {
474            None
475        };
476
477        // ---- Body -----------------------------------------------------
478        // Sort by id so the xref subsection slot table lines up neatly.
479        objects_to_emit.sort_by_key(|o| o.id.number);
480
481        // Offsets[i] = byte offset of the indirect object whose id is
482        // (i+1). Slot 0 of the xref is reserved for the head of the
483        // free list (always entry `0000000000 65535 f`).
484        let body_max_id = objects_to_emit
485            .last()
486            .map(|o| o.id.number as usize)
487            .unwrap_or(0);
488        let mut max_id = match xref_stream_id_opt {
489            Some(id) => id.number as usize,
490            None => body_max_id,
491        };
492        // Compressed-only ids might exceed body_max_id when the ObjStm
493        // container has fewer ids than the original objects.
494        if let Some(top) = compressed_map.keys().max() {
495            max_id = max_id.max(*top as usize);
496        }
497        // Honour the requested minimum (incremental updates: trailer
498        // /Size must be at least the previous revision's value).
499        if let Some(min) = self.min_size {
500            if (min as usize) > max_id + 1 {
501                max_id = (min as usize).saturating_sub(1);
502            }
503        }
504        let mut offsets: Vec<u64> = vec![0; max_id + 1];
505
506        for ind in &objects_to_emit {
507            let off = out.len() as u64;
508            offsets[ind.id.number as usize] = off;
509            write_indirect(out, ind).map_err(PdfError::Io)?;
510        }
511
512        // ---- Cross-reference + trailer -------------------------------
513        let xref_off = out.len() as u64;
514        // Build the subsection list — for a one-shot write, this is
515        // [(0, max_id+1)]; for an incremental update, only the changed
516        // ids land in subsections (plus id 0 if not already excluded).
517        let subsections: Vec<(u32, u32)> = match &self.xref_only_ids {
518            Some(ids) => Self::group_into_subsections(ids),
519            None => vec![(0, (max_id + 1) as u32)],
520        };
521
522        if let Some(xref_stream_id) = xref_stream_id_opt {
523            // Record the xref stream's own offset in the entry table.
524            offsets[xref_stream_id.number as usize] = xref_off;
525            self.write_xref_stream(
526                out,
527                xref_stream_id,
528                root,
529                encrypt_id_opt,
530                objstm_id_opt,
531                &offsets,
532                &compressed_map,
533                max_id,
534                &subsections,
535            )?;
536        } else {
537            out.extend_from_slice(b"xref\n");
538            for (start, count) in &subsections {
539                let header_line = format!("{} {}\n", start, count);
540                out.extend_from_slice(header_line.as_bytes());
541                for id in *start..(*start + *count) {
542                    if id == 0 {
543                        // Free-list head — slot 0 always 0..f.
544                        out.extend_from_slice(b"0000000000 65535 f \n");
545                    } else {
546                        let off = offsets.get(id as usize).copied().unwrap_or(0);
547                        // 10-digit zero-padded byte offset, 5-digit
548                        // zero-padded generation, 'n' (in-use), exact
549                        // two-character newline terminator per §7.5.4.
550                        let line = format!("{:010} {:05} n \n", off, 0);
551                        out.extend_from_slice(line.as_bytes());
552                    }
553                }
554            }
555            out.extend_from_slice(b"trailer\n");
556            let trailer_dict = self.build_trailer_dict(root, encrypt_id_opt, max_id);
557            let trailer = Object::Dict(trailer_dict);
558            write_object(out, &trailer).map_err(PdfError::Io)?;
559            out.extend_from_slice(b"\n");
560        }
561        out.extend_from_slice(b"startxref\n");
562        out.extend_from_slice(format!("{}\n", xref_off).as_bytes());
563        out.extend_from_slice(b"%%EOF\n");
564
565        Ok(())
566    }
567
568    /// Group a sorted list of ids into contiguous `(start, count)`
569    /// subsections. Used by the incremental-update path to emit only
570    /// the changed slots in the new xref section. Id 0 is always
571    /// included so the free-list head is rewritten on every revision.
572    fn group_into_subsections(ids: &[u32]) -> Vec<(u32, u32)> {
573        let mut all = Vec::with_capacity(ids.len() + 1);
574        all.push(0);
575        all.extend_from_slice(ids);
576        all.sort_unstable();
577        all.dedup();
578        let mut out: Vec<(u32, u32)> = Vec::new();
579        let mut iter = all.iter().copied();
580        let Some(mut start) = iter.next() else {
581            return out;
582        };
583        let mut prev = start;
584        let mut count: u32 = 1;
585        for v in iter {
586            if v == prev + 1 {
587                count += 1;
588            } else {
589                out.push((start, count));
590                start = v;
591                count = 1;
592            }
593            prev = v;
594        }
595        out.push((start, count));
596        out
597    }
598
599    /// Build the trailer dict shared between the classical-xref and
600    /// xref-stream emission paths. Carries `/Size`, `/Root`, optional
601    /// `/Info`, and (when encrypted) `/Encrypt` + `/ID`. When
602    /// `prev_xref_offset` is set (incremental update),
603    /// `/Prev <prev_off>` is emitted so a reader can chain back to
604    /// the previous revision's cross-reference section per
605    /// ISO 32000-1 §7.5.6.
606    fn build_trailer_dict(
607        &self,
608        root: ObjectId,
609        encrypt_id_opt: Option<ObjectId>,
610        max_id: usize,
611    ) -> Dict {
612        let mut trailer_dict = Dict::new()
613            .with("Size", Object::Integer((max_id + 1) as i64))
614            .with("Root", Object::Reference(root));
615        if let Some(info_id) = self.info {
616            trailer_dict.set("Info", Object::Reference(info_id));
617        }
618        if let Some(prev) = self.prev_xref_offset {
619            trailer_dict.set("Prev", Object::Integer(prev as i64));
620        }
621        if let (Some(eid), Some(state)) = (encrypt_id_opt, &self.encryption) {
622            trailer_dict.set("Encrypt", Object::Reference(eid));
623            // /ID is required when /Encrypt is present (§7.5.5 +
624            // §7.6.3.3). We emit ID[0] == ID[1] (no incremental
625            // updates → both halves point to the same permanent
626            // identifier).
627            let id_array = Object::Array(vec![
628                Object::LiteralString(state.file_id.clone()),
629                Object::LiteralString(state.file_id.clone()),
630            ]);
631            trailer_dict.set("ID", id_array);
632        }
633        trailer_dict
634    }
635
636    /// Emit a PDF 1.5+ cross-reference stream (ISO 32000-1 §7.5.8).
637    /// `offsets[i]` is the byte offset of the indirect object whose
638    /// id is `i`; slot 0 is the free-list head and is encoded as
639    /// `(0, 0, 65535)` per §7.5.8.3 (Type 0 entry). Compressed-object
640    /// entries (type 2) come from `compressed_map[id] = (container,
641    /// index)` and override the type-1 default.
642    #[allow(clippy::too_many_arguments)]
643    fn write_xref_stream(
644        &self,
645        out: &mut Vec<u8>,
646        xref_id: ObjectId,
647        root: ObjectId,
648        encrypt_id_opt: Option<ObjectId>,
649        objstm_id_opt: Option<ObjectId>,
650        offsets: &[u64],
651        compressed_map: &std::collections::HashMap<u32, (u32, u32)>,
652        max_id: usize,
653        subsections: &[(u32, u32)],
654    ) -> Result<(), PdfError> {
655        // Field widths: type=1 byte, offset=4 bytes (handles ≤4 GiB),
656        // generation=2 bytes. PDFs above 4 GiB would need w[1]=8; we
657        // guard against the overflow rather than silently truncating.
658        const W: [usize; 3] = [1, 4, 2];
659        let entry_width = W[0] + W[1] + W[2];
660
661        // Collect every id we're emitting an entry for, in subsection
662        // order. The /Index array on the stream dict mirrors
663        // `subsections` exactly.
664        let mut emit_ids: Vec<u32> = Vec::new();
665        for (start, count) in subsections {
666            for id in *start..(*start + *count) {
667                emit_ids.push(id);
668            }
669        }
670        let n_entries = emit_ids.len();
671        let mut raw_table = Vec::with_capacity(n_entries * entry_width);
672
673        for id in &emit_ids {
674            if *id == 0 {
675                // Slot 0 — free-list head. Type 0, next=0, gen=65535.
676                raw_table.push(0);
677                raw_table.extend_from_slice(&0u32.to_be_bytes());
678                raw_table.extend_from_slice(&65535u16.to_be_bytes());
679            } else if let Some((container, idx)) = compressed_map.get(id).copied() {
680                // Type 2 — compressed inside an ObjStm container.
681                raw_table.push(2);
682                raw_table.extend_from_slice(&container.to_be_bytes());
683                raw_table.extend_from_slice(&(idx as u16).to_be_bytes());
684            } else {
685                let off = offsets.get(*id as usize).copied().unwrap_or(0);
686                if off > u32::MAX as u64 {
687                    return Err(PdfError::other(format!(
688                        "Document::write_xref_stream: object {id} offset {off} exceeds 32-bit\
689                         limit — bump /W[1] to 8 bytes"
690                    )));
691                }
692                raw_table.push(1);
693                raw_table.extend_from_slice(&(off as u32).to_be_bytes());
694                raw_table.extend_from_slice(&0u16.to_be_bytes());
695            }
696        }
697
698        // PNG-Up forward predictor (tag 2): each row is `entry[i] -
699        // prev[i]` (mod 256). Match the round-6 reader's reversal.
700        let mut predicted = Vec::with_capacity(n_entries * (entry_width + 1));
701        let mut prev = vec![0u8; entry_width];
702        for chunk in raw_table.chunks_exact(entry_width) {
703            predicted.push(0x02); // PNG-Up tag.
704            for i in 0..entry_width {
705                predicted.push(chunk[i].wrapping_sub(prev[i]));
706            }
707            prev.copy_from_slice(chunk);
708        }
709
710        // FlateDecode the predicted bytes.
711        let compressed = flate_compress(&predicted);
712
713        // Build the xref-stream dict — fold trailer entries in.
714        let trailer_dict = self.build_trailer_dict(root, encrypt_id_opt, max_id);
715        let mut index_array: Vec<Object> = Vec::with_capacity(subsections.len() * 2);
716        for (start, count) in subsections {
717            index_array.push(Object::Integer(*start as i64));
718            index_array.push(Object::Integer(*count as i64));
719        }
720        let mut stream_dict = Dict::new()
721            .with("Type", Object::Name("XRef".into()))
722            .with("Filter", Object::Name("FlateDecode".into()))
723            .with(
724                "DecodeParms",
725                Object::Dict(
726                    Dict::new()
727                        .with("Predictor", Object::Integer(12))
728                        .with("Columns", Object::Integer(entry_width as i64)),
729                ),
730            )
731            .with(
732                "W",
733                Object::Array(vec![
734                    Object::Integer(W[0] as i64),
735                    Object::Integer(W[1] as i64),
736                    Object::Integer(W[2] as i64),
737                ]),
738            )
739            .with("Index", Object::Array(index_array));
740        // Copy trailer fields (Size, Root, Info, Prev, Encrypt, ID).
741        for (k, v) in trailer_dict.entries() {
742            stream_dict.set(k, v.clone());
743        }
744
745        let stream = Stream::new(stream_dict, compressed);
746        let indirect = IndirectObject {
747            id: xref_id,
748            object: Object::Stream(stream),
749        };
750        write_indirect(out, &indirect).map_err(PdfError::Io)?;
751        // Suppress unused-variable warning when `objstm_id_opt` is set
752        // but the caller doesn't need it here — kept on the signature
753        // so future revisions (per-stream encryption opt-out for the
754        // ObjStm container itself) can read it without re-threading.
755        let _ = objstm_id_opt;
756        Ok(())
757    }
758}
759
760/// FlateDecode helper shared between the xref-stream encoder and any
761/// future stream-compression call sites in this module. Keeping it
762/// inline avoids a circular import on `resources::flate_compress`.
763fn flate_compress(input: &[u8]) -> Vec<u8> {
764    crate::zlib::flate_compress(input)
765}
766
767/// Recursively encrypt every literal/hex string and stream payload in
768/// `obj`, in place, using the per-object key derivation associated with
769/// `id`. Numeric / boolean / name / reference values pass through
770/// unchanged. Nested dicts and arrays are walked recursively so nested
771/// strings (e.g. `/Title (...)` inside an /Info dict) are encrypted.
772///
773/// Streams whose first `/Filter` is `/Crypt` with a `/Name /Identity`
774/// crypt-filter parm (or a missing parm — the default Name is
775/// `/Identity` per §7.4.10 Table 24) are explicitly NOT encrypted —
776/// this is the §7.6.5 opt-out for "this stream is intentionally
777/// cleartext even though the rest of the file is encrypted".
778fn encrypt_object_in_place(
779    obj: &mut Object,
780    id: ObjectId,
781    state: &EncryptionState,
782) -> Result<(), PdfError> {
783    match obj {
784        Object::LiteralString(s) | Object::HexString(s) => {
785            *s = state.handler.encrypt_object(id, s, &state.aes_iv)?;
786        }
787        Object::Array(items) => {
788            for item in items {
789                encrypt_object_in_place(item, id, state)?;
790            }
791        }
792        Object::Dict(d) => {
793            encrypt_dict_in_place(d, id, state)?;
794        }
795        Object::Stream(s) => {
796            // Recurse into the dict for any nested string values.
797            encrypt_dict_in_place(&mut s.dict, id, state)?;
798            // §7.6.5 opt-out: /Filter /Crypt + /DecodeParms /Name
799            // /Identity → leave the body untouched.
800            if has_identity_crypt_filter(&s.dict) {
801                return Ok(());
802            }
803            // Encrypt the stream body. Note: per §7.6.1, the
804            // body-already-Filter-encoded layer is what gets encrypted —
805            // FlateDecode etc. are applied first, then the bytes are
806            // ciphered.
807            s.data = state.handler.encrypt_object(id, &s.data, &state.aes_iv)?;
808        }
809        _ => {}
810    }
811    Ok(())
812}
813
814/// Match a stream-dict shape that opts out of per-stream encryption
815/// per ISO 32000-1 §7.6.5. Mirror of the reader-side detector in
816/// [`crate::reader::document`]; kept private here so the encoder
817/// doesn't need to round-trip through the reader.
818fn has_identity_crypt_filter(dict: &Dict) -> bool {
819    let filter = dict
820        .entries()
821        .iter()
822        .find(|(k, _)| k == "Filter")
823        .map(|(_, v)| v);
824    let parms = dict
825        .entries()
826        .iter()
827        .find(|(k, _)| k == "DecodeParms")
828        .map(|(_, v)| v);
829    let crypt_pos: Option<usize> = match filter {
830        Some(Object::Name(s)) if s == "Crypt" => Some(0),
831        Some(Object::Array(items)) => items
832            .iter()
833            .position(|f| matches!(f, Object::Name(n) if n == "Crypt")),
834        _ => None,
835    };
836    let Some(idx) = crypt_pos else {
837        return false;
838    };
839    let parms_dict = match parms {
840        Some(Object::Dict(d)) if idx == 0 => Some(d.clone()),
841        Some(Object::Array(items)) => match items.get(idx) {
842            Some(Object::Dict(d)) => Some(d.clone()),
843            _ => None,
844        },
845        _ => None,
846    };
847    let Some(d) = parms_dict else {
848        return true;
849    };
850    match d
851        .entries()
852        .iter()
853        .find(|(k, _)| k == "Name")
854        .map(|(_, v)| v)
855    {
856        Some(Object::Name(s)) => s == "Identity",
857        None => true,
858        _ => false,
859    }
860}
861
862fn encrypt_dict_in_place(
863    d: &mut Dict,
864    id: ObjectId,
865    state: &EncryptionState,
866) -> Result<(), PdfError> {
867    let mut new_entries: Vec<(String, Object)> = Vec::with_capacity(d.entries().len());
868    for (k, v) in d.entries() {
869        let mut v = v.clone();
870        encrypt_object_in_place(&mut v, id, state)?;
871        new_entries.push((k.clone(), v));
872    }
873    *d = Dict::default();
874    for (k, v) in new_entries {
875        d.set(&k, v);
876    }
877    Ok(())
878}
879
880/// Crate-private re-export of [`write_object`] for the linearize
881/// module — it serialises one [`Object`] body into a byte buffer using
882/// exactly the same shape as [`Document::write_to`] does internally
883/// (so /Length on streams gets auto-patched, etc.). Kept private to
884/// the crate so external callers don't depend on the writer's
885/// internals.
886pub(crate) fn write_object_to(out: &mut Vec<u8>, obj: &Object) -> io::Result<()> {
887    write_object(out, obj)
888}
889
890/// Drain every [`IndirectObject`] from `doc` into a fresh `Vec`,
891/// in insertion order. Used by the linearize module to capture the
892/// gradient / image sub-objects allocated by
893/// [`crate::resources::ResourceCollector::flatten_into_resources_dict`]
894/// without having to re-implement that walker.
895pub(crate) fn take_objects(doc: &mut Document) -> Vec<IndirectObject> {
896    std::mem::take(&mut doc.objects)
897}
898
899fn write_indirect(out: &mut Vec<u8>, ind: &IndirectObject) -> io::Result<()> {
900    let header = format!("{} {} obj\n", ind.id.number, ind.id.generation);
901    out.write_all(header.as_bytes())?;
902    write_object(out, &ind.object)?;
903    out.write_all(b"\nendobj\n")?;
904    Ok(())
905}
906
907fn write_object(out: &mut Vec<u8>, obj: &Object) -> io::Result<()> {
908    match obj {
909        Object::Null => out.write_all(b"null"),
910        Object::Bool(b) => out.write_all(if *b { b"true" } else { b"false" }),
911        Object::Integer(n) => out.write_all(format!("{}", n).as_bytes()),
912        Object::Real(f) => out.write_all(format_real(*f).as_bytes()),
913        Object::Name(s) => {
914            out.write_all(b"/")?;
915            // Per §7.3.5, characters 0x21..=0x7E that are not delimiters
916            // are emitted verbatim; everything else uses #xx hex
917            // escapes. Round-1 callers only generate names from a
918            // closed alphabet (Page, Pages, Catalog, GS<n>, Pat<n>,
919            // Im<n>, etc.) so the loop almost always falls through to
920            // the verbatim path — but the escape is here for safety.
921            for &b in s.as_bytes() {
922                let needs_escape = matches!(
923                    b,
924                    0x00..=0x20 | 0x23 | 0x25 | 0x28 | 0x29 | 0x2F | 0x3C | 0x3E | 0x5B | 0x5D
925                        | 0x7B | 0x7D | 0x7F..=0xFF
926                );
927                if needs_escape {
928                    out.write_all(format!("#{:02X}", b).as_bytes())?;
929                } else {
930                    out.write_all(&[b])?;
931                }
932            }
933            Ok(())
934        }
935        Object::LiteralString(bytes) => {
936            out.write_all(b"(")?;
937            for &b in bytes {
938                match b {
939                    b'\\' => out.write_all(br"\\")?,
940                    b'(' => out.write_all(br"\(")?,
941                    b')' => out.write_all(br"\)")?,
942                    b'\n' => out.write_all(br"\n")?,
943                    b'\r' => out.write_all(br"\r")?,
944                    b'\t' => out.write_all(br"\t")?,
945                    _ => out.write_all(&[b])?,
946                }
947            }
948            out.write_all(b")")
949        }
950        Object::HexString(bytes) => {
951            out.write_all(b"<")?;
952            for b in bytes {
953                out.write_all(format!("{:02X}", b).as_bytes())?;
954            }
955            out.write_all(b">")
956        }
957        Object::Array(items) => {
958            out.write_all(b"[")?;
959            for (i, it) in items.iter().enumerate() {
960                if i > 0 {
961                    out.write_all(b" ")?;
962                }
963                write_object(out, it)?;
964            }
965            out.write_all(b"]")
966        }
967        Object::Dict(d) => write_dict(out, d),
968        Object::Reference(id) => {
969            out.write_all(format!("{} {} R", id.number, id.generation).as_bytes())
970        }
971        Object::Stream(s) => {
972            // Always patch /Length to match the payload — this is the
973            // only field the serializer owns; everything else (filters,
974            // type, image params) was set by the caller.
975            let mut d = s.dict.clone();
976            d.set("Length", Object::Integer(s.data.len() as i64));
977            write_dict(out, &d)?;
978            // Per §7.3.8.1, `stream` keyword followed by an EOL marker
979            // (CRLF or just LF) is required; the data starts at the
980            // byte right after the marker. Use LF — single byte is
981            // legal and keeps the output more compact than CRLF.
982            out.write_all(b"\nstream\n")?;
983            out.write_all(&s.data)?;
984            // The data must be followed by an EOL before `endstream`
985            // (whether the data already ends with one or not).
986            out.write_all(b"\nendstream")
987        }
988    }
989}
990
991fn write_dict(out: &mut Vec<u8>, d: &Dict) -> io::Result<()> {
992    out.write_all(b"<<")?;
993    for (k, v) in &d.entries {
994        out.write_all(b" /")?;
995        out.write_all(k.as_bytes())?;
996        out.write_all(b" ")?;
997        write_object(out, v)?;
998    }
999    out.write_all(b" >>")
1000}
1001
1002/// Round-30 sig-writer entry point — serialise one [`Dict`] into a
1003/// caller-provided buffer using the same byte sequence the document
1004/// writer emits. Used by the `/Sig` writer's incremental-update
1005/// section so the appended-revision objects (Catalog override,
1006/// AcroForm, Sig field) come out byte-stable with the rest of the
1007/// file.
1008pub fn write_dict_to(out: &mut Vec<u8>, d: &Dict) -> Result<(), PdfError> {
1009    write_dict(out, d).map_err(PdfError::Io)
1010}
1011
1012/// Format a PDF real number per §7.3.3: no scientific notation,
1013/// trailing zeros trimmed, integer values written without a decimal
1014/// point. Bounded fractional precision keeps the output compact.
1015fn format_real(f: f64) -> String {
1016    if !f.is_finite() {
1017        // PDF has no Inf/NaN representation; clamp to 0 — the alternative
1018        // would be to refuse to write, but that would force every
1019        // gradient/transform call site to validate float inputs first.
1020        return "0".to_string();
1021    }
1022    if f.fract() == 0.0 && f.abs() < 1e16 {
1023        // Integer-valued — emit without a fractional component.
1024        return format!("{}", f as i64);
1025    }
1026    // 6 digits of fractional precision is what most PDF writers use
1027    // (matches qpdf's default). Trim trailing zeros to keep streams
1028    // small; never leave a bare trailing dot.
1029    let s = format!("{:.6}", f);
1030    let trimmed = s.trim_end_matches('0').trim_end_matches('.');
1031    if trimmed.is_empty() || trimmed == "-" {
1032        "0".to_string()
1033    } else {
1034        trimmed.to_string()
1035    }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041
1042    fn write_one(obj: &Object) -> Vec<u8> {
1043        let mut buf = Vec::new();
1044        write_object(&mut buf, obj).unwrap();
1045        buf
1046    }
1047
1048    #[test]
1049    fn primitives_serialize() {
1050        assert_eq!(write_one(&Object::Null), b"null");
1051        assert_eq!(write_one(&Object::Bool(true)), b"true");
1052        assert_eq!(write_one(&Object::Bool(false)), b"false");
1053        assert_eq!(write_one(&Object::Integer(42)), b"42");
1054        assert_eq!(write_one(&Object::Integer(-7)), b"-7");
1055    }
1056
1057    #[test]
1058    fn real_numbers_have_no_trailing_zeros() {
1059        assert_eq!(write_one(&Object::Real(0.0)), b"0");
1060        assert_eq!(write_one(&Object::Real(1.0)), b"1");
1061        assert_eq!(write_one(&Object::Real(0.5)), b"0.5");
1062        assert_eq!(write_one(&Object::Real(-1.25)), b"-1.25");
1063        assert_eq!(write_one(&Object::Real(2.345678987654)), b"2.345679");
1064    }
1065
1066    #[test]
1067    fn names_are_slash_prefixed() {
1068        assert_eq!(write_one(&Object::Name("Pages".into())), b"/Pages");
1069        // Whitespace gets escaped.
1070        let escaped = write_one(&Object::Name("a b".into()));
1071        assert_eq!(escaped, b"/a#20b");
1072    }
1073
1074    #[test]
1075    fn arrays_have_space_separated_items() {
1076        let a = Object::Array(vec![
1077            Object::Integer(1),
1078            Object::Integer(2),
1079            Object::Real(0.5),
1080        ]);
1081        assert_eq!(write_one(&a), b"[1 2 0.5]");
1082    }
1083
1084    #[test]
1085    fn dicts_iterate_in_insertion_order() {
1086        let d = Dict::new()
1087            .with("Type", Object::Name("Pages".into()))
1088            .with("Count", Object::Integer(1));
1089        assert_eq!(write_one(&Object::Dict(d)), b"<< /Type /Pages /Count 1 >>");
1090    }
1091
1092    #[test]
1093    fn streams_serialize_with_length() {
1094        let body = b"hello".to_vec();
1095        let s = Stream::new(Dict::new(), body);
1096        let bytes = write_one(&Object::Stream(s));
1097        let needle = b"/Length 5";
1098        assert!(
1099            bytes.windows(needle.len()).any(|w| w == needle),
1100            "expected /Length 5 in {:?}",
1101            String::from_utf8_lossy(&bytes)
1102        );
1103        assert!(bytes.windows(7).any(|w| w == b"stream\n"));
1104        assert!(bytes.windows(9).any(|w| w == b"endstream"));
1105    }
1106
1107    #[test]
1108    fn document_writes_full_pdf_envelope() {
1109        let mut doc = Document::new();
1110        let pages_id = doc.allocate_id();
1111        let catalog = Object::Dict(
1112            Dict::new()
1113                .with("Type", Object::Name("Catalog".into()))
1114                .with("Pages", Object::Reference(pages_id)),
1115        );
1116        let catalog_id = doc.add(catalog);
1117        doc.add_object(
1118            pages_id,
1119            Object::Dict(
1120                Dict::new()
1121                    .with("Type", Object::Name("Pages".into()))
1122                    .with("Count", Object::Integer(0))
1123                    .with("Kids", Object::Array(Vec::new())),
1124            ),
1125        );
1126        doc.root = Some(catalog_id);
1127
1128        let mut bytes = Vec::new();
1129        doc.write_to(&mut bytes).unwrap();
1130        assert!(bytes.starts_with(b"%PDF-1.4\n"));
1131        assert!(bytes.ends_with(b"%%EOF\n"));
1132        assert!(bytes.windows(5).any(|w| w == b"xref\n"));
1133        assert!(bytes.windows(8).any(|w| w == b"trailer\n"));
1134        assert!(bytes.windows(10).any(|w| w == b"startxref\n"));
1135    }
1136}