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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub struct ImportOptions {
96    /// Where in the destination's page list the imported pages go. Pages at
97    /// and after this index shift up.
98    pub at: PageIndex,
99    /// Also copy the source catalog's `/ViewerPreferences`.
100    pub viewer_preferences: bool,
101}
102
103/// How an N-up imposition behaves.
104#[derive(Debug, Clone, Copy, PartialEq)]
105pub struct NUpOptions {
106    /// The sheet's size in points.
107    pub sheet: (f32, f32),
108    /// Columns and rows of sub-pages per sheet.
109    pub grid: (u32, u32),
110}
111
112impl Default for NUpOptions {
113    fn default() -> Self {
114        Self {
115            sheet: (612.0, 792.0),
116            grid: (2, 1),
117        }
118    }
119}
120
121/// Prepare `dest` to be imported into, repairing its catalog as needed.
122///
123/// Idempotent, and every step is a repair rather than a requirement: a
124/// catalog with a wrong-but-present `/Type` is left alone, a `/Pages` that
125/// does not resolve to a dictionary is replaced with a fresh one, and a
126/// `/Kids` that is not an array is replaced along with a force-zeroed
127/// `/Count` — those two are written together, so a document with a broken
128/// `/Kids` loses whatever `/Count` claimed.
129///
130/// Returns the destination's `/Pages` object number, which the copier needs.
131///
132/// # Errors
133///
134/// [`Error::NoDestinationCatalog`] when there is no catalog to repair — the
135/// one hard failure in the whole import path.
136pub(crate) fn init_dest(dest: &mut EditDoc<'_>) -> Result<u32, Error> {
137    let catalog_ref = dest
138        .base()
139        .trailer()
140        .reference(names::ROOT)
141        .ok_or(Error::NoDestinationCatalog)?;
142    let catalog = dest
143        .fetch(catalog_ref)
144        .ok()
145        .and_then(|o| o.as_dict().cloned())
146        .ok_or(Error::NoDestinationCatalog)?;
147
148    let mut catalog = catalog;
149
150    // An empty or missing `/Type` is repaired; a wrong one is left alone,
151    // because a producer that wrote something else may have meant it.
152    if catalog
153        .name(names::TYPE)
154        .is_none_or(|n| n.as_bytes().is_empty())
155    {
156        set(
157            &mut catalog,
158            names::TYPE,
159            Object::Name(edit_names::CATALOG.clone()),
160        );
161    }
162
163    // `/Pages` is accepted as a direct dictionary or as a reference. Anything
164    // that does not resolve to a dictionary is replaced outright.
165    let pages_ref = match catalog.raw(names::PAGES) {
166        Some(Object::Ref(r)) if dest.fetch(*r).is_ok_and(|o| o.as_dict().is_some()) => *r,
167        _ => {
168            let fresh = fresh_pages_node(dest);
169            set(&mut catalog, names::PAGES, Object::Ref(fresh));
170            fresh
171        }
172    };
173
174    let mut pages = dest
175        .fetch(pages_ref)
176        .ok()
177        .and_then(|o| o.as_dict().cloned())
178        .unwrap_or_default();
179    if pages
180        .name(names::TYPE)
181        .is_none_or(|n| n.as_bytes().is_empty())
182    {
183        set(&mut pages, names::TYPE, Object::Name(names::PAGES.clone()));
184    }
185    // A non-array `/Kids` takes `/Count` down with it: both are written
186    // together, so a document that lied about one loses the other.
187    if !matches!(pages.raw(names::KIDS), Some(Object::Array(_))) {
188        set(&mut pages, names::KIDS, Object::Array(Array::new()));
189        set(&mut pages, names::COUNT, Object::Int(0));
190    }
191
192    dest.replace(pages_ref, Object::Dict(pages));
193    dest.replace(catalog_ref, Object::Dict(catalog));
194    Ok(pages_ref.num)
195}
196
197/// A fresh, empty `/Pages` node.
198fn fresh_pages_node(dest: &mut EditDoc<'_>) -> ObjRef {
199    dest.add(Object::Dict(Dict::from_pairs([
200        (names::TYPE.clone(), Object::Name(names::PAGES.clone())),
201        (names::COUNT.clone(), Object::Int(0)),
202        (names::KIDS.clone(), Object::Array(Array::new())),
203    ])))
204}
205
206/// Import `pages` from `src` into `dest`.
207///
208/// The pages land as a contiguous run at `opts.at`, in the order the range
209/// names them, with duplicates producing duplicate destination pages.
210///
211/// Objects shared between two imported pages — a font, an image `XObject`, a
212/// `/Resources` dictionary — are copied **once**, and both destination pages
213/// point at the one copy.
214///
215/// # Errors
216///
217/// [`Error::NoDestinationCatalog`] when the destination has no catalog, and
218/// [`Error::PageIndexOutOfRange`] when the range names a page the source does
219/// not have. The destination is untouched on either.
220pub fn import_pages(
221    dest: &mut EditDoc<'_>,
222    src: &Document,
223    pages: &PageRange,
224    opts: &ImportOptions,
225) -> Result<(), Error> {
226    // Check the whole range before mutating anything: an import either
227    // happens or it does not.
228    for index in pages.indices() {
229        if index.get() >= src.page_count() {
230            return Err(Error::PageIndexOutOfRange(*index));
231        }
232    }
233
234    let pages_node = init_dest(dest)?;
235    let mut map = ObjectMap::new();
236    let mut created = Vec::new();
237
238    for index in pages.indices() {
239        let page = src
240            .page(*index)
241            .map_err(|_| Error::PageIndexOutOfRange(*index))?;
242        let dest_page = dest.add(Object::Null);
243
244        // Register the page's own mapping *first*, so a self-reference from
245        // inside it — an annotation's `/P` back-pointer — lands on the new
246        // page rather than being pruned by the copier's cross-page rule.
247        if let Some(source_ref) = page.reference {
248            map.record(source_ref.num, dest_page.num);
249        }
250
251        let built = build_page(dest, src, &page.dict, pages_node, &mut map, dest_page);
252        dest.replace(dest_page, Object::Dict(built));
253        created.push(dest_page);
254    }
255
256    insert_into_tree(dest, pages_node, &created, opts.at.get());
257
258    if opts.viewer_preferences
259        && let Ok(catalog) = src.catalog()
260        && let Some(prefs) = viewer::filtered(&catalog, src)
261    {
262        copy_viewer_preferences(dest, prefs);
263    }
264    Ok(())
265}
266
267/// Build one destination page dictionary from a source page.
268fn build_page(
269    dest: &mut EditDoc<'_>,
270    src: &Document,
271    source: &Dict,
272    pages_node: u32,
273    map: &mut ObjectMap,
274    self_ref: ObjRef,
275) -> Dict {
276    let mut out = Dict::from_pairs([
277        (names::TYPE.clone(), Object::Name(names::PAGE.clone())),
278        (
279            names::PARENT.clone(),
280            Object::Ref(ObjRef::new(pages_node, 0)),
281        ),
282    ]);
283
284    // Every source key except the two just written: `/Type` is already
285    // correct and `/Parent` must name the *destination's* tree.
286    for (key, value) in source.iter() {
287        if key == names::TYPE || key == names::PARENT {
288            continue;
289        }
290        let mut value = value.clone();
291        if rewrite_value(dest, src, &mut value, map, pages_node) {
292            out.push(key.clone(), value);
293        }
294    }
295
296    flatten_inherited(dest, src, source, &mut out, map, pages_node);
297    let _ = self_ref;
298    out
299}
300
301/// Write the four inheritable keys onto the copy, with their fallbacks.
302fn flatten_inherited(
303    dest: &mut EditDoc<'_>,
304    src: &Document,
305    source: &Dict,
306    out: &mut Dict,
307    map: &mut ObjectMap,
308    pages_node: u32,
309) {
310    // `/MediaBox`, falling back to `/CropBox`, then to US Letter. A page with
311    // no box at all is still a page, and Letter is what it becomes.
312    if !copy_inherited(dest, src, source, out, names::MEDIA_BOX, map, pages_node)
313        && !copy_inherited(dest, src, source, out, names::CROP_BOX, map, pages_node)
314    {
315        // Written as `left bottom right top`, the order a box array uses.
316        set(
317            out,
318            names::MEDIA_BOX,
319            Object::Array(Array::of(LETTER.map(Object::Int))),
320        );
321    } else if !out.contains_key(names::MEDIA_BOX) {
322        // The `/CropBox` fallback fired: it becomes the `/MediaBox`.
323        if let Some(crop) = out.raw(names::CROP_BOX).cloned() {
324            set(out, names::MEDIA_BOX, crop);
325        }
326    }
327
328    // `/Resources`, falling back to an empty dictionary.
329    if !copy_inherited(dest, src, source, out, names::RESOURCES, map, pages_node) {
330        set(out, names::RESOURCES, Object::Dict(Dict::new()));
331    }
332
333    // `/CropBox` and `/Rotate`: taken when inheritable, absent otherwise.
334    // `/Rotate` is not normalized — a value of 450 travels as 450, and only
335    // the renderer reduces it.
336    copy_inherited(dest, src, source, out, names::CROP_BOX, map, pages_node);
337    copy_inherited(dest, src, source, out, names::ROTATE, map, pages_node);
338}
339
340/// Copy one inheritable key onto the destination page, renumbering any
341/// references it carries.
342fn copy_inherited(
343    dest: &mut EditDoc<'_>,
344    src: &Document,
345    source: &Dict,
346    out: &mut Dict,
347    key: &Name,
348    map: &mut ObjectMap,
349    pages_node: u32,
350) -> bool {
351    if out.contains_key(key) {
352        return true;
353    }
354    let Some(mut value) = inheritable(source, key, src) else {
355        return false;
356    };
357    if !rewrite_value(dest, src, &mut value, map, pages_node) {
358        return false;
359    }
360    out.push(key.clone(), value);
361    true
362}
363
364/// Rewrite one value's references into the destination's numbering.
365fn rewrite_value(
366    dest: &mut EditDoc<'_>,
367    src: &Document,
368    value: &mut Object,
369    map: &mut ObjectMap,
370    pages_node: u32,
371) -> bool {
372    copy::rewrite_in_place(dest, src, value, map, pages_node)
373}
374
375/// Insert the created pages into the destination's `/Kids` at `at`.
376pub(crate) fn insert_into_tree(
377    dest: &mut EditDoc<'_>,
378    pages_node: u32,
379    created: &[ObjRef],
380    at: u32,
381) {
382    let node_ref = ObjRef::new(pages_node, 0);
383    let mut node = dest
384        .fetch(node_ref)
385        .ok()
386        .and_then(|o| o.as_dict().cloned())
387        .unwrap_or_default();
388
389    let existing: Vec<Object> = node
390        .raw(names::KIDS)
391        .and_then(Object::as_array)
392        .map(|a| a.iter().cloned().collect())
393        .unwrap_or_default();
394
395    // Past the end lands at the end, which is what "append" means to every
396    // caller that passes a large index.
397    let split = (at as usize).min(existing.len());
398    let mut kids = Array::new();
399    for value in existing.get(..split).unwrap_or_default() {
400        kids.push(value.clone());
401    }
402    for page in created {
403        kids.push(Object::Ref(*page));
404    }
405    for value in existing.get(split..).unwrap_or_default() {
406        kids.push(value.clone());
407    }
408
409    let count = i64::try_from(kids.len()).unwrap_or(i64::MAX);
410    set(&mut node, names::KIDS, Object::Array(kids));
411    set(&mut node, names::COUNT, Object::Int(count));
412    dest.replace(node_ref, Object::Dict(node));
413}
414
415/// Write a filtered `/ViewerPreferences` onto the destination catalog,
416/// replacing whatever was there.
417fn copy_viewer_preferences(dest: &mut EditDoc<'_>, prefs: Dict) {
418    let Some(catalog_ref) = dest.base().trailer().reference(names::ROOT) else {
419        return;
420    };
421    let Some(mut catalog) = dest
422        .fetch(catalog_ref)
423        .ok()
424        .and_then(|o| o.as_dict().cloned())
425    else {
426        return;
427    };
428    // Written as a **direct** dictionary, and replacing unconditionally.
429    set(&mut catalog, names::VIEWER_PREFERENCES, Object::Dict(prefs));
430    dest.replace(catalog_ref, Object::Dict(catalog));
431}
432
433/// Impose `pages` from `src` onto sheets in `dest`.
434///
435/// Each source page becomes a Form `XObject` carrying only its `/Resources` —
436/// `/Annots`, `/Group`, `/CropBox` and everything else is dropped — and each
437/// sheet's content stream invokes the forms in slot order.
438///
439/// A source page used on two different sheets produces **one** form and two
440/// invocations, and the name is registered in both sheets' resources.
441/// The C++ registers it only on the first, so the sub-page silently
442/// renders blank on every later sheet.
443///
444/// # Errors
445///
446/// [`Error::BadNupParams`] for a zero grid dimension or a zero sheet
447/// dimension, [`Error::NoDestinationCatalog`], and
448/// [`Error::PageIndexOutOfRange`].
449pub fn n_page_to_one(
450    dest: &mut EditDoc<'_>,
451    src: &Document,
452    pages: &PageRange,
453    opts: &NUpOptions,
454) -> Result<(), Error> {
455    let (x, y) = opts.grid;
456    let (width, height) = opts.sheet;
457    if x == 0 || y == 0 || width <= 0.0 || height <= 0.0 {
458        return Err(Error::BadNupParams);
459    }
460    for index in pages.indices() {
461        if index.get() >= src.page_count() {
462            return Err(Error::PageIndexOutOfRange(*index));
463        }
464    }
465
466    let pages_node = init_dest(dest)?;
467    let grid = NupGrid {
468        sheet_width: width,
469        sheet_height: height,
470        x,
471        y,
472    };
473
474    let mut map = ObjectMap::new();
475    // Source page number to the form that was made from it, so a page used
476    // twice makes one form.
477    let mut forms: Vec<(u32, ObjRef)> = Vec::new();
478    let mut created = Vec::new();
479
480    for chunk in pages.indices().chunks(grid.per_sheet().max(1) as usize) {
481        let mut content = String::new();
482        let mut xobjects = Dict::new();
483
484        for (slot, index) in chunk.iter().enumerate() {
485            let page = src
486                .page(*index)
487                .map_err(|_| Error::PageIndexOutOfRange(*index))?;
488            let key = page.reference.map_or(u32::MAX - index.get(), |r| r.num);
489
490            let form = if let Some((_, existing)) = forms.iter().find(|(k, _)| *k == key) {
491                *existing
492            } else {
493                let made = make_form(dest, src, &page.dict, pages_node, &mut map);
494                forms.push((key, made));
495                made
496            };
497
498            let (page_w, page_h) = page_size(&page.dict, src);
499            #[expect(
500                clippy::cast_possible_truncation,
501                reason = "a slot index is bounded by the grid, which is a u32"
502            )]
503            let edit = grid.edit(slot as u32, page_w, page_h);
504            let name = format!("X{}", xobjects.len() + 1);
505            content.push_str(&sub_page_fragment(&name, edit));
506            // Registered on *every* sheet the form appears on, not just the
507            // first.
508            xobjects.push(Name::from(name.as_str()), Object::Ref(form));
509        }
510
511        created.push(make_sheet(
512            dest, pages_node, &content, xobjects, width, height,
513        ));
514    }
515
516    insert_into_tree(dest, pages_node, &created, 0);
517    Ok(())
518}
519
520/// One source page as a Form `XObject`.
521fn make_form(
522    dest: &mut EditDoc<'_>,
523    src: &Document,
524    page: &Dict,
525    pages_node: u32,
526    map: &mut ObjectMap,
527) -> ObjRef {
528    // Content is **decoded** here — the one place in the import path that
529    // re-encodes — because an array-valued `/Contents` may split a token
530    // across elements, and the join needs a separator between every pair.
531    let content = assemble_content(page, src);
532
533    let mut resources = Dict::new();
534    let mut carrier = Dict::new();
535    if copy_inherited(
536        dest,
537        src,
538        page,
539        &mut carrier,
540        names::RESOURCES,
541        map,
542        pages_node,
543    ) && let Some(found) = carrier.raw(names::RESOURCES).and_then(Object::as_dict)
544    {
545        resources = found.clone();
546    }
547
548    let (media, crop) = boxes(page, src);
549    let bbox = intersect(media, crop);
550
551    // `/Type`, `/Subtype` and `/FormType` are written **after** everything
552    // else, so the reference walk above saw a `/Type`-less dictionary and did
553    // not trip the copier's Pages/Page special cases.
554    let dict = Dict::from_pairs([
555        (names::RESOURCES.clone(), Object::Dict(resources)),
556        (
557            edit_names::BBOX.clone(),
558            Object::Array(Array::of(bbox.map(Object::Real))),
559        ),
560        (
561            edit_names::MATRIX.clone(),
562            Object::Array(Array::of(
563                [1.0, 0.0, 0.0, 1.0, -bbox[0], -bbox[1]].map(Object::Real),
564            )),
565        ),
566        (
567            names::TYPE.clone(),
568            Object::Name(edit_names::XOBJECT.clone()),
569        ),
570        (
571            names::SUBTYPE.clone(),
572            Object::Name(edit_names::FORM.clone()),
573        ),
574        (edit_names::FORM_TYPE.clone(), Object::Int(1)),
575        (
576            names::LENGTH.clone(),
577            Object::Int(i64::try_from(content.len()).unwrap_or(0)),
578        ),
579    ]);
580
581    dest.add(Object::Stream(Box::new(pdfrum_object::Stream::new(
582        dict,
583        pdfrum_object::ByteSpan::from(content),
584    ))))
585}
586
587/// One output sheet.
588fn make_sheet(
589    dest: &mut EditDoc<'_>,
590    pages_node: u32,
591    content: &str,
592    xobjects: Dict,
593    width: f32,
594    height: f32,
595) -> ObjRef {
596    let stream = dest.add(Object::Stream(Box::new(pdfrum_object::Stream::new(
597        Dict::from_pairs([(
598            names::LENGTH.clone(),
599            Object::Int(i64::try_from(content.len()).unwrap_or(0)),
600        )]),
601        pdfrum_object::ByteSpan::from(content.as_bytes().to_vec()),
602    ))));
603
604    dest.add(Object::Dict(Dict::from_pairs([
605        (names::TYPE.clone(), Object::Name(names::PAGE.clone())),
606        (
607            names::PARENT.clone(),
608            Object::Ref(ObjRef::new(pages_node, 0)),
609        ),
610        (
611            names::MEDIA_BOX.clone(),
612            Object::Array(Array::of([
613                Object::Int(0),
614                Object::Int(0),
615                Object::Real(width),
616                Object::Real(height),
617            ])),
618        ),
619        (
620            names::RESOURCES.clone(),
621            Object::Dict(Dict::from_pairs([(
622                edit_names::XOBJECT.clone(),
623                Object::Dict(xobjects),
624            )])),
625        ),
626        (names::CONTENTS.clone(), Object::Ref(stream)),
627    ])))
628}
629
630/// A page's `/Contents`, decoded and joined with a newline after **every**
631/// element — including the last, which is what stops a token split across two
632/// array elements running into whatever follows.
633fn assemble_content(page: &Dict, src: &Document) -> Vec<u8> {
634    let limits = pdfrum_common::Limits::default();
635    let mut diags = pdfrum_common::Diagnostics::default();
636    let mut out = Vec::new();
637
638    match page.get(names::CONTENTS, src).as_deref() {
639        Some(Object::Stream(s)) => {
640            out = pdfrum_parser::decoded_stream(s, src, &limits, &mut diags);
641        }
642        Some(Object::Array(a)) => {
643            for i in 0..a.len() {
644                let Some(s) = a.stream_at(i, src) else {
645                    continue;
646                };
647                out.extend_from_slice(&pdfrum_parser::decoded_stream(&s, src, &limits, &mut diags));
648                out.push(b'\n');
649            }
650        }
651        // No contents at all is an empty form, not a failure.
652        _ => {}
653    }
654    out
655}
656
657/// A page's media and crop boxes, defaulted and normalized.
658fn boxes(page: &Dict, src: &Document) -> ([f32; 4], Option<[f32; 4]>) {
659    let read = |key: &Name| -> Option<[f32; 4]> {
660        let a = match inheritable(page, key, src) {
661            Some(Object::Array(a)) => a,
662            _ => page.array(key, src)?,
663        };
664        if a.len() < 4 {
665            return None;
666        }
667        Some(normalize([
668            a.number_at_or_zero(0),
669            a.number_at_or_zero(1),
670            a.number_at_or_zero(2),
671            a.number_at_or_zero(3),
672        ]))
673    };
674    let media = read(names::MEDIA_BOX).unwrap_or([0.0, 0.0, 612.0, 792.0]);
675    (media, read(names::CROP_BOX))
676}
677
678/// A box with its corners in ascending order.
679fn normalize(b: [f32; 4]) -> [f32; 4] {
680    [
681        b[0].min(b[2]),
682        b[1].min(b[3]),
683        b[0].max(b[2]),
684        b[1].max(b[3]),
685    ]
686}
687
688/// The crop box intersected with the media box, or the media box alone.
689fn intersect(media: [f32; 4], crop: Option<[f32; 4]>) -> [f32; 4] {
690    let Some(crop) = crop else {
691        return media;
692    };
693    [
694        media[0].max(crop[0]),
695        media[1].max(crop[1]),
696        media[2].min(crop[2]),
697        media[3].min(crop[3]),
698    ]
699}
700
701/// A page's visible size, which is what an N-up slot scales to fit.
702fn page_size(page: &Dict, src: &Document) -> (f32, f32) {
703    let (media, crop) = boxes(page, src);
704    let b = intersect(media, crop);
705    let (w, h) = (b[2] - b[0], b[3] - b[1]);
706    // A quarter turn swaps the visible dimensions.
707    let quarter = matches!(
708        inheritable(page, names::ROTATE, src)
709            .or_else(|| page.raw(names::ROTATE).cloned())
710            .and_then(|o| o.as_int())
711            .map(|v| ((v / 90) % 4 + 4) % 4),
712        Some(1 | 3)
713    );
714    if quarter { (h, w) } else { (w, h) }
715}
716
717/// Set a key, replacing in place so the emitted key order does not shuffle.
718fn set(dict: &mut Dict, key: &Name, value: Object) {
719    if dict.contains_key(key) {
720        *dict = Dict::from_pairs(dict.iter().map(|(k, v)| {
721            if k == key {
722                (k.clone(), value.clone())
723            } else {
724                (k.clone(), v.clone())
725            }
726        }));
727    } else {
728        dict.push(key.clone(), value);
729    }
730}