Skip to main content

pdfboss_write/
assemble.rs

1//! Merging documents into one fresh output (ISO 32000 §7.7.3, page tree):
2//! selected pages from each source, gathered in argument order under a
3//! single new `/Pages` node.
4
5use pdfboss_core::{Dict, Document, Name, Object};
6#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
7use pdfboss_core::{Encryptor, Permissions};
8
9use crate::error::{Error, Result};
10use crate::importer::Importer;
11use crate::pdf::Metadata;
12use crate::update::{
13    catalog_metadata_ref, core_error, merge_metadata, resolve_dict, xmp_metadata_stream,
14};
15use crate::writer::{WriteOptions, Writer};
16
17/// Assembles `inputs` into one document: each source's selected pages
18/// (`None` takes every page), gathered in argument order under a fresh
19/// `/Pages` node. The catalog and page tree are new; no `/Info` is set and
20/// no `/ID` is inherited (the writer derives its own from the emitted
21/// content). Document-level trees of the inputs -- outlines, names,
22/// optional content -- are not carried, since only individual pages are
23/// imported. A locked input is refused, the same way a lone import would
24/// be; an already password-opened encrypted input copies its plaintext
25/// content across like any unencrypted source.
26pub fn merge_documents(
27    inputs: &[(&Document, Option<&[usize]>)],
28    options: WriteOptions,
29) -> Result<Vec<u8>> {
30    let mut writer = Writer::new(options);
31    let pages_ref = writer.reserve();
32    let mut kids = Vec::new();
33    for (source, selection) in inputs {
34        let mut importer = Importer::new(&mut writer, source)?;
35        let indices: Vec<usize> = match selection {
36            Some(indices) => indices.to_vec(),
37            None => (0..source.page_count()).collect(),
38        };
39        for index in indices {
40            kids.push(importer.page(index, pages_ref)?);
41        }
42    }
43    if kids.is_empty() {
44        return Err(Error::Other(
45            "a document needs at least one page".to_string(),
46        ));
47    }
48    let mut tree = Dict::new();
49    tree.insert(name("Type"), Object::Name(name("Pages")));
50    tree.insert(
51        name("Kids"),
52        Object::Array(kids.iter().copied().map(Object::Ref).collect()),
53    );
54    tree.insert(name("Count"), Object::Int(kids.len() as i64));
55    writer.fill(pages_ref, Object::Dict(tree))?;
56    let mut catalog = Dict::new();
57    catalog.insert(name("Type"), Object::Name(name("Catalog")));
58    catalog.insert(name("Pages"), Object::Ref(pages_ref));
59    let root = writer.put(Object::Dict(catalog));
60    writer.finish(root)
61}
62
63/// A `Name` from a string literal.
64fn name(text: &str) -> Name {
65    Name(text.to_string())
66}
67
68/// Rewrites `doc` fresh, like [`merge_documents`] but keeping the whole
69/// document rather than assembling selected pages into a new tree: every
70/// object the catalog and `/Info` reach is copied over, and each of
71/// `pages` (0-based indices) gets its own leaf dictionary substituted with
72/// `/Rotate` set to its current effective rotation plus `by`, normalized
73/// with `rem_euclid(360)`. Substitution keys by the source object
74/// reference, so a selected page with no object of its own (inlined
75/// directly into `/Kids`) is refused, naming its 1-based page number:
76/// pdfboss does not yet restructure such a page into one with its own
77/// object. `by` must be a multiple of 90; anything else is refused before
78/// any object is copied.
79pub fn rotate_rewrite(
80    doc: &Document,
81    pages: &[usize],
82    by: i32,
83    options: WriteOptions,
84) -> Result<Vec<u8>> {
85    if by % 90 != 0 {
86        return Err(Error::Other(
87            "rotation must be a multiple of 90 degrees".to_string(),
88        ));
89    }
90    let mut writer = Writer::new(options);
91    let mut importer = Importer::new(&mut writer, doc)?;
92    let new_info = doc
93        .xref()
94        .trailer
95        .get_ref("Info")
96        .map(|info| importer.reference(info));
97    for &index in pages {
98        let page = doc.page(index).map_err(core_error)?;
99        let Some(page_ref) = page.object_ref() else {
100            return Err(Error::Other(format!(
101                "page {} is inlined into /Kids and cannot be edited in place; \
102                 pdfboss does not yet restructure such pages to rotate them",
103                index + 1
104            )));
105        };
106        let mut dict = page.dict().clone();
107        let rotate = (page.rotate + by).rem_euclid(360);
108        dict.insert(name("Rotate"), Object::Int(i64::from(rotate)));
109        let body = importer.copy(&Object::Dict(dict))?;
110        importer.substitute(page_ref, body);
111    }
112    let new_root = importer.document()?;
113    if let Some(new_info) = new_info {
114        writer.set_info(new_info);
115    }
116    writer.finish(new_root)
117}
118
119/// The whole document through the [`Writer`]: recompressed, object streams
120/// per `options`, unreachable objects and earlier update sections left
121/// behind. Carries `/Info` along the same way [`rotate_rewrite`] does: it
122/// is a trailer key `Importer::document` alone can never reach, since
123/// nothing in the catalog's own graph points at it.
124pub fn rewrite_document(doc: &Document, options: WriteOptions) -> Result<Vec<u8>> {
125    rewrite_into(Writer::new(options), doc)
126}
127
128/// Shared by [`rewrite_document`] and [`encrypt_document`]: the whole
129/// reachable graph from `doc`'s catalog copied into `writer`, already
130/// constructed plain or encrypting, carrying `/Info` along the same way
131/// [`rotate_rewrite`] does.
132fn rewrite_into(mut writer: Writer, doc: &Document) -> Result<Vec<u8>> {
133    let mut importer = Importer::new(&mut writer, doc)?;
134    let new_info = doc
135        .xref()
136        .trailer
137        .get_ref("Info")
138        .map(|info| importer.reference(info));
139    let new_root = importer.document()?;
140    if let Some(new_info) = new_info {
141        writer.set_info(new_info);
142    }
143    writer.finish(new_root)
144}
145
146/// [`rewrite_document`], writing through an encrypting [`Writer`] instead
147/// of a plain one: every copied string and stream is AES-256 protected
148/// under `user_password` and `owner_password` (ISO 32000-2 §7.6.4.3), with
149/// `permissions` as the restrictions a reader opening under the user
150/// password is granted. An empty `owner_password` falls back to
151/// `user_password`; both empty is refused, since neither password would
152/// then protect the file at all. `doc` is refused when
153/// [`Document::is_locked`], the same refusal [`Importer::new`] already
154/// raises. An already password-opened encrypted `doc` is fine: its
155/// content already reads as plaintext through `Document::get`, so it
156/// copies across like any unencrypted source and gets encrypted afresh
157/// under the new passwords.
158///
159/// Not available on `wasm32-unknown-unknown`: it builds its `Encryptor`
160/// with [`Encryptor::aes256`], which needs the operating system's random
161/// source. Construct an `Encryptor` with `Encryptor::aes256_with_rng` and
162/// [`Writer::new_encrypted`] directly there instead.
163#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
164pub fn encrypt_document(
165    doc: &Document,
166    user_password: &str,
167    owner_password: &str,
168    permissions: Permissions,
169    options: WriteOptions,
170) -> Result<Vec<u8>> {
171    if user_password.is_empty() && owner_password.is_empty() {
172        return Err(Error::Other(
173            "user_password and owner_password cannot both be empty".to_string(),
174        ));
175    }
176    let owner_password = if owner_password.is_empty() {
177        user_password
178    } else {
179        owner_password
180    };
181    let (encryptor, encrypt_dict) = Encryptor::aes256(user_password, owner_password, permissions);
182    rewrite_into(Writer::new_encrypted(options, encryptor, encrypt_dict), doc)
183}
184
185/// [`rewrite_document`], named for the decryption it performs when `doc`
186/// was opened under a password: its content already reads as plaintext
187/// through `Document::get`, and the fresh [`Writer`] this builds carries
188/// no `/Encrypt` of its own, so the output is plainly unencrypted no
189/// matter what protected the input. Refuses a [`Document::is_locked`]
190/// `doc` exactly as [`Importer::new`] does, a second safeguard behind
191/// `Document::load_with_password`'s own refusal of a wrong or missing
192/// password: that refusal happens before a `Document` exists, so a locked
193/// one never reaches this function through the public load path in the
194/// first place.
195pub fn decrypt_document(doc: &Document, options: WriteOptions) -> Result<Vec<u8>> {
196    rewrite_document(doc, options)
197}
198
199/// [`rewrite_document`], first replacing `/Info` (and, when the catalog
200/// names one, the XMP packet) with `meta` merged over whatever the base
201/// already carried: the same merge [`crate::update::set_metadata_with`]
202/// performs for an appended update, applied here as substitutions into the
203/// copied graph instead. An existing `/Info` object is translated into the
204/// target's own numbering via [`Importer::copy`] before the substitution
205/// (`resolve_dict` only chases a value's own top-level reference, so a
206/// nested or unresolvable one still names a source object; `copy`
207/// translates it correctly, the same pattern [`rotate_rewrite`] uses for a
208/// page body). A base with no `/Info` gets a fresh one put directly into
209/// the writer, since there is no source object to substitute into and
210/// nothing in a freshly built dict can name one.
211pub fn rewrite_with_metadata(
212    doc: &Document,
213    meta: Metadata,
214    options: WriteOptions,
215) -> Result<Vec<u8>> {
216    let mut writer = Writer::new(options);
217    let mut importer = Importer::new(&mut writer, doc)?;
218    let trailer = &doc.xref().trailer;
219    let root = trailer.get_ref("Root").ok_or(Error::MissingRoot)?;
220    let info_ref = trailer.get_ref("Info");
221    let existing_dict = info_ref.and_then(|r| {
222        let dict = doc.get(r).ok()?.as_dict()?.clone();
223        Some(resolve_dict(doc, &dict))
224    });
225    let xmp_ref = catalog_metadata_ref(doc, root);
226    let (dict, merged) = merge_metadata(existing_dict, &meta);
227
228    let new_info_target = match info_ref {
229        Some(r) => {
230            let target = importer.reference(r);
231            let body = importer.copy(&Object::Dict(dict.clone()))?;
232            importer.substitute(r, body);
233            Some(target)
234        }
235        None => None,
236    };
237    if let Some(r) = xmp_ref {
238        importer.substitute(r, xmp_metadata_stream(&merged));
239    }
240
241    let new_root = importer.document()?;
242    let new_info = match new_info_target {
243        Some(target) => target,
244        None => writer.put(Object::Dict(dict)),
245    };
246    writer.set_info(new_info);
247    writer.finish(new_root)
248}
249
250/// Consecutive chunks of `every` pages, each a fresh document. `every` must
251/// be at least 1; the last chunk carries whatever remains, so no chunk is
252/// ever empty.
253pub fn split_document(doc: &Document, every: usize, options: WriteOptions) -> Result<Vec<Vec<u8>>> {
254    if every == 0 {
255        return Err(Error::Other(
256            "every must be at least 1 page per part".to_string(),
257        ));
258    }
259    let total = doc.page_count();
260    let mut parts = Vec::new();
261    let mut start = 0;
262    while start < total {
263        let end = (start + every).min(total);
264        let indices: Vec<usize> = (start..end).collect();
265        parts.push(merge_documents(&[(doc, Some(&indices))], options)?);
266        start = end;
267    }
268    Ok(parts)
269}
270
271#[cfg(test)]
272mod tests {
273    use pdfboss_core::xref::{parse_section_at, startxref, XrefEntry};
274    use pdfboss_output::{extract_text, ReadingOrder};
275    use pdfboss_testkit::{encrypted_rc4_doc, multi_page_doc, PdfBuilder};
276
277    use crate::pdf::{Metadata, Page, PageSize, Pdf};
278    use crate::update::Update;
279    use crate::writer::XrefStyle;
280
281    use super::*;
282
283    #[test]
284    fn merge_keeps_sources_in_argument_order() {
285        let a = Document::load(multi_page_doc(&["a1", "a2"])).expect("doc a loads");
286        let b = Document::load(multi_page_doc(&["b1", "b2"])).expect("doc b loads");
287        let bytes = merge_documents(&[(&a, None), (&b, None)], WriteOptions::default())
288            .expect("merge succeeds");
289        let merged = Document::load(bytes).expect("merged document loads");
290        assert_eq!(merged.page_count(), 4);
291        let texts: Vec<String> = (0..4)
292            .map(|i| {
293                let page = merged.page(i).expect("page exists");
294                extract_text(&merged, &page, ReadingOrder::Content).expect("text extracts")
295            })
296            .collect();
297        assert!(texts[0].contains("a1"), "page 0: {:?}", texts[0]);
298        assert!(texts[1].contains("a2"), "page 1: {:?}", texts[1]);
299        assert!(texts[2].contains("b1"), "page 2: {:?}", texts[2]);
300        assert!(texts[3].contains("b2"), "page 3: {:?}", texts[3]);
301    }
302
303    #[test]
304    fn a_range_selects_and_reorders_pages() {
305        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).expect("doc loads");
306        let bytes = merge_documents(&[(&doc, Some(&[2, 0]))], WriteOptions::default())
307            .expect("merge succeeds");
308        let merged = Document::load(bytes).expect("merged document loads");
309        assert_eq!(merged.page_count(), 2);
310        let first = merged.page(0).expect("first page exists");
311        let second = merged.page(1).expect("second page exists");
312        assert!(extract_text(&merged, &first, ReadingOrder::Content)
313            .unwrap()
314            .contains("three"));
315        assert!(extract_text(&merged, &second, ReadingOrder::Content)
316            .unwrap()
317            .contains("one"));
318    }
319
320    /// The fixture's empty user password opens transparently, so `doc`
321    /// carries a working decryptor and is not locked: `merge_documents`
322    /// now accepts it, copying its already-decrypted content across like
323    /// any unencrypted source, and the copied page's text still reads
324    /// correctly in the plain output.
325    #[test]
326    fn merge_documents_accepts_a_password_opened_encrypted_source() {
327        let doc = Document::load(encrypted_rc4_doc("secret")).expect("empty-password doc loads");
328        let bytes = merge_documents(&[(&doc, None)], WriteOptions::default())
329            .expect("a password-opened encrypted source is not locked, so merge succeeds");
330        let merged = Document::load(bytes).expect("merged document loads");
331        assert!(
332            !merged.is_encrypted(),
333            "the merged output carries no /Encrypt"
334        );
335        let page = merged.page(0).expect("page 0 exists");
336        let text = extract_text(&merged, &page, ReadingOrder::Content).expect("text extracts");
337        assert!(text.contains("secret"), "{text:?}");
338    }
339
340    #[test]
341    fn split_makes_parts_of_the_requested_size_and_a_shorter_last_part() {
342        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).expect("doc loads");
343        let parts = split_document(&doc, 2, WriteOptions::default()).expect("split succeeds");
344        assert_eq!(parts.len(), 2);
345
346        let first = Document::load(parts[0].clone()).expect("first part loads");
347        assert_eq!(first.page_count(), 2);
348        let texts: Vec<String> = (0..2)
349            .map(|i| {
350                let page = first.page(i).expect("page exists");
351                extract_text(&first, &page, ReadingOrder::Content).expect("text extracts")
352            })
353            .collect();
354        assert!(texts[0].contains("one"), "page 0: {:?}", texts[0]);
355        assert!(texts[1].contains("two"), "page 1: {:?}", texts[1]);
356
357        let second = Document::load(parts[1].clone()).expect("second part loads");
358        assert_eq!(second.page_count(), 1);
359        let page = second.page(0).expect("page exists");
360        let text = extract_text(&second, &page, ReadingOrder::Content).expect("text extracts");
361        assert!(text.contains("three"), "page 0: {:?}", text);
362    }
363
364    #[test]
365    fn split_larger_than_the_page_count_makes_one_part() {
366        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).expect("doc loads");
367        let parts = split_document(&doc, 10, WriteOptions::default()).expect("split succeeds");
368        assert_eq!(parts.len(), 1);
369        let only = Document::load(parts[0].clone()).expect("part loads");
370        assert_eq!(only.page_count(), 3);
371    }
372
373    #[test]
374    fn split_rejects_zero_pages_per_part_with_an_honest_message() {
375        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).expect("doc loads");
376        let result = split_document(&doc, 0, WriteOptions::default());
377        let Err(Error::Other(message)) = result else {
378            panic!("expected Error::Other, got {result:?}");
379        };
380        assert!(message.contains("every"), "message: {message}");
381    }
382
383    /// Rotating pages 1 and 3 of a three-page document by 90 degrees
384    /// clockwise substitutes each page's own object with its effective
385    /// rotation plus 90, leaving the untouched page at 0. Unlike the
386    /// append path, the whole document is copied fresh.
387    #[test]
388    fn rotate_rewrite_rotates_the_selected_pages() {
389        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).expect("doc loads");
390        let bytes =
391            rotate_rewrite(&doc, &[0, 2], 90, WriteOptions::default()).expect("rotate succeeds");
392        let rotated = Document::load(bytes).expect("rotated document loads");
393        for (index, expected) in [90, 0, 90].iter().enumerate() {
394            let page = rotated.page(index).expect("page exists");
395            assert_eq!(page.rotate, *expected, "page {index}");
396        }
397    }
398
399    /// A page inlined directly into `/Kids`, with no object of its own, has
400    /// no reference to substitute a rewritten body onto: `rotate_rewrite`
401    /// refuses it, naming its 1-based page number, rather than silently
402    /// leaving it unrotated.
403    #[test]
404    fn rotate_rewrite_refuses_an_inline_page() {
405        let mut b = PdfBuilder::new();
406        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
407        b.object(
408            2,
409            "<< /Type /Pages /Count 1 /Kids [ << /Type /Page /Parent 2 0 R \
410             /MediaBox [0 0 612 792] >> ] >>",
411        );
412        let doc = Document::load(b.build(1)).expect("doc loads");
413
414        let result = rotate_rewrite(&doc, &[0], 90, WriteOptions::default());
415        let Err(Error::Other(message)) = result else {
416            panic!("expected Error::Other, got {result:?}");
417        };
418        assert!(message.contains("page 1"), "message: {message}");
419        assert!(
420            message.contains("cannot be edited in place"),
421            "message: {message}"
422        );
423        assert!(
424            message.contains("does not yet restructure"),
425            "message: {message}"
426        );
427    }
428
429    /// A `by` that is not a multiple of 90 is refused before any object is
430    /// copied, rather than silently truncated or wrapped into a confusing
431    /// rotation.
432    #[test]
433    fn rotate_rewrite_refuses_a_non_multiple_of_90() {
434        let doc = Document::load(multi_page_doc(&["one"])).expect("doc loads");
435        let result = rotate_rewrite(&doc, &[0], 45, WriteOptions::default());
436        let Err(Error::Other(message)) = result else {
437            panic!("expected Error::Other, got {result:?}");
438        };
439        assert!(message.contains("multiple of 90"), "message: {message}");
440    }
441
442    /// A negative multiple of 90 stays legal: `rem_euclid(360)` normalizes
443    /// it into the usual 0..360 range instead of refusing it.
444    #[test]
445    fn rotate_rewrite_accepts_a_negative_multiple_of_90() {
446        let doc = Document::load(multi_page_doc(&["one"])).expect("doc loads");
447        let bytes =
448            rotate_rewrite(&doc, &[0], -90, WriteOptions::default()).expect("rotate succeeds");
449        let rotated = Document::load(bytes).expect("rotated document loads");
450        let page = rotated.page(0).expect("page exists");
451        assert_eq!(page.rotate, 270);
452    }
453
454    /// A rewrite carries `/Info` along: the reloaded catalog's trailer
455    /// still resolves an `/Info` dictionary, and its `/Title` still reads
456    /// the base document's title after rotation.
457    #[test]
458    fn rotate_rewrite_carries_info_along() {
459        let base = Pdf {
460            pages: vec![Page::new(PageSize::A4)],
461            metadata: Some(Metadata {
462                title: Some("Rotated Title".to_string()),
463                ..Metadata::default()
464            }),
465            ..Pdf::default()
466        }
467        .to_bytes()
468        .expect("base builds");
469        let doc = Document::load(base).expect("base loads");
470        assert!(
471            doc.xref().trailer.get_ref("Info").is_some(),
472            "the base's trailer must carry /Info for this test to exercise the carry"
473        );
474
475        let bytes =
476            rotate_rewrite(&doc, &[0], 90, WriteOptions::default()).expect("rotate succeeds");
477        let rotated = Document::load(bytes).expect("rotated document loads");
478        assert!(
479            rotated.xref().trailer.get_ref("Info").is_some(),
480            "the rewritten trailer still names an /Info dictionary"
481        );
482        assert_eq!(rotated.metadata().title.as_deref(), Some("Rotated Title"));
483    }
484
485    /// Counts non-free cross-reference entries: the objects a document
486    /// actually carries, whether stored directly or packed into an object
487    /// stream.
488    fn live_count(doc: &Document) -> usize {
489        doc.xref()
490            .iter()
491            .filter(|(_, entry)| !matches!(entry, XrefEntry::Free))
492            .count()
493    }
494
495    /// A rewrite carries `/Info` along, the same way [`rotate_rewrite`]
496    /// does: the reloaded trailer still resolves an `/Info` dictionary, and
497    /// its `/Title` still reads the base document's title.
498    #[test]
499    fn rewrite_document_carries_info_along() {
500        let base = Pdf {
501            pages: vec![Page::new(PageSize::A4)],
502            metadata: Some(Metadata {
503                title: Some("Rewritten Title".to_string()),
504                ..Metadata::default()
505            }),
506            ..Pdf::default()
507        }
508        .to_bytes()
509        .expect("base builds");
510        let doc = Document::load(base).expect("base loads");
511        assert!(
512            doc.xref().trailer.get_ref("Info").is_some(),
513            "the base's trailer must carry /Info for this test to exercise the carry"
514        );
515
516        let bytes = rewrite_document(&doc, WriteOptions::default()).expect("rewrite succeeds");
517        let rewritten = Document::load(bytes).expect("rewritten document loads");
518        assert!(
519            rewritten.xref().trailer.get_ref("Info").is_some(),
520            "the rewritten trailer still names an /Info dictionary"
521        );
522        assert_eq!(
523            rewritten.metadata().title.as_deref(),
524            Some("Rewritten Title")
525        );
526    }
527
528    /// A rewrite recomputes the whole object graph from the catalog and
529    /// `/Info` alone: an object neither one reaches is dropped, even though
530    /// the base carried it, and the pages that remain still read back.
531    #[test]
532    fn rewrite_document_drops_an_unreferenced_object_and_keeps_text() {
533        let options = WriteOptions {
534            xref: XrefStyle::Table,
535            compress: false,
536            object_streams: false,
537            version: (1, 7),
538        };
539        let mut b = PdfBuilder::new();
540        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
541        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
542        b.object(
543            3,
544            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
545             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
546        );
547        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (hello) Tj ET");
548        b.object(
549            5,
550            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
551        );
552        b.object(6, "<< /Extra (unreferenced) >>");
553        let doc = Document::load(b.build(1)).expect("doc loads");
554        assert_eq!(
555            live_count(&doc),
556            6,
557            "the fixture carries the extra object alongside the reachable five"
558        );
559
560        let bytes = rewrite_document(&doc, options).expect("rewrite succeeds");
561        let rewritten = Document::load(bytes).expect("rewritten document loads");
562        assert_eq!(
563            live_count(&rewritten),
564            5,
565            "the unreferenced object must not survive the rewrite"
566        );
567
568        let page = rewritten.page(0).expect("page exists");
569        let text = extract_text(&rewritten, &page, ReadingOrder::Content).expect("text extracts");
570        assert!(text.contains("hello"), "text: {text:?}");
571    }
572
573    /// A base already carrying an appended update section (two
574    /// cross-reference sections chained by `/Prev`) collapses to one fresh
575    /// section: a rewrite always builds a whole new graph, never an append
576    /// of its own.
577    #[test]
578    fn rewrite_document_collapses_an_appended_update_into_one_section() {
579        let base = Pdf {
580            pages: vec![Page::new(PageSize::A4)],
581            ..Pdf::default()
582        }
583        .to_bytes()
584        .expect("base builds");
585        let base_doc = Document::load(base).expect("base loads");
586        let mut update = Update::new(&base_doc).expect("update opens");
587        let extra = update.reserve();
588        let mut dict = Dict::new();
589        dict.insert(name("Marker"), Object::Int(1));
590        update.set(extra, Object::Dict(dict));
591        let appended = update.bytes().expect("update appends");
592
593        let control_offset = startxref(&appended).expect("startxref present in the input");
594        let control_section =
595            parse_section_at(&appended, control_offset).expect("input section parses");
596        assert!(
597            control_section.prev.is_some(),
598            "the input must really carry a /Prev chain for this test to exercise the collapse"
599        );
600
601        let appended_doc = Document::load(appended).expect("appended document loads");
602        let bytes =
603            rewrite_document(&appended_doc, WriteOptions::default()).expect("rewrite succeeds");
604
605        let offset = startxref(&bytes).expect("startxref present");
606        let section = parse_section_at(&bytes, offset).expect("section parses");
607        assert!(
608            section.prev.is_none(),
609            "the rewrite must collapse the update chain into one section"
610        );
611    }
612
613    /// Rewriting with metadata merges `meta`'s `Some` fields over whatever
614    /// the base already carried, the same as an appended `set_metadata`,
615    /// but into a whole fresh file: the output cannot start with the
616    /// base's own bytes, since there is no append to preserve a prefix of.
617    #[test]
618    fn rewrite_with_metadata_merges_fields_into_a_fresh_file() {
619        let base = Pdf {
620            pages: vec![Page::new(PageSize::A4)],
621            metadata: Some(Metadata {
622                title: Some("Old".to_string()),
623                author: Some("Keep".to_string()),
624                ..Metadata::default()
625            }),
626            ..Pdf::default()
627        }
628        .to_bytes()
629        .expect("base builds");
630        let doc = Document::load(base.clone()).expect("base loads");
631
632        let bytes = rewrite_with_metadata(
633            &doc,
634            Metadata {
635                title: Some("New".to_string()),
636                ..Metadata::default()
637            },
638            WriteOptions::default(),
639        )
640        .expect("rewrite succeeds");
641        assert!(
642            !bytes.starts_with(&base[..]),
643            "a metadata rewrite must not merely append an update onto the base"
644        );
645
646        let rewritten = Document::load(bytes).expect("rewritten document loads");
647        let meta = rewritten.metadata();
648        assert_eq!(meta.title.as_deref(), Some("New"));
649        assert_eq!(meta.author.as_deref(), Some("Keep"));
650
651        let new_root = rewritten
652            .xref()
653            .trailer
654            .get_ref("Root")
655            .expect("rewritten trailer names /Root");
656        let catalog = rewritten.get(new_root).expect("catalog resolves");
657        let metadata_ref = catalog
658            .as_dict()
659            .expect("catalog is a dictionary")
660            .get_ref("Metadata")
661            .expect("the base's XMP packet must still be named");
662        let stream = rewritten
663            .get(metadata_ref)
664            .expect("metadata stream resolves");
665        let text = String::from_utf8(
666            stream
667                .as_stream()
668                .expect("metadata is a stream")
669                .data
670                .clone(),
671        )
672        .expect("packet is utf-8");
673        assert!(text.contains("New"), "packet: {text}");
674        assert!(text.contains("Keep"), "packet: {text}");
675        assert!(!text.contains("Old"), "packet: {text}");
676    }
677
678    /// A base with no `/Info` at all still gets one from
679    /// `rewrite_with_metadata`: the merge target is a fresh object put
680    /// directly into the writer, never an `Importer` substitution. A base
681    /// with no XMP packet either must not gain one: `set_metadata_with`'s
682    /// own rule (never build a fresh packet where none existed) applies
683    /// here too.
684    #[test]
685    fn rewrite_with_metadata_creates_info_when_absent() {
686        let base = Pdf {
687            pages: vec![Page::new(PageSize::A4)],
688            ..Pdf::default()
689        }
690        .to_bytes()
691        .expect("base builds");
692        let doc = Document::load(base).expect("base loads");
693
694        let bytes = rewrite_with_metadata(
695            &doc,
696            Metadata {
697                title: Some("Fresh".to_string()),
698                ..Metadata::default()
699            },
700            WriteOptions::default(),
701        )
702        .expect("rewrite succeeds");
703
704        let rewritten = Document::load(bytes).expect("rewritten document loads");
705        assert_eq!(rewritten.metadata().title.as_deref(), Some("Fresh"));
706
707        let new_root = rewritten
708            .xref()
709            .trailer
710            .get_ref("Root")
711            .expect("rewritten trailer names /Root");
712        let catalog = rewritten.get(new_root).expect("catalog resolves");
713        assert!(
714            catalog
715                .as_dict()
716                .expect("catalog is a dictionary")
717                .get("Metadata")
718                .is_none(),
719            "a base with no XMP packet must not gain one from a metadata rewrite"
720        );
721    }
722
723    /// A kept `/Info` value stored as an indirect reference must be
724    /// translated into the target's own numbering, not carried verbatim:
725    /// `resolve_dict` only chases a value's own top-level reference chain,
726    /// so the merged dict still names the source object directly, and
727    /// `Importer::substitute` fills bodies verbatim with no renumbering of
728    /// its own. Left untranslated, the raw source number would alias
729    /// whatever the target happens to number the same in the rewritten
730    /// file.
731    #[test]
732    fn rewrite_with_metadata_translates_a_kept_indirect_info_value() {
733        let mut w = Writer::new(WriteOptions {
734            xref: XrefStyle::Table,
735            ..WriteOptions::default()
736        });
737        let pages_root = w.reserve();
738        let page = w.reserve();
739
740        let mut page_dict = Dict::new();
741        page_dict.insert(name("Type"), Object::Name(name("Page")));
742        page_dict.insert(name("Parent"), Object::Ref(pages_root));
743        page_dict.insert(name("Resources"), Object::Dict(Dict::new()));
744        page_dict.insert(
745            name("MediaBox"),
746            Object::Array(vec![
747                Object::Int(0),
748                Object::Int(0),
749                Object::Int(612),
750                Object::Int(792),
751            ]),
752        );
753        w.fill(page, Object::Dict(page_dict))
754            .expect("page slot fills");
755
756        let mut pages_dict = Dict::new();
757        pages_dict.insert(name("Type"), Object::Name(name("Pages")));
758        pages_dict.insert(name("Kids"), Object::Array(vec![Object::Ref(page)]));
759        pages_dict.insert(name("Count"), Object::Int(1));
760        w.fill(pages_root, Object::Dict(pages_dict))
761            .expect("pages slot fills");
762
763        let title_ref = w.put(Object::String(b"Indirect Title".to_vec()));
764        let mut info = Dict::new();
765        info.insert(name("Title"), Object::Ref(title_ref));
766        let info_ref = w.put(Object::Dict(info));
767        w.set_info(info_ref);
768
769        let mut catalog = Dict::new();
770        catalog.insert(name("Type"), Object::Name(name("Catalog")));
771        catalog.insert(name("Pages"), Object::Ref(pages_root));
772        let root = w.put(Object::Dict(catalog));
773        let base = w.finish(root).expect("base finishes");
774        let doc = Document::load(base).expect("base loads");
775
776        let bytes = rewrite_with_metadata(
777            &doc,
778            Metadata {
779                author: Some("New Author".to_string()),
780                ..Metadata::default()
781            },
782            WriteOptions::default(),
783        )
784        .expect("rewrite succeeds");
785
786        let rewritten = Document::load(bytes).expect("rewritten document loads");
787        let meta = rewritten.metadata();
788        assert_eq!(
789            meta.title.as_deref(),
790            Some("Indirect Title"),
791            "a kept indirect /Info value must translate rather than alias"
792        );
793        assert_eq!(meta.author.as_deref(), Some("New Author"));
794
795        let page = rewritten.page(0).expect("page still resolves");
796        assert_eq!(
797            page.dict().get_name("Type"),
798            Some(&Name("Page".to_string())),
799            "the page object must not have been aliased by an untranslated /Info reference"
800        );
801    }
802
803    /// `resolve_dict` resolves a key's own top-level reference chain, but
804    /// never recurses into a value that is itself an array or a nested
805    /// dictionary: a reference held inside one survives the merge
806    /// untouched, still naming a source object. `rewrite_with_metadata`
807    /// must translate it into the target's own numbering rather than
808    /// substituting it verbatim, or the raw source number would alias
809    /// whatever the rewrite happens to number the same.
810    #[test]
811    fn rewrite_with_metadata_translates_a_reference_nested_in_an_info_value() {
812        let mut w = Writer::new(WriteOptions {
813            xref: XrefStyle::Table,
814            ..WriteOptions::default()
815        });
816        let pages_root = w.reserve();
817        let page = w.reserve();
818
819        let mut page_dict = Dict::new();
820        page_dict.insert(name("Type"), Object::Name(name("Page")));
821        page_dict.insert(name("Parent"), Object::Ref(pages_root));
822        page_dict.insert(name("Resources"), Object::Dict(Dict::new()));
823        page_dict.insert(
824            name("MediaBox"),
825            Object::Array(vec![
826                Object::Int(0),
827                Object::Int(0),
828                Object::Int(612),
829                Object::Int(792),
830            ]),
831        );
832        w.fill(page, Object::Dict(page_dict))
833            .expect("page slot fills");
834
835        let mut pages_dict = Dict::new();
836        pages_dict.insert(name("Type"), Object::Name(name("Pages")));
837        pages_dict.insert(name("Kids"), Object::Array(vec![Object::Ref(page)]));
838        pages_dict.insert(name("Count"), Object::Int(1));
839        w.fill(pages_root, Object::Dict(pages_dict))
840            .expect("pages slot fills");
841
842        let witness = w.put(Object::String(b"Witness".to_vec()));
843        let mut info = Dict::new();
844        info.insert(name("Title"), Object::String(b"Plain Title".to_vec()));
845        info.insert(
846            name("CustomRefs"),
847            Object::Array(vec![Object::Ref(witness)]),
848        );
849        let info_ref = w.put(Object::Dict(info));
850        w.set_info(info_ref);
851
852        let mut catalog = Dict::new();
853        catalog.insert(name("Type"), Object::Name(name("Catalog")));
854        catalog.insert(name("Pages"), Object::Ref(pages_root));
855        let root = w.put(Object::Dict(catalog));
856        let base = w.finish(root).expect("base finishes");
857        let doc = Document::load(base).expect("base loads");
858
859        let bytes = rewrite_with_metadata(
860            &doc,
861            Metadata {
862                author: Some("New Author".to_string()),
863                ..Metadata::default()
864            },
865            WriteOptions::default(),
866        )
867        .expect("rewrite succeeds");
868
869        let rewritten = Document::load(bytes).expect("rewritten document loads");
870        let new_info_ref = rewritten
871            .xref()
872            .trailer
873            .get_ref("Info")
874            .expect("rewritten trailer names /Info");
875        let info_dict = rewritten.get(new_info_ref).expect("info resolves");
876        let custom = info_dict
877            .as_dict()
878            .expect("info is a dictionary")
879            .get("CustomRefs")
880            .expect("CustomRefs survives the merge, untouched by the recognized fields");
881        let Object::Array(items) = custom else {
882            panic!("CustomRefs must still be an array, got {custom:?}");
883        };
884        let Object::Ref(witness_target) = items[0] else {
885            panic!(
886                "CustomRefs[0] must still be a reference, got {:?}",
887                items[0]
888            );
889        };
890        let resolved = rewritten
891            .get(witness_target)
892            .expect("the translated reference must resolve to a real object");
893        assert_eq!(
894            resolved.as_str_bytes(),
895            Some(&b"Witness"[..]),
896            "a reference nested inside an /Info value must translate into the \
897             target's own numbering, not alias whatever the rewrite happens to \
898             number the same"
899        );
900    }
901}