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}