Skip to main content

pdfrum_edit/import/
mod.rs

1//! Importing pages from one document into another (ISO 32000-1 §7.7.3).
2//!
3//! Three operations share one copier: importing pages as pages, imposing
4//! several source pages onto one sheet (N-up), and copying viewer
5//! preferences.
6//!
7//! # Importing is transactional
8//!
9//! The C++ leaves debris behind a failure: a missing source page returns
10//! false *after* the destination page was created and inserted, and after
11//! earlier pages of the same batch were fully exported. Every destination
12//! mutation here is staged in the [`EditDoc`] overlay and committed only on
13//! success, so a failed import leaves the destination exactly as it was.
14//! The success path is byte-identical; only failure differs, and no test
15//! asserts on the destination after a failed import.
16//!
17//! # Importing never mutates the source
18//!
19//! PDFium's N-up path writes `/Type /Page` into a source page dictionary that
20//! lacked one, so it is not read-only with respect to what it is copying
21//! from. Our source is a `&Document` over shared bytes and cannot be mutated;
22//! the missing `/Type` is supplied on the *copy*.
23//!
24//! # Four keys are flattened, in this order
25//!
26//! An imported page is detached from its `/Parent` chain, so whatever it was
27//! inheriting must be written onto it: `/MediaBox` — falling back to
28//! `/CropBox`, then to US Letter — then `/Resources` — falling back to an
29//! empty dictionary — then `/CropBox` and `/Rotate`, whose absence is simply
30//! accepted. `/BleedBox`, `/TrimBox` and `/ArtBox` are **not** in the list
31//! and are lost unless the page stated them itself.
32
33// [oracle-bug] The four import-path defects this module fixes rather than
34// ports, each verified at the line. Each is pinned by its own test in
35// `tests/import.rs`.
36//
37// 1. **The hardcoded destination object number.**
38//    `cpdf_pageorganizer.cpp:150-153`: a cloned object whose `/Type` is
39//    `Pages` returns the literal `4`, which is right only because
40//    `FPDF_CreateNewDocument` happens to number its page tree node 4. Any
41//    other destination gets a reference to whatever object 4 is. §7.7.3.2
42//    makes `/Parent` a reference to the node's actual parent, not to a
43//    number a producer guessed. Ours returns the destination's real
44//    `/Root /Pages`.
45// 2. **Import is not transactional.** A missing source page returns false
46//    *after* the destination page was created and inserted, and after
47//    earlier pages of the batch were exported, leaving a stray blank page
48//    and a half-imported document. Nothing in ISO 32000-1 sanctions a failed
49//    operation leaving debris; ours stages every mutation in the overlay.
50// 3. **The source is mutated.** `CPDF_Page`'s constructor writes
51//    `/Type /Page` into a source page dictionary that lacked one
52//    (`cpdf_page.cpp:33-36`), so the N-up path is not read-only with respect
53//    to what it copies from. Ours cannot be: the source is shared immutable
54//    bytes, and the missing `/Type` is supplied on the copy.
55// 4. **The N-up name reuse.** `cpdf_npagetooneexporter.cpp:224-228`
56//    (`AddSubPage`) reuses a name from `src_page_xobject_map_`, cleared once
57//    at `:171`, but the registry it must also appear in,
58//    `xobject_name_to_number_map_`, is cleared **per output sheet** at
59//    `:180` and written only inside `MakeXObjectFromPage` (`:284-285`),
60//    which the cache hit skips. So a source page reused on a later sheet
61//    emits `/Xn Do` against a `/Resources /XObject` with no `Xn` entry:
62//    §8.10.1 requires the name to be in the resources, and the sub-page
63//    renders blank. Ours registers the name on every sheet it appears on.
64//
65// pdf.js implements no page import or N-up imposition, so there is no
66// independent implementation to weigh; the reading rests on the spec, and on
67// three of the four producing output PDFium itself would then fail to render
68// correctly. Two are annotated as bugs in the C++ source.
69
70mod copy;
71mod inherit;
72mod nup;
73mod range;
74mod viewer;
75
76use pdfrum_common::PageIndex;
77use pdfrum_object::{Array, Dict, Name, ObjRef, Object, Resolve, names};
78use pdfrum_parser::Document;
79
80use crate::doc::EditDoc;
81use crate::error::Error;
82use crate::names as edit_names;
83
84use copy::ObjectMap;
85use inherit::inheritable;
86use nup::{NupGrid, sub_page_fragment};
87
88pub use range::PageRange;
89
90/// US Letter, the last fallback when a page states no box and inherits none.
91const LETTER: [i64; 4] = [0, 0, 612, 792];
92
93/// How an import behaves.
94///
95/// `#[non_exhaustive]`; build one with [`ImportOptions::builder`], or start
96/// from [`ImportOptions::default`] and assign the fields.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
98#[non_exhaustive]
99pub struct ImportOptions {
100    /// Where in the destination's page list the imported pages go. Pages at
101    /// and after this index shift up.
102    pub at: PageIndex,
103    /// Also copy the source catalog's `/ViewerPreferences`.
104    pub viewer_preferences: bool,
105}
106
107/// How an N-up imposition behaves.
108///
109/// `#[non_exhaustive]`; build one with [`NUpOptions::builder`], whose
110/// `.sheet` takes a [`kurbo::Size`] and whose `.grid` takes columns and rows
111/// as two arguments — the bare tuples below are unlabelled, and a caller who
112/// swaps them gets a rotated sheet or a transposed grid with no complaint.
113#[derive(Debug, Clone, Copy, PartialEq)]
114#[non_exhaustive]
115pub struct NUpOptions {
116    /// The sheet's size in points, as `(width, height)`.
117    pub sheet: (f32, f32),
118    /// Sub-pages per sheet, as `(columns, rows)`.
119    pub grid: (u32, u32),
120}
121
122impl Default for NUpOptions {
123    fn default() -> Self {
124        Self {
125            sheet: (612.0, 792.0),
126            grid: (2, 1),
127        }
128    }
129}
130
131impl ImportOptions {
132    /// A builder over the defaults.
133    ///
134    /// ```
135    /// use pdfrum_edit::ImportOptions;
136    ///
137    /// let options = ImportOptions::builder().at(3u32).viewer_preferences(true).build();
138    /// assert_eq!(u32::from(options.at), 3);
139    /// assert!(options.viewer_preferences);
140    /// ```
141    #[must_use]
142    pub fn builder() -> ImportOptionsBuilder {
143        ImportOptionsBuilder(ImportOptions::default())
144    }
145}
146
147/// Builds an [`ImportOptions`] one setting at a time.
148#[derive(Debug, Clone, Copy)]
149pub struct ImportOptionsBuilder(ImportOptions);
150
151impl ImportOptionsBuilder {
152    /// Where in the destination's page list the imported pages go.
153    #[must_use]
154    pub fn at(mut self, at: impl Into<PageIndex>) -> Self {
155        self.0.at = at.into();
156        self
157    }
158
159    /// Also copy the source catalog's `/ViewerPreferences`.
160    #[must_use]
161    pub fn viewer_preferences(mut self, copy: bool) -> Self {
162        self.0.viewer_preferences = copy;
163        self
164    }
165
166    /// The options built so far.
167    #[must_use]
168    pub fn build(self) -> ImportOptions {
169        self.0
170    }
171}
172
173impl NUpOptions {
174    /// A builder over the defaults: US Letter, two across and one down.
175    ///
176    /// ```
177    /// use kurbo::Size;
178    /// use pdfrum_edit::NUpOptions;
179    ///
180    /// // A4 landscape, four up in a 2x2 grid.
181    /// let options = NUpOptions::builder()
182    ///     .sheet(Size::new(842.0, 595.0))
183    ///     .grid(2, 2)
184    ///     .build();
185    ///
186    /// assert_eq!(options.grid, (2, 2));
187    /// ```
188    #[must_use]
189    pub fn builder() -> NUpOptionsBuilder {
190        NUpOptionsBuilder(NUpOptions::default())
191    }
192}
193
194/// Builds an [`NUpOptions`] one setting at a time.
195///
196/// The two setters exist to name what the bare tuples do not: a
197/// [`kurbo::Size`] says which number is the width, and `grid(columns, rows)`
198/// says which is which.
199#[derive(Debug, Clone, Copy)]
200pub struct NUpOptionsBuilder(NUpOptions);
201
202impl NUpOptionsBuilder {
203    /// The sheet's size in points.
204    #[must_use]
205    #[expect(
206        clippy::cast_possible_truncation,
207        reason = "PDF reals are f32; sheet sizes fit"
208    )]
209    pub fn sheet(mut self, sheet: kurbo::Size) -> Self {
210        self.0.sheet = (sheet.width as f32, sheet.height as f32);
211        self
212    }
213
214    /// Sub-pages per sheet.
215    #[must_use]
216    pub fn grid(mut self, columns: u32, rows: u32) -> Self {
217        self.0.grid = (columns, rows);
218        self
219    }
220
221    /// The options built so far.
222    #[must_use]
223    pub fn build(self) -> NUpOptions {
224        self.0
225    }
226}
227
228/// Prepare `dest` to be imported into, repairing its catalog as needed.
229///
230/// Idempotent, and every step is a repair rather than a requirement: a
231/// catalog with a wrong-but-present `/Type` is left alone, a `/Pages` that
232/// does not resolve to a dictionary is replaced with a fresh one, and a
233/// `/Kids` that is not an array is replaced along with a force-zeroed
234/// `/Count` — those two are written together, so a document with a broken
235/// `/Kids` loses whatever `/Count` claimed.
236///
237/// Returns the destination's `/Pages` object number, which the copier needs.
238///
239/// # Errors
240///
241/// [`Error::NoDestinationCatalog`] when there is no catalog to repair — the
242/// one hard failure in the whole import path.
243pub(crate) fn init_dest(dest: &mut EditDoc<'_>) -> Result<u32, Error> {
244    let catalog_ref = dest
245        .base()
246        .trailer()
247        .reference(names::ROOT)
248        .ok_or(Error::NoDestinationCatalog)?;
249    let catalog = dest
250        .fetch(catalog_ref)
251        .ok()
252        .and_then(|o| o.as_dict().cloned())
253        .ok_or(Error::NoDestinationCatalog)?;
254
255    let mut catalog = catalog;
256
257    // An empty or missing `/Type` is repaired; a wrong one is left alone,
258    // because a producer that wrote something else may have meant it.
259    if catalog
260        .name(names::TYPE)
261        .is_none_or(|n| n.as_bytes().is_empty())
262    {
263        set(
264            &mut catalog,
265            names::TYPE,
266            Object::Name(edit_names::CATALOG.clone()),
267        );
268    }
269
270    // `/Pages` is accepted as a direct dictionary or as a reference. Anything
271    // that does not resolve to a dictionary is replaced outright.
272    let pages_ref = match catalog.raw(names::PAGES) {
273        Some(Object::Ref(r)) if dest.fetch(*r).is_ok_and(|o| o.as_dict().is_some()) => *r,
274        _ => {
275            let fresh = fresh_pages_node(dest);
276            set(&mut catalog, names::PAGES, Object::Ref(fresh));
277            fresh
278        }
279    };
280
281    let mut pages = dest
282        .fetch(pages_ref)
283        .ok()
284        .and_then(|o| o.as_dict().cloned())
285        .unwrap_or_default();
286    if pages
287        .name(names::TYPE)
288        .is_none_or(|n| n.as_bytes().is_empty())
289    {
290        set(&mut pages, names::TYPE, Object::Name(names::PAGES.clone()));
291    }
292    // A non-array `/Kids` takes `/Count` down with it: both are written
293    // together, so a document that lied about one loses the other.
294    if !matches!(pages.raw(names::KIDS), Some(Object::Array(_))) {
295        set(&mut pages, names::KIDS, Object::Array(Array::new()));
296        set(&mut pages, names::COUNT, Object::Int(0));
297    }
298
299    dest.replace(pages_ref, Object::Dict(pages));
300    dest.replace(catalog_ref, Object::Dict(catalog));
301    Ok(pages_ref.num)
302}
303
304/// A fresh, empty `/Pages` node.
305fn fresh_pages_node(dest: &mut EditDoc<'_>) -> ObjRef {
306    dest.add(Object::Dict(Dict::from_pairs([
307        (names::TYPE.clone(), Object::Name(names::PAGES.clone())),
308        (names::COUNT.clone(), Object::Int(0)),
309        (names::KIDS.clone(), Object::Array(Array::new())),
310    ])))
311}
312
313/// Import `pages` from `src` into `dest`.
314///
315/// The pages land as a contiguous run at `opts.at`, in the order the range
316/// names them, with duplicates producing duplicate destination pages.
317///
318/// Objects shared between two imported pages — a font, an image `XObject`, a
319/// `/Resources` dictionary — are copied **once**, and both destination pages
320/// point at the one copy.
321///
322/// # Errors
323///
324/// [`Error::NoDestinationCatalog`] when the destination has no catalog, and
325/// [`Error::PageIndexOutOfRange`] when the range names a page the source does
326/// not have. The destination is untouched on either.
327pub fn import_pages(
328    dest: &mut EditDoc<'_>,
329    src: &Document,
330    pages: &PageRange,
331    opts: &ImportOptions,
332) -> Result<(), Error> {
333    // Check the whole range before mutating anything: an import either
334    // happens or it does not.
335    for index in pages.indices() {
336        if index.get() >= src.page_count() {
337            return Err(Error::PageIndexOutOfRange(*index));
338        }
339    }
340
341    let pages_node = init_dest(dest)?;
342    let mut map = ObjectMap::new();
343    let mut created = Vec::new();
344
345    for index in pages.indices() {
346        let page = src
347            .page(*index)
348            .map_err(|_| Error::PageIndexOutOfRange(*index))?;
349        let dest_page = dest.add(Object::Null);
350
351        // Register the page's own mapping *first*, so a self-reference from
352        // inside it — an annotation's `/P` back-pointer — lands on the new
353        // page rather than being pruned by the copier's cross-page rule.
354        if let Some(source_ref) = page.reference {
355            map.record(source_ref.num, dest_page.num);
356        }
357
358        let built = build_page(dest, src, &page.dict, pages_node, &mut map, dest_page);
359        dest.replace(dest_page, Object::Dict(built));
360        created.push(dest_page);
361    }
362
363    insert_into_tree(dest, pages_node, &created, opts.at.get());
364
365    if opts.viewer_preferences
366        && let Ok(catalog) = src.catalog()
367        && let Some(prefs) = viewer::filtered(&catalog, src)
368    {
369        copy_viewer_preferences(dest, prefs);
370    }
371    Ok(())
372}
373
374/// Build one destination page dictionary from a source page.
375fn build_page(
376    dest: &mut EditDoc<'_>,
377    src: &Document,
378    source: &Dict,
379    pages_node: u32,
380    map: &mut ObjectMap,
381    self_ref: ObjRef,
382) -> Dict {
383    let mut out = Dict::from_pairs([
384        (names::TYPE.clone(), Object::Name(names::PAGE.clone())),
385        (
386            names::PARENT.clone(),
387            Object::Ref(ObjRef::new(pages_node, 0)),
388        ),
389    ]);
390
391    // Every source key except the two just written: `/Type` is already
392    // correct and `/Parent` must name the *destination's* tree.
393    for (key, value) in source.iter() {
394        if key == names::TYPE || key == names::PARENT {
395            continue;
396        }
397        let mut value = value.clone();
398        if rewrite_value(dest, src, &mut value, map, pages_node) {
399            out.push(key.clone(), value);
400        }
401    }
402
403    flatten_inherited(dest, src, source, &mut out, map, pages_node);
404    let _ = self_ref;
405    out
406}
407
408/// Write the four inheritable keys onto the copy, with their fallbacks.
409fn flatten_inherited(
410    dest: &mut EditDoc<'_>,
411    src: &Document,
412    source: &Dict,
413    out: &mut Dict,
414    map: &mut ObjectMap,
415    pages_node: u32,
416) {
417    // `/MediaBox`, falling back to `/CropBox`, then to US Letter. A page with
418    // no box at all is still a page, and Letter is what it becomes.
419    if !copy_inherited(dest, src, source, out, names::MEDIA_BOX, map, pages_node)
420        && !copy_inherited(dest, src, source, out, names::CROP_BOX, map, pages_node)
421    {
422        // Written as `left bottom right top`, the order a box array uses.
423        set(
424            out,
425            names::MEDIA_BOX,
426            Object::Array(Array::of(LETTER.map(Object::Int))),
427        );
428    } else if !out.contains_key(names::MEDIA_BOX) {
429        // The `/CropBox` fallback fired: it becomes the `/MediaBox`.
430        if let Some(crop) = out.raw(names::CROP_BOX).cloned() {
431            set(out, names::MEDIA_BOX, crop);
432        }
433    }
434
435    // `/Resources`, falling back to an empty dictionary.
436    if !copy_inherited(dest, src, source, out, names::RESOURCES, map, pages_node) {
437        set(out, names::RESOURCES, Object::Dict(Dict::new()));
438    }
439
440    // `/CropBox` and `/Rotate`: taken when inheritable, absent otherwise.
441    // `/Rotate` is not normalized — a value of 450 travels as 450, and only
442    // the renderer reduces it.
443    copy_inherited(dest, src, source, out, names::CROP_BOX, map, pages_node);
444    copy_inherited(dest, src, source, out, names::ROTATE, map, pages_node);
445}
446
447/// Copy one inheritable key onto the destination page, renumbering any
448/// references it carries.
449fn copy_inherited(
450    dest: &mut EditDoc<'_>,
451    src: &Document,
452    source: &Dict,
453    out: &mut Dict,
454    key: &Name,
455    map: &mut ObjectMap,
456    pages_node: u32,
457) -> bool {
458    if out.contains_key(key) {
459        return true;
460    }
461    let Some(mut value) = inheritable(source, key, src) else {
462        return false;
463    };
464    if !rewrite_value(dest, src, &mut value, map, pages_node) {
465        return false;
466    }
467    out.push(key.clone(), value);
468    true
469}
470
471/// Rewrite one value's references into the destination's numbering.
472fn rewrite_value(
473    dest: &mut EditDoc<'_>,
474    src: &Document,
475    value: &mut Object,
476    map: &mut ObjectMap,
477    pages_node: u32,
478) -> bool {
479    copy::rewrite_in_place(dest, src, value, map, pages_node)
480}
481
482/// Insert the created pages into the destination's `/Kids` at `at`.
483pub(crate) fn insert_into_tree(
484    dest: &mut EditDoc<'_>,
485    pages_node: u32,
486    created: &[ObjRef],
487    at: u32,
488) {
489    let node_ref = ObjRef::new(pages_node, 0);
490    let mut node = dest
491        .fetch(node_ref)
492        .ok()
493        .and_then(|o| o.as_dict().cloned())
494        .unwrap_or_default();
495
496    let existing: Vec<Object> = node
497        .raw(names::KIDS)
498        .and_then(Object::as_array)
499        .map(|a| a.iter().cloned().collect())
500        .unwrap_or_default();
501
502    // Past the end lands at the end, which is what "append" means to every
503    // caller that passes a large index.
504    let split = (at as usize).min(existing.len());
505    let mut kids = Array::new();
506    for value in existing.get(..split).unwrap_or_default() {
507        kids.push(value.clone());
508    }
509    for page in created {
510        kids.push(Object::Ref(*page));
511    }
512    for value in existing.get(split..).unwrap_or_default() {
513        kids.push(value.clone());
514    }
515
516    let count = i64::try_from(kids.len()).unwrap_or(i64::MAX);
517    set(&mut node, names::KIDS, Object::Array(kids));
518    set(&mut node, names::COUNT, Object::Int(count));
519    dest.replace(node_ref, Object::Dict(node));
520}
521
522/// Write a filtered `/ViewerPreferences` onto the destination catalog,
523/// replacing whatever was there.
524fn copy_viewer_preferences(dest: &mut EditDoc<'_>, prefs: Dict) {
525    let Some(catalog_ref) = dest.base().trailer().reference(names::ROOT) else {
526        return;
527    };
528    let Some(mut catalog) = dest
529        .fetch(catalog_ref)
530        .ok()
531        .and_then(|o| o.as_dict().cloned())
532    else {
533        return;
534    };
535    // Written as a **direct** dictionary, and replacing unconditionally.
536    set(&mut catalog, names::VIEWER_PREFERENCES, Object::Dict(prefs));
537    dest.replace(catalog_ref, Object::Dict(catalog));
538}
539
540/// Impose `pages` from `src` onto sheets in `dest`.
541///
542/// Each source page becomes a Form `XObject` carrying only its `/Resources` —
543/// `/Annots`, `/Group`, `/CropBox` and everything else is dropped — and each
544/// sheet's content stream invokes the forms in slot order.
545///
546/// A source page used on two different sheets produces **one** form and two
547/// invocations, and the name is registered in both sheets' resources.
548/// The C++ registers it only on the first, so the sub-page silently
549/// renders blank on every later sheet.
550///
551/// # Errors
552///
553/// [`Error::BadNupParams`] for a zero grid dimension or a zero sheet
554/// dimension, [`Error::NoDestinationCatalog`], and
555/// [`Error::PageIndexOutOfRange`].
556pub fn n_page_to_one(
557    dest: &mut EditDoc<'_>,
558    src: &Document,
559    pages: &PageRange,
560    opts: &NUpOptions,
561) -> Result<(), Error> {
562    let (x, y) = opts.grid;
563    let (width, height) = opts.sheet;
564    if x == 0 || y == 0 || width <= 0.0 || height <= 0.0 {
565        return Err(Error::BadNupParams);
566    }
567    for index in pages.indices() {
568        if index.get() >= src.page_count() {
569            return Err(Error::PageIndexOutOfRange(*index));
570        }
571    }
572
573    let pages_node = init_dest(dest)?;
574    let grid = NupGrid {
575        sheet_width: width,
576        sheet_height: height,
577        x,
578        y,
579    };
580
581    let mut map = ObjectMap::new();
582    // Source page number to the form that was made from it, so a page used
583    // twice makes one form.
584    let mut forms: Vec<(u32, ObjRef)> = Vec::new();
585    let mut created = Vec::new();
586
587    for chunk in pages.indices().chunks(grid.per_sheet().max(1) as usize) {
588        let mut content = String::new();
589        let mut xobjects = Dict::new();
590
591        for (slot, index) in chunk.iter().enumerate() {
592            let page = src
593                .page(*index)
594                .map_err(|_| Error::PageIndexOutOfRange(*index))?;
595            let key = page.reference.map_or(u32::MAX - index.get(), |r| r.num);
596
597            let form = if let Some((_, existing)) = forms.iter().find(|(k, _)| *k == key) {
598                *existing
599            } else {
600                let made = make_form(dest, src, &page.dict, pages_node, &mut map);
601                forms.push((key, made));
602                made
603            };
604
605            let (page_w, page_h) = page_size(&page.dict, src);
606            #[expect(
607                clippy::cast_possible_truncation,
608                reason = "a slot index is bounded by the grid, which is a u32"
609            )]
610            let edit = grid.edit(slot as u32, page_w, page_h);
611            let name = format!("X{}", xobjects.len() + 1);
612            content.push_str(&sub_page_fragment(&name, edit));
613            // Registered on *every* sheet the form appears on, not just the
614            // first.
615            xobjects.push(Name::from(name.as_str()), Object::Ref(form));
616        }
617
618        created.push(make_sheet(
619            dest, pages_node, &content, xobjects, width, height,
620        ));
621    }
622
623    insert_into_tree(dest, pages_node, &created, 0);
624    Ok(())
625}
626
627/// One source page as a Form `XObject`.
628fn make_form(
629    dest: &mut EditDoc<'_>,
630    src: &Document,
631    page: &Dict,
632    pages_node: u32,
633    map: &mut ObjectMap,
634) -> ObjRef {
635    // Content is **decoded** here — the one place in the import path that
636    // re-encodes — because an array-valued `/Contents` may split a token
637    // across elements, and the join needs a separator between every pair.
638    let content = assemble_content(page, src);
639
640    let mut resources = Dict::new();
641    let mut carrier = Dict::new();
642    if copy_inherited(
643        dest,
644        src,
645        page,
646        &mut carrier,
647        names::RESOURCES,
648        map,
649        pages_node,
650    ) && let Some(found) = carrier.raw(names::RESOURCES).and_then(Object::as_dict)
651    {
652        resources = found.clone();
653    }
654
655    let (media, crop) = boxes(page, src);
656    let bbox = intersect(media, crop);
657
658    // `/Type`, `/Subtype` and `/FormType` are written **after** everything
659    // else, so the reference walk above saw a `/Type`-less dictionary and did
660    // not trip the copier's Pages/Page special cases.
661    let dict = Dict::from_pairs([
662        (names::RESOURCES.clone(), Object::Dict(resources)),
663        (
664            edit_names::BBOX.clone(),
665            Object::Array(Array::of(bbox.map(Object::Real))),
666        ),
667        (
668            edit_names::MATRIX.clone(),
669            Object::Array(Array::of(
670                [1.0, 0.0, 0.0, 1.0, -bbox[0], -bbox[1]].map(Object::Real),
671            )),
672        ),
673        (
674            names::TYPE.clone(),
675            Object::Name(edit_names::XOBJECT.clone()),
676        ),
677        (
678            names::SUBTYPE.clone(),
679            Object::Name(edit_names::FORM.clone()),
680        ),
681        (edit_names::FORM_TYPE.clone(), Object::Int(1)),
682        (
683            names::LENGTH.clone(),
684            Object::Int(i64::try_from(content.len()).unwrap_or(0)),
685        ),
686    ]);
687
688    dest.add(Object::Stream(Box::new(pdfrum_object::Stream::new(
689        dict,
690        pdfrum_object::ByteSpan::from(content),
691    ))))
692}
693
694/// One output sheet.
695fn make_sheet(
696    dest: &mut EditDoc<'_>,
697    pages_node: u32,
698    content: &str,
699    xobjects: Dict,
700    width: f32,
701    height: f32,
702) -> ObjRef {
703    let stream = dest.add(Object::Stream(Box::new(pdfrum_object::Stream::new(
704        Dict::from_pairs([(
705            names::LENGTH.clone(),
706            Object::Int(i64::try_from(content.len()).unwrap_or(0)),
707        )]),
708        pdfrum_object::ByteSpan::from(content.as_bytes().to_vec()),
709    ))));
710
711    dest.add(Object::Dict(Dict::from_pairs([
712        (names::TYPE.clone(), Object::Name(names::PAGE.clone())),
713        (
714            names::PARENT.clone(),
715            Object::Ref(ObjRef::new(pages_node, 0)),
716        ),
717        (
718            names::MEDIA_BOX.clone(),
719            Object::Array(Array::of([
720                Object::Int(0),
721                Object::Int(0),
722                Object::Real(width),
723                Object::Real(height),
724            ])),
725        ),
726        (
727            names::RESOURCES.clone(),
728            Object::Dict(Dict::from_pairs([(
729                edit_names::XOBJECT.clone(),
730                Object::Dict(xobjects),
731            )])),
732        ),
733        (names::CONTENTS.clone(), Object::Ref(stream)),
734    ])))
735}
736
737/// A page's `/Contents`, decoded and joined with a newline after **every**
738/// element — including the last, which is what stops a token split across two
739/// array elements running into whatever follows.
740fn assemble_content(page: &Dict, src: &Document) -> Vec<u8> {
741    let limits = pdfrum_common::Limits::default();
742    let mut diags = pdfrum_common::Diagnostics::default();
743    let mut out = Vec::new();
744
745    match page.get(names::CONTENTS, src).as_deref() {
746        Some(Object::Stream(s)) => {
747            out = pdfrum_parser::decoded_stream(s, src, &limits, &mut diags);
748        }
749        Some(Object::Array(a)) => {
750            for i in 0..a.len() {
751                let Some(s) = a.stream_at(i, src) else {
752                    continue;
753                };
754                out.extend_from_slice(&pdfrum_parser::decoded_stream(&s, src, &limits, &mut diags));
755                out.push(b'\n');
756            }
757        }
758        // No contents at all is an empty form, not a failure.
759        _ => {}
760    }
761    out
762}
763
764/// A page's media and crop boxes, defaulted and normalized.
765fn boxes(page: &Dict, src: &Document) -> ([f32; 4], Option<[f32; 4]>) {
766    let read = |key: &Name| -> Option<[f32; 4]> {
767        let a = match inheritable(page, key, src) {
768            Some(Object::Array(a)) => a,
769            _ => page.array(key, src)?,
770        };
771        if a.len() < 4 {
772            return None;
773        }
774        Some(normalize([
775            a.number_at_or_zero(0),
776            a.number_at_or_zero(1),
777            a.number_at_or_zero(2),
778            a.number_at_or_zero(3),
779        ]))
780    };
781    let media = read(names::MEDIA_BOX).unwrap_or([0.0, 0.0, 612.0, 792.0]);
782    (media, read(names::CROP_BOX))
783}
784
785/// A box with its corners in ascending order.
786fn normalize(b: [f32; 4]) -> [f32; 4] {
787    [
788        b[0].min(b[2]),
789        b[1].min(b[3]),
790        b[0].max(b[2]),
791        b[1].max(b[3]),
792    ]
793}
794
795/// The crop box intersected with the media box, or the media box alone.
796fn intersect(media: [f32; 4], crop: Option<[f32; 4]>) -> [f32; 4] {
797    let Some(crop) = crop else {
798        return media;
799    };
800    [
801        media[0].max(crop[0]),
802        media[1].max(crop[1]),
803        media[2].min(crop[2]),
804        media[3].min(crop[3]),
805    ]
806}
807
808/// A page's visible size, which is what an N-up slot scales to fit.
809fn page_size(page: &Dict, src: &Document) -> (f32, f32) {
810    let (media, crop) = boxes(page, src);
811    let b = intersect(media, crop);
812    let (w, h) = (b[2] - b[0], b[3] - b[1]);
813    // A quarter turn swaps the visible dimensions.
814    let quarter = matches!(
815        inheritable(page, names::ROTATE, src)
816            .or_else(|| page.raw(names::ROTATE).cloned())
817            .and_then(|o| o.as_int())
818            .map(|v| ((v / 90) % 4 + 4) % 4),
819        Some(1 | 3)
820    );
821    if quarter { (h, w) } else { (w, h) }
822}
823
824/// Set a key, replacing in place so the emitted key order does not shuffle.
825fn set(dict: &mut Dict, key: &Name, value: Object) {
826    if dict.contains_key(key) {
827        *dict = Dict::from_pairs(dict.iter().map(|(k, v)| {
828            if k == key {
829                (k.clone(), value.clone())
830            } else {
831                (k.clone(), v.clone())
832            }
833        }));
834    } else {
835        dict.push(key.clone(), value);
836    }
837}