pdfboss-write 2.0.0

PDF creation in pure Rust: COS object writer, content canvas, composed elements and document assembly
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
//! Merging documents into one fresh output (ISO 32000 ยง7.7.3, page tree):
//! selected pages from each source, gathered in argument order under a
//! single new `/Pages` node.

use pdfboss_core::{Dict, Document, Name, Object};
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
use pdfboss_core::{Encryptor, Permissions};

use crate::error::{Error, Result};
use crate::importer::Importer;
use crate::pdf::Metadata;
use crate::update::{
    catalog_metadata_ref, core_error, merge_metadata, resolve_dict, xmp_metadata_stream,
};
use crate::writer::{WriteOptions, Writer};

/// Assembles `inputs` into one document: each source's selected pages
/// (`None` takes every page), gathered in argument order under a fresh
/// `/Pages` node. The catalog and page tree are new; no `/Info` is set and
/// no `/ID` is inherited (the writer derives its own from the emitted
/// content). Document-level trees of the inputs -- outlines, names,
/// optional content -- are not carried, since only individual pages are
/// imported. A locked input is refused, the same way a lone import would
/// be; an already password-opened encrypted input copies its plaintext
/// content across like any unencrypted source.
pub fn merge_documents(
    inputs: &[(&Document, Option<&[usize]>)],
    options: WriteOptions,
) -> Result<Vec<u8>> {
    let mut writer = Writer::new(options);
    let pages_ref = writer.reserve();
    let mut kids = Vec::new();
    for (source, selection) in inputs {
        let mut importer = Importer::new(&mut writer, source)?;
        let indices: Vec<usize> = match selection {
            Some(indices) => indices.to_vec(),
            None => (0..source.page_count()).collect(),
        };
        for index in indices {
            kids.push(importer.page(index, pages_ref)?);
        }
    }
    if kids.is_empty() {
        return Err(Error::Other(
            "a document needs at least one page".to_string(),
        ));
    }
    let mut tree = Dict::new();
    tree.insert(name("Type"), Object::Name(name("Pages")));
    tree.insert(
        name("Kids"),
        Object::Array(kids.iter().copied().map(Object::Ref).collect()),
    );
    tree.insert(name("Count"), Object::Int(kids.len() as i64));
    writer.fill(pages_ref, Object::Dict(tree))?;
    let mut catalog = Dict::new();
    catalog.insert(name("Type"), Object::Name(name("Catalog")));
    catalog.insert(name("Pages"), Object::Ref(pages_ref));
    let root = writer.put(Object::Dict(catalog));
    writer.finish(root)
}

/// A `Name` from a string literal.
fn name(text: &str) -> Name {
    Name(text.to_string())
}

/// Rewrites `doc` fresh, like [`merge_documents`] but keeping the whole
/// document rather than assembling selected pages into a new tree: every
/// object the catalog and `/Info` reach is copied over, and each of
/// `pages` (0-based indices) gets its own leaf dictionary substituted with
/// `/Rotate` set to its current effective rotation plus `by`, normalized
/// with `rem_euclid(360)`. Substitution keys by the source object
/// reference, so a selected page with no object of its own (inlined
/// directly into `/Kids`) is refused, naming its 1-based page number:
/// pdfboss does not yet restructure such a page into one with its own
/// object. `by` must be a multiple of 90; anything else is refused before
/// any object is copied.
pub fn rotate_rewrite(
    doc: &Document,
    pages: &[usize],
    by: i32,
    options: WriteOptions,
) -> Result<Vec<u8>> {
    if by % 90 != 0 {
        return Err(Error::Other(
            "rotation must be a multiple of 90 degrees".to_string(),
        ));
    }
    let mut writer = Writer::new(options);
    let mut importer = Importer::new(&mut writer, doc)?;
    let new_info = doc
        .xref()
        .trailer
        .get_ref("Info")
        .map(|info| importer.reference(info));
    for &index in pages {
        let page = doc.page(index).map_err(core_error)?;
        let Some(page_ref) = page.object_ref() else {
            return Err(Error::Other(format!(
                "page {} is inlined into /Kids and cannot be edited in place; \
                 pdfboss does not yet restructure such pages to rotate them",
                index + 1
            )));
        };
        let mut dict = page.dict().clone();
        let rotate = (page.rotate + by).rem_euclid(360);
        dict.insert(name("Rotate"), Object::Int(i64::from(rotate)));
        let body = importer.copy(&Object::Dict(dict))?;
        importer.substitute(page_ref, body);
    }
    let new_root = importer.document()?;
    if let Some(new_info) = new_info {
        writer.set_info(new_info);
    }
    writer.finish(new_root)
}

/// The whole document through the [`Writer`]: recompressed, object streams
/// per `options`, unreachable objects and earlier update sections left
/// behind. Carries `/Info` along the same way [`rotate_rewrite`] does: it
/// is a trailer key `Importer::document` alone can never reach, since
/// nothing in the catalog's own graph points at it.
pub fn rewrite_document(doc: &Document, options: WriteOptions) -> Result<Vec<u8>> {
    rewrite_into(Writer::new(options), doc)
}

/// Shared by [`rewrite_document`] and [`encrypt_document`]: the whole
/// reachable graph from `doc`'s catalog copied into `writer`, already
/// constructed plain or encrypting, carrying `/Info` along the same way
/// [`rotate_rewrite`] does.
fn rewrite_into(mut writer: Writer, doc: &Document) -> Result<Vec<u8>> {
    let mut importer = Importer::new(&mut writer, doc)?;
    let new_info = doc
        .xref()
        .trailer
        .get_ref("Info")
        .map(|info| importer.reference(info));
    let new_root = importer.document()?;
    if let Some(new_info) = new_info {
        writer.set_info(new_info);
    }
    writer.finish(new_root)
}

/// [`rewrite_document`], writing through an encrypting [`Writer`] instead
/// of a plain one: every copied string and stream is AES-256 protected
/// under `user_password` and `owner_password` (ISO 32000-2 ยง7.6.4.3), with
/// `permissions` as the restrictions a reader opening under the user
/// password is granted. An empty `owner_password` falls back to
/// `user_password`; both empty is refused, since neither password would
/// then protect the file at all. `doc` is refused when
/// [`Document::is_locked`], the same refusal [`Importer::new`] already
/// raises. An already password-opened encrypted `doc` is fine: its
/// content already reads as plaintext through `Document::get`, so it
/// copies across like any unencrypted source and gets encrypted afresh
/// under the new passwords.
///
/// Not available on `wasm32-unknown-unknown`: it builds its `Encryptor`
/// with [`Encryptor::aes256`], which needs the operating system's random
/// source. Construct an `Encryptor` with `Encryptor::aes256_with_rng` and
/// [`Writer::new_encrypted`] directly there instead.
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
pub fn encrypt_document(
    doc: &Document,
    user_password: &str,
    owner_password: &str,
    permissions: Permissions,
    options: WriteOptions,
) -> Result<Vec<u8>> {
    if user_password.is_empty() && owner_password.is_empty() {
        return Err(Error::Other(
            "user_password and owner_password cannot both be empty".to_string(),
        ));
    }
    let owner_password = if owner_password.is_empty() {
        user_password
    } else {
        owner_password
    };
    let (encryptor, encrypt_dict) = Encryptor::aes256(user_password, owner_password, permissions);
    rewrite_into(Writer::new_encrypted(options, encryptor, encrypt_dict), doc)
}

/// [`rewrite_document`], named for the decryption it performs when `doc`
/// was opened under a password: its content already reads as plaintext
/// through `Document::get`, and the fresh [`Writer`] this builds carries
/// no `/Encrypt` of its own, so the output is plainly unencrypted no
/// matter what protected the input. Refuses a [`Document::is_locked`]
/// `doc` exactly as [`Importer::new`] does, a second safeguard behind
/// `Document::load_with_password`'s own refusal of a wrong or missing
/// password: that refusal happens before a `Document` exists, so a locked
/// one never reaches this function through the public load path in the
/// first place.
pub fn decrypt_document(doc: &Document, options: WriteOptions) -> Result<Vec<u8>> {
    rewrite_document(doc, options)
}

/// [`rewrite_document`], first replacing `/Info` (and, when the catalog
/// names one, the XMP packet) with `meta` merged over whatever the base
/// already carried: the same merge [`crate::update::set_metadata_with`]
/// performs for an appended update, applied here as substitutions into the
/// copied graph instead. An existing `/Info` object is translated into the
/// target's own numbering via [`Importer::copy`] before the substitution
/// (`resolve_dict` only chases a value's own top-level reference, so a
/// nested or unresolvable one still names a source object; `copy`
/// translates it correctly, the same pattern [`rotate_rewrite`] uses for a
/// page body). A base with no `/Info` gets a fresh one put directly into
/// the writer, since there is no source object to substitute into and
/// nothing in a freshly built dict can name one.
pub fn rewrite_with_metadata(
    doc: &Document,
    meta: Metadata,
    options: WriteOptions,
) -> Result<Vec<u8>> {
    let mut writer = Writer::new(options);
    let mut importer = Importer::new(&mut writer, doc)?;
    let trailer = &doc.xref().trailer;
    let root = trailer.get_ref("Root").ok_or(Error::MissingRoot)?;
    let info_ref = trailer.get_ref("Info");
    let existing_dict = info_ref.and_then(|r| {
        let dict = doc.get(r).ok()?.as_dict()?.clone();
        Some(resolve_dict(doc, &dict))
    });
    let xmp_ref = catalog_metadata_ref(doc, root);
    let (dict, merged) = merge_metadata(existing_dict, &meta);

    let new_info_target = match info_ref {
        Some(r) => {
            let target = importer.reference(r);
            let body = importer.copy(&Object::Dict(dict.clone()))?;
            importer.substitute(r, body);
            Some(target)
        }
        None => None,
    };
    if let Some(r) = xmp_ref {
        importer.substitute(r, xmp_metadata_stream(&merged));
    }

    let new_root = importer.document()?;
    let new_info = match new_info_target {
        Some(target) => target,
        None => writer.put(Object::Dict(dict)),
    };
    writer.set_info(new_info);
    writer.finish(new_root)
}

/// Consecutive chunks of `every` pages, each a fresh document. `every` must
/// be at least 1; the last chunk carries whatever remains, so no chunk is
/// ever empty.
pub fn split_document(doc: &Document, every: usize, options: WriteOptions) -> Result<Vec<Vec<u8>>> {
    if every == 0 {
        return Err(Error::Other(
            "every must be at least 1 page per part".to_string(),
        ));
    }
    let total = doc.page_count();
    let mut parts = Vec::new();
    let mut start = 0;
    while start < total {
        let end = (start + every).min(total);
        let indices: Vec<usize> = (start..end).collect();
        parts.push(merge_documents(&[(doc, Some(&indices))], options)?);
        start = end;
    }
    Ok(parts)
}

#[cfg(test)]
mod tests {
    use pdfboss_core::xref::{parse_section_at, startxref, XrefEntry};
    use pdfboss_output::{extract_text, ReadingOrder};
    use pdfboss_testkit::{encrypted_rc4_doc, multi_page_doc, PdfBuilder};

    use crate::pdf::{Metadata, Page, PageSize, Pdf};
    use crate::update::Update;
    use crate::writer::XrefStyle;

    use super::*;

    #[test]
    fn merge_keeps_sources_in_argument_order() {
        let a = Document::load(multi_page_doc(&["a1", "a2"])).expect("doc a loads");
        let b = Document::load(multi_page_doc(&["b1", "b2"])).expect("doc b loads");
        let bytes = merge_documents(&[(&a, None), (&b, None)], WriteOptions::default())
            .expect("merge succeeds");
        let merged = Document::load(bytes).expect("merged document loads");
        assert_eq!(merged.page_count(), 4);
        let texts: Vec<String> = (0..4)
            .map(|i| {
                let page = merged.page(i).expect("page exists");
                extract_text(&merged, &page, ReadingOrder::Content).expect("text extracts")
            })
            .collect();
        assert!(texts[0].contains("a1"), "page 0: {:?}", texts[0]);
        assert!(texts[1].contains("a2"), "page 1: {:?}", texts[1]);
        assert!(texts[2].contains("b1"), "page 2: {:?}", texts[2]);
        assert!(texts[3].contains("b2"), "page 3: {:?}", texts[3]);
    }

    #[test]
    fn a_range_selects_and_reorders_pages() {
        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).expect("doc loads");
        let bytes = merge_documents(&[(&doc, Some(&[2, 0]))], WriteOptions::default())
            .expect("merge succeeds");
        let merged = Document::load(bytes).expect("merged document loads");
        assert_eq!(merged.page_count(), 2);
        let first = merged.page(0).expect("first page exists");
        let second = merged.page(1).expect("second page exists");
        assert!(extract_text(&merged, &first, ReadingOrder::Content)
            .unwrap()
            .contains("three"));
        assert!(extract_text(&merged, &second, ReadingOrder::Content)
            .unwrap()
            .contains("one"));
    }

    /// The fixture's empty user password opens transparently, so `doc`
    /// carries a working decryptor and is not locked: `merge_documents`
    /// now accepts it, copying its already-decrypted content across like
    /// any unencrypted source, and the copied page's text still reads
    /// correctly in the plain output.
    #[test]
    fn merge_documents_accepts_a_password_opened_encrypted_source() {
        let doc = Document::load(encrypted_rc4_doc("secret")).expect("empty-password doc loads");
        let bytes = merge_documents(&[(&doc, None)], WriteOptions::default())
            .expect("a password-opened encrypted source is not locked, so merge succeeds");
        let merged = Document::load(bytes).expect("merged document loads");
        assert!(
            !merged.is_encrypted(),
            "the merged output carries no /Encrypt"
        );
        let page = merged.page(0).expect("page 0 exists");
        let text = extract_text(&merged, &page, ReadingOrder::Content).expect("text extracts");
        assert!(text.contains("secret"), "{text:?}");
    }

    #[test]
    fn split_makes_parts_of_the_requested_size_and_a_shorter_last_part() {
        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).expect("doc loads");
        let parts = split_document(&doc, 2, WriteOptions::default()).expect("split succeeds");
        assert_eq!(parts.len(), 2);

        let first = Document::load(parts[0].clone()).expect("first part loads");
        assert_eq!(first.page_count(), 2);
        let texts: Vec<String> = (0..2)
            .map(|i| {
                let page = first.page(i).expect("page exists");
                extract_text(&first, &page, ReadingOrder::Content).expect("text extracts")
            })
            .collect();
        assert!(texts[0].contains("one"), "page 0: {:?}", texts[0]);
        assert!(texts[1].contains("two"), "page 1: {:?}", texts[1]);

        let second = Document::load(parts[1].clone()).expect("second part loads");
        assert_eq!(second.page_count(), 1);
        let page = second.page(0).expect("page exists");
        let text = extract_text(&second, &page, ReadingOrder::Content).expect("text extracts");
        assert!(text.contains("three"), "page 0: {:?}", text);
    }

    #[test]
    fn split_larger_than_the_page_count_makes_one_part() {
        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).expect("doc loads");
        let parts = split_document(&doc, 10, WriteOptions::default()).expect("split succeeds");
        assert_eq!(parts.len(), 1);
        let only = Document::load(parts[0].clone()).expect("part loads");
        assert_eq!(only.page_count(), 3);
    }

    #[test]
    fn split_rejects_zero_pages_per_part_with_an_honest_message() {
        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).expect("doc loads");
        let result = split_document(&doc, 0, WriteOptions::default());
        let Err(Error::Other(message)) = result else {
            panic!("expected Error::Other, got {result:?}");
        };
        assert!(message.contains("every"), "message: {message}");
    }

    /// Rotating pages 1 and 3 of a three-page document by 90 degrees
    /// clockwise substitutes each page's own object with its effective
    /// rotation plus 90, leaving the untouched page at 0. Unlike the
    /// append path, the whole document is copied fresh.
    #[test]
    fn rotate_rewrite_rotates_the_selected_pages() {
        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).expect("doc loads");
        let bytes =
            rotate_rewrite(&doc, &[0, 2], 90, WriteOptions::default()).expect("rotate succeeds");
        let rotated = Document::load(bytes).expect("rotated document loads");
        for (index, expected) in [90, 0, 90].iter().enumerate() {
            let page = rotated.page(index).expect("page exists");
            assert_eq!(page.rotate, *expected, "page {index}");
        }
    }

    /// A page inlined directly into `/Kids`, with no object of its own, has
    /// no reference to substitute a rewritten body onto: `rotate_rewrite`
    /// refuses it, naming its 1-based page number, rather than silently
    /// leaving it unrotated.
    #[test]
    fn rotate_rewrite_refuses_an_inline_page() {
        let mut b = PdfBuilder::new();
        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
        b.object(
            2,
            "<< /Type /Pages /Count 1 /Kids [ << /Type /Page /Parent 2 0 R \
             /MediaBox [0 0 612 792] >> ] >>",
        );
        let doc = Document::load(b.build(1)).expect("doc loads");

        let result = rotate_rewrite(&doc, &[0], 90, WriteOptions::default());
        let Err(Error::Other(message)) = result else {
            panic!("expected Error::Other, got {result:?}");
        };
        assert!(message.contains("page 1"), "message: {message}");
        assert!(
            message.contains("cannot be edited in place"),
            "message: {message}"
        );
        assert!(
            message.contains("does not yet restructure"),
            "message: {message}"
        );
    }

    /// A `by` that is not a multiple of 90 is refused before any object is
    /// copied, rather than silently truncated or wrapped into a confusing
    /// rotation.
    #[test]
    fn rotate_rewrite_refuses_a_non_multiple_of_90() {
        let doc = Document::load(multi_page_doc(&["one"])).expect("doc loads");
        let result = rotate_rewrite(&doc, &[0], 45, WriteOptions::default());
        let Err(Error::Other(message)) = result else {
            panic!("expected Error::Other, got {result:?}");
        };
        assert!(message.contains("multiple of 90"), "message: {message}");
    }

    /// A negative multiple of 90 stays legal: `rem_euclid(360)` normalizes
    /// it into the usual 0..360 range instead of refusing it.
    #[test]
    fn rotate_rewrite_accepts_a_negative_multiple_of_90() {
        let doc = Document::load(multi_page_doc(&["one"])).expect("doc loads");
        let bytes =
            rotate_rewrite(&doc, &[0], -90, WriteOptions::default()).expect("rotate succeeds");
        let rotated = Document::load(bytes).expect("rotated document loads");
        let page = rotated.page(0).expect("page exists");
        assert_eq!(page.rotate, 270);
    }

    /// A rewrite carries `/Info` along: the reloaded catalog's trailer
    /// still resolves an `/Info` dictionary, and its `/Title` still reads
    /// the base document's title after rotation.
    #[test]
    fn rotate_rewrite_carries_info_along() {
        let base = Pdf {
            pages: vec![Page::new(PageSize::A4)],
            metadata: Some(Metadata {
                title: Some("Rotated Title".to_string()),
                ..Metadata::default()
            }),
            ..Pdf::default()
        }
        .to_bytes()
        .expect("base builds");
        let doc = Document::load(base).expect("base loads");
        assert!(
            doc.xref().trailer.get_ref("Info").is_some(),
            "the base's trailer must carry /Info for this test to exercise the carry"
        );

        let bytes =
            rotate_rewrite(&doc, &[0], 90, WriteOptions::default()).expect("rotate succeeds");
        let rotated = Document::load(bytes).expect("rotated document loads");
        assert!(
            rotated.xref().trailer.get_ref("Info").is_some(),
            "the rewritten trailer still names an /Info dictionary"
        );
        assert_eq!(rotated.metadata().title.as_deref(), Some("Rotated Title"));
    }

    /// Counts non-free cross-reference entries: the objects a document
    /// actually carries, whether stored directly or packed into an object
    /// stream.
    fn live_count(doc: &Document) -> usize {
        doc.xref()
            .iter()
            .filter(|(_, entry)| !matches!(entry, XrefEntry::Free))
            .count()
    }

    /// A rewrite carries `/Info` along, the same way [`rotate_rewrite`]
    /// does: the reloaded trailer still resolves an `/Info` dictionary, and
    /// its `/Title` still reads the base document's title.
    #[test]
    fn rewrite_document_carries_info_along() {
        let base = Pdf {
            pages: vec![Page::new(PageSize::A4)],
            metadata: Some(Metadata {
                title: Some("Rewritten Title".to_string()),
                ..Metadata::default()
            }),
            ..Pdf::default()
        }
        .to_bytes()
        .expect("base builds");
        let doc = Document::load(base).expect("base loads");
        assert!(
            doc.xref().trailer.get_ref("Info").is_some(),
            "the base's trailer must carry /Info for this test to exercise the carry"
        );

        let bytes = rewrite_document(&doc, WriteOptions::default()).expect("rewrite succeeds");
        let rewritten = Document::load(bytes).expect("rewritten document loads");
        assert!(
            rewritten.xref().trailer.get_ref("Info").is_some(),
            "the rewritten trailer still names an /Info dictionary"
        );
        assert_eq!(
            rewritten.metadata().title.as_deref(),
            Some("Rewritten Title")
        );
    }

    /// A rewrite recomputes the whole object graph from the catalog and
    /// `/Info` alone: an object neither one reaches is dropped, even though
    /// the base carried it, and the pages that remain still read back.
    #[test]
    fn rewrite_document_drops_an_unreferenced_object_and_keeps_text() {
        let options = WriteOptions {
            xref: XrefStyle::Table,
            compress: false,
            object_streams: false,
            version: (1, 7),
        };
        let mut b = PdfBuilder::new();
        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
        b.object(
            3,
            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
        );
        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (hello) Tj ET");
        b.object(
            5,
            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
        );
        b.object(6, "<< /Extra (unreferenced) >>");
        let doc = Document::load(b.build(1)).expect("doc loads");
        assert_eq!(
            live_count(&doc),
            6,
            "the fixture carries the extra object alongside the reachable five"
        );

        let bytes = rewrite_document(&doc, options).expect("rewrite succeeds");
        let rewritten = Document::load(bytes).expect("rewritten document loads");
        assert_eq!(
            live_count(&rewritten),
            5,
            "the unreferenced object must not survive the rewrite"
        );

        let page = rewritten.page(0).expect("page exists");
        let text = extract_text(&rewritten, &page, ReadingOrder::Content).expect("text extracts");
        assert!(text.contains("hello"), "text: {text:?}");
    }

    /// A base already carrying an appended update section (two
    /// cross-reference sections chained by `/Prev`) collapses to one fresh
    /// section: a rewrite always builds a whole new graph, never an append
    /// of its own.
    #[test]
    fn rewrite_document_collapses_an_appended_update_into_one_section() {
        let base = Pdf {
            pages: vec![Page::new(PageSize::A4)],
            ..Pdf::default()
        }
        .to_bytes()
        .expect("base builds");
        let base_doc = Document::load(base).expect("base loads");
        let mut update = Update::new(&base_doc).expect("update opens");
        let extra = update.reserve();
        let mut dict = Dict::new();
        dict.insert(name("Marker"), Object::Int(1));
        update.set(extra, Object::Dict(dict));
        let appended = update.bytes().expect("update appends");

        let control_offset = startxref(&appended).expect("startxref present in the input");
        let control_section =
            parse_section_at(&appended, control_offset).expect("input section parses");
        assert!(
            control_section.prev.is_some(),
            "the input must really carry a /Prev chain for this test to exercise the collapse"
        );

        let appended_doc = Document::load(appended).expect("appended document loads");
        let bytes =
            rewrite_document(&appended_doc, WriteOptions::default()).expect("rewrite succeeds");

        let offset = startxref(&bytes).expect("startxref present");
        let section = parse_section_at(&bytes, offset).expect("section parses");
        assert!(
            section.prev.is_none(),
            "the rewrite must collapse the update chain into one section"
        );
    }

    /// Rewriting with metadata merges `meta`'s `Some` fields over whatever
    /// the base already carried, the same as an appended `set_metadata`,
    /// but into a whole fresh file: the output cannot start with the
    /// base's own bytes, since there is no append to preserve a prefix of.
    #[test]
    fn rewrite_with_metadata_merges_fields_into_a_fresh_file() {
        let base = Pdf {
            pages: vec![Page::new(PageSize::A4)],
            metadata: Some(Metadata {
                title: Some("Old".to_string()),
                author: Some("Keep".to_string()),
                ..Metadata::default()
            }),
            ..Pdf::default()
        }
        .to_bytes()
        .expect("base builds");
        let doc = Document::load(base.clone()).expect("base loads");

        let bytes = rewrite_with_metadata(
            &doc,
            Metadata {
                title: Some("New".to_string()),
                ..Metadata::default()
            },
            WriteOptions::default(),
        )
        .expect("rewrite succeeds");
        assert!(
            !bytes.starts_with(&base[..]),
            "a metadata rewrite must not merely append an update onto the base"
        );

        let rewritten = Document::load(bytes).expect("rewritten document loads");
        let meta = rewritten.metadata();
        assert_eq!(meta.title.as_deref(), Some("New"));
        assert_eq!(meta.author.as_deref(), Some("Keep"));

        let new_root = rewritten
            .xref()
            .trailer
            .get_ref("Root")
            .expect("rewritten trailer names /Root");
        let catalog = rewritten.get(new_root).expect("catalog resolves");
        let metadata_ref = catalog
            .as_dict()
            .expect("catalog is a dictionary")
            .get_ref("Metadata")
            .expect("the base's XMP packet must still be named");
        let stream = rewritten
            .get(metadata_ref)
            .expect("metadata stream resolves");
        let text = String::from_utf8(
            stream
                .as_stream()
                .expect("metadata is a stream")
                .data
                .clone(),
        )
        .expect("packet is utf-8");
        assert!(text.contains("New"), "packet: {text}");
        assert!(text.contains("Keep"), "packet: {text}");
        assert!(!text.contains("Old"), "packet: {text}");
    }

    /// A base with no `/Info` at all still gets one from
    /// `rewrite_with_metadata`: the merge target is a fresh object put
    /// directly into the writer, never an `Importer` substitution. A base
    /// with no XMP packet either must not gain one: `set_metadata_with`'s
    /// own rule (never build a fresh packet where none existed) applies
    /// here too.
    #[test]
    fn rewrite_with_metadata_creates_info_when_absent() {
        let base = Pdf {
            pages: vec![Page::new(PageSize::A4)],
            ..Pdf::default()
        }
        .to_bytes()
        .expect("base builds");
        let doc = Document::load(base).expect("base loads");

        let bytes = rewrite_with_metadata(
            &doc,
            Metadata {
                title: Some("Fresh".to_string()),
                ..Metadata::default()
            },
            WriteOptions::default(),
        )
        .expect("rewrite succeeds");

        let rewritten = Document::load(bytes).expect("rewritten document loads");
        assert_eq!(rewritten.metadata().title.as_deref(), Some("Fresh"));

        let new_root = rewritten
            .xref()
            .trailer
            .get_ref("Root")
            .expect("rewritten trailer names /Root");
        let catalog = rewritten.get(new_root).expect("catalog resolves");
        assert!(
            catalog
                .as_dict()
                .expect("catalog is a dictionary")
                .get("Metadata")
                .is_none(),
            "a base with no XMP packet must not gain one from a metadata rewrite"
        );
    }

    /// A kept `/Info` value stored as an indirect reference must be
    /// translated into the target's own numbering, not carried verbatim:
    /// `resolve_dict` only chases a value's own top-level reference chain,
    /// so the merged dict still names the source object directly, and
    /// `Importer::substitute` fills bodies verbatim with no renumbering of
    /// its own. Left untranslated, the raw source number would alias
    /// whatever the target happens to number the same in the rewritten
    /// file.
    #[test]
    fn rewrite_with_metadata_translates_a_kept_indirect_info_value() {
        let mut w = Writer::new(WriteOptions {
            xref: XrefStyle::Table,
            ..WriteOptions::default()
        });
        let pages_root = w.reserve();
        let page = w.reserve();

        let mut page_dict = Dict::new();
        page_dict.insert(name("Type"), Object::Name(name("Page")));
        page_dict.insert(name("Parent"), Object::Ref(pages_root));
        page_dict.insert(name("Resources"), Object::Dict(Dict::new()));
        page_dict.insert(
            name("MediaBox"),
            Object::Array(vec![
                Object::Int(0),
                Object::Int(0),
                Object::Int(612),
                Object::Int(792),
            ]),
        );
        w.fill(page, Object::Dict(page_dict))
            .expect("page slot fills");

        let mut pages_dict = Dict::new();
        pages_dict.insert(name("Type"), Object::Name(name("Pages")));
        pages_dict.insert(name("Kids"), Object::Array(vec![Object::Ref(page)]));
        pages_dict.insert(name("Count"), Object::Int(1));
        w.fill(pages_root, Object::Dict(pages_dict))
            .expect("pages slot fills");

        let title_ref = w.put(Object::String(b"Indirect Title".to_vec()));
        let mut info = Dict::new();
        info.insert(name("Title"), Object::Ref(title_ref));
        let info_ref = w.put(Object::Dict(info));
        w.set_info(info_ref);

        let mut catalog = Dict::new();
        catalog.insert(name("Type"), Object::Name(name("Catalog")));
        catalog.insert(name("Pages"), Object::Ref(pages_root));
        let root = w.put(Object::Dict(catalog));
        let base = w.finish(root).expect("base finishes");
        let doc = Document::load(base).expect("base loads");

        let bytes = rewrite_with_metadata(
            &doc,
            Metadata {
                author: Some("New Author".to_string()),
                ..Metadata::default()
            },
            WriteOptions::default(),
        )
        .expect("rewrite succeeds");

        let rewritten = Document::load(bytes).expect("rewritten document loads");
        let meta = rewritten.metadata();
        assert_eq!(
            meta.title.as_deref(),
            Some("Indirect Title"),
            "a kept indirect /Info value must translate rather than alias"
        );
        assert_eq!(meta.author.as_deref(), Some("New Author"));

        let page = rewritten.page(0).expect("page still resolves");
        assert_eq!(
            page.dict().get_name("Type"),
            Some(&Name("Page".to_string())),
            "the page object must not have been aliased by an untranslated /Info reference"
        );
    }

    /// `resolve_dict` resolves a key's own top-level reference chain, but
    /// never recurses into a value that is itself an array or a nested
    /// dictionary: a reference held inside one survives the merge
    /// untouched, still naming a source object. `rewrite_with_metadata`
    /// must translate it into the target's own numbering rather than
    /// substituting it verbatim, or the raw source number would alias
    /// whatever the rewrite happens to number the same.
    #[test]
    fn rewrite_with_metadata_translates_a_reference_nested_in_an_info_value() {
        let mut w = Writer::new(WriteOptions {
            xref: XrefStyle::Table,
            ..WriteOptions::default()
        });
        let pages_root = w.reserve();
        let page = w.reserve();

        let mut page_dict = Dict::new();
        page_dict.insert(name("Type"), Object::Name(name("Page")));
        page_dict.insert(name("Parent"), Object::Ref(pages_root));
        page_dict.insert(name("Resources"), Object::Dict(Dict::new()));
        page_dict.insert(
            name("MediaBox"),
            Object::Array(vec![
                Object::Int(0),
                Object::Int(0),
                Object::Int(612),
                Object::Int(792),
            ]),
        );
        w.fill(page, Object::Dict(page_dict))
            .expect("page slot fills");

        let mut pages_dict = Dict::new();
        pages_dict.insert(name("Type"), Object::Name(name("Pages")));
        pages_dict.insert(name("Kids"), Object::Array(vec![Object::Ref(page)]));
        pages_dict.insert(name("Count"), Object::Int(1));
        w.fill(pages_root, Object::Dict(pages_dict))
            .expect("pages slot fills");

        let witness = w.put(Object::String(b"Witness".to_vec()));
        let mut info = Dict::new();
        info.insert(name("Title"), Object::String(b"Plain Title".to_vec()));
        info.insert(
            name("CustomRefs"),
            Object::Array(vec![Object::Ref(witness)]),
        );
        let info_ref = w.put(Object::Dict(info));
        w.set_info(info_ref);

        let mut catalog = Dict::new();
        catalog.insert(name("Type"), Object::Name(name("Catalog")));
        catalog.insert(name("Pages"), Object::Ref(pages_root));
        let root = w.put(Object::Dict(catalog));
        let base = w.finish(root).expect("base finishes");
        let doc = Document::load(base).expect("base loads");

        let bytes = rewrite_with_metadata(
            &doc,
            Metadata {
                author: Some("New Author".to_string()),
                ..Metadata::default()
            },
            WriteOptions::default(),
        )
        .expect("rewrite succeeds");

        let rewritten = Document::load(bytes).expect("rewritten document loads");
        let new_info_ref = rewritten
            .xref()
            .trailer
            .get_ref("Info")
            .expect("rewritten trailer names /Info");
        let info_dict = rewritten.get(new_info_ref).expect("info resolves");
        let custom = info_dict
            .as_dict()
            .expect("info is a dictionary")
            .get("CustomRefs")
            .expect("CustomRefs survives the merge, untouched by the recognized fields");
        let Object::Array(items) = custom else {
            panic!("CustomRefs must still be an array, got {custom:?}");
        };
        let Object::Ref(witness_target) = items[0] else {
            panic!(
                "CustomRefs[0] must still be a reference, got {:?}",
                items[0]
            );
        };
        let resolved = rewritten
            .get(witness_target)
            .expect("the translated reference must resolve to a real object");
        assert_eq!(
            resolved.as_str_bytes(),
            Some(&b"Witness"[..]),
            "a reference nested inside an /Info value must translate into the \
             target's own numbering, not alias whatever the rewrite happens to \
             number the same"
        );
    }
}